Circular Dependency Report (worked example)
A worked example: a real four-unit uses cycle, and the report drag-lint produces for it.
Details
A worked example: a real four-unit uses cycle, and the report drag-lint
produces for it.
**This page's report excerpt is generated by drag-lint, not written by an AI.** Every line quoted under "The report (excerpt)" below is literal stdout of one command run against an indexed project (a line that is only
...marks an elision). Every file name, line number and symbol name in it was read out of the index -- which was itself built by parsing the source with tree-sitter -- not inferred from prose. The full, unabridged report iscircular-demo/CYCLE-REPORT.md.tests\autotest\run_cycle_examples_fresh.ps1re-runs the command against the current engine and fails the test battery if either document drifts from it.
The sample project
circular-demo/ (repo root, alongside README.md) is a small, deliberately
minimal Delphi console project: 10 units, ~135 symbols, holding three
dependency cycles of three different shapes that all still compile. This
page is about the first and largest, a four-unit cycle in which one of the four
edges is an implementation uses rather than an interface uses:
DemoConfig -> DemoLogger interface
DemoLogger -> DemoSession interface
DemoSession -> DemoAudit interface
DemoAudit -> DemoConfig IMPLEMENTATION <-- why this compiles at all
A cycle closed entirely through interface uses would not compile -- Delphi's
compiler forbids it. This project is shaped so that exactly one edge is an
implementation uses, which is legal, and that is the edge that lets the
other three close the loop.
The project also declares seven module-level globals shared across the four units, each written by one unit and read by another:
| Global | Declared in | Written by | Read by |
|---|---|---|---|
GDemoSettings | DemoConfig | DemoConfig | DemoAudit |
GDemoMinLevel | DemoLogger | DemoConfig | DemoLogger, DemoConfig |
GDemoLogCount | DemoLogger | DemoLogger | DemoConfig |
GDemoSessionCount | DemoSession | DemoSession | DemoLogger |
GDemoActiveSession | DemoSession | DemoSession | DemoLogger |
GDemoAuditCount | DemoAudit | DemoAudit | DemoSession |
GDemoAuditTrail | DemoAudit | DemoAudit | DemoSession |
Shared mutable globals are what make a cycle like this expensive in practice: they create ordering dependencies between units that the compiler will not complain about, so the cycle is not merely a tidiness problem -- it is a real coupling the reader has to hold in their head.
The other two cycles are implementation-only and modelled on real client code:
a three-unit one (`DemoBlueprint -> DemoBlueprint.ViewModel -> DemoControlPlan
-> DemoBlueprint`) whose only cheap cut is a single routine call, and a
two-unit one (DemoUIConnect <-> DemoGagePort) whose only cheap cut is a
member access behind is / as. Nothing on either is a plain constant or
variable, so the playbook's "move the globals into a leaf" recipe does not
apply; the report says so per edge, and refuses them. What a wider recipe would
do with them is written up in circular-demo/CYCLE-REPORT.md.
Reproduce it yourself
drag-lint index --project circular-demo\CircularDemo.dproj --db circular-demo\_D-RAG\CircularDemo.sqlite
drag-lint cycles --db circular-demo\_D-RAG\CircularDemo.sqlite --edges --causes --plan --format text
Indexing this project takes well under a second (11 files, 134 symbols, 451 refs) -- small enough to run on a whim to check the claims on this page yourself.
Can a model follow it?
That is the question the report is built to answer, and it has been measured. Haiku, at its lowest effort, was given a fresh copy of this project and the report, with the prompt *"This report describes a circular unit dependency in this project. Break the cycle."* -- no drag-lint, no index, no repository conventions. Four things were checked independently: the cycle broken exactly as predicted, the project compiling, the nominated cut, no unrelated edits.
- The previous report shape: FAILED. Haiku moved the class
TDemoSessionwithout its method bodies (E2065 x5), and the report never defined "done". - The report shown here: PASSED 4/4 -- 0 compile errors, program output
byte-identical,
cyclesprinting exactly the predicted result, only the named edits. Re-run on 2026-09-28 against engine 1.19.0-alpha: PASSED 4/4 again.
The protocol and both runs are recorded in
docs\INBOX-test-cycle-playbook-followability-haiku-flash.md.
The report (excerpt)
The playbook is long -- it carries the full text of every new unit and every edit -- so this is an excerpt of the real output:
<!-- dl:excerpt-cycles --edges --causes --plan --format text -->
## Cycle 1: demologger <-> democonfig <-> demoaudit <-> demosession
Status: **interface coupling** -- units of this cycle use each other in their INTERFACE uses clauses.
### Why it cycles
- `demologger` interface uses `TDemoSession` (class) at `DemoLogger.pas:40`; declared in `DemoSession.pas:20`.
- `democonfig` interface uses `TDemoLogLevel` (enum) at `DemoConfig.pas:23`; declared in `DemoLogger.pas:15`.
- `demosession` interface uses `TDemoAuditKind` (enum) at `DemoSession.pas:35`; declared in `DemoAudit.pas:24`.
### Step 1: what moves where
1. `TDemoSession` -- **class with methods**, declared at `DemoSession.pas` lines 14-47; method bodies at `DemoSession.pas` lines 64-73, 75-80, 82-86, 88-95, 97-100.
- Recipe: **extract a base class** and keep `TDemoSession` where it is. Why: its method bodies use `GDemoSessionCount`, `GDemoAuditCount`, `GDemoAuditTrail`, which live in units of this cycle; moving the class would mean moving those bodies (lines 64-73, 75-80, 82-86, 88-95, 97-100) AND everything they use.
- New class `TDemoSessionBase` in `DemoSession.Untangle` carries exactly what the units below use: `FId`, `FUser`, `Id`, `User`.
2. `TDemoLogLevel` -- **enum**, declared at `DemoLogger.pas` lines 14-15.
- Recipe: an enum has no code of its own -- move the declaration, unchanged, into `DemoLogger.Untangle`.
### Step 2: uses clauses -- keep, move or remove the old unit
- `DemoConfig.pas` / `DemoLogger`: the interface no longer needs it; the implementation still does (`GDemoMinLevel` line 56, `DemoLog` line 58, `DemoLogLevelName` line 59, `GDemoLogCount` line 67) -> **move** it from the interface uses to the implementation uses.
### Checklist
...
12. [ ] Run `drag-lint cycles --db "circular-demo\_D-RAG\CircularDemo.sqlite"`. It must print exactly:
3 circular unit group(s) found:
...
[4 units] demologger <-> democonfig <-> demoaudit <-> demosession (implementation-only -- legal, lower impact)
### Part B (optional): remove the implementation-only cycle too
- `demoaudit -> democonfig`: `GDemoSettings (var)` -- mechanical: these can move into a new leaf unit
Applied literally (a script that only knows the playbook's own markup), Part A
compiles and cycles prints the predicted three groups, the four-unit one now
implementation-only; re-running --plan then prints Part B, which moves
TDemoSettings + GDemoSettings into DemoConfig.Untangle, compiles, and
leaves only the two cycles the playbook refuses. The program's output is
unchanged throughout.
The full, unabridged report -- regenerated from the current engine and checked
for drift -- lives in the repository at
circular-demo/CYCLE-REPORT.md.
That is the file to hand to a model.
What is worth noticing in that output
It names the exact symbol that forces each edge, not just the unit pair.
"DemoLogger depends on DemoSession" is easy to say. Saying why -- TDemoSession,
a class, referenced at DemoLogger.pas:40, declared at DemoSession.pas:20 --
is what makes the cycle actionable. That is the difference between a warning
and a fix plan, and it is only possible because the underlying index is
AST-exact rather than a text/grep match.
It reports three edges, not four. The project has a four-unit cycle, but
drag-lint lists only the three interface edges under "Why it cycles",
because those are the ones that constrain compilation -- the ones you actually
have to break. The fourth, implementation-level edge (DemoAudit -> DemoConfig)
is exactly what makes the project legal Delphi in the first place, so it is
reported separately (see --edges) rather than folded into the "why" list.
The tool is not merely detecting a loop in a graph; it is distinguishing the
edges that matter from the edge that makes the loop survivable.
The fix it proposes fits the kind of each symbol. TDemoLogLevel and
TDemoAuditKind are enums, so they move unchanged. TDemoSession is a class
whose method bodies use the cycle's globals, so moving it would drag them along;
the playbook extracts a base class with only the members DemoLogger uses and
keeps the class where it is. Either way no interface edge survives, and
behaviour does not change. --causes and --plan are what
produce this level of detail; see Circular Uses Report (cycles + fix plan) for the general feature page.
Honest caveats
- The report's own preamble says the index can miss references such as
settypes, so its checklist ends with a compile and a re-run ofcyclesagainst a predicted output, and lists what to do for each compile error -- that caveat is drag-lint's own, not added for this page. - This project is deliberately small. It exists to make the output legible, not to prove the tool scales -- for cycle reports on real, larger projects, run the same two commands against your own index.
See also
- Features -- the "Uses cycles" row in Graphs and reports links back here.
- Circular Uses Report (cycles + fix plan) -- the general
cyclesfeature page (IDE menu item, all flags). - Uses Audit -- interface->impl moves + unused (this unit) and Uses Cleanup Preview (compiler-verified, this unit) -- the compiler-verified follow-up once a cycle is diagnosed.