Circular Uses Report (cycles + fix plan)
Finds uses cycles among indexed units and, on request, proposes a followable refactoring plan to break them.
Read this when the compiler complains about circular references or a project dependency graph feels tangled. It finds uses cycles among indexed units and, on request, proposes a followable refactoring plan to break them.
How to run it
Full flag list: cycles in the CLI reference.
Flags
| Flag | Argument | Meaning | Default |
|---|---|---|---|
--db | <file.sqlite> | ||
--edges | |||
--causes | |||
--plan | |||
--format |
Where to click
- RAD Studio main menu: drag-lint > Uses & Dependencies > Circular Uses Report (cycles + fix plan)...
Needs: index
Details
Finds uses cycles among indexed units and, on request, proposes a followable
refactoring plan to break them. Reach for it when the compiler is fighting
circular references, or a project's dependency graph feels tangled.
Running it from the CLI
drag-lint cycles --db <file.sqlite> [--edges] [--causes] [--plan [--project <X.dproj>]] [--format json|text]
--edges and --causes add detail to the cycle report; --plan produces the
refactoring playbook; --format selects json or text output. --fix makes the edits
for you -- see Fix it mechanically below.
What --plan gives you
A mechanical procedure, written so that a reader with no project context can follow it literally:
- What moves where -- each symbol that ties the cycle together, with its kind
and the recipe for that kind. Enums, records, constants and variables move
unchanged into a new leaf unit
<Declaring>.Untangle(or into an existing one that is still a leaf) (exact line ranges, doc comment included). A class whose method bodies use the cycle is not moved: a base class with just the members its consumers use is extracted instead, and the playbook says why. Every consuming unit is listed with its section and first-use line. - Uses decisions -- per unit: keep the old partner, move it from the interface to the implementation uses, or remove it.
- The new units' full text, and the rule for their uses clause.
- Every edit (units,
.dpr,.dproj) as current text + new text, bottom-up. - DONE and a checklist ending in the re-index command and the exact
output
cyclesmust print afterwards, plus what to do for each compile error.
An interface-coupled cycle becomes a legal implementation-only one (Part A). The
optional Part B names the edge that can be cut next; re-running --plan after
Part A prints it as the same kind of steps. When no edge can be cut mechanically
(it carries a routine, or a class whose bodies use the cycle), the playbook says
so and gives the standard remedy instead of guessing.
Worked example, and whether a model can follow it
Circular Dependency Report walks through a real
four-unit cycle. The full, unabridged playbook for it is
circular-demo/CYCLE-REPORT.md in the repository -- that is the file to hand to
a model. Its followability is measured: Haiku at its lowest effort, given only
that report and a fresh copy of the project ("Break the cycle."), produced a
tree that compiled with 0 errors, printed the predicted cycles output, and
touched only the named files (4 of 4 criteria; re-run on engine 1.19.0-alpha).
The examples are regenerated from the engine and checked for drift by the test
battery, so they always show what the current --plan prints.
Fix it mechanically: cycles --fix
--plan tells you what to edit; --fix makes the edits for you, one circular group per run,
and proves them with the compiler.
drag-lint cycles --fix --project <X.dproj> --db <file.sqlite> [--cycle <n> | --unit <stem>]
[--part a|b|all] [--apply] [--no-backup] [--no-reindex] [--no-compile]
[--platform win32|win64] [--library-db <lib.sqlite>] [--rules-dir <dir>]
[--format json|text]
Which group. --cycle <n> (the number drag-lint cycles prints) or --unit <stem> (the
group that unit belongs to). Without either, the run walks the groups in order, prints each
refused one with its first refusal, and plans the first mechanical one; the text output then
ends with one line naming every group it never reached:
Not planned: cycle(s) 2, 3 -- this run plans one group; add --cycle <n> to plan another
Dry run (the default). Plans the group, checks the plan against the files on disk, stages
the result in a shadow tree (C:\TEMP\draglint_cyclefix_<pid>\, or --shadow-out <dir>) and
prints the verdict. No source file of the project is written -- but the dry run **builds the
project by default** (the baseline gate), and that build writes what any build writes (dcu,
exe). --no-compile skips it. Exit 0 mechanical, 1 refused, 2 error (bad flags, no such group).
Builds. Every build is msbuild /p:Config=Debug of the project for --platform (default
win64; the IDE passes its active platform). The Release configuration is never built.
Scratch. The default shadow tree and the gate's C:\TEMP\draglint_cyclefix_<pid>_build\
(build logs, reindex.log, all-iteration-<n>.json) are deleted at exit -- the text dry run
says so on its line: Shadow tree: <dir> (deleted at exit; --shadow-out keeps it). A run whose
output cites a file in there (a build log after an msbuild failure or timeout, reindex.log,
all-iteration-<n>.json, ...) keeps them, so the cited file still exists, and names each kept
dir on stderr: drag-lint: note: cycles --fix exited 1; its scratch is kept for the files it cites: <dir>.
A message cites a scratch file only when it exists. An ordinary refusal cites none and keeps
nothing. A --shadow-out dir is never deleted. In the JSON, shadow_kept says whether shadow
still exists after the run.
Apply. --apply runs the same plan, then:
- the baseline build must pass (else
dirty-baseline, nothing written); - every file it touches is backed up as
<file>.BCK<n>(the lowest free n) plus arecovery.txt, unless--no-backup; - the edits are written and the project is built in place; one bounded repair (R4: a cast
through the plan's ancestor, a missing leaf unit in
uses, a re-emitted pointer type) is tried on a compile error, otherwise every file is rolled back. The rollback restores the sources byte for byte but not the DCUs the failed build wrote: **rebuild the project after a rollback** before you trust its output; - the project is re-indexed (unless
--no-reindex) andcyclesre-run: the output must equal the plan's prediction.
Exit codes with --apply: 0 written and verified (or written with --no-reindex);
1 refused, rolled back after a compile error or build timeout, or written but not
verified; 2 error -- a write or build failed part-way (rolled back, project not changed,
or the rollback failure is named), or bad flags. --apply --no-compile is refused with
exit 2: an apply always builds.
Parts. An interface-coupled group is Part A (it becomes a legal implementation-only
group); an implementation-only one is Part B (one edge is cut). --part a|b asks for one;
--part all applies Part A and then Part B in one run, re-planning on the re-indexed project
after each iteration -- so it needs --apply and refuses --no-reindex.
Recipes. Part A moves globals into a <Declaring>.Untangle leaf, as --plan describes.
Part B cuts one edge: R1 routine-call inversion (an edge that is exactly one routine whose
signature types all live outside the cycle is called through a procedural variable declared in
a leaf and assigned in the declaring unit's initialization section); R2 member hand-over
(an edge that is only is / as against one class plus reads of its members: the class
implements an I<Class>Surface interface declared in its leaf, and the consumer uses
Supports / as).
JSON. --format json prints one cycle-fix/1 document: mode, cycles[] (per group:
n, units, part, verdict, recipe, edge, refusals[], moves, uses, new_units,
edits, compile, baseline, repairs, written, backups, reindex, predicted,
actual, verified), shadow, shadow_kept, baseline, ok, exit_code. An error that
ends the run before any group is planned (bad flags, a --library-db that does not open) is
the document {schema, error, ok: false, exit_code: 2}. Separately,
cycles --format json --causes [--project <X.dproj>] adds a fix object to every group of
the report -- available, part, dry_run and apply (the exact commands; --project names
the project in them, else <your.dproj>), edges[], reason, and for a Part A group whose
Part B would cut an edge afterwards, followup {from,to,recipe}.
Refusal kinds
A refused group prints Verdict: refused and one - <kind>: <reason> line per refusal.
The engine never guesses; every kind it can give:
- Run and gate:
dirty-baseline(the project does not build before any edit),no-compiler(rsvars.bat missing; use--no-compilefor a dry run),compile-error(the project does not build after the edit; rolled back),build-timeout(the build did not finish; rolled back),build-error(the build itself failed to run; rolled back, exit 2),write-failed(a write failed part-way; rolled back, exit 2),stale-file(a file changed after it was indexed: reindex and retry),anchor-mismatch(a line the plan edits is not what the planner saw),non-ascii-text(an inserted line holds a non-ASCII character and the file is ANSI),outside-roots(the plan would edit a file outside the project's own roots). - Plan:
base-class(a class whose bodies use the cycle would need a base class extracted -- a manual step),blocked-symbol(a declaration the move needs cannot move: a routine, a class whose method bodies use the cycle, an unrecognised declaration block),directive(a compiler directive inside an edited region),unresolved-edge(the index could not resolve what one unit of the group uses from another),edge-still-used(after an R1 / R2 cut the consumer still uses the declaring unit for another name, so the uses entry -- and the edge -- would stay),manual-edit(the default for any edit the planner cannot make mechanically). - R1 (routine-call inversion):
not-a-routine,signature-in-cycle,overloaded-routine,signature-unknown,qualified-ref,routine-not-called,init-order,unsupported-directive,duplicate-identifier(the leaf already declares the pointer type or variable),unit-leaves-program(the declaring unit is used only by the consumer and the program's.dprdoes not list it: after the cut the linker drops it, its initialization never assigns the pointer and the call is a nil call at run time -- add the unit to the.dpruses; aDCCReferencealone does not link it). - R2 (member hand-over):
member-write,construction,class-method,class-ref,type-decl,type-cast,other-ref,multiple-classes,member-unsupported,duplicate-identifier,no-iinterface,ref-counted,receiver-unknown,cast-unseen,exception-semantics,ancestry-unknown,with-member,unresolved-qualified-type,type-unresolved,leaf-uses-cycle.
What stays manual
A refused group is yours: --plan still prints the playbook and the standard remedy for it,
and tools\follow-cycle-plan.ps1 applies a playbook's edits literally when you have decided
to follow it by hand. Moving a class whose method bodies use the cycle, and any cut the
refusals above name, stay human decisions.
Per-file finding
lint <file> --db <db> --format json reports circular-uses on every unit of a cycle, with
"fix_hint": "cycles --fix --project \"<X.dproj>\" --db \"<db>\" --unit <stem>"; lint-all
keeps one finding per cycle and no hint.
Reaching it in the IDE
- drag-lint > Uses & Dependencies > Circular Uses Report (cycles + fix plan)...
- drag-lint > Uses & Dependencies > Fix Circular Uses Mechanically (dry run)... -- a dry
run only (the engine picks the group; no
--applyfrom a menu click). Save first: the engine plans and compiles from the files on disk. - View > Tool Windows > drag-lint > Uses & Deps tab, section *This project -- Circular uses (cycles --fix)*: one row per group, Dry run / Apply buttons. Apply asks first (Yes / No, default No), like the Structure tree.
- drag-lint Structure window, Diagnostics tree, right-click a
circular-usesfinding: Fix this cycle (dry run)... and Apply cycle fix... (a dry run, your confirmation, then--apply).
Every IDE-started run passes the IDE's active platform as --platform. The IDE gives each
cycle-fix job a 120 minute limit: above the longest run the engine's own limits allow one
--apply (three builds of up to 30 minutes -- baseline, after, one repair rebuild -- plus a
re-index and a cycles run of up to 10 minutes each), so the engine always times out and
rolls back first; a kill by the IDE would bypass the rollback. A --part all iteration gets
the same 120 minutes.
What it needs
Required. You must have indexed the project before running this -- cycle
detection reads unit-to-unit uses edges from the index, not from disk.
Example
Illustrative only, against a project database that has already been indexed:
drag-lint cycles --db C:\Projects\MyApp\_D-RAG\MyApp.sqlite --plan --format text
This would report any uses cycles involving, say, the unit that declares
TfrmMain, plus a suggested order of moves to break them.