From 56ddba9cc80e2c45673280a65d0aa63b40a568bd Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 22 Sep 2026 13:42:28 -0700 Subject: [PATCH 1/2] feat(lint): detect import cycles with import-x/no-cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- eslint.config.js | 99 ++++++ package.json | 1 + packages/cli/test/import-cycle-lint.test.ts | 84 +++++ pnpm-lock.yaml | 331 ++++++++++++++++++++ 4 files changed, 515 insertions(+) create mode 100644 packages/cli/test/import-cycle-lint.test.ts diff --git a/eslint.config.js b/eslint.config.js index 492e2375..dfc0c733 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,7 @@ import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; import unicorn from "eslint-plugin-unicorn"; +import importX, { createNodeResolver } from "eslint-plugin-import-x"; import prettierConfig from "eslint-config-prettier"; export default tseslint.config( @@ -100,6 +101,104 @@ export default tseslint.config( ], }, }, + // Import cycle detection. + // + // A circular import leaves one of the modules in the cycle holding + // `undefined` for whatever it imported, and which module loses depends on + // which one the graph is entered at first. That makes it a bug that the + // built CLI and most tests never see: it only fires when something enters + // the graph at the unlucky module. We hit exactly that — a migration + // registry that read as `undefined` and failed with + // `TypeError: migrate is not a function`, visible in two tests by luck. + // This rule is the check that would have caught it at author time. + { + files: ["**/*.ts", "**/*.tsx"], + plugins: { "import-x": importX }, + settings: { + // Which extensions the plugin will parse when it follows an edge out of + // the file being linted. This is NOT cosmetic and it is not the same + // knob as the resolver below: the default is `['.js', '.mjs', '.cjs']`, + // and a file whose extension is not on this list is dropped by + // `ExportMap.get` before its imports are ever read. Without `.ts` here + // the rule resolves our files correctly, walks into them, finds nothing, + // and reports no cycles — on a tree that provably contains one. A lint + // run that is green because the rule is inert looks exactly like a lint + // run that is green because the code is clean, which is why the + // reintroduced-cycle check in this PR's description exists. + "import-x/extensions": [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", + ], + // The plugin's own resolver, configured for TypeScript. It is backed by + // `unrs-resolver`, a direct dependency of eslint-plugin-import-x, so + // this needs no separate resolver package. It does need the extension + // list spelled out: the built-in default is + // `['.mjs', '.cjs', '.js', '.json', '.node']`, which resolves no `.ts` + // at all, and our sources import extensionlessly under + // `moduleResolution: "bundler"`. `.js` stays in the list for the handful + // of specifiers that carry an explicit extension. + "import-x/resolver-next": [ + createNodeResolver({ + extensions: [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".mjs", + ".cjs", + ".json", + ], + }), + ], + }, + rules: { + // On `import type` edges: the rule ignores them, in both directions — + // it returns early on an `ImportDeclaration` whose `importKind` is + // `type` (or whose every specifier is), and it filters + // `isOnlyImportingTypes` edges out of the graph walk. That is the + // behavior we want and it is not configurable, so there is no option + // below for it. It is also correct for us: a type-only edge is erased + // before the module ever runs, so it cannot produce the `undefined` + // binding this rule exists to catch, and flagging it would push people + // toward restructuring real code to satisfy an import that has no + // runtime existence. `verbatimModuleSyntax: true` in `tsconfig.base.json` + // is what makes this safe to lean on: it forces a type-only import to be + // written as `import type`, so the erasure is explicit in the syntax the + // rule reads rather than something the compiler infers later. + "import-x/no-cycle": [ + "error", + { + // `maxDepth` is deliberately not set. Omitting it means unlimited + // (the rule reads it as `Number.POSITIVE_INFINITY` unless a number + // is given), and unlimited is what we want: the cycle we shipped was + // not a two-module A->B->A, and capping the depth would trade away + // exactly the cycles that are hard to spot by reading the code, + // which are the only ones worth spending a lint rule on. It is left + // out rather than passed as `Infinity` because the rule's schema + // accepts only an integer or the string "∞" there. + // Do not traverse into node_modules. A cycle that runs through a + // published dependency is not ours to break — we cannot edit it, so + // a report on it is noise we would have to suppress — and walking + // the dependency graph is where this rule's cost actually goes. + ignoreExternal: true, + // Keep the default (false). This would suppress a cycle whenever any + // edge in it is a dynamic `import()`, on the theory that the deferred + // evaluation breaks the loop. It does not reliably: a dynamic import + // awaited during module init is as circular as a static one, and the + // failure mode is the same `undefined`. We have no cycle that needs + // the escape hatch, so we do not open it. + allowUnsafeDynamicCyclicDependency: false, + }, + ], + }, + }, // File naming conventions - enforce kebab-case for all TS/TSX files { files: ["**/*.ts", "**/*.tsx"], diff --git a/package.json b/package.json index 346a8bad..3a4a13bb 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/node": "^25.3.0", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-x": "^4.17.1", "eslint-plugin-unicorn": "^62.0.0", "husky": "^9.1.7", "lint-staged": "^15.4.3", diff --git a/packages/cli/test/import-cycle-lint.test.ts b/packages/cli/test/import-cycle-lint.test.ts new file mode 100644 index 00000000..03930128 --- /dev/null +++ b/packages/cli/test/import-cycle-lint.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { ESLint } from "eslint"; +import { afterAll, describe, expect, it } from "vitest"; + +/** + * A guard that `import-x/no-cycle` is actually ON and actually reaching + * `packages/cli/src`. + * + * This does NOT re-implement cycle detection — that would be exactly the + * "re-derive what the tool already knows" mistake the style guide forbids. + * Every assertion below asks ESLint, running the repository's real + * `eslint.config.js`, and checks what it answers. + * + * It exists because the rule's failure mode is silence. While this rule was + * being added, the config resolved correctly, matched the right files, and + * reported `import-x/no-cycle` as an enabled error — and still found nothing on + * a tree that provably contained a cycle, because `import-x/extensions` + * defaults to `['.js', '.mjs', '.cjs']` and so every `.ts` file was dropped + * before its imports were read. A green `pnpm lint` looked identical whether + * the rule was working or inert. Nothing but an actual cycle distinguishes + * those two states, which is why the second test below writes one. + */ + +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", ".."); +const CLI_SOURCE = resolve(REPO_ROOT, "packages/cli/src"); + +const temporaryDirectories: string[] = []; + +afterAll(async () => { + await Promise.all( + temporaryDirectories.map(async (directory) => + rm(directory, { recursive: true, force: true }) + ) + ); +}); + +function createESLint(): ESLint { + return new ESLint({ cwd: REPO_ROOT }); +} + +describe("import-x/no-cycle", () => { + it("is enabled as an error for files in packages/cli/src", async () => { + const config = (await createESLint().calculateConfigForFile( + join(CLI_SOURCE, "index.ts") + )) as { rules?: Record }; + + // "error" is 2 once ESLint normalizes it. A config block that stopped + // matching `packages/cli/src` would leave this undefined. + expect(config.rules?.["import-x/no-cycle"]).toBeDefined(); + expect((config.rules?.["import-x/no-cycle"] as unknown[])[0]).toBe(2); + }); + + it("reports a value cycle written into packages/cli/src", async () => { + // Written inside `packages/cli/src` on purpose: the point of the check is + // that the rule reaches THIS tree, so linting a fixture parked somewhere + // the config does not match would prove nothing. The directory name is + // prefixed so it is obviously not product code if cleanup is ever missed. + const directory = await mkdtemp(join(CLI_SOURCE, "__cycle-guard-")); + temporaryDirectories.push(directory); + + // A -> B -> A over VALUE imports. Kept to real value edges because + // type-only edges are erased before the module runs and the rule ignores + // them by design; see the note in eslint.config.js. + await writeFile( + join(directory, "a.ts"), + 'import { b } from "./b";\n\nexport const a = (): string => b();\n' + ); + await writeFile( + join(directory, "b.ts"), + 'import { a } from "./a";\n\nexport const b = (): string => a();\n' + ); + + const results = await createESLint().lintFiles([join(directory, "*.ts")]); + const cycleMessages = results.flatMap((result) => + result.messages.filter( + (message) => message.ruleId === "import-x/no-cycle" + ) + ); + + expect(cycleMessages.length).toBeGreaterThan(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 144bbf56..25af24de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import-x: + specifier: ^4.17.1 + version: 4.17.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.3(jiti@2.6.1)) @@ -310,6 +313,15 @@ packages: '@clack/prompts@1.2.0': resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -689,6 +701,13 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -885,6 +904,9 @@ packages: cpu: [x64] os: [win32] + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -965,6 +987,116 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1184,6 +1316,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + comment-parser@1.4.9: + resolution: {integrity: sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==} + engines: {node: '>= 12.0.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1289,6 +1425,28 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-plugin-import-x@4.17.1: + resolution: {integrity: sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/utils': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + eslint-import-resolver-node: '*' + peerDependenciesMeta: + '@typescript-eslint/utils': + optional: true + eslint-import-resolver-node: + optional: true + eslint-plugin-unicorn@62.0.0: resolution: {integrity: sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==} engines: {node: ^20.10.0 || >=21.0.0} @@ -1852,6 +2010,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -2169,6 +2332,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2293,6 +2460,9 @@ packages: typescript: optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -2363,6 +2533,9 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2715,6 +2888,22 @@ snapshots: fast-wrap-ansi: 0.1.6 sisteransi: 1.0.5 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -3023,6 +3212,13 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3170,6 +3366,11 @@ snapshots: '@taskless/vale-win32-x64@3.22.0-20260921180930': optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3280,6 +3481,76 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -3474,6 +3745,8 @@ snapshots: commander@14.0.3: {} + comment-parser@1.4.9: {} + concat-map@0.0.1: {} consola@3.4.2: {} @@ -3580,6 +3853,30 @@ snapshots: dependencies: eslint: 9.39.3(jiti@2.6.1) + eslint-import-context@0.1.9(unrs-resolver@1.12.2): + dependencies: + get-tsconfig: 4.13.6 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.12.2 + + eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@typescript-eslint/types': 8.56.1 + comment-parser: 1.4.9 + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.3(jiti@2.6.1) + eslint-import-context: 0.1.9(unrs-resolver@1.12.2) + is-glob: 4.0.3 + minimatch: 10.2.4 + semver: 7.7.4 + stable-hash-x: 0.2.0 + unrs-resolver: 1.12.2 + optionalDependencies: + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + eslint-plugin-unicorn@62.0.0(eslint@9.39.3(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4114,6 +4411,8 @@ snapshots: nanoid@3.3.11: {} + napi-postinstall@0.3.4: {} + natural-compare@1.4.0: {} node-releases@2.0.27: {} @@ -4422,6 +4721,8 @@ snapshots: sprintf-js@1.1.3: {} + stable-hash-x@0.2.0: {} + stackback@0.0.2: {} std-env@3.10.0: {} @@ -4526,6 +4827,9 @@ snapshots: optionalDependencies: typescript: 5.9.3 + tslib@2.8.1: + optional: true + tsx@4.21.0: dependencies: esbuild: 0.27.3 @@ -4585,6 +4889,33 @@ snapshots: universalify@0.1.2: {} + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 From e8eed89927de056e8e8b6a561c0a9ebed86fcb78 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 22 Sep 2026 14:06:20 -0700 Subject: [PATCH 2/2] chore(lint): gitignore the import-cycle test's in-source scratch fixture 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. --- .gitignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.gitignore b/.gitignore index 32e445c2..4cb66a50 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,16 @@ __pycache__/ # Agent and manual git worktrees (full second checkouts; see worktrees-pnpm skill) worktrees/ + +# Scratch fixture written by `packages/cli/test/import-cycle-lint.test.ts`. That +# test writes a real a.ts <-> b.ts cycle INSIDE `packages/cli/src` and asserts +# `import-x/no-cycle` reports it. The location is forced, not a convenience: the +# rule only sees files the flat config matches, and the type-aware block needs +# the file inside a tsconfig (`packages/cli/tsconfig.json` includes `src`) — a +# fixture in the OS temp dir is refused as outside the config base path, and one +# elsewhere in the repo fails to parse and reports zero cycles, which is the +# vacuous green the test exists to rule out. Cleanup runs in `afterAll`, so a +# killed run (Ctrl+C, OOM, CI cancellation) can strand a live cycle in the +# source tree. This keeps that debris out of commits; delete the directory, not +# this line. +/packages/cli/src/__cycle-guard-*/