Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
@@ -1,5 +1,8 @@
## Unreleased

- 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))
- 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))
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ ciach --no-public -f json
# GitHub Actions annotations; fail the job if anything is found
ciach -f github --set-exit-if-changed

# Fail CI only on unused private declarations; still report public ones
ciach . --set-exit-if-changed --no-fail-public

# Remove what's found, after confirming
ciach --remove

Expand All @@ -93,6 +96,7 @@ ciach --remove --force
| `--[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 @@ -149,6 +153,14 @@ it from the repository root so annotation paths resolve; when scanning a
sub-package (e.g. `ciach -f github app`), the scan path is
prepended automatically so annotations still point at the right files.

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 from source — its doc comment
Expand Down
11 changes: 9 additions & 2 deletions bin/ciach.dart
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,15 @@ Future<int> _run(List<String> arguments) async {
await _removeUnused(result, rootDir.absolute.path, args, format, useColor);
}

if (result.unused.isNotEmpty && args.flag('set-exit-if-changed')) {
return 1;
if (args.flag('set-exit-if-changed')) {
// 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 = args.flag('fail-public')
? result.unused
: result.unused.where((d) => d.isPrivate);
if (failing.isNotEmpty) {
return 1;
}
}
return 0;
}
Expand Down
8 changes: 8 additions & 0 deletions lib/src/cli/args.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ ArgParser buildParser() => .new()
'private (underscore-prefixed) declarations, which are the\n'
'highest-confidence dead code.',
)
..addFlag(
'fail-public',
defaultsTo: true,
help:
'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.',
)
..addFlag(
'generated',
help: 'Scan generated files (*.g.dart, *.freezed.dart, …). Off by default.',
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