diff --git a/scripts/import-cycles.ts b/scripts/import-cycles.ts index ee0cd144d5..4ceb3c5973 100644 --- a/scripts/import-cycles.ts +++ b/scripts/import-cycles.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; /** @@ -74,6 +74,8 @@ export function staticImports(source: string, tsx = false): string[] { } export function scanGraph(root: string, sourceRoot = SOURCE_ROOT): ImportGraph { + // Bun resolves imports to physical paths; graph keys must use the same root (e.g. /var on macOS). + root = realpathSync(root); const graph: ImportGraph = new Map(); for (const abs of listSources(join(root, sourceRoot)).sort()) { const targets = new Set(); diff --git a/structure/overview.md b/structure/overview.md index f5cab64283..4fccbfab9b 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -138,7 +138,8 @@ still cover the rule, which is a judgement only review makes. request-path entrypoints sit on no cycle at all. Type-only imports and dynamic `import()` form no edge, so deferring an edge behind `import()` is a sanctioned way to break a loop (issue #6386). Bun parses the declarations and resolves local file and directory imports, preserving compact - syntax, erased type-only specifiers, and the runtime's TypeScript index-file precedence. + syntax, erased type-only specifiers, and the runtime's TypeScript index-file precedence. The + scanner canonicalizes the root so symlinked roots and resolved imports share graph identities. Enforced by `tests/ci-workflows/import-cycle-ratchet.test.ts`. - **INV-RESTORE-01** — `ocx restore` restores native Codex from the pristine catalog with retired bare/account-qualified native rows omitted from the output; the original backup stays diff --git a/tests/ci-workflows/import-cycle-ratchet.test.ts b/tests/ci-workflows/import-cycle-ratchet.test.ts index 64aeb402dc..9f8378991c 100644 --- a/tests/ci-workflows/import-cycle-ratchet.test.ts +++ b/tests/ci-workflows/import-cycle-ratchet.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { @@ -69,6 +69,25 @@ describe("import-cycle ratchet: scanner", () => { expect(scanGraph(root).get("src/a.ts")).toEqual([`src/nested/${resolved}`]); }); + test.each(["ts", "tsx", "mts"])("symlinked roots preserve index.%s cycle identities", extension => { + const base = mkdtempSync(join(tmpdir(), "ocx-import-symlink-")); + const physical = join(base, "physical"); + const root = join(base, "alias"); + try { + mkdirSync(join(physical, "src", "nested"), { recursive: true }); + writeFileSync(join(physical, "src", "a.ts"), 'import { b } from "./nested"; export const a = b;'); + writeFileSync(join(physical, "src", "nested", `index.${extension}`), + 'import { a } from "../a"; export const b = a;' + (extension === "tsx" ? " export const view = ;" : "")); + symlinkSync(physical, root, process.platform === "win32" ? "junction" : "dir"); + const graph = scanGraph(root); + expect(graph.get("src/a.ts")).toEqual([`src/nested/index.${extension}`]); + expect(graph.get(`src/nested/index.${extension}`)).toEqual(["src/a.ts"]); + expect(cyclicModules(graph)).toEqual(["src/a.ts", `src/nested/index.${extension}`]); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + test("only load-time value imports form edges", () => { const source = [ 'import { a } from "./a";',