Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <glob>` | — | Skip files matching the glob (repeatable). |
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions bin/ciach.dart
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,15 @@ Future<int> _run(List<String> 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 above; --no-fail-public only keeps
// them out of the exit code, so the build fails on private findings alone.
final failing = resolved.failPublic
? result.unused
: result.unused.where((d) => d.isPrivate);
if (failing.isNotEmpty) {
return 1;
}
}
return 0;
}
Expand Down
11 changes: 11 additions & 0 deletions lib/src/cli/args.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ enum CiachOption<V> implements OptionDefinition<V> {
'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',
Expand Down
7 changes: 7 additions & 0 deletions lib/src/cli/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +39,11 @@ class ResolvedOptions {
final List<String> additionalGeneratedSuffixes;
final Set<SymbolKind> kinds;
final bool includePublic;

/// Whether unused public declarations count toward the exit code under
/// [setExitIfChanged]. When `false` (`--no-fail-public`) they are still
/// reported, but the build fails on unused private declarations alone.
final bool failPublic;
final bool includeGenerated;

/// Whether to report `@override` members — inverted for the finder.
Expand Down Expand Up @@ -113,6 +119,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),
Expand Down
1 change: 1 addition & 0 deletions lib/src/cli/verbose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand Down
82 changes: 82 additions & 0 deletions test/cli_exit_code_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 CLI (bin/ciach.dart) against the example package, the same
// `sample_pkg` fixture the finder tests use, and asserts the process exit
// code — the behavior --set-exit-if-changed / --no-fail-public controls.
final fixturePath = p.join(Directory.current.path, 'example');
final entrypoint = p.join('bin', 'ciach.dart');

setUpAll(() async {
// The fixture is a real package; the analysis server needs its
// package_config.json to resolve `package:sample_pkg/...` imports.
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<ProcessResult> runCli(List<String> args) => Process.run(
Platform.resolvedExecutable,
['run', entrypoint, fixturePath, '--no-progress', ...args],
);

// orphans.dart has only unused *public* declarations; greeting.dart also has
// an unused *private* one (`_danglingPrivate`).
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}');
});
}
Loading