Skip to content
leancodeplPublic

About

Dead code detector for Dart and Flutter. Finds declarations that are never referenced — classes, functions, methods, fields, constants, enum values — and can remove them for you.

Topics

Resources

Stars

22 stars

Watchers

2 watching

Forks

Repository files navigation

Banner

ciach 🔪

ciach pub.dev badge Test status License: Apache 2.0 style: leancode_lint Pub Points

Dead code detector for Dart and Flutter. Finds declarations that are never referenced — classes, functions, methods, fields, constants, enum values — and can remove them for you.

🌐 Landing page — a tour of what ciach finds, skips, and removes.

"Ciach!" — pronounced /t͡ɕax/ — is Polish for the sound of a clean chop, the noise a knife makes right before something falls off.

Installation

Install it globally, as a native ciach binary you can run from anywhere:

dart install ciach

A compiled ciach runs the analysis server with the dart on your PATH (an fvm or Flutter dart works too); --dart <path> picks a different one.

Or add it as a dev dependency, which pins the version for the team and CI:

dart pub add --dev ciach
dart run ciach

Examples below show bare ciach …; prefix them with dart run for the second.

ciach runs on Dart 3.10 and up, but analyzes with the SDK it is invoked with, so scanning code needs an SDK new enough to parse it.

Usage

ciach                                  # current package
ciach path/to/package                  # another package
ciach --no-public -f json              # private-only, as JSON
ciach --no-exported                    # skip the package's public API
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 each step

Options

Option Default Description
[path] . Package root to analyze.
-h, --help — Print usage information.
--version — Print the ciach version and exit. The --help header carries it too.
--config <path> auto Read settings from this YAML file instead of the auto-discovered one. See Configuration file.
--no-config off Ignore the config file, even if one is found.
--analysis-root <path> the scanned path Count references from this whole directory, not just the scanned package — for a monorepo wired by path: dependencies. See Monorepos.
--[no-]public on Report unused public declarations too. Disable to report only private (_-prefixed) ones.
--[no-]generated off Scan generated files (*.g.dart, *.freezed.dart, *.mocks.dart, …).
--[no-]project-config on Read entry points and generated files from pubspec.yaml, build.yaml and l10n.yaml.
--[no-]unused-translations off Scan the gen-l10n template file and report its unused messages. They are only reported, never removed: remove them from the template ARB file instead.
--[no-]overrides off Report @override members too. Off by default — see limitations.
--[no-]operators off Report operator overloads (operator +, operator ==, …) too. Off by default — see limitations.
--[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.
--[no-]transitive off Also report declarations referenced only from other findings, and dead cycles. See Transitively dead code.
--set-exit-if-changed off Exit with status 1 when anything is found (for CI). Named after dart format.
--[no-]exported on Report declarations other packages can import. See Library packages.
--[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). They are not opened, so a declaration used only from them is reported; see --generated-glob.
-i, --include <glob> — Only scan files matching the glob (repeatable).
--generated-suffix <suffix> — Extra filename suffix (with leading dot) to treat as generated and skip, on top of the built-in set; repeatable. Ignored when --generated is set.
--generated-glob <glob> — Treat files matching the glob as generated: their references count, but nothing in them is reported or removed. For output without a suffix or banner, or code kept as is, like a vendored copy. Repeatable; ignored when --generated is set.
-k, --kinds <list> all Restrict to kinds: class, mixin, interface, enum, extension, extension-type, function, method, constructor, field, property, getter, setter, variable, constant, enum-value.
-f, --format <fmt> text text, json, or github (GitHub Actions ::warning annotations).
-j, --concurrency <n> 16 Reference queries kept in flight against the analysis server.
--[no-]color auto Colorize the output. Auto-detected per stream; honors NO_COLOR.
--[no-]progress auto Show scan progress on stderr.
-v, --verbose off Explain what's happening on stderr. See Verbose mode.
--dart <path> auto Path to the dart executable to launch the server with. Defaults to the SDK running ciach, or to dart from PATH for a compiled binary.

Exit codes: 0 success, 1 unused found with --set-exit-if-changed, 2 usage or analysis error.

The result goes to stdout; progress, -v output, warnings, errors and the --remove prompt go to stderr.

If the analysis server fails on a declaration or file, the run continues: that code is kept and listed under "Not analyzed" (problems in JSON).

Configuration file

Every option above can live in a ciach.yaml in the package root, keyed by its long name minus the --, plus path for the positional argument:

public: false                     # --no-public
analysis-root: ..                 # --analysis-root; see Monorepos
exclude: ['test/**', 'tool/**']   # repeatable options take a list, or a bare string
kinds: [class, function, method]
format: github
set-exit-if-changed: true
entry-points:                     # file-only; see Entry points below
  - name: bootstrap
    glob: 'lib/src/isolate.dart'

Command line beats config file beats default, even when the flag matches the default (ciach --public overrides public: false), and a repeatable option on the command line replaces the config's list rather than adding to it. Unknown keys and wrong-typed values are usage errors naming the file and the key.

Discovery looks for that one file name in the analyzed package root, never in a parent, so each package in a monorepo owns its config. --config <path> reads one from elsewhere; --no-config ignores a discovered one; the two can't be combined.

Verbose mode

-v prints the whole log on stderr: config, settings, each phase, and what --remove touches.

$ ciach -v
[  0.0s] [cli]     Read config from ciach.yaml.
[  0.0s] [cli]       It sets 2 options:
[  0.0s] [cli]         public: false
[  0.0s] [cli]         exclude: test/**
[  0.0s] [cli]     Settings for this run:
[  0.0s] [cli]       path: /home/me/pkg (command line)
[  0.0s] [cli]       public: false (config file)
[  0.0s] [cli]       concurrency: 16 (default)
…
[  0.1s] [finder]  Starting Dart analysis server…
[  0.1s] [lsp]     Started `/sdk/bin/dart language-server` (pid 4242).
[  0.3s] [finder]  Collecting declarations from 13 file(s)…
[  0.5s] [cli]     Scanned 13 file(s) and checked 44 declaration(s) in 478ms: 4 unused, 1 referenced only from doc comments.

It all goes to stderr, so ciach -v -f json | jq still works. Reach for it when a config file seems not to apply, or to find the phase eating the time. It supersedes --progress, whose self-overwriting line would fight with it.

Doc-only findings

A dartdoc [Xxx] link resolves to a real declaration, so the analysis server counts it as a reference — but a comment mentioning something isn't the same as code calling it. Declarations with no code references are reported separately, in every format:

lib/greeting.dart
  15:6  function  danglingFunction  (public)

Referenced only from doc comments — not counted as unused, never removed:
lib/greeting.dart
  40:6  function  docOnlyMentioned  (public)

These never count toward --set-exit-if-changed, are never touched by --remove, and get a ::notice rather than a ::warning in -f github. Drop the doc link to have one reported as properly unused.

Transitively dead code

A reference from a finding still counts as a use, so a helper called only by dead code shows up only once that code is removed and ciach runs again. --transitive reports it in the same run:

lib/report.dart
  12:6  function  _buildReport   (private)
  20:6  function  _formatRow     (private)  (only referenced from dead _buildReport (lib/report.dart:12))
  31:6  function  _pad           (private)  (only referenced from dead _formatRow (lib/report.dart:20))

A report-only finding stays in the code, so what it references stays used. A class found dead this way is reported without its members.

It's off by default because one false positive also flags everything only it referenced. -f json lists every finding a declaration depends on in onlyReferencedFrom; the other formats show the first and a count.

Dead cycles

--transitive also reports declarations that only reference each other:

lib/report.dart
  40:6  function  _ping  (private)  (only referenced from dead _pong (lib/report.dart:42))
  42:6  function  _pong  (private)  (only referenced from dead _ping (lib/report.dart:40))

GitHub Actions

- run: dart pub get
- run: dart run ciach -f github --set-exit-if-changed

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-exported, or --no-fail-public to still surface those findings while gating the job on unused private declarations only:

- run: dart run ciach -f github --set-exit-if-changed --no-fail-public

Library packages

--no-exported skips what another package can reach: libraries under lib/ outside lib/src/, what they export (show/hide respected), and the members of any type named outside a function body in an exported signature, field or supertype, even through other types, or in the body of one whose signature is dynamic.

Removing declarations

--remove deletes every reported declaration — doc comment and annotations included — after showing what it is about to remove and asking:

Found 4 unused declarations in 2 files (scanned 6 files, 44 declarations, 0.5s).
Remove 4 unused declarations? [y/N] y
Removed 4 unused declarations from 2 files.

--force skips the prompt (and is a usage error on its own); with no terminal to confirm on and no --force, nothing is removed. Run dart format afterward: removal is conservative about what it deletes — an ambiguous int a = 1, b = 2; is left alone unless every declarator is unused — but not about spacing.

Removal acts on whatever the finder reports, so it inherits the same false-positive risk, which --overrides and --operators widen considerably. Doc-only findings are never included. Review the diff, as you would after any automated refactor.

A file left with only library/import/part of lines is deleted too, and so are the imports of it elsewhere, so nothing points at a file that is gone. One that still exports or owns a part stays.

A dead member's overrides are dead too — a call through any subclass would have referenced the member — so --remove deletes them with it, wherever they live. They are not reported separately: the finder skips @override declarations.

Findings whose removal wouldn't compile are report-only: marked unsafe to auto-remove — remove manually and skipped, along with anything coupled to them.

Report-only finding Why
A sealed member matched only by type patterns (--unused-union-members) its case arms would need rewriting
Every value of a still-referenced enum enum E {} doesn't compile
The sole constructor of a live class with final fields, or whose superclass needs constructor arguments the implicit default constructor can't replace it
A primary constructor or its declaring parameters only part of the class header
A member whose override is a declaring parameter, or is in a file the run didn't scan that override can't be deleted, and would be left overriding nothing
A gen-l10n message (--unused-translations) the message is defined in the ARB file, and gen-l10n would generate it again

What it skips by default

Each of these is a known source of false positives; the flag opts back in at that cost.

Skipped Why Flag
main the entry point is never unused —
testExecutable in a flutter_test_config.dart called by the flutter test bootstrap —
Entry points from the project config they are called by a framework or a tool --no-project-config
@override members — never reported, but removed with a dead member often reached polymorphically or by a framework (build, initState, ==, …), which a name-based search misses, so none of them are findings. One that overrides a dead member is dead too, so --remove takes both --overrides
Operator overloads the server doesn't resolve a + b back to the declaration, so a used operator is flagged every time --operators
call methods implicit-call syntax (obj(…)) is unresolvable the same way —
@pragma('vm:entry-point') reachable from native code or reflection —
@JSExport, and the public members of a @JSExport class they can be called from JavaScript —
test_… methods of a @reflectiveTest class they are run through dart:mirrors —
Generated files by filename convention, a generated-code banner, the project config, and --generated-suffix / --generated-glob. Still opened during analysis, so a declaration used only from a .g.dart isn't misreported --generated
toJson() jsonEncode(obj) calls it by dynamic dispatch, leaving no source-level reference --report-tojson
Type parameters always "used" within their scope —
dartdoc [Xxx] links not a code reference; reported as doc-only instead of hidden —

Private constructors are not skipped: an unused ClassName._ is dead code like any other. A sole zero-parameter ClassName._() — the classic prevent-instantiation marker — is reported with a hint suggesting abstract final class instead. See example/ for a runnable demonstration of each case.

Entry points

Some declarations are only ever called from code a tool generates: flutter test runs testExecutable from the nearest flutter_test_config.dart, flutter_tools calls MyPlugin.registerWith() on the plugin class named in pubspec.yaml. Nothing in the package references them, so they would read as dead. ciach knows about main and testExecutable. It also reads these entry points from the project's own config files:

Source Entry point
build.yaml builders the functions listed under builder_factories or builder_factory
pubspec.yaml plugin registerWith on each platform's dartPluginClass and on the web pluginClass
dart_frog onRequest, middleware, init, run
serverpod the classes that directly extend Endpoint, and their public methods
analysis_server_plugin plugin in lib/main.dart
custom_lint_builder createPlugin in lib/<package>.dart

List any other entry points under entry-points: in ciach.yaml:

entry-points:
  - name: MyHost.callback         # a member: `Type.member`, no type parameters
    glob: 'lib/my_host.dart'      # one glob or a list; omit for any file
  - name: bootstrap               # a top-level declaration
    glob: 'lib/src/isolate.dart'

A matching declaration is neither reported nor removed, whatever its signature. A member rule also keeps its type, while the type's other members are still checked. -v names each skipped entry point. entry-points: can't be set on the command line.

Generated files from the project config

ciach also treats these files as generated:

  • The files build_runner writes into the source tree. ciach works out which builders build_runner applies to the package, and reads the outputs they declare.
  • The files flutter gen-l10n writes, as l10n.yaml configures them. With --unused-translations, the template file is scanned instead, and its unused messages are reported.

Every package under the scanned path is read from its own files, so scanning the root of a pub workspace or monorepo covers all of its members. build.yaml is read together with every build.<name>.yaml, because build_runner --config can use any of them.

Monorepos

In a monorepo where sibling packages depend on this one by path:, their calls are invisible, so a declaration used only across that boundary reads as dead. --analysis-root widens where references are counted, and nothing else:

ciach pkgs/core --analysis-root .    # scan pkgs/core, count uses from the whole repo
  • Candidates, reported paths, --include/--exclude, ciach.yaml discovery and --remove stay on the scanned package.
  • The path must contain that package; narrowing is a usage error.
  • The whole repo gets analyzed, so the run is slower. --no-public is unaffected: private declarations stay library-scoped.

A pub workspace needs no setting — the analyzer roots its context there already. A published package's consumers stay invisible either way; treat public findings there as advisory.

Limitations

This is a static, reference-based heuristic, so review its output rather than deleting blindly:

  • A library package's public API is legitimately unused from inside the package. Prefer --no-exported there. In a monorepo, --analysis-root recovers uses that live in a sibling package; a published package's consumers stay invisible.
  • Reflection, dynamic invocation, and names referenced only from generated code you excluded are invisible to a reference search.
  • Other entry points, such as isolate entry points and native callbacks, have to be listed under entry-points or marked with @pragma('vm:entry-point').
  • A primary constructor shares its class's references, since a query at the header resolves to the class: a never-invoked one only surfaces once the class itself is dead.
  • Code referenced only from dead code, or in a dead cycle, is reported only with --transitive.
  • A package that doesn't analyze cleanly (missing pub get, errors) yields incomplete references.
  • Bugs in the analysis server's reference search can report used code as unused, or make a query fail, which leaves that declaration or file under "Not analyzed". doc/upstream_bugs.md lists the ones ciach has found and the SDK commits that fixed them.

Performance

Runtime is the analysis server's, not the tool's. It analyzes the whole package once per run — tens of seconds for a large Flutter app, and unskippable, since incomplete analysis means wrong reference counts — then answers one textDocument/references per declaration through a pool of -j (default 16), with scanned files kept open so its resolved-unit cache stays warm.

The lever is how much you ask for. --no-public is by far the cheapest mode: private declarations are library-scoped, so each query searches one library instead of the whole workspace, and it surfaces the highest-confidence dead code anyway. --include/--exclude narrow the scan while still counting references from everywhere. A dart installed ciach is a native binary, so there's no JIT warmup per run.

Library usage

The finder is also available programmatically:

import 'package:ciach/ciach.dart';

final result = await Ciach(
  FinderOptions(rootPath: 'path/to/package', includePublic: false),
).run();
for (final decl in result.unused) {
  print('${decl.filePath}:${decl.line} ${decl.qualifiedName}');
}

ciach logs through package:logging, with loggers such as ciach.finder and ciach.lsp. It never configures the root logger; listen on Logger.root.onRecord to see the records.

Development

dart pub get
dart analyze
dart test          # spins up a real analysis server against the example/ package

The implementation lives under lib/src/; the CLI entry point is bin/.

License

Licensed under the Apache License, Version 2.0. See LICENSE.


🛠️ Maintained by LeanCode

LeanCode Logo

This package is built with 💙 by LeanCode. We are top-tier experts focused on Flutter Enterprise solutions.

Why LeanCode?

  • Creators of Patrol – the next-gen testing framework for Flutter.
  • Battle-Tested – we run ciach across our own Flutter and Dart codebases to keep them free of dead code.
  • Full-Cycle Product Development – we take your product from scratch to long-term maintenance.

Need help with your Flutter project?

👉 Hire our team   •   Check our other packages

About

Dead code detector for Dart and Flutter. Finds declarations that are never referenced — classes, functions, methods, fields, constants, enum values — and can remove them for you.

Topics

Resources

Stars

22 stars

Watchers

2 watching

Forks

Releases

Contributors

Languages