dallow 0.2.1
dallow: ^0.2.1 copied to clipboard
Codebase intelligence for Dart and Flutter. Finds unreachable dead code, dependency drift, circular imports, duplicate blocks, and complexity hot spots.
dallow #
Codebase intelligence for Dart and Flutter. A fast, zero-config CLI that
finds the structural rot dart analyze leaves behind: dead code that no
entrypoint can reach, dependency drift in pubspec.yaml, circular imports, and
duplicated code blocks, plus complexity hot spots and an aggregate project
health score.
dallow is a Dart-native take on the ideas behind
fallow (codebase intelligence for
TypeScript/JavaScript). Where fallow approximates reachability syntactically,
dallow builds on the Dart analyzer's resolved element model — so the
"what is connected to what?" graph is real, not heuristic.
What it checks #
| Check | What it finds |
|---|---|
| dead-code | Top-level symbols (functions, classes, enums, mixins, extensions, typedefs, variables) and class members (methods, getters/setters, fields) unreachable from any entrypoint. Entrypoints are your bin/, test/, example/ files and the public API surface under lib/. Only private symbols and lib/src/ internals are reported — a legitimately-exported public symbol, or a public member of a public-API class, is never flagged as dead. Members reached through inheritance (an @override, an interface implementation, or a member overridden by a subtype) are kept, since they may be dispatched dynamically; a field initialised through a this.x constructor parameter counts as used. |
| deps | Dependencies declared in pubspec.yaml but never imported, packages imported but not declared, and dev-dependencies imported from lib/. Federated plugin implementations (<base>_web, <base>_android, <base>_platform_interface, …) are not flagged unused when their base plugin is declared. |
| circular | Import cycles between files, found as strongly-connected components of the import graph. |
| duplication | Structurally duplicated Dart token blocks. Identifiers and literals are normalised so copied code with renamed variables still matches; keywords and punctuation stay exact to avoid noisy matches. |
| complexity | Cyclomatic complexity for functions, methods, constructors, and closures. Complexity starts at 1 per body and adds one for each if, for, while, do, case, catch, &&, ` |
Install #
dart pub global activate --source git https://github.com/LeeMatthewHiggins/dallow
Or clone and run locally:
git clone https://github.com/LeeMatthewHiggins/dallow
cd dallow && dart pub get
dart run bin/dallow.dart analyze /path/to/your/package
Usage #
dallow analyze [path] # run every check (default: current directory)
dallow dead-code [path] # only reachability-based dead code
dallow deps [path] # only dependency hygiene
dallow circular [path] # only circular imports
dallow duplication [path] # only duplicated token blocks
dallow complexity [path] # only complexity metrics and health score
Options #
| Flag | Description | Default |
|---|---|---|
-f, --format |
console, json, markdown, or sarif (see SARIF output) |
console |
-r, --recursive |
Scan every member package of a monorepo in one run (see Monorepos) | off |
--fail-on |
Lowest severity that exits non-zero: error, warning, info, never |
error |
--max-cycle-size |
Skip import/export cycles with more than this many files — ignore a known barrel mega-cycle while still catching small new ones | unlimited |
--min-block-size |
Minimum duplicate token block size for duplication and analyze |
20 |
--max-complexity |
Maximum cyclomatic complexity before a function is reported by complexity and analyze |
10 |
--changed-since <ref> |
Only report findings in files changed since a git ref (the merge-base of <ref>...HEAD) — see PR gate |
off |
--baseline <file> |
Suppress findings recorded in a baseline file, so the gate fails only on findings introduced after it was written | off |
--write-baseline <file> |
Write the current findings to <file> as a baseline and exit 0, instead of gating |
off |
--report-unused-ignores |
Emit an info-level unused-ignore finding for every dallow-ignore comment that suppressed nothing — see Inline suppression |
off |
These options apply to every subcommand (analyze, dead-code, deps,
circular, duplication, complexity).
Monorepos #
By default dallow analyses a single package. Pointed at a monorepo root it would
see only the root and silently miss the package-scoped dead-code and
dependency findings of every member. Pass -r, --recursive to discover and
analyse all member packages in one run:
dallow analyze -r . # scan the whole workspace
dallow analyze -r --fail-on warning # CI gate across every package
dallow deps -r . # any sub-command works recursively too
Member packages are discovered in priority order:
- a melos workspace —
melos.yamlwithpackages:globs (and an optionalignore:list); - a pub workspace — a root
pubspec.yamlwith aworkspace:member list; - otherwise, every nested
pubspec.yamlfound by walking the tree.
melos packages:/ignore: globs support * (one path segment), ** (any
depth), ? (a single character), and {a,b} brace alternation — so a
mixed config like ["packages/**", "{examples,e2e}/**"] selects all three
trees. Any other glob syntax (character classes […], unbalanced braces) is
rejected with exit 64 rather than silently matching nothing, so a
misunderstood glob can never produce a falsely-clean report.
Build, tooling and symlink directories (.dart_tool, build, .symlinks, …)
are never treated as packages. Findings are attributed to their package: the
console and markdown reports prefix each finding with its package path, and JSON
findings carry a package field. The exit code honours --fail-on evaluated
across all packages.
Exit codes #
| Code | Meaning |
|---|---|
0 |
Nothing at or above the --fail-on threshold was found |
1 |
Findings at or above the --fail-on threshold |
64 |
Usage error (bad directory, bad --max-cycle-size, an unknown --changed-since ref, a non-git work tree, or an unreadable baseline) |
69 |
No Dart SDK could be located to back the analyzer |
This makes dallow a CI gate:
dallow analyze . --fail-on warning
PR gate #
On an established codebase you rarely want to fail CI on the entire backlog of findings — only on what a change actually introduces. Two composable filters turn dallow into a pull-request gate. Both apply between analysis and the exit-code decision, and to every subcommand.
--changed-since <ref>: only changed files #
dallow analyze . --changed-since origin/main --fail-on warning
This keeps only findings whose file changed relative to <ref>, computed as
the merge-base diff git diff --name-only <ref>...HEAD — i.e. what your branch
added since it diverged from <ref>, not every difference between the two
tips. <ref> can be a branch, tag, or SHA (origin/main, HEAD~1, …).
Findings that aren't tied to a single source line — whole-package signals with
no file, and dependency-hygiene findings against pubspec.yaml — are always
kept, so a newly broken dependency still fails the gate. A finding that sits
unchanged in a file you did touch is also kept (filtering is at file
granularity; line-level diffing is a possible future refinement). If the
package isn't inside a git work tree, or <ref> doesn't resolve, dallow prints
a clear message to stderr and exits 64 rather than crashing.
--baseline <file>: ignore a known backlog #
Adopt the gate on a dirty codebase in two steps. First, capture today's
findings as a baseline (this writes the file and exits 0):
dallow analyze . --write-baseline .dallow-baseline.json
Then have CI run against it — only findings not in the baseline gate:
dallow analyze . --baseline .dallow-baseline.json --fail-on warning
Each baselined finding is matched by a fingerprint derived from its kind,
file, symbol and normalised message — deliberately not its line number — so
a finding keeps its identity when unrelated edits shift it up or down its file.
Commit the baseline; regenerate it with --write-baseline after you burn the
backlog down. The two flags compose: --changed-since origin/main --baseline .dallow-baseline.json gates only on new findings, in changed files, that
aren't already baselined.
Inline suppression (dallow-ignore) #
When a single finding is a deliberate, reviewed exception, silence it in place
with a dallow-ignore comment instead of carrying it in the baseline. The
comment goes on the finding's line or on the line directly above it:
// dallow-ignore
void _legacyEntryPoint() {} // kept for the old plugin ABI
void _debugDump() {} // dallow-ignore: dead-code
- Unscoped —
// dallow-ignoresuppresses every finding on its target line. - Scoped —
// dallow-ignore: <check-kind>suppresses only that kind; pass a comma-separated list (dallow-ignore: dead-code, duplicate-code) for several. The kind is the stable id shown in[brackets]in console output (dead-code,duplicate-code,unused-dependency, …). Anything after the kinds is treated as a free-text reason.
Suppressed findings are removed before the gate, so they neither print nor
affect the exit code — exactly like a baselined finding. Suppression composes
with --changed-since and --baseline; it is applied first, against the raw
findings. Comments are read from the analyzer's own token stream, not by
scanning text, so a dallow-ignore inside a string literal is not a directive.
Whole-package findings (no source line — a missing dependency, say) can't be annotated on a line and so can't be suppressed this way; baseline them instead.
A directive that matches nothing — typically one left behind after the finding
it silenced was fixed — is silently ignored by default. Pass
--report-unused-ignores to surface each as an info-level unused-ignore
finding so stale directives can be cleaned up (they gate only under
--fail-on info).
SARIF output #
--format sarif emits a SARIF 2.1.0 log
— the standard interchange format for static-analysis results — so dallow's
findings can be uploaded to a code-scanning platform (GitHub Advanced Security,
Azure DevOps, …) and rendered as inline annotations on a pull request.
dallow analyze . --format sarif > dallow.sarif
The log contains a single run. Its tool.driver advertises one rule per
check kind (dead-code, unused-dependency, circular-import, …), each with
a shortDescription and a defaultConfiguration.level mapped from the check's
severity (error → error, warning → warning, info → note). Each
finding becomes one result, with ruleId set to its check kind, the same
mapped level, the finding message, and — when the finding is tied to a source
line — a physicalLocation carrying a relative POSIX artifactLocation.uri and
a region.startLine. Whole-package signals (no file) and pubspec.yaml-level
dependency findings (a file but no single source line) are emitted as valid
results without a physicalLocation rather than being dropped: GitHub
code-scanning needs region.startLine to place an annotation, so a location
with a file but no region would be pinned to line 1 — these findings name the
file they concern in the result message instead of inventing that line.
In a GitHub Actions workflow, upload the file with
github/codeql-action/upload-sarif:
- run: dallow analyze . --format sarif > dallow.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: dallow.sarif
How it works #
- The package is resolved with the
analyzer'sAnalysisContextCollection, giving a fully type-resolved element model. - Every top-level declaration and class member becomes a node; resolved references between them become edges (synthetic accessors are unwrapped to their backing field).
- Entrypoints seed a reachability walk over that graph — anything unreached and not part of the public API is a dead-code candidate. Members reachable through inheritance (overrides, interface members) are kept as roots so dynamically-dispatched code is never false-flagged.
pubspec.yamlis cross-referenced against thepackage:imports actually present in the source tree, and the file-level import graph is scanned for cycles.- Dart source files are tokenised with the analyzer scanner, normalised, and compared with a suffix array to find repeated token blocks.
- Function-like bodies already available in the resolved AST are walked for
cyclomatic complexity. The health score is:
clamp(0, 100, 100 - complexityPenalty - findingsPenalty), wherecomplexityPenalty = min(60, round(sum(max(0, complexity - maxComplexity)) / functionCount * 3))andfindingsPenalty = min(40, round((2 * errors + warnings + 0.25 * info) / max(functionCount, 1) * 5))across the other enabled checks. Complexity and health findings themselves are excluded from the findings-density penalty. - The same token stream is read for
dallow-ignorecomments, which suppress matching findings before the gate runs.
Roadmap #
- Architecture boundary rules (layered / feature-first presets).
- Barrel-cycle collapsing: name the barrel that induces a mega-cycle instead of
listing every member (today
--max-cycle-sizefilters it out).
License #
MIT — see LICENSE.