diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 4f17f33..271ea7b 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -39,11 +39,12 @@ const ANALYZER_NAME = "codeanalyzer-typescript"; const MAX_IMPLEMENTED = 4; /** - * Structural internal-field strip on the WIRE CLONE: module cache trio + callable join fields. - * Structural (walks the tree shape) rather than key-name-based, for two load-bearing reasons: - * the artifact layer's `content_hash` is WIRE payload (a name-keyed replacer would eat it), and - * a `JSON.stringify` deep-copy roundtrip builds one multi-GB string at vscode-L4 scale and OOMs - * (measured). `structuredClone` + targeted deletes never materializes a string. + * Structural internal-field strip on the WIRE COPY's per-module clones: module cache trio + + * callable join fields. Structural (walks the tree shape) rather than key-name-based, for two + * load-bearing reasons: the artifact layer's `content_hash` is WIRE payload (a name-keyed replacer + * would eat it), and a `JSON.stringify` deep-copy roundtrip builds one multi-GB string at + * vscode-L4 scale and OOMs (measured). Per-module `structuredClone` + targeted deletes never + * materializes a string and never serializes more than one module at a time (#180). */ function stripInternal(root: TSApplication): void { const stripCallable = (c: Record): void => { @@ -156,9 +157,17 @@ export function finalizeAnalysis( analyzer: { name: ANALYZER_NAME, version: ANALYZER_VERSION }, application: root, }; - // The wire copy: deep, detached from the live tree, internals stripped STRUCTURALLY — - // structuredClone instead of a stringify roundtrip (the string form OOMs at vscode-L4 scale). - const application = structuredClone(envelope) as TSAnalysis; + // The wire copy: detached from the live tree, internals stripped STRUCTURALLY. The strip only + // ever touches modules (and what hangs off them), so the clone is per MODULE (#180): one + // structuredClone of the whole envelope hits Bun's serialization ceiling on a large repository + // (a TypeError at 13k files on 1.3.0; a hard abort with no exception at ~2 GiB on 1.3.14 — + // measured), while the largest single module is megabytes. Everything else on the root + // (call_graph, param_in/out, artifacts, config edges, entrypoint report) is untouched by the + // strip and shared by reference. A stringify roundtrip is out for the same reason: the string + // form OOMs at vscode-L4 scale. + const symbol_table: TSApplication["symbol_table"] = {}; + for (const [key, mod] of Object.entries(root.symbol_table)) symbol_table[key] = structuredClone(mod); + const application: TSAnalysis = { ...envelope, application: { ...root, symbol_table } }; stripInternal(application.application); return { application, internal: app, ...(pg ? { program_graphs: pg } : {}), idBySig, collisions, dangling }; } diff --git a/test/finalize-clone.test.ts b/test/finalize-clone.test.ts new file mode 100644 index 0000000..77fa120 --- /dev/null +++ b/test/finalize-clone.test.ts @@ -0,0 +1,72 @@ +/** + * #180 — finalizeAnalysis must never structuredClone the whole envelope. + * + * Bun's structuredClone has a hard serialization ceiling (measured on 1.3.14: a ~2.2 GiB object + * aborts the process with no exception; a 13k-file repository hit a TypeError at 1.3.0). The + * envelope is only cloned so the wire copy can be internal-field-stripped without touching the + * live tree that `result.internal` hands back — and the strip only ever touches modules. So the + * clone is per MODULE (the largest is megabytes, never gigabytes); everything else on the root is + * shared by reference. This pins both halves: the wire is detached from and stripped relative to + * the internal tree, and the strip never reached into the internal tree. + */ +import { describe, expect, spyOn, test } from "bun:test"; +import * as path from "node:path"; +import { analyze } from "../src/core"; +import type { AnalysisOptions } from "../src/options"; +import type { TSAnalysis } from "../src/schema"; + +const FIXTURE = path.resolve(import.meta.dir, "fixtures/sample-app"); +const opts = { input: FIXTURE, appName: "sa", analysisLevel: 2, noBuild: true, emit: "json", eager: true } as unknown as AnalysisOptions; + +describe("#180 finalize clones per module", () => { + test("structuredClone is called once per module and never on the envelope or the root", async () => { + const spy = spyOn(globalThis, "structuredClone"); + try { + const r = await analyze(opts); + const modules = Object.keys(r.internal.symbol_table).length; + const cloned = spy.mock.calls.map((c) => c[0] as Record); + expect(cloned.length).toBe(modules); + for (const arg of cloned) { + expect(arg.kind).toBe("module"); + expect(arg.schema_version).toBeUndefined(); + expect(arg.symbol_table).toBeUndefined(); + } + } finally { + spy.mockRestore(); + } + }); + + test("every wire module is a distinct object from its internal twin, stripped, with the twin intact", async () => { + const r = await analyze(opts); + const wire = (r.application as TSAnalysis).application; + const keys = Object.keys(wire.symbol_table); + expect(keys.length).toBeGreaterThan(0); + for (const k of keys) { + const w = wire.symbol_table[k] as unknown as Record; + const i = r.internal.symbol_table[k] as unknown as Record; + expect(w).not.toBe(i); + // the strip landed on the wire... + expect(w.call_sites).toBeUndefined(); + expect(w.last_modified).toBeUndefined(); + expect(w.file_size).toBeUndefined(); + expect(w.content_hash).toBe(i.content_hash); // #118: content_hash stays on the wire + // ...and only on the wire: the live tree still carries the per-run join fields + expect(Array.isArray(i.call_sites)).toBe(true); + expect(typeof i.last_modified).toBe("number"); + } + // a module with callables: the callable-level strip detached too + const mod = wire.symbol_table["src/services.ts"]!; + const fn = Object.values(mod.types)[0]!.callables ? Object.values(Object.values(mod.types)[0]!.callables!)[0] : undefined; + expect(fn).toBeDefined(); + expect((fn as unknown as Record).call_sites).toBeUndefined(); + expect((fn as unknown as Record).abs_path).toBeUndefined(); + }); + + test("the wire survives a JSON roundtrip identical to itself (no shared-reference surprise)", async () => { + const r = await analyze(opts); + const once = JSON.stringify(r.application); + // mutating the internal tree after finalize must not leak into an already-finalized wire + delete (r.internal.symbol_table["src/index.ts"] as unknown as Record).functions; + expect(JSON.stringify(r.application)).toBe(once); + }); +});