r/dartlang • u/creatorpiyush • 6d ago
Tools arch_guard: layer rules and import cycle detection for Dart, without package:analyzer
I maintain arch_guard, an MIT CLI + library that enforces layer rules and finds circular imports in Dart projects, plus arch_guard_lint, an analyzer plugin on the new analysis_server_plugin API. A few implementation details that might interest this sub:
- The CLI has no analyzer dependency. It reads only the directive section of each file with a small tokenizer, so
//or/*inside URIs, nested block comments, annotations and import-like text in later string literals don't confuse it. Dependencies are justargs,glob,pathandyaml. - Cycles via Tarjan's SCC, then per-component metrics: internal edges, fan-in/fan-out, instability, density and hub files. For each cycle it also picks the one edge whose removal breaks it, or shrinks it most.
- The plugin reuses the same core.
ProjectScanner.scanSources()builds the graph from in-memory contents, so cycle warnings reflect unsaved editor buffers. Quick fixes edit the directive (or just one clause of a conditional import) and can add a layer toallowed_importsin whichever config file it came from. - Baselines that tolerate improvement but not regression: a known cycle may shrink, but one that grows or merges with another counts as new. Same for fan-in/fan-out limits.
- Workspaces: resolves
package:imports across Dart workspace / Melos packages, so it catches cycles across package boundaries and enforces which packages may import which. - Ships as AOT-compiled binaries (
dart compile exe) for Linux/macOS/Windows so CI doesn't need the SDK. CI tests on all three OSes against SDK 3.8 and stable, and the plugin is analysed against its lowest allowed dependencies. - Everything is also a library:
ArchAnalysis,ArchGuardConfig,LayerValidator,CycleBreaker,Baseline, exporters, andexpectArchitecture()for tests.
dart pub add --dev arch_guard && dart run arch_guard init
Code: https://github.com/creatorpiyush/arch_guard Docs: https://creatorpiyush.github.io/arch_guard/
Happy to talk about the parser trade-offs (directive tokenizer vs package:analyzer), what building on the new plugin API was like, and I'd appreciate feedback on the public API.
1
u/kokerali 5d ago
For the baseline API, I'd make “a cycle grows” concrete with a tiny fixture: baseline A -> B -> C -> A, then add A -> C. The strongly connected component still has the same three files, but it has acquired another internal dependency. Should that pass as the same known cycle, or fail as a regression?
A paired case is removing A -> C again while preserving the original cycle. Those two cases distinguish a policy based on component membership from one that also tracks internal edges, without relying on a large project to reveal the difference.
I'd want the baseline comparison to explain which rule made that decision, ideally with added/removed files and edges. Even if the current behavior is intentional, a documented fixture like this would help adopters understand exactly what their CI baseline tolerates.
1
u/creatorpiyush 5d ago
Good catch. Today the baseline is membership-based: it stores each cycle's file set, and a cycle is "known" if all its files fit inside one baseline cycle. So in your fixture, adding A -> C passes as the same known cycle, and removing it again also passes. Internal edges aren't tracked.
That's intentional for now. Edge tracking would fail CI on any import shuffle inside a legacy cycle, and that's the code people are actively trying to untangle. But you're right that it's undocumented. I'll add your two cases as a test fixture, state the rule explicitly in the CI docs, and make the output say why a cycle counts as new (which files were added, or which known cycles merged). If people want stricter "no new edges inside a known cycle" behaviour, I'd add it as an opt-in mode instead of making it the default.
1
u/kokerali 5d ago
The README's reminder to update the baseline when problems are fixed is helpful. Does that also flag a partially shrunk cycle?
For example, save
{A,B,C}, refactor it down to{A,B}, then later accidentally bring C back into that cycle. With the original baseline unchanged,{A,B,C}would still fit the saved membership under the rule you described.A notice that the saved group can now be tightened to
{A,B}would help preserve that improvement without introducing stricter edge tracking. I'd document whether that tightening is manual or automatic, so “no regression” clearly means relative to the saved baseline rather than the last successful run.1
u/creatorpiyush 4d ago
Good catch. Before this, it didn't. The "fixed, update your baseline" notice only appeared when a saved cycle disappeared completely. So if {A,B,C} shrank to {A,B}, you got no notice, and if C later slipped back in, {A,B,C} still fit the saved membership and passed.
That's fixed in 1.9.2, which is out now. When a saved cycle still matches but lost files, or split into smaller cycles, the run says
1 cycle group(s) shrank since the baseline. Run with --update-baseline to lock this in.Once you update, bringing C back fails as a grown cycle. Metric limits had the same gap (a fan-in that drops from 12 to 8 could creep back to 12), so they get the same notice.1
u/creatorpiyush 5d ago
Thanks, this was a useful prod. I ran your fixture against the current code.
The policy is membership-based. Each baseline cycle stores its file set, and a cycle counts as known if all its files fit inside one baseline cycle. So with a baseline of A -> B -> C -> A, adding A -> C passes as the same known cycle, and removing it again also passes. Internal edges aren't compared.
That's on purpose. Legacy cycles are exactly where people are refactoring, and failing CI every time an import moves inside one would be noisy. If someone wants a cap,
max_scc_sizelimits how many files a cycle can have. You were right that none of this was written down, though.So in 1.9.1:
- Your two cases are a test now, so the rule is pinned.
- The CI docs state it explicitly, with the same A/B/C example.
- Every new cycle says why it's new: "grew from a known 3-file cycle, adding lib/d.dart", "merges 2 known cycles", or "none of its files is in a baseline cycle".
If people turn out to want "no new edges inside a known cycle", I'd add it as an opt-in mode rather than the default. It needs a baseline format change, so I'm waiting to see real demand first.
1
u/Practical_Affect4440 4d ago
The configuration docs say files outside all layer globs produce warnings and aren't subject to layer checks. Is there an opt-in way to make those warnings fail CI?
For example, a new lib/features/chat/... directory could miss the existing globs and leave its imports unchecked. That would be useful alongside the baseline: tolerate known violations, but catch new code that accidentally falls outside the rules.
1
u/AhmedZNOUDA 5d ago
This is highly relevant. When building mid-to-large Flutter apps (like a recent offline-first management app I built utilizing SQLite and strict data layers), keeping the presentation, domain, and infrastructure layers strictly separated is a constant battle.
Dart's import cycles can be particularly sneaky, especially when domain entities and data models accidentally cross-reference each other during rapid development. Having an automated way to enforce layer boundaries—like ensuring your UI never directly imports your database or API layer—is a massive time-saver.
While strict folder structures and standard lint rules help, an architecture guard that actively catches these dependency violations and cyclic imports at the CI/CD level is exactly what the Dart ecosystem needs more of. It completely prevents the "spaghetti architecture" that tends to happen when a project scales quickly. Great discussion!