diff --git a/CHANGELOG.md b/CHANGELOG.md index b4cb678..68fe913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - Add a secondary `textDocument/definition` check for zero-reference declarations, so valid uses the reference search misses no longer produce false 'unused' reports. ([#26](https://github.com/leancodepl/ciach/pull/26)) +- Add `--[no-]fail-public` (default on): `--no-fail-public` still reports unused + public declarations but excludes them from `--set-exit-if-changed`, so CI fails + only on unused private ones. ([#23](https://github.com/leancodepl/ciach/pull/23)) - Document the `--unused-union-members`, `--report-tojson`, `--generated-suffix`, and `--help` options in the README, which existed in the CLI but were missing from the options table. diff --git a/README.md b/README.md index cf94fcb..429c478 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,13 @@ Examples below show bare `ciach …`; prefix them with `dart run` for the second ## Usage ```bash -ciach # scan the current package -ciach path/to/package # scan another package -ciach --no-public -f json # private-only (highest confidence), as JSON -ciach -f github --set-exit-if-changed # CI: annotations, non-zero if anything is found -ciach --remove # delete the findings, after confirming +ciach # current package +ciach path/to/package # another package +ciach --no-public -f json # private-only, as JSON +ciach -f github --set-exit-if-changed # CI: annotations, non-zero on finds +ciach --remove # delete findings, asks first ciach --remove --force # …without asking -ciach --verbose # explain what's happening +ciach --verbose # explain each step ``` ### Options @@ -71,6 +71,7 @@ ciach --verbose # explain what's happening | `--[no-]unused-union-members` | off | Also flag a (sealed) supertype member matched only by type patterns, never constructed. Report-only — never touched by `--remove`. | | `--[no-]report-tojson` | off | Report an otherwise-unused `toJson()` serialization hook too. Off by default — `jsonEncode` dispatches to it dynamically. | | `--set-exit-if-changed` | off | Exit with status `1` when anything is found (for CI). Named after `dart format`. | +| `--[no-]fail-public` | on | Count unused public declarations toward the exit code (with `--set-exit-if-changed`). `--no-fail-public` reports them but fails only on private findings. | | `--remove` | off | Remove unused declarations after reporting them. Prompts for confirmation first. | | `--force` | off | Skip the confirmation prompt for `--remove`. Requires `--remove`. | | `-e, --exclude ` | — | Skip files matching the glob (repeatable). | @@ -169,6 +170,14 @@ Each finding becomes a `::warning` annotation inline on the PR diff. Run it from the repository root so paths resolve; when scanning a sub-package (`ciach -f github app`), the scan path is prepended automatically. +For a library or workspace package whose public API is legitimately "unused" +from its own perspective, add `--no-fail-public` to still surface those +findings while gating the job on unused *private* declarations only: + +```yaml +- run: dart run ciach -f github --set-exit-if-changed --no-fail-public +``` + ### Removing declarations `--remove` deletes every reported declaration — doc comment and annotations diff --git a/bin/ciach.dart b/bin/ciach.dart index 50cff50..215ce60 100644 --- a/bin/ciach.dart +++ b/bin/ciach.dart @@ -166,8 +166,14 @@ Future _run(List arguments) async { log?.write('Leaving the findings in place; --remove was not given.'); } - if (result.unused.isNotEmpty && resolved.setExitIfChanged) { - return 1; + if (resolved.setExitIfChanged) { + // Public findings are still reported; --no-fail-public only drops them from the exit code. + final failing = resolved.failPublic + ? result.unused + : result.unused.where((d) => d.isPrivate); + if (failing.isNotEmpty) { + return 1; + } } return 0; } diff --git a/lib/src/cli/args.dart b/lib/src/cli/args.dart index 08fd794..ac92756 100644 --- a/lib/src/cli/args.dart +++ b/lib/src/cli/args.dart @@ -112,6 +112,17 @@ enum CiachOption implements OptionDefinition { 'highest-confidence dead code.', ), ), + failPublic( + FlagOption( + argName: 'fail-public', + configKey: '/fail-public', + defaultsTo: true, + helpText: + 'Count unused public declarations toward the exit code (with\n' + '--set-exit-if-changed). Use --no-fail-public to report them\n' + 'without failing the build.', + ), + ), generated( FlagOption( argName: 'generated', diff --git a/lib/src/cli/options.dart b/lib/src/cli/options.dart index 9b5fd50..1d7c407 100644 --- a/lib/src/cli/options.dart +++ b/lib/src/cli/options.dart @@ -15,6 +15,7 @@ class ResolvedOptions { required this.additionalGeneratedSuffixes, required this.kinds, required this.includePublic, + required this.failPublic, required this.includeGenerated, required this.overrides, required this.operators, @@ -38,6 +39,7 @@ class ResolvedOptions { final List additionalGeneratedSuffixes; final Set kinds; final bool includePublic; + final bool failPublic; final bool includeGenerated; /// Whether to report `@override` members — inverted for the finder. @@ -113,6 +115,7 @@ ResolvedOptions resolveOptions( // Already validated by the option; this only converts the names. kinds: parseKinds(configuration.value(CiachOption.kinds)), includePublic: configuration.value(CiachOption.public), + failPublic: configuration.value(CiachOption.failPublic), includeGenerated: configuration.value(CiachOption.generated), overrides: configuration.value(CiachOption.overrides), operators: configuration.value(CiachOption.operators), diff --git a/lib/src/cli/verbose.dart b/lib/src/cli/verbose.dart index 48f6e36..87f8d97 100644 --- a/lib/src/cli/verbose.dart +++ b/lib/src/cli/verbose.dart @@ -68,6 +68,7 @@ String _setting( ) => switch (option) { .path => resolved.absoluteRootPath, .public => '${resolved.includePublic}', + .failPublic => '${resolved.failPublic}', .generated => '${resolved.includeGenerated}', .overrides => '${resolved.overrides}', .operators => '${resolved.operators}', diff --git a/test/cli_exit_code_test.dart b/test/cli_exit_code_test.dart new file mode 100644 index 0000000..3066519 --- /dev/null +++ b/test/cli_exit_code_test.dart @@ -0,0 +1,78 @@ +/* + * AI-Provenance: + * model: claude-opus-4-8 + * harness: Claude Code + * plugins: + * - lean-ai-provenance + * skills: + * - mark-ai-provenance + */ + +@Timeout(Duration(minutes: 5)) +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + // Drives the real binary and asserts its exit code. + final fixturePath = p.join(Directory.current.path, 'example'); + final entrypoint = p.join('bin', 'ciach.dart'); + + setUpAll(() async { + // pub get first: the analyzer needs the fixture's package_config.json. + final config = File( + p.join(fixturePath, '.dart_tool', 'package_config.json'), + ); + if (!config.existsSync()) { + final result = await Process.run(Platform.resolvedExecutable, [ + 'pub', + 'get', + ], workingDirectory: fixturePath); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + } + }); + + Future runCli(List args) => Process.run( + Platform.resolvedExecutable, + ['run', entrypoint, fixturePath, '--no-progress', ...args], + ); + + // orphans.dart: unused public only; greeting.dart: also an unused private one. + const publicOnly = ['--include', 'lib/orphans.dart']; + const withPrivate = ['--include', 'lib/greeting.dart']; + + test( + '--set-exit-if-changed --no-fail-public: only public unused -> exit 0', + () async { + final result = await runCli([ + ...publicOnly, + '--set-exit-if-changed', + '--no-fail-public', + ]); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + // Public findings are still reported, just not counted toward the exit. + expect(result.stdout, contains('UnusedClass')); + }, + ); + + test( + '--set-exit-if-changed --no-fail-public: an unused private -> exit 1', + () async { + final result = await runCli([ + ...withPrivate, + '--set-exit-if-changed', + '--no-fail-public', + ]); + expect(result.exitCode, 1, reason: '${result.stdout}\n${result.stderr}'); + expect(result.stdout, contains('_danglingPrivate')); + }, + ); + + test('--set-exit-if-changed alone: public counts -> exit 1', () async { + final result = await runCli([...publicOnly, '--set-exit-if-changed']); + expect(result.exitCode, 1, reason: '${result.stdout}\n${result.stderr}'); + }); +}