diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 9acbb432d..96fd382de 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1468,14 +1468,7 @@ function collectClassShapeDeferring(lowerer: Lowerer, decl: ts.ClassDeclaration) (m) => m.kind === ts.SyntaxKind.Decorator, ); if (symbol && (hasDeclTimeCode(decl) || decl.members.some(hasDeclTimeCode))) { - const diags = lowerer.deferredDiags.get(symbol); - if (diags) { - lowerer.deferredDiags.delete(symbol); - if (!lowerer.alreadyFlushed.has(symbol)) { - lowerer.flushedSymbols.add(symbol); - for (const d of diags) lowerer.pushDiag(d); - } - } + lowerer.reportDeferred(symbol); } // A poisoned BASE this class EXTENDS must report EAGERLY for the same // reason: the derived statement evaluates its heritage when module @@ -1493,14 +1486,7 @@ function collectClassShapeDeferring(lowerer: Lowerer, decl: ts.ClassDeclaration) ?.types.map((t) => t.expression) .filter(ts.isIdentifier)[0]; const baseSym = baseIdent ? lowerer.resolveValueSymbol(baseIdent) : null; - const baseDiags = baseSym ? lowerer.deferredDiags.get(baseSym) : undefined; - if (baseSym && baseDiags) { - lowerer.deferredDiags.delete(baseSym); - if (!lowerer.alreadyFlushed.has(baseSym)) { - lowerer.flushedSymbols.add(baseSym); - for (const d of baseDiags) lowerer.pushDiag(d); - } - } + if (baseSym) lowerer.reportDeferred(baseSym); } } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 003717001..ac825b2eb 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1287,7 +1287,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // that does not exist. The blocked-binding cascade names the use; // without this the validator's registration check ICEs on the live // reference (`@this {T}` inference — signature 07). - if (lowerer.typeNamesUnregisteredClass(g.type)) { + const unregistered = lowerer.unregisteredClassOf(g.type, new Set()); + if (unregistered !== null) { + // The class's own rejection is the root cause the cascade names. + lowerer.flushDeferredClass(unregistered); lowerer.pushDiag(blockedBindingUseDiag(expr.text, loc)); throw new PoisonError(); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index f216f121c..20ba4b9a7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2653,6 +2653,13 @@ export class Lowerer { * coverage remainder as alreadyFlushed). */ readonly flushedSymbols = new Set(); readonly alreadyFlushed: ReadonlySet; + /** Deferred diagnostics a flush handed to a capture buffer (diagSink) + * instead of the build, keyed by the copy in that buffer. A buffer can + * be discarded (a declined probe, a collection that succeeded) or + * re-deferred under another declaration, so the declaration stays + * deferred until one of these copies reaches `diags` (pushDiag commits + * it); until then a later flush reports the diagnostics again. */ + readonly pendingFlushes = new Map(); /** The build's target platform ("win32" | "darwin" | "linux" | ...): * selects the platform-keyed builtin surfaces (builtinModuleFnsOf / * builtinModuleConstOf in surfaces.ts). */ @@ -5666,9 +5673,9 @@ export class Lowerer { // Deferred collection diagnostics nothing flushed — declarations no // reference ever made relevant. They belong to the unreached group // (collection order keeps them deterministic). - for (const [symbol, diags] of this.deferredDiags) { + for (const [symbol, diags] of [...this.deferredDiags]) { if (this.alreadyFlushed.has(symbol)) continue; - for (const d of diags) this.pushDiag(d); + for (const d of [...diags]) this.pushDiag(d); } return { module: null, @@ -5813,42 +5820,49 @@ export class Lowerer { * registered ClassInfo — the shape of a JS class whose collection fenced. * Used by run()'s global pruning; shapes/unions recurse with a seen-set * (interned ids can nest). */ - typeNamesUnregisteredClass(t: IrType, seen: Set = new Set()): boolean { + typeNamesUnregisteredClass(t: IrType): boolean { + return this.unregisteredClassOf(t, new Set()) !== null; + } + + /** The first class name typeNamesUnregisteredClass finds in `t`. */ + unregisteredClassOf(t: IrType, seen: Set): string | null { + const first = (types: readonly IrType[]): string | null => { + for (const type of types) { + const name = this.unregisteredClassOf(type, seen); + if (name !== null) return name; + } + return null; + }; switch (t.kind) { case "object": - return !this.classes.has(t.className); + return this.classes.has(t.className) ? null : t.className; case "array": case "set": - return this.typeNamesUnregisteredClass(t.elem, seen); + return first([t.elem]); case "map": - return ( - this.typeNamesUnregisteredClass(t.key, seen) || - this.typeNamesUnregisteredClass(t.value, seen) - ); + return first([t.key, t.value]); case "promise": - return this.typeNamesUnregisteredClass(t.inner, seen); + return first([t.inner]); case "func": - return ( - t.params.some((p) => this.typeNamesUnregisteredClass(p, seen)) || - this.typeNamesUnregisteredClass(t.ret, seen) - ); + return first([...t.params, t.ret]); case "record": { - if (seen.has(t.shapeId)) return false; + if (seen.has(t.shapeId)) return null; seen.add(t.shapeId); const shape = this.shapes.get(t.shapeId); - if (!shape) return false; - if (shape.indexValue && this.typeNamesUnregisteredClass(shape.indexValue, seen)) - return true; - return shape.fields.some((f) => this.typeNamesUnregisteredClass(f.type, seen)); + if (!shape) return null; + return first([ + ...(shape.indexValue ? [shape.indexValue] : []), + ...shape.fields.map((f) => f.type), + ]); } case "union": { - if (seen.has(t.unionId)) return false; + if (seen.has(t.unionId)) return null; seen.add(t.unionId); const def = this.unions.get(t.unionId); - return !!def && def.arms.some((a) => this.typeNamesUnregisteredClass(a, seen)); + return def ? first(def.arms) : null; } default: - return false; + return null; } } @@ -6476,13 +6490,16 @@ export class Lowerer { const d = this.instantiationContext ? { ...diag, message: `${diag.message} (${this.instantiationContext})` } : diag; + const pending = this.pendingFlushes.get(diag); // Deferred collection: the wrapper decides whether these ever report // (a reference flushes them; unreferenced declarations stay silent in // builds and report under coverage's unreached group). if (this.diagSink) { + if (pending && d !== diag) this.pendingFlushes.set(d, pending); this.diagSink.push(d); return; } + if (pending) this.commitFlush(pending.symbol, pending.source); // One site, one report: some declarations map a type twice (module // globals pre-register before their initializers lower) — an exact // duplicate (code + span + message) adds noise, not information. @@ -9081,12 +9098,44 @@ export class Lowerer { * diagnostics the eager collector historically produced. */ flushDeferred(symbol: ts.Symbol): void { if (this.collecting) return; + this.reportDeferred(symbol); + } + + /** flushDeferred without the collection-pass guard: for declarations + * whose diagnostics must report eagerly (class statements with + * declaration-time code, extended bases — collectClassShapeDeferring). */ + reportDeferred(symbol: ts.Symbol): void { const diags = this.deferredDiags.get(symbol); if (!diags) return; - this.deferredDiags.delete(symbol); - if (this.alreadyFlushed.has(symbol)) return; // the emit pass reported these + if (this.alreadyFlushed.has(symbol)) { + this.deferredDiags.delete(symbol); // the emit pass reported these + return; + } + if (this.diagSink === null) { + this.deferredDiags.delete(symbol); + this.flushedSymbols.add(symbol); + for (const d of diags) this.pushDiag(d); + return; + } + // A capture buffer is active (a probe, or another declaration's + // deferred collection): the copies ride that buffer, and the + // declaration stays deferred until one of them reaches the build. + for (const d of diags) { + this.pendingFlushes.set(d, { symbol, source: d }); + this.pushDiag(d); + } + } + + /** A flushed copy of a deferred diagnostic reached `diags`: that + * diagnostic is reported, so it leaves the declaration's deferred list + * (the declaration counts as flushed for the coverage remainder). */ + private commitFlush(symbol: ts.Symbol, source: ScrDiagnostic): void { this.flushedSymbols.add(symbol); - for (const d of diags) this.pushDiag(d); + const list = this.deferredDiags.get(symbol); + if (!list) return; + const index = list.indexOf(source); + if (index >= 0) list.splice(index, 1); + if (list.length === 0) this.deferredDiags.delete(symbol); } flushDeferredClass(className: string): void { diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 043132be4..5120e6934 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10692,18 +10692,6 @@ ], "diags": [] }, - "/tests/corpus/native-worker-publish-classes.ts": { - "order": [ - "/tests/corpus/native-worker-publish-classes.ts" - ], - "diags": [] - }, - "/tests/corpus/native-worker-publish-refusals.ts": { - "order": [ - "/tests/corpus/native-worker-publish-refusals.ts" - ], - "diags": [] - }, "/tests/corpus/nested-map-cycles.ts": { "order": [ "/tests/corpus/nested-map-cycles.ts" @@ -14620,12 +14608,6 @@ ], "diags": [] }, - "/tests/corpus/native-worker-published-graphs.ts": { - "order": [ - "/tests/corpus/native-worker-published-graphs.ts" - ], - "diags": [] - }, "/tests/corpus/native-worker-references.ts": { "order": [ "/tests/corpus/native-worker-references.ts" @@ -14662,12 +14644,6 @@ ], "diags": [] }, - "/tests/corpus/native-worker-typed-arrays.ts": { - "order": [ - "/tests/corpus/native-worker-typed-arrays.ts" - ], - "diags": [] - }, "/tests/corpus/caught-class-identity.ts": { "order": [ "/tests/corpus/caught-class-identity.ts" @@ -15617,6 +15593,72 @@ ], "diags": [] }, + "/tests/corpus/stack-depth-async-bodies.ts": { + "order": [ + "/tests/corpus/stack-depth-async-bodies.ts" + ], + "diags": [] + }, + "/tests/corpus/4591-weak-keys-union-records.ts": { + "order": [ + "/tests/corpus/4591-weak-keys-union-records.ts" + ], + "diags": [] + }, + "/tests/corpus/4592-cycle-const-from-import/main.ts": { + "order": [ + "/tests/corpus/4592-cycle-const-from-import/report.ts", + "/tests/corpus/4592-cycle-const-from-import/stock.ts", + "/tests/corpus/4592-cycle-const-from-import/markup.ts", + "/tests/corpus/4592-cycle-const-from-import/pricing.ts", + "/tests/corpus/4592-cycle-const-from-import/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4593-weak-keys-empty-objects.ts": { + "order": [ + "/tests/corpus/4593-weak-keys-empty-objects.ts" + ], + "diags": [] + }, + "/tests/diagnostics/deferred-class-binding/main.ts": { + "order": [ + "/tests/diagnostics/deferred-class-binding/crate.ts", + "/tests/diagnostics/deferred-class-binding/main.ts" + ], + "diags": [] + }, + "/tests/diagnostics/deferred-class-receiver/main.ts": { + "order": [ + "/tests/diagnostics/deferred-class-receiver/shelf.ts", + "/tests/diagnostics/deferred-class-receiver/main.ts" + ], + "diags": [] + }, + "/tests/corpus/native-worker-publish-classes.ts": { + "order": [ + "/tests/corpus/native-worker-publish-classes.ts" + ], + "diags": [] + }, + "/tests/corpus/native-worker-publish-refusals.ts": { + "order": [ + "/tests/corpus/native-worker-publish-refusals.ts" + ], + "diags": [] + }, + "/tests/corpus/native-worker-published-graphs.ts": { + "order": [ + "/tests/corpus/native-worker-published-graphs.ts" + ], + "diags": [] + }, + "/tests/corpus/native-worker-typed-arrays.ts": { + "order": [ + "/tests/corpus/native-worker-typed-arrays.ts" + ], + "diags": [] + }, "/tests/corpus/4541-nullable-wrap-borrows.ts": { "order": [ "/tests/corpus/4541-nullable-wrap-borrows.ts" @@ -15653,34 +15695,6 @@ ], "diags": [] }, - "/tests/corpus/stack-depth-async-bodies.ts": { - "order": [ - "/tests/corpus/stack-depth-async-bodies.ts" - ], - "diags": [] - }, - "/tests/corpus/4591-weak-keys-union-records.ts": { - "order": [ - "/tests/corpus/4591-weak-keys-union-records.ts" - ], - "diags": [] - }, - "/tests/corpus/4592-cycle-const-from-import/main.ts": { - "order": [ - "/tests/corpus/4592-cycle-const-from-import/report.ts", - "/tests/corpus/4592-cycle-const-from-import/stock.ts", - "/tests/corpus/4592-cycle-const-from-import/markup.ts", - "/tests/corpus/4592-cycle-const-from-import/pricing.ts", - "/tests/corpus/4592-cycle-const-from-import/main.ts" - ], - "diags": [] - }, - "/tests/corpus/4593-weak-keys-empty-objects.ts": { - "order": [ - "/tests/corpus/4593-weak-keys-empty-objects.ts" - ], - "diags": [] - }, "/tests/corpus/4568-for-of-hole-elements.ts": { "order": [ "/tests/corpus/4568-for-of-hole-elements.ts" diff --git a/tests/diagnostics/deferred-class-binding/crate.ts b/tests/diagnostics/deferred-class-binding/crate.ts new file mode 100644 index 000000000..7a24ac02e --- /dev/null +++ b/tests/diagnostics/deferred-class-binding/crate.ts @@ -0,0 +1,8 @@ +export class Crate { + readonly label: string; + contents: unknown; + constructor(label: string) { + this.label = label; + this.contents = undefined; + } +} diff --git a/tests/diagnostics/deferred-class-binding/main.ts b/tests/diagnostics/deferred-class-binding/main.ts new file mode 100644 index 000000000..88efcfb52 --- /dev/null +++ b/tests/diagnostics/deferred-class-binding/main.ts @@ -0,0 +1,5 @@ +import { Crate } from "./crate.ts"; + +const pack = (label: string) => new Crate(label); + +console.log(pack("spare parts").label); diff --git a/tests/diagnostics/deferred-class-receiver/main.ts b/tests/diagnostics/deferred-class-receiver/main.ts new file mode 100644 index 000000000..dfc1de64d --- /dev/null +++ b/tests/diagnostics/deferred-class-receiver/main.ts @@ -0,0 +1,7 @@ +import { Shelf } from "./shelf.ts"; + +function label(name: string): string { + return new Shelf(name).describe(); +} + +console.log(label("north")); diff --git a/tests/diagnostics/deferred-class-receiver/shelf.ts b/tests/diagnostics/deferred-class-receiver/shelf.ts new file mode 100644 index 000000000..1308ce5dc --- /dev/null +++ b/tests/diagnostics/deferred-class-receiver/shelf.ts @@ -0,0 +1,10 @@ +export class Shelf { + readonly name: string; + private readonly notes = new WeakMap(); + constructor(name: string) { + this.name = name; + } + describe(): string { + return `shelf ${this.name}`; + } +} diff --git a/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt b/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt new file mode 100644 index 000000000..4f144907c --- /dev/null +++ b/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt @@ -0,0 +1,15 @@ +deferred-class-binding/crate.ts:3:3 - error SC1090: 'unknown'-typed class fields are not supported yet + + 2 | readonly label: string; + 3 | contents: unknown; + | ^~~~~~~~ + 4 | constructor(label: string) { + +deferred-class-binding/main.ts:5:13 - error SC2004: uses of 'pack' inherit the blocker on its declaration + + 4 | + 5 | console.log(pack("spare parts").label); + | ^~~~ + 6 | + + hint: the declaration of 'pack' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt b/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt new file mode 100644 index 000000000..4431445ce --- /dev/null +++ b/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt @@ -0,0 +1,13 @@ +deferred-class-receiver/main.ts:4:10 - error SC1090: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet + + 3 | function label(name: string): string { + 4 | return new Shelf(name).describe(); + | ^~~~~~~~~~~~~~~ + 5 | } + +deferred-class-receiver/shelf.ts:3:20 - error SC1090: 'unknown'-typed class fields are not supported yet + + 2 | readonly name: string; + 3 | private readonly notes = new WeakMap(); + | ^~~~~ + 4 | constructor(name: string) { \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 52d420977..ab62c34fa 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -285,6 +285,14 @@ test("runtime-optional refined unions report blockers without crashing", () => { } }); +test("a rejected class reached only through a receiver probe reports its own diagnostic once", () => { + const { coverage } = analyze(join(repoRoot, "tests/diagnostics/deferred-class-receiver/main.ts")); + const messages = coverage.diagnostics.map((d) => d.message); + expect(messages.filter((m) => m.startsWith("'unknown'-typed class fields"))).toHaveLength(1); + expect(messages.some((m) => m.startsWith("constructing through a class value"))).toBe(true); + expect(coverage.unreached?.diagnostics ?? []).toEqual([]); +}); + test("settled generic rest-order fences count each source statement once", () => { const { coverage } = analyze(join(repoRoot, "tests/diagnostics/retained-generic-rest-order.ts")); expect(coverage.diagnostics.map((d) => d.code)).toEqual(["SC1031", "SC2004"]); diff --git a/tests/harness/self-hosting-llvm-emitter.test.ts b/tests/harness/self-hosting-llvm-emitter.test.ts index 2297c52fa..5649aafed 100644 --- a/tests/harness/self-hosting-llvm-emitter.test.ts +++ b/tests/harness/self-hosting-llvm-emitter.test.ts @@ -100,180 +100,184 @@ const programs = [ ]; for (const backend of ["llvm"] as const) { - test(`the complete LLVM emitter bootstraps natively (${backend})`, async () => { - const dir = mkdtempSync( - join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-llvm-emitter-"), - ); - const executable = (name: string) => - join(dir, name + (process.platform === "win32" ? ".exe" : "")); - const stage = executable("emitter"); - const input = join(dir, "input.json"); - const output = join(dir, "native.ll"); - const config = join(dir, "config.json"); - try { - // Lower the full production graph in a child so synchronous frontend - // work cannot block Vitest's worker RPC while it compiles the seed. - const api = pathToFileURL(join(root, "packages/compiler/src/index.ts")).href; - const { stdout } = await execFileAsync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - `import { compile } from ${JSON.stringify(api)}; + test( + `the complete LLVM emitter bootstraps natively (${backend})`, + async () => { + const dir = mkdtempSync( + join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-llvm-emitter-"), + ); + const executable = (name: string) => + join(dir, name + (process.platform === "win32" ? ".exe" : "")); + const stage = executable("emitter"); + const input = join(dir, "input.json"); + const output = join(dir, "native.ll"); + const config = join(dir, "config.json"); + try { + // Lower the full production graph in a child so synchronous frontend + // work cannot block Vitest's worker RPC while it compiles the seed. + const api = pathToFileURL(join(root, "packages/compiler/src/index.ts")).href; + const { stdout } = await execFileAsync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + `import { compile } from ${JSON.stringify(api)}; const result = await compile(process.argv[1], { outDir: process.argv[2], outPath: process.argv[3], backend: process.argv[4], dynamic: false, optimization: 'dev', sanitize: process.argv[5] === '1', emitIr: true, }); console.log(JSON.stringify(result));`, - entry, - dir, - stage, - backend, - sanitize ? "1" : "0", - ], - { ...runOptions, timeout: 600_000 }, - ); - const built = JSON.parse(stdout) as Awaited>; - if (!built.ok) - throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); - if (!("binaryPath" in built)) throw new Error("bootstrap did not produce an executable"); - expect(built.backend).toBe(backend); - - expect(built.irPath).toBeDefined(); - const ownIr = deserializeModule(readFileSync(built.irPath!, "utf8")); - expect(ownIr.functions.length).toBeGreaterThan(800); - expect(validateModule(ownIr)).toEqual([]); - - const emit = async ( - mod: IrModule, - name: string, - request = llvmEmitterRequest(), - ): Promise => { - writeFileSync(input, serializeModule(mod)); - writeFileSync(config, JSON.stringify(request)); - const result = await execFileAsync(stage, [input, output, config], { - ...runOptions, - timeout: emitTimeout, - }).catch((cause: unknown) => { - throw new Error(`native LLVM emission failed for ${name}`, { cause }); - }); - expect(result.stdout, name).toBe(""); - expect(result.stderr, name).toBe(""); - const text = readFileSync(output, "utf8"); - const expected = emitLlvmModule(mod, llvmEmitterOptions(request)); - expect(normalizedEmbeddingLlvm(text, mod), name).toBe( - normalizedEmbeddingLlvm(expected, mod), + entry, + dir, + stage, + backend, + sanitize ? "1" : "0", + ], + { ...runOptions, timeout: 600_000 }, ); - return text; - }; + const built = JSON.parse(stdout) as Awaited>; + if (!built.ok) + throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + if (!("binaryPath" in built)) throw new Error("bootstrap did not produce an executable"); + expect(built.backend).toBe(backend); - for (const item of llvmEmitterCases()) { - expect(validateModule(item.module), item.name).toEqual([]); - const text = await emit(item.module, item.name, item.request); - for (const fragment of item.contains) expect(text, item.name).toContain(fragment); - for (const fragment of item.excludes) expect(text, item.name).not.toContain(fragment); - } + expect(built.irPath).toBeDefined(); + const ownIr = deserializeModule(readFileSync(built.irPath!, "utf8")); + expect(ownIr.functions.length).toBeGreaterThan(800); + expect(validateModule(ownIr)).toEqual([]); - // A refusal must leave the requested output untouched, with the same - // exception category/message under Node and the compiled emitter. - const rejected = llvmEmitterCases()[0]!.module; - rejected.classes = [ - { - name: "UnimplementedNative", - fields: [], - runtime: true, - loc: { file: rejected.sourceFile, start: 0, end: 0 }, - }, - ]; - writeFileSync(input, serializeModule(rejected)); - writeFileSync(config, JSON.stringify(llvmEmitterRequest())); - writeFileSync(output, "untouched"); - const args = [input, output, config]; - const oracleRefusal = spawnSync( - process.execPath, - ["--import", "tsx", entry, ...args], - runOptions, - ); - const nativeRefusal = spawnSync(stage, args, runOptions); - for (const result of [oracleRefusal, nativeRefusal]) { - expect(result.error).toBeUndefined(); - expect(result.signal).toBeNull(); - expect(result.status).toBe(2); - expect(result.stderr.toString()).toBe(""); - } - expect(nativeRefusal.stdout).toEqual(oracleRefusal.stdout); - expect(nativeRefusal.stdout.toString()).toContain("classDef:UnimplementedNative"); - expect(readFileSync(output, "utf8")).toBe("untouched"); + const emit = async ( + mod: IrModule, + name: string, + request = llvmEmitterRequest(), + ): Promise => { + writeFileSync(input, serializeModule(mod)); + writeFileSync(config, JSON.stringify(request)); + const result = await execFileAsync(stage, [input, output, config], { + ...runOptions, + timeout: emitTimeout, + }).catch((cause: unknown) => { + throw new Error(`native LLVM emission failed for ${name}`, { cause }); + }); + expect(result.stdout, name).toBe(""); + expect(result.stderr, name).toBe(""); + const text = readFileSync(output, "utf8"); + const expected = emitLlvmModule(mod, llvmEmitterOptions(request)); + expect(normalizedEmbeddingLlvm(text, mod), name).toBe( + normalizedEmbeddingLlvm(expected, mod), + ); + return text; + }; - for (const source of programs) { - const sourcePath = join(root, "tests/corpus", source); - const irPath = join(dir, "program.ir.json"); - const lowered = await compile(sourcePath, { - outDir: dir, - outPath: irPath, - outputKind: "ir", - dynamic: false, - }); - if (!lowered.ok) - throw new Error(`${source}: ${lowered.diagnostics.map((d) => d.message).join("\n")}`); - const mod = deserializeModule(readFileSync(irPath, "utf8")); - const request: LlvmEmitterRequest = llvmEmitterRequest({ - debug: true, - sources: [{ file: sourcePath, text: readFileSync(sourcePath, "utf8") }], - }); - const text = await emit(mod, source, request); - const llvmPath = join(dir, "program.ll"); - const outPath = executable("program"); - writeFileSync(llvmPath, text); + for (const item of llvmEmitterCases()) { + expect(validateModule(item.module), item.name).toEqual([]); + const text = await emit(item.module, item.name, item.request); + for (const fragment of item.contains) expect(text, item.name).toContain(fragment); + for (const fragment of item.excludes) expect(text, item.name).not.toContain(fragment); + } + + // A refusal must leave the requested output untouched, with the same + // exception category/message under Node and the compiled emitter. + const rejected = llvmEmitterCases()[0]!.module; + rejected.classes = [ + { + name: "UnimplementedNative", + fields: [], + runtime: true, + loc: { file: rejected.sourceFile, start: 0, end: 0 }, + }, + ]; + writeFileSync(input, serializeModule(rejected)); + writeFileSync(config, JSON.stringify(llvmEmitterRequest())); + writeFileSync(output, "untouched"); + const args = [input, output, config]; + const oracleRefusal = spawnSync( + process.execPath, + ["--import", "tsx", entry, ...args], + runOptions, + ); + const nativeRefusal = spawnSync(stage, args, runOptions); + for (const result of [oracleRefusal, nativeRefusal]) { + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(2); + expect(result.stderr.toString()).toBe(""); + } + expect(nativeRefusal.stdout).toEqual(oracleRefusal.stdout); + expect(nativeRefusal.stdout.toString()).toContain("classDef:UnimplementedNative"); + expect(readFileSync(output, "utf8")).toBe("untouched"); + + for (const source of programs) { + const sourcePath = join(root, "tests/corpus", source); + const irPath = join(dir, "program.ir.json"); + const lowered = await compile(sourcePath, { + outDir: dir, + outPath: irPath, + outputKind: "ir", + dynamic: false, + }); + if (!lowered.ok) + throw new Error(`${source}: ${lowered.diagnostics.map((d) => d.message).join("\n")}`); + const mod = deserializeModule(readFileSync(irPath, "utf8")); + const request: LlvmEmitterRequest = llvmEmitterRequest({ + debug: true, + sources: [{ file: sourcePath, text: readFileSync(sourcePath, "utf8") }], + }); + const text = await emit(mod, source, request); + const llvmPath = join(dir, "program.ll"); + const outPath = executable("program"); + writeFileSync(llvmPath, text); + await compileC({ + cPath: llvmPath, + outPath, + // Emitted modules are generated programs closed over the runtime + // tree, as in compile(): reuse cached runtime objects instead of + // rebuilding every runtime unit for each program. + cacheIdentity: generatedProgram, + sanitize, + optimization: "dev", + ...executableLinkFeatures(mod, false), + }); + const node = spawnSync(process.execPath, [sourcePath], runOptions); + const native = spawnSync(outPath, [], runOptions); + for (const result of [node, native]) { + expect(result.error, source).toBeUndefined(); + expect(result.signal, `${source}: ${result.stderr}`).toBeNull(); + expect(result.status, `${source}: ${result.stderr}`).toBe(0); + } + expect(native.stdout, source).toEqual(node.stdout); + expect(programStderr(native.stderr), source).toBe(programStderr(node.stderr)); + } + + // The seed emits its own complete IR, including the production + // optimizer and LLVM expression/library graph. Compile those bytes + // and require a second native generation to reproduce the module. + const self = await emit(ownIr, "LLVM emitter emits itself"); + const selfPath = join(dir, "self.ll"); + const secondStage = executable("emitter-second"); + writeFileSync(selfPath, self); await compileC({ - cPath: llvmPath, - outPath, - // Emitted modules are generated programs closed over the runtime - // tree, as in compile(): reuse cached runtime objects instead of - // rebuilding every runtime unit for each program. + cPath: selfPath, + outPath: secondStage, cacheIdentity: generatedProgram, sanitize, optimization: "dev", - ...executableLinkFeatures(mod, false), + ...executableLinkFeatures(ownIr, false), }); - const node = spawnSync(process.execPath, [sourcePath], runOptions); - const native = spawnSync(outPath, [], runOptions); - for (const result of [node, native]) { - expect(result.error, source).toBeUndefined(); - expect(result.signal, `${source}: ${result.stderr}`).toBeNull(); - expect(result.status, `${source}: ${result.stderr}`).toBe(0); - } - expect(native.stdout, source).toEqual(node.stdout); - expect(programStderr(native.stderr), source).toBe(programStderr(node.stderr)); + const again = join(dir, "again.ll"); + const second = await execFileAsync(secondStage, [input, again, config], { + ...runOptions, + timeout: emitTimeout, + }); + expect(second.stdout).toBe(""); + expect(second.stderr).toBe(""); + expect(readFileSync(again, "utf8")).toBe(self); + } finally { + rmSync(dir, { recursive: true, force: true }); } - - // The seed emits its own complete IR, including the production - // optimizer and LLVM expression/library graph. Compile those bytes - // and require a second native generation to reproduce the module. - const self = await emit(ownIr, "LLVM emitter emits itself"); - const selfPath = join(dir, "self.ll"); - const secondStage = executable("emitter-second"); - writeFileSync(selfPath, self); - await compileC({ - cPath: selfPath, - outPath: secondStage, - cacheIdentity: generatedProgram, - sanitize, - optimization: "dev", - ...executableLinkFeatures(ownIr, false), - }); - const again = join(dir, "again.ll"); - const second = await execFileAsync(secondStage, [input, again, config], { - ...runOptions, - timeout: emitTimeout, - }); - expect(second.stdout).toBe(""); - expect(second.stderr).toBe(""); - expect(readFileSync(again, "utf8")).toBe(self); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, testTimeout); + }, + testTimeout, + ); }