diff --git a/lib/src/commands/build_command.dart b/lib/src/commands/build_command.dart index 11b30a24..61c88408 100644 --- a/lib/src/commands/build_command.dart +++ b/lib/src/commands/build_command.dart @@ -1,5 +1,8 @@ import 'dart:io'; import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; + +import 'build_yaml_guard.dart'; class BuildCommand extends Command { @override @@ -40,6 +43,10 @@ class BuildCommand extends Command { await _cleanBuildCache(); } + // Self-healing pre-flight (zuraffa#276): ensure build.yaml registers the + // zorphy builder, otherwise build_runner exits 0 having written 0 outputs + // and we'd silently report success. Run BEFORE announcing the build so a + // misconfigured project fails fast without invoking build_runner. if (dryRun) { print('šŸ” Dry-run mode: previewing changes...'); print(' Entities: $entityCount, Dart files: $dartFileCount'); @@ -50,13 +57,19 @@ class BuildCommand extends Command { ' 🧠 Smart merge: will preserve user code and @preserve blocks', ); } + _reportBuildYamlDryRun(); print(''); } else { - print('šŸ”Ø Running build_runner build...'); print(' Entities: $entityCount, Dart files: $dartFileCount'); if (force) { print(' āš ļø Force mode: regenerating from scratch'); } + final guardResult = await _ensureBuildYaml(); + if (!guardResult) { + // _ensureBuildYaml already printed an actionable error. + exit(1); + } + print('šŸ”Ø Running build_runner build...'); } final exitCode = await _runBuild(); @@ -64,6 +77,11 @@ class BuildCommand extends Command { if (exitCode == 0) { if (!dryRun) print(''); print(dryRun ? 'āœ… Dry-run completed' : 'āœ… Build completed successfully'); + // Safety net: if build_runner wrote 0 outputs while @Zorphy sources + // exist, warn loudly so silent regressions don't slip through. + if (!dryRun) { + _warnIfNoOutputsGenerated(); + } } else if (!clean) { print( '\nāš ļø Build failed (exit $exitCode). Retrying with clean cache...', @@ -72,6 +90,7 @@ class BuildCommand extends Command { final retryCode = await _runBuild(); if (retryCode == 0) { print('\nāœ… Build completed successfully after cache clean'); + _warnIfNoOutputsGenerated(); } else { print('\nāŒ Build failed with exit code $retryCode'); } @@ -80,12 +99,115 @@ class BuildCommand extends Command { } } + /// Ensures `build.yaml` is in a state that lets build_runner produce zorphy + /// outputs. Returns `true` when the build may proceed; `false` when the + /// project has a `build.yaml` that omits the zorphy builder (the caller + /// should abort — the user must fix their config). + Future _ensureBuildYaml() async { + final status = BuildYamlGuard.check(); + switch (status) { + case BuildYamlStatus.ok: + return true; + case BuildYamlStatus.missing: + print( + 'šŸ›  No build.yaml found — scaffolding one that registers the zorphy builder.', + ); + await BuildYamlGuard.scaffold(); + print(' Created: build.yaml'); + return true; + case BuildYamlStatus.missingZorphyBuilder: + print(BuildYamlGuard.missingZorphyBuilderMessage); + return false; + } + } + + /// Dry-run counterpart of [_ensureBuildYaml]: reports what would happen + /// without writing. + void _reportBuildYamlDryRun() { + final status = BuildYamlGuard.check(); + switch (status) { + case BuildYamlStatus.ok: + break; + case BuildYamlStatus.missing: + print(' Would scaffold: build.yaml (registers zorphy builder)'); + break; + case BuildYamlStatus.missingZorphyBuilder: + print( + ' āš ļø build.yaml exists but omits the zorphy builder — build would write 0 outputs.', + ); + print(' Add `zorphy:zorphy` under targets.\$default.builders.'); + break; + } + } + + /// Warns (non-fatally) when build_runner exited 0 but produced no `.zorphy` + /// / `.g.dart` outputs despite `@Zorphy`-annotated sources being present. + /// Catches misconfigurations the pre-flight check can't detect statically. + void _warnIfNoOutputsGenerated() { + try { + final hasZorphySources = _hasZorphyAnnotatedSources(); + final hasOutputs = _hasGeneratedOutputs(); + if (hasZorphySources && !hasOutputs) { + print( + '\nāš ļø build_runner wrote 0 outputs although @Zorphy sources exist.\n' + ' Check build.yaml registers `zorphy:zorphy` for lib/src/** and\n' + ' that the annotated files are under the configured generate_for\n' + ' glob. Run `zfa setup` to regenerate a known-good build.yaml.', + ); + } + } catch (_) { + // Best-effort safety net — never fail the build from this path. + } + } + + bool _hasGeneratedOutputs() { + final libDir = Directory('lib'); + if (!libDir.existsSync()) return false; + bool found = false; + for (final entity in libDir.listSync(recursive: true)) { + if (entity is File) { + final name = p.basename(entity.path); + if (name.endsWith('.zorphy.dart') || name.endsWith('.g.dart')) { + found = true; + break; + } + } + } + return found; + } + + bool _hasZorphyAnnotatedSources() { + final libDir = Directory('lib'); + if (!libDir.existsSync()) return false; + bool found = false; + for (final entity in libDir.listSync(recursive: true)) { + if (entity is File && entity.path.endsWith('.dart')) { + // Skip generated files themselves. + final name = p.basename(entity.path); + if (name.endsWith('.zorphy.dart') || name.endsWith('.g.dart')) continue; + try { + final src = entity.readAsStringSync(); + if (src.contains('@Zorphy') || src.contains('@ZorphyMixin')) { + found = true; + break; + } + } catch (_) { + // Ignore unreadable files. + } + } + } + return found; + } + Future _runBuild() async { - final args = [ + // `--delete-conflicting-outputs` was removed in build_runner 2.16.0 and + // emits a "These options have been removed" warning on every invocation. + // build_runner now resolves conflicting outputs via the build cache, so + // the flag is no longer needed. + final args = [ 'run', 'build_runner', 'build', - '--delete-conflicting-outputs', ]; final process = await Process.start( diff --git a/lib/src/commands/build_yaml_guard.dart b/lib/src/commands/build_yaml_guard.dart new file mode 100644 index 00000000..5ff5be03 --- /dev/null +++ b/lib/src/commands/build_yaml_guard.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../core/dependencies/dependency_wirer.dart'; + +/// Outcome of inspecting a project's `build.yaml` from the build command. +enum BuildYamlStatus { + /// No `build.yaml` exists at the project root. [BuildYamlGuard.scaffold] + /// can create one that registers the zorphy builder. + missing, + + /// A `build.yaml` exists but it does not register the `zorphy:zorphy` + /// builder. build_runner will exit 0 having written 0 outputs, silently. + /// The user must be told to add the zorphy builder registration. + missingZorphyBuilder, + + /// A `build.yaml` exists and registers the `zorphy:zorphy` builder. + ok, +} + +/// Self-healing guard for `zfa build`. +/// +/// Before delegating to `build_runner`, [BuildCommand] asks this guard whether +/// the project's `build.yaml` is in a state that will actually produce +/// `.zorphy.dart` / `.g.dart` outputs. When the file is missing, the build +/// command scaffolds it (reusing [DependencyWirer.buildYamlContent]); when the +/// file exists but omits the zorphy builder, the build command fails loudly +/// with an actionable error instead of silently reporting success with 0 +/// outputs (zuraffa#276). +class BuildYamlGuard { + /// The builder key that must appear in `build.yaml` for zorphy codegen to + /// run. Matches `DependencyWirer.buildYamlContent`. + static const String zorphyBuilderKey = 'zorphy:zorphy'; + + /// Returns the current [BuildYamlStatus] for [projectRoot] + /// (defaults to [Directory.current]). + static BuildYamlStatus check({String? projectRoot}) { + final root = projectRoot ?? Directory.current.path; + final file = File(p.join(root, 'build.yaml')); + if (!file.existsSync()) { + return BuildYamlStatus.missing; + } + final contents = file.readAsStringSync(); + if (!_registersZorphyBuilder(contents)) { + return BuildYamlStatus.missingZorphyBuilder; + } + return BuildYamlStatus.ok; + } + + /// Scaffolds a `build.yaml` that registers the zorphy + json_serializable + /// builders. Only call when [check] returned [BuildYamlStatus.missing] — + /// never overwrite an existing `build.yaml`. + static Future scaffold({String? projectRoot}) async { + final root = projectRoot ?? Directory.current.path; + final file = File(p.join(root, 'build.yaml')); + await file.writeAsString(DependencyWirer.buildYamlContent); + } + + /// True when [contents] enables the `zorphy:zorphy` builder. + /// + /// Accepts either the `builders:` mapping form or a bare `zorphy:zorphy` + /// reference, and tolerates leading whitespace. This is intentionally a + /// substring/regex check rather than a full YAML parse so the guard stays + /// dependency-free and robust to hand-edited formatting. + static bool _registersZorphyBuilder(String contents) { + if (contents.isEmpty) return false; + // Match `zorphy:zorphy` possibly followed by `:` (mapping) or whitespace, + // at the start of a line (builder keys are map keys under `builders:`). + final re = RegExp(r'^\s*zorphy:zorphy\s*:?', multiLine: true); + return re.hasMatch(contents); + } + + /// The actionable error message shown when `build.yaml` exists but does not + /// register the zorphy builder. Kept here so tests can assert against it. + static const String missingZorphyBuilderMessage = ''' +āŒ build.yaml exists but does not register the zorphy builder. + + build_runner ran successfully but wrote 0 outputs because no builder is + configured to process @Zorphy annotations. Add the following under + `targets:\$default.builders:` in build.yaml: + + builders: + zorphy:zorphy: + enabled: true + generate_for: + - lib/src/** + - test/** + + Alternatively, run `zfa setup` to regenerate a correct build.yaml, or + delete build.yaml and re-run `zfa build` to have it scaffolded for you. +'''; +} diff --git a/test/commands/build_command_test.dart b/test/commands/build_command_test.dart new file mode 100644 index 00000000..cd65b229 --- /dev/null +++ b/test/commands/build_command_test.dart @@ -0,0 +1,218 @@ +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:path/path.dart' as path; + +import '../helpers/project_root.dart'; + +/// Integration tests for `zfa build` self-healing when `build.yaml` is +/// missing or misconfigured (zuraffa#276). +/// +/// These spawn the real `zfa build` subprocess against a temp workspace so we +/// exercise the full pre-flight → build_runner → report path. The +/// build_runner call itself is the slow part, so each test uses a minimal +/// project where build_runner finishes quickly (or fails fast). +void main() { + group('zfa build (build.yaml self-healing — #276)', () { + late Directory workspace; + late String zfaBin; + late bool useCompiledBinary; + + Future startZfa( + List args, { + required String workingDirectory, + }) { + if (useCompiledBinary) { + return Process.start(zfaBin, args, workingDirectory: workingDirectory); + } + return Process.start( + 'dart', + [zfaBin, ...args], + workingDirectory: workingDirectory, + ); + } + + setUpAll(() async { + final homeDir = Platform.environment['HOME'] ?? ''; + final compiledBin = path.join(homeDir, '.local', 'bin', 'zfa'); + final compiledExists = File(compiledBin).existsSync(); + if (compiledExists) { + zfaBin = compiledBin; + useCompiledBinary = true; + } else { + final projectRoot = await findProjectRoot(); + zfaBin = path.join(projectRoot, 'bin', 'zfa.dart'); + useCompiledBinary = false; + } + }); + + setUp(() async { + // The full test suite has pre-existing CWD-contamination: some test + // files delete their temp CWD without restoring it, so a later + // subprocess `zfa` invocation blows up in MakeCommand._findProjectRoot + // -> Directory.current. findProjectRoot() runs _ensureValidCwd first, + // recovering CWD to the project root if it was deleted. We call it here + // so every test in this file starts from a valid CWD. + await findProjectRoot(); + workspace = await Directory.systemTemp.createTemp('zfa_build_guard_'); + }); + + tearDown(() async { + // NOTE: we deliberately never call `Directory.current =` here. This test + // passes `workingDirectory` to Process.start, so it never needs to change + // CWD. Capturing/restoring CWD would inherit a stale path from other test + // files (e.g. make_command_test) that DO change CWD, causing a + // PathNotFoundException on tearDown. See zuraffa#276 test hygiene. + if (workspace.existsSync()) { + try { + await workspace.delete(recursive: true); + } catch (_) { + // Best-effort cleanup; temp dirs are OS-reaped anyway. + } + } + }); + + test('--help still works and documents the build command', () async { + final proc = await startZfa( + ['build', '--help'], + workingDirectory: workspace.path, + ); + final stdout = await proc.stdout.transform(systemEncoding.decoder).join(); + final stderr = await proc.stderr.transform(systemEncoding.decoder).join(); + final code = await proc.exitCode; + expect(code, 0, reason: stderr); + expect(stdout, contains('Run zuraffa_build')); + // build.yaml scaffolding is automatic; no flag for it. + expect(stdout, isNot(contains('--build-yaml'))); + }, + timeout: const Timeout(Duration(minutes: 1)), + ); + + test( + 'dry-run reports it would scaffold build.yaml when missing (#276)', + () async { + // No build.yaml in workspace. + final proc = await startZfa( + ['build', '--dry-run'], + workingDirectory: workspace.path, + ); + final stdout = await proc.stdout.transform(systemEncoding.decoder).join(); + final stderr = await proc.stderr.transform(systemEncoding.decoder).join(); + final code = await proc.exitCode; + // dry-run should not invoke build_runner; it prints the pre-flight plan. + expect(code, 0, reason: stderr); + expect( + stdout, + contains('Would scaffold'), + reason: 'dry-run should announce it would create build.yaml', + ); + // And must NOT actually create one. + expect( + File(path.join(workspace.path, 'build.yaml')).existsSync(), + isFalse, + ); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + + test( + 'dry-run warns when build.yaml exists but omits zorphy builder (#276)', + () async { + final buildYaml = File(path.join(workspace.path, 'build.yaml')); + await buildYaml.writeAsString(''' +targets: + \$default: + builders: + json_serializable: + enabled: true +'''); + final proc = await startZfa( + ['build', '--dry-run'], + workingDirectory: workspace.path, + ); + final stdout = await proc.stdout.transform(systemEncoding.decoder).join(); + final stderr = await proc.stderr.transform(systemEncoding.decoder).join(); + final code = await proc.exitCode; + expect(code, 0, reason: stderr); + expect( + stdout, + contains('omits the zorphy builder'), + reason: 'dry-run should warn about the missing zorphy registration', + ); + // Should not modify the user's build.yaml. + expect(buildYaml.readAsStringSync(), contains('json_serializable')); + expect(buildYaml.readAsStringSync(), isNot(contains('zorphy:zorphy'))); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + + test( + 'build scaffolds build.yaml when missing, then proceeds (#276)', + () async { + // This actually runs build_runner against the scaffolded build.yaml. + // We don't need codegen to succeed — we just need to prove: + // (1) build.yaml was missing, + // (2) zfa build created it, + // (3) the message announces the scaffolding. + // build_runner may fail (no pubspec etc.) but the pre-flight must run. + final proc = await startZfa( + ['build'], + workingDirectory: workspace.path, + ); + final stdout = await proc.stdout.transform(systemEncoding.decoder).join(); + final stderr = await proc.stderr.transform(systemEncoding.decoder).join(); + await proc.exitCode; + // Pre-flight scaffolding message must appear regardless of build_runner + // outcome. + expect( + stdout, + contains('No build.yaml found'), + reason: 'zfa build must announce the missing build.yaml. stderr:\n$stderr', + ); + expect( + stdout, + contains('scaffolding'), + reason: 'zfa build must announce it is scaffolding build.yaml', + ); + // build.yaml must now exist and register zorphy. + final created = File(path.join(workspace.path, 'build.yaml')); + expect(created.existsSync(), isTrue); + expect(created.readAsStringSync(), contains('zorphy:zorphy')); + }, + timeout: const Timeout(Duration(minutes: 5)), + ); + + test( + 'build fails loudly when build.yaml exists but omits zorphy builder (#276)', + () async { + final buildYaml = File(path.join(workspace.path, 'build.yaml')); + await buildYaml.writeAsString(''' +targets: + \$default: + builders: + json_serializable: + enabled: true +'''); + final proc = await startZfa( + ['build'], + workingDirectory: workspace.path, + ); + final stdout = await proc.stdout.transform(systemEncoding.decoder).join(); + final code = await proc.exitCode; + expect(code, isNot(0), + reason: 'misconfigured build.yaml must abort with non-zero exit'); + expect( + stdout, + contains('does not register the zorphy builder'), + ); + expect(stdout, contains('zorphy:zorphy')); + expect(stdout, contains('zfa setup')); + // build_runner must NOT have been invoked. + expect(stdout, isNot(contains('Running build_runner build'))); + // The user's build.yaml must be untouched. + expect(buildYaml.readAsStringSync(), isNot(contains('zorphy:zorphy'))); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + }); +} diff --git a/test/commands/build_yaml_guard_test.dart b/test/commands/build_yaml_guard_test.dart new file mode 100644 index 00000000..50833a48 --- /dev/null +++ b/test/commands/build_yaml_guard_test.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:path/path.dart' as p; + +import 'package:zuraffa/src/commands/build_yaml_guard.dart'; + +void main() { + group('BuildYamlGuard', () { + late Directory sandbox; + + setUp(() async { + sandbox = await Directory.systemTemp.createTemp('build_yaml_guard_'); + }); + + tearDown(() async { + if (sandbox.existsSync()) { + await sandbox.delete(recursive: true); + } + }); + + test('check() returns BuildYamlStatus.missing when no build.yaml exists', + () { + expect( + BuildYamlGuard.check(projectRoot: sandbox.path), + BuildYamlStatus.missing, + ); + }); + + test('check() returns BuildYamlStatus.ok for the canonical build.yaml', + () { + final f = File(p.join(sandbox.path, 'build.yaml')); + f.writeAsStringSync(''' +targets: + \$default: + builders: + zorphy:zorphy: + enabled: true + generate_for: + - lib/src/** + - test/** + source_gen:combining_builder: + enabled: true +'''); + expect( + BuildYamlGuard.check(projectRoot: sandbox.path), + BuildYamlStatus.ok, + ); + }); + + test( + 'check() returns missingZorphyBuilder when build.yaml omits zorphy:zorphy', + () { + final f = File(p.join(sandbox.path, 'build.yaml')); + f.writeAsStringSync(''' +targets: + \$default: + builders: + json_serializable: + enabled: true + generate_for: + - lib/** +'''); + expect( + BuildYamlGuard.check(projectRoot: sandbox.path), + BuildYamlStatus.missingZorphyBuilder, + ); + }); + + test('check() returns missingZorphyBuilder for an empty build.yaml', () { + File(p.join(sandbox.path, 'build.yaml')).writeAsStringSync(''); + expect( + BuildYamlGuard.check(projectRoot: sandbox.path), + BuildYamlStatus.missingZorphyBuilder, + ); + }); + + test('check() tolerates indented zorphy:zorphy keys', () { + final f = File(p.join(sandbox.path, 'build.yaml')); + f.writeAsStringSync(''' +targets: + \$default: + builders: + zorphy:zorphy: + enabled: true +'''); + expect( + BuildYamlGuard.check(projectRoot: sandbox.path), + BuildYamlStatus.ok, + ); + }); + + test('check() does not false-positive on a zorphy comment', () { + final f = File(p.join(sandbox.path, 'build.yaml')); + f.writeAsStringSync(''' +# TODO: add zorphy:zorphy here +targets: + \$default: + builders: + json_serializable: + enabled: true +'''); + expect( + BuildYamlGuard.check(projectRoot: sandbox.path), + BuildYamlStatus.missingZorphyBuilder, + ); + }); + + test('scaffold() writes build.yaml with the zorphy builder registered', + () async { + await BuildYamlGuard.scaffold(projectRoot: sandbox.path); + final written = File(p.join(sandbox.path, 'build.yaml')); + expect(written.existsSync(), isTrue); + expect( + BuildYamlGuard.check(projectRoot: sandbox.path), + BuildYamlStatus.ok, + reason: 'scaffolded build.yaml must register zorphy:zorphy', + ); + }); + + test('scaffold() content matches DependencyWirer.buildYamlContent', () async { + await BuildYamlGuard.scaffold(projectRoot: sandbox.path); + final written = File(p.join(sandbox.path, 'build.yaml')).readAsStringSync(); + expect(written, contains('zorphy:zorphy')); + expect(written, contains('json_serializable')); + expect(written, contains('generate_for')); + }); + + test('check() defaults to Directory.current when projectRoot is null', () { + // Smoke test: should not throw and should return a valid status. + final status = BuildYamlGuard.check(); + expect( + status, + anyOf( + BuildYamlStatus.missing, + BuildYamlStatus.missingZorphyBuilder, + BuildYamlStatus.ok, + ), + ); + }); + + test('missingZorphyBuilderMessage is actionable', () { + final msg = BuildYamlGuard.missingZorphyBuilderMessage; + expect(msg, contains('zorphy:zorphy')); + expect(msg, contains('0 outputs')); + expect(msg, contains('zfa setup')); + }); + }); +}