From f64b49cf554a4d3cad3d80d12d015b912e7d30cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 04:29:57 +0200 Subject: [PATCH] test(guards): freeze runtime import cycles and re-export-only modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime import graph (static value imports and re-exports, side-effect imports, dynamic import() with a literal; `import type` erased) is built with the TypeScript compiler's module resolution under the `@/*` mapping, over every production module. No new dependency. - Cycles: 13, counted one per back edge of a sorted depth-first walk (the number `madge --circular` reports), in 7 strongly connected components: the audit/geo/notifications/managed-profiles cluster (10 modules), the compliance facade cluster (9), command palette ↔ store ↔ shortcuts, modules ↔ cycle gate, boss-instance ↔ boss-ready, mood aggregates ↔ patterns, features ↔ signals-of-day. A new component or a component that draws in another module fails whatever the count says; a lower count or a shrunk component fails until written back. The target is zero. - Re-export-only modules (every statement an `export … from`): 16 listed; a new one fails. Proof: two planted modules importing each other failed with "lib/zz-probe/a.ts <-> lib/zz-probe/b.ts", a planted `export * from "./a"` module failed the re-export ratchet, and a baseline raised to 14 cycles failed with the write-back message until RATCHET_WRITE=1 restored it. --- src/__tests__/baselines/import-cycles.json | 50 ++++++ .../baselines/reexport-only-modules.json | 23 +++ src/__tests__/import-cycle-guard.test.ts | 151 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 src/__tests__/baselines/import-cycles.json create mode 100644 src/__tests__/baselines/reexport-only-modules.json create mode 100644 src/__tests__/import-cycle-guard.test.ts diff --git a/src/__tests__/baselines/import-cycles.json b/src/__tests__/baselines/import-cycles.json new file mode 100644 index 000000000..d92e0b7a9 --- /dev/null +++ b/src/__tests__/baselines/import-cycles.json @@ -0,0 +1,50 @@ +{ + "$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.", + "cycles": 13, + "components": [ + [ + "components/command-palette/command-palette.tsx", + "components/command-palette/palette-store.ts", + "components/layout/global-shortcuts.tsx" + ], + [ + "lib/analytics/compliance.ts", + "lib/analytics/compliance/display.ts", + "lib/analytics/compliance/ledger.ts", + "lib/analytics/compliance/occurrences.ts", + "lib/analytics/compliance/windows.ts", + "lib/medications/scheduling/band-minter.ts", + "lib/medications/scheduling/cadence.ts", + "lib/medications/scheduling/compliance.ts", + "lib/medications/scheduling/schedule-eras.ts" + ], + [ + "lib/auth/audit.ts", + "lib/geo.ts", + "lib/integrations/oauth-kit/connect-target.ts", + "lib/jobs/offhost-purge.ts", + "lib/managed-profiles/lifecycle.ts", + "lib/notifications/channel-state.ts", + "lib/notifications/delivery-identity.ts", + "lib/notifications/dispatch-localised.ts", + "lib/notifications/dispatcher.ts", + "lib/sharing/grants.ts" + ], + [ + "lib/cycle/gate.ts", + "lib/modules/gate.ts" + ], + [ + "lib/insights/features.ts", + "lib/insights/signals-of-day.ts" + ], + [ + "lib/insights/mood-aggregates.ts", + "lib/insights/mood-patterns.ts" + ], + [ + "lib/jobs/boss-instance.ts", + "lib/jobs/boss-ready.ts" + ] + ] +} diff --git a/src/__tests__/baselines/reexport-only-modules.json b/src/__tests__/baselines/reexport-only-modules.json new file mode 100644 index 000000000..2202753f0 --- /dev/null +++ b/src/__tests__/baselines/reexport-only-modules.json @@ -0,0 +1,23 @@ +{ + "$comment": "Modules under src/ whose every statement is an export … from. Maintained by src/__tests__/import-cycle-guard.test.ts.", + "count": 16, + "total": 16, + "entries": { + "components/data-list/index.ts": 1, + "components/insights/derived/index.ts": 1, + "components/insights/workout-detail/index.ts": 1, + "lib/ai/coach/tools/index.ts": 1, + "lib/analytics/compliance.ts": 1, + "lib/cycle/index.ts": 1, + "lib/doctor-report-data.ts": 1, + "lib/fhir/resources/index.ts": 1, + "lib/insights/derived/index.ts": 1, + "lib/links/index.ts": 1, + "lib/logging/index.ts": 1, + "lib/mcp/index.ts": 1, + "lib/openapi/routes/insights/index.ts": 1, + "lib/openapi/routes/medications/index.ts": 1, + "lib/record-settings/index.ts": 1, + "lib/validations/medication/index.ts": 1 + } +} diff --git a/src/__tests__/import-cycle-guard.test.ts b/src/__tests__/import-cycle-guard.test.ts new file mode 100644 index 000000000..b7fd1a6c8 --- /dev/null +++ b/src/__tests__/import-cycle-guard.test.ts @@ -0,0 +1,151 @@ +/** + * No new runtime import cycle, and no new re-export-only module. + * + * A value-import cycle means module evaluation order decides whether a + * binding is initialised when it is first read, and it ties the modules in + * it into one unit nobody can load or split alone. The graph here is the + * runtime one: static imports and re-exports that are not type-only, + * side-effect imports, and dynamic `import("…")` with a literal specifier + * (a lazy edge still loads the module into the same process). `import type` + * is erased and left out. Modules are resolved with the TypeScript compiler + * under the app's `@/*` mapping (`helpers/source-metrics.ts`). + * + * Two baselines in `baselines/import-cycles.json`: + * + * - the cycle count, as a depth-first walk in sorted order meets them (one + * per back edge, the number `madge --circular` reports); + * - the strongly connected components, member by member. + * + * A component that is new, or gained a member, fails: that is a new cycle, + * whatever the count says. A count above the baseline fails. A count below + * it, or a component that lost members, fails until the lower state is + * written back with + * `RATCHET_WRITE=1 pnpm vitest run src/__tests__/import-cycle-guard.test.ts`. + * The target is zero. + * + * A re-export-only module (every statement an `export … from`) is a facade: + * it is where most of the cycles above came from, because children of a + * split module import back through the old file. Existing ones are listed + * in `baselines/reexport-only-modules.json`; a new one fails. Import the + * module that defines the binding instead. + */ +import { describe, expect, it } from "vitest"; + +import { requireFloor } from "./helpers/source-files"; +import { + backEdgeCycles, + buildImportGraph, + productionSources, + stronglyConnected, +} from "./helpers/source-metrics"; +import { + readBaselineJson, + runRatchet, + writeBackHint, + writeBaselineJson, +} from "./helpers/ratchet"; + +const GUARD = "src/__tests__/import-cycle-guard.test.ts"; +const CYCLES_FILE = "import-cycles.json"; + +interface CycleBaseline { + $comment: string; + cycles: number; + components: string[][]; +} + +describe("runtime import cycles", () => { + const files = productionSources(); + const graph = buildImportGraph(files); + const components = stronglyConnected(graph.edges); + const cycles = backEdgeCycles(graph.edges); + + it("builds a graph of the whole tree", () => { + expect(graph.edges.size).toBeGreaterThan(3000); + expect(graph.resolved).toBeGreaterThan(15_000); + }); + + it("adds no cycle and leaves no improvement unrecorded", () => { + if (process.env.RATCHET_WRITE === "1") { + const old = readBaselineJson(CYCLES_FILE) ?? { + $comment: `Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by ${GUARD}.`, + cycles: cycles.length, + components, + }; + const kept = old.components + .map((c) => c.filter((m) => components.some((now) => now.includes(m)))) + .filter((c) => c.length > 1); + writeBaselineJson(CYCLES_FILE, { + $comment: old.$comment, + cycles: Math.min(old.cycles, cycles.length), + components: kept, + }); + } + const baseline = readBaselineJson(CYCLES_FILE); + if (!baseline) throw new Error(`${CYCLES_FILE} is missing`); + + const grown = components.filter( + (c) => !baseline.components.some((b) => c.every((m) => b.includes(m))), + ); + expect( + grown.map((c) => c.join(" <-> ")), + "A new runtime import cycle (or a known one that drew in another " + + "module). Break it: move the shared piece into a leaf module, turn " + + "the edge into `import type` if only a type crosses, or import the " + + "defining module instead of a facade.", + ).toEqual([]); + expect( + cycles.length, + `Runtime import cycles: ${cycles.map((c) => c.join(" -> ")).join("; ")}`, + ).toBeLessThanOrEqual(baseline.cycles); + + const shrunk = baseline.components.filter( + (b) => !components.some((c) => b.every((m) => c.includes(m))), + ); + expect( + shrunk.map((c) => c.join(" <-> ")), + writeBackHint(GUARD), + ).toEqual([]); + expect(cycles.length, writeBackHint(GUARD)).toBe(baseline.cycles); + }); + + it("adds no re-export-only module", () => { + requireFloor("re-export-only modules", graph.reexportOnly, 1); + const r = runRatchet( + "reexport-only-modules.json", + Object.fromEntries(graph.reexportOnly.map((rel) => [rel, 1])), + { + comment: `Modules under src/ whose every statement is an export … from. Maintained by ${GUARD}.`, + }, + ); + expect( + r.grew, + "A new re-export-only module. Import the module that defines the binding instead of adding a facade.", + ).toEqual([]); + expect(r.stale, writeBackHint(GUARD)).toEqual([]); + }); +}); + +describe("the cycle finder", () => { + const graphOf = (edges: Record) => + new Map(Object.entries(edges)); + + it("finds a two-module and a three-module cycle, and nothing in a tree", () => { + const g = graphOf({ + a: ["b"], + b: ["a", "c"], + c: ["d"], + d: ["e"], + e: ["c"], + f: ["a"], + }); + expect(stronglyConnected(g)).toEqual([ + ["a", "b"], + ["c", "d", "e"], + ]); + expect(backEdgeCycles(g)).toHaveLength(2); + expect(stronglyConnected(graphOf({ a: ["b"], b: ["c"], c: [] }))).toEqual( + [], + ); + }); +});