feat(lint): detect import cycles with import-x/no-cycle - #391
Conversation
|
@claude /review |
|
Claude finished @thecodedrift's task in 4m 10s —— View job Review complete
Review mode: incremental — read 0 prior review thread(s) before reviewing. AssessmentThis is a tightly scoped, well-documented change: one lint rule ( I checked:
One finding, posted inline on
Nothing else rose to the level of a correctness, security, or test-adequacy concern. |
8e1a50b to
4faa41d
Compare
Read and verified. The review raised exactly one finding — the in-source scratch directory in The gitignore backstop for Also confirmed the two — AI Coding Agent |
85bc7a7 to
056d452
Compare
A circular import leaves one module in the cycle holding `undefined`, and which module loses depends on where the graph is entered. That makes it invisible to the built CLI and to most tests — the migrate/reconcile-marker cycle surfaced in exactly two tests, by luck, as `TypeError: migrate is not a function`. Nothing in the repo looked for it. Adds eslint-plugin-import-x and turns on `import-x/no-cycle` over the TypeScript sources. Only that rule; no other rules from the plugin. Two settings are load-bearing and easy to get wrong: - `import-x/extensions` must list the TS extensions. Its default is `['.js', '.mjs', '.cjs']`, and a file outside that list is dropped by `ExportMap.get` before its imports are read. Without it the rule resolves our files, walks into them, finds nothing, and passes on a tree that provably contains a cycle. - `import-x/resolver-next` needs the same list for a different reason: the built-in resolver defaults to `['.mjs', '.cjs', '.js', '.json', '.node']` and our sources import extensionlessly. Type-only edges are ignored, which is the rule's own non-configurable behavior and the behavior we want: an `import type` edge is erased before the module runs, so it cannot produce the `undefined` binding this rule exists to catch. `verbatimModuleSyntax` is what makes that safe to lean on. The only cycle on main is `filesystem/migrate` <-> `rules/reconcile-marker`, which PR #388 already breaks by splitting out `filesystem/manifest.ts`. Exempted at both ends with a comment rather than fixed here, to avoid conflicting with #388 in the same file; the disables become unused-disable warnings once it lands. The guard test asks ESLint whether the rule is on and whether it reports a real cycle written into packages/cli/src. It does not re-implement cycle detection. It exists because the failure mode is silence: a green lint run looks identical whether the rule works or is inert.
import-cycle-lint.test.ts writes a real a.ts <-> b.ts cycle inside packages/cli/src, because that is the only place import-x/no-cycle actually reaches. Cleanup runs in afterAll, so a killed run can strand the fixture in the tracked source tree. Ignore the prefix as a backstop.
056d452 to
e8eed89
Compare
Why
A circular import leaves one module in the cycle holding
undefinedfor what it imported, and which module loses depends on where the module graph is entered. That is what makes this class of bug so quiet: the built CLI and nearly every test enter the graph elsewhere and are completely unaffected.We shipped one.
filesystem/migrate.tsandrules/reconcile-marker.tsimported each other's values, which left the migration registry holdingundefinedand failing asTypeError: migrate is not a function— visible in exactly two tests, by luck. The repo had no cycle detection of any kind.What
Adds
eslint-plugin-import-xand enables onlyimport-x/no-cycleover the TypeScript sources. No other rules from the plugin.Two settings that are load-bearing
Both default to JavaScript-only extension lists, and getting either wrong makes the rule silently inert rather than noisy:
import-x/extensions['.js', '.mjs', '.cjs']ExportMap.getbefore its imports are read. Without.ts, the rule resolves our files, walks into them, finds nothing, and passes.import-x/resolver-next['.mjs', '.cjs', '.js', '.json', '.node']moduleResolution: "bundler".The first one bit during development: the config resolved correctly, matched the right files, and reported
import-x/no-cycleas an enabled error — and still found nothing on a tree that provably contained a cycle.Options chosen
maxDepth— deliberately unset, which the rule reads as unlimited. The cycle we shipped was not a trivialA -> B -> A; capping depth would trade away exactly the cycles that are hardest to spot by reading the code.ignoreExternal: true— a cycle running through a published dependency is not ours to break, so a report on it is noise we would only suppress. Walkingnode_modulesis also where this rule's cost goes.allowUnsafeDynamicCyclicDependency: false(default) — it would suppress a cycle whenever any edge is a dynamicimport(). That is not reliably safe: a dynamic import awaited during module init is as circular as a static one, with the sameundefinedfailure.import typeedges are ignored. This is the rule's own non-configurable behavior, and it is the behavior we want: a type-only edge is erased before the module ever runs, so it cannot produce theundefinedbinding this rule exists to catch.verbatimModuleSyntax: trueis what makes it safe to rely on — it forces type-only imports to be written asimport type, so the erasure is explicit in the syntax the rule reads.The repo is cycle-free, with no carve-outs
The rule ships clean and unexempted. There are no
eslint-disablecomments forimport-x/no-cycleanywhere in the tree:Earlier revisions of this branch carried a narrow two-ended exemption for the one real cycle on
main—migrate.tstookpathExistsfromreconcile-marker, andreconcile-markertookreadManifest/writeManifestfrommigrate.ts. #388 has since landed and fixed it properly, moving the manifest half ofmigrate.tsintofilesystem/manifest.tsso both sides depend on a leaf and neither depends on the other. This branch has been rebased onto that, and both disables plus their explanatory notes are deleted.migrate.tsandrules/reconcile-marker.tsare now byte-identical tomain, so this PR touches no product source at all — only the lint config, a devDependency, a test, and a.gitignoreentry.That is the reason to trust the clean run: ESLint reports
Unused eslint-disable directivefor a directive that no longer suppresses anything, so a leftover exemption could not have passed silently.Proof the rule actually fires
A green lint run proves nothing — a config block matching no files also lints green, and an inert rule looks identical to clean code. So a real value cycle was written into
packages/cli/srcafter the rebase onto currentmain, andpnpm lintrun:The fixture was then removed,
git status --porcelainconfirmed empty, andpnpm lintre-run clean. This was re-proven against the post-#388 tree rather than relied on from an earlier run, because ten commits of new source landed under a rule whose entire job is walking the import graph.An
import typevariant of the same modules lints clean, confirming the documented type-only decision is real behavior and not just an assertion in a comment.Test
packages/cli/test/import-cycle-lint.test.ts— a guard that the rule is on and reachingpackages/cli/src, not a reimplementation of cycle detection. Every assertion asks ESLint, running the repo's real config, and checks the answer; the style guide's "don't re-derive what the tool already knows" is the reason it is shaped this way.It was validated by breaking the config: removing
import-x/extensionsmakes it fail withexpected 0 to be greater than 0, i.e. it catches precisely the silent-inertness bug hit during development. A pure config-shape assertion would not have.The test writes its scratch fixture inside
packages/cli/src, which is forced rather than convenient: a fixture in the OS temp dir is refused by ESLint as outside the config base path, and one elsewhere in the repo fails to parse (was not found by the project service) and reports zero cycles. Both of those look like a passing test while proving nothing. Since cleanup runs inafterAll, a killed run could strand a live cycle in the source tree, so/packages/cli/src/__cycle-guard-*/is gitignored as a backstop, with a comment explaining why the entry exists.Checks
pnpm typecheck,pnpm lint(includingpnpm cli check), andpnpm --filter @taskless/cli test(102 files, 1698 tests) all pass against currentmain.Changeset
None. This is repo tooling: a devDependency, a lint config, a test, and a
.gitignoreentry. No product source is modified. Nothing a consumer of@taskless/clican observe across a release boundary, which is the testCLAUDE.mdsets for whether a release note is owed.