From 9c73be4ee5d20407eb9e1f26a3b258ecd157c482 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Tue, 28 Jul 2026 17:15:44 -0300 Subject: [PATCH 1/3] fix(compiler): preserve FFI binding initializer calls --- .../src/frontend/lowering/lower-calls.ts | 2 +- .../src/frontend/lowering/lower-namespaces.ts | 78 +++++++++++-------- .../compiler/src/frontend/lowering/lowerer.ts | 9 +++ tests/harness/ffi.test.ts | 57 +++++++++++++- 4 files changed, 109 insertions(+), 37 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 766fb2976..5791e74a1 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -2684,7 +2684,7 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null // No entry means the program-level pass already diagnosed this // binding. Poison the statement without duplicating that diagnostic. if (validSymbols === undefined) throw new PoisonError(); - if (!validSymbols.has(symbol)) { + if (!L.ownsValidatedFfiSymbol(binding.name, symbol)) { // TypeScript resolved this call to a distinct local declaration. // The manifest owns only the exact validated ambient binding; a // same-named function with a body remains ordinary scriptc code. diff --git a/packages/compiler/src/frontend/lowering/lower-namespaces.ts b/packages/compiler/src/frontend/lowering/lower-namespaces.ts index 0ac9def7f..303d3400f 100644 --- a/packages/compiler/src/frontend/lowering/lower-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/lower-namespaces.ts @@ -340,37 +340,6 @@ export function ambientNsRootOf(L: Lowerer, e: ts.Expression): ts.Identifier | n * the order Node dies in. Null for stdlib/@types roots (their own * chokepoints stand) and anything declared with a value. */ export function ambientUndefVarRootOf(L: Lowerer, e: ts.Expression): ts.Identifier | null { - let root: ts.Expression = e; - for (;;) { - if ( - ts.isParenthesizedExpression(root) || - ts.isNonNullExpression(root) || - ts.isAsExpression(root) || - ts.isSatisfiesExpression(root) || - ts.isTypeAssertion(root) - ) { - root = root.expression; - continue; - } - if (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) { - root = root.expression; - continue; - } - if (ts.isCallExpression(root) || ts.isNewExpression(root)) { - root = root.expression; - continue; - } - if (ts.isExpressionWithTypeArguments(root)) { - root = root.expression; - continue; - } - if (ts.isTaggedTemplateExpression(root)) { - root = root.tag; - continue; - } - break; - } - if (!ts.isIdentifier(root)) return null; // PROBE resolution: every caller asks "is this chain ambient-rooted?" // and proceeds to its ordinary lowering on a null answer — so the // question must not carry resolution's side effects. Bare @@ -378,12 +347,53 @@ export function ambientUndefVarRootOf(L: Lowerer, e: ts.Expression): ts.Identifi // diagnostics onto the build (reached-only-by-the-probe declarations // reported eagerly — collectGlobals runs this walk on every // initializer) and throws the cross-block merged-namespace fence's - // PoisonError out of collection entirely. The collect-phase guard - // suppresses both; the ordinary lowering that follows a null answer - // re-resolves with full effects at its own site. + // PoisonError out of collection entirely. The collect-phase guard also + // covers exact FFI ownership checks encountered while walking the chain; + // the ordinary lowering that follows a null answer re-resolves with full + // effects at its own site. const wasCollecting = L.collecting; L.collecting = true; try { + let root: ts.Expression = e; + for (;;) { + if ( + ts.isParenthesizedExpression(root) || + ts.isNonNullExpression(root) || + ts.isAsExpression(root) || + ts.isSatisfiesExpression(root) || + ts.isTypeAssertion(root) + ) { + root = root.expression; + continue; + } + if (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) { + root = root.expression; + continue; + } + if (ts.isCallExpression(root) || ts.isNewExpression(root)) { + if (ts.isCallExpression(root) && ts.isIdentifier(root.expression)) { + const symbol = L.resolveValueSymbol(root.expression); + if (symbol && L.ownsValidatedFfiSymbol(root.expression.text, symbol)) { + // The manifest supplies this exact ambient declaration. Stop at + // the call boundary so normal lowering can emit the native call + // or its existing call-shape diagnostic. + return null; + } + } + root = root.expression; + continue; + } + if (ts.isExpressionWithTypeArguments(root)) { + root = root.expression; + continue; + } + if (ts.isTaggedTemplateExpression(root)) { + root = root.tag; + continue; + } + break; + } + if (!ts.isIdentifier(root)) return null; const sym = L.resolveValueSymbol(root); if (!sym) return null; if (L.trapBindings.has(sym)) return root; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index dcd2999c2..95d5f9035 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1264,6 +1264,15 @@ export class Lowerer { ? this.qualify(decl.getSourceFile(), `%cx${decl.getStart()}.${decl.name?.text ?? ""}`) : this.qualify(decl.getSourceFile(), nsPathPrefix(decl) + (decl.name ? decl.name.text : "%anon")); + /** Whether whole-program validation assigned this exact source symbol to + * the configured native binding. Name agreement alone never owns a call. */ + ownsValidatedFfiSymbol(name: string, symbol: ts.Symbol): boolean { + return ( + this.ffiImportsByName.has(name) && + this.ffiBindingSymbols?.get(name)?.has(symbol) === true + ); + } + /** Follows import aliases to the original declaration's symbol. Every * value reference resolves through here, so it doubles as the flush * point for deferred collection diagnostics: resolving a reference to a diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts index 644f103ea..8b922de21 100644 --- a/tests/harness/ffi.test.ts +++ b/tests/harness/ffi.test.ts @@ -38,12 +38,16 @@ function nativeArchive(): string { return archive; } -function manifest(archive: string): string { +function manifest(archive: string, functionNames?: readonly string[]): string { const outDir = join(cacheRoot, "manifest"); mkdirSync(outDir, { recursive: true }); const profile = JSON.parse( readFileSync(join(fixtureRoot, "profile.json"), "utf8"), - ) as { libraries: string[] }; + ) as { functions: { name: string }[]; libraries: string[] }; + if (functionNames !== undefined) { + const names = new Set(functionNames); + profile.functions = profile.functions.filter((entry) => names.has(entry.name)); + } profile.libraries = [archive]; const path = join(outDir, "profile.json"); writeFileSync(path, JSON.stringify(profile, null, 2)); @@ -82,6 +86,55 @@ describe.each(["c", "llvm"] as const)("outbound native FFI, %s backend", (backen }); }); +describe.each(["c", "llvm"] as const)("FFI binding initializers, %s backend", (backend) => { + test("stores the result of a manifest-bound call in a function-local const", async () => { + const outDir = join(cacheRoot, `binding-initializer-${backend}`); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, "main.ts"); + writeFileSync( + entry, + [ + "declare function nativeScale(value: number): number;", + "function main(): void {", + " const result = nativeScale(21);", + " console.log('const:', result);", + "}", + "main();", + "", + ].join("\n"), + ); + + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + backend, + sanitize, + ffiProfilePath: manifest(nativeArchive(), ["nativeScale"]), + emitIr: true, + }); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + + const native = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect({ + stdout: native.stdout, + stderr: native.stderr, + status: native.status, + }).toEqual({ + stdout: "const: 42\n", + stderr: "", + status: 0, + }); + + const ir = JSON.stringify(JSON.parse(readFileSync(result.irPath!, "utf8"))); + expect(ir).toContain('"kind":"ffiCall"'); + expect(ir).not.toContain('"fn":"global.undefRead"'); + }); +}); + test("manifest validation is strict and source-facing", () => { const path = join(cacheRoot, "invalid.json"); mkdirSync(cacheRoot, { recursive: true }); From 86b96cee29ef28a7d429b678811aed70b1714375 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Tue, 28 Jul 2026 17:23:33 -0300 Subject: [PATCH 2/3] test(compiler): cover FFI call ownership boundaries --- tests/harness/ffi.test.ts | 213 +++++++++++++++++++++++++++++++++----- 1 file changed, 189 insertions(+), 24 deletions(-) diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts index 8b922de21..2a31dd92e 100644 --- a/tests/harness/ffi.test.ts +++ b/tests/harness/ffi.test.ts @@ -21,7 +21,10 @@ const cacheRoot = join( flavor, ); +let cachedNativeArchive: string | undefined; + function nativeArchive(): string { + if (cachedNativeArchive !== undefined) return cachedNativeArchive; const outDir = join(cacheRoot, "native"); mkdirSync(outDir, { recursive: true }); const object = join(outDir, "native.o"); @@ -35,7 +38,8 @@ function nativeArchive(): string { object, ]); execFileSync("ar", ["rcs", archive, object]); - return archive; + cachedNativeArchive = archive; + return cachedNativeArchive; } function manifest(archive: string, functionNames?: readonly string[]): string { @@ -54,6 +58,52 @@ function manifest(archive: string, functionNames?: readonly string[]): string { return path; } +async function compileScaleFixture( + id: string, + body: readonly string[], + options: { + backend?: "c" | "llvm"; + emitIr?: boolean; + ffi?: boolean; + } = {}, +) { + const outDir = join(cacheRoot, id); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, "main.ts"); + writeFileSync( + entry, + [ + "declare function nativeScale(value: number): number;", + ...body, + "", + ].join("\n"), + ); + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + backend: options.backend ?? "c", + sanitize, + ...(options.ffi === false + ? {} + : { ffiProfilePath: manifest(nativeArchive(), ["nativeScale"]) }), + emitIr: options.emitIr, + }); + return { entry, result }; +} + +function expectUndefinedAmbient(binaryPath: string): void { + const native = spawnSync(binaryPath, [], { encoding: "utf8" }); + expect({ + stdout: native.stdout, + stderr: native.stderr, + status: native.status, + }).toEqual({ + stdout: "", + stderr: "Uncaught ReferenceError: nativeScale is not defined\n", + status: 1, + }); +} + const expected = [ "42", "true false", @@ -87,31 +137,34 @@ describe.each(["c", "llvm"] as const)("outbound native FFI, %s backend", (backen }); describe.each(["c", "llvm"] as const)("FFI binding initializers, %s backend", (backend) => { - test("stores the result of a manifest-bound call in a function-local const", async () => { - const outDir = join(cacheRoot, `binding-initializer-${backend}`); - mkdirSync(outDir, { recursive: true }); - const entry = join(outDir, "main.ts"); - writeFileSync( - entry, + test("preserves exact calls across binding and early-probe contexts", async () => { + const { result } = await compileScaleFixture( + `binding-initializer-${backend}`, [ - "declare function nativeScale(value: number): number;", + "const moduleResult = nativeScale(2);", "function main(): void {", - " const result = nativeScale(21);", - " console.log('const:', result);", + " const functionResult = nativeScale(21);", + " let once = nativeScale(3);", + " console.log('module:', moduleResult);", + " console.log('const:', functionResult);", + " console.log('let:', once);", + " for (const value of [1, 2]) {", + " const loopResult = nativeScale(value);", + " console.log('loop:', loopResult);", + " }", + " let assigned = 0;", + " assigned = nativeScale(5);", + " console.log('assignment:', assigned);", + " const text = nativeScale(6).toString();", + " console.log('chain:', text);", + " let calls = 0;", + " const sideEffectResult = nativeScale(++calls);", + " console.log('side effect:', sideEffectResult, calls);", "}", "main();", - "", - ].join("\n"), + ], + { backend, emitIr: true }, ); - - const result = await compile(entry, { - outDir, - outPath: join(outDir, "program"), - backend, - sanitize, - ffiProfilePath: manifest(nativeArchive(), ["nativeScale"]), - emitIr: true, - }); if (!result.ok) { throw new Error( result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), @@ -124,17 +177,128 @@ describe.each(["c", "llvm"] as const)("FFI binding initializers, %s backend", (b stderr: native.stderr, status: native.status, }).toEqual({ - stdout: "const: 42\n", + stdout: [ + "module: 4", + "const: 42", + "let: 6", + "loop: 2", + "loop: 4", + "assignment: 10", + "chain: 12", + "side effect: 2 1", + "", + ].join("\n"), stderr: "", status: 0, }); const ir = JSON.stringify(JSON.parse(readFileSync(result.irPath!, "utf8"))); - expect(ir).toContain('"kind":"ffiCall"'); + expect(ir.match(/"kind":"ffiCall"/g)).toHaveLength(7); expect(ir).not.toContain('"fn":"global.undefRead"'); }); }); +test("keeps a no-manifest ambient initializer failure ahead of its arguments", async () => { + const { result } = await compileScaleFixture( + "binding-initializer-no-manifest", + [ + "function argument(): number {", + " console.log('argument evaluated');", + " return 21;", + "}", + "const result = nativeScale(argument());", + "console.log(result);", + ], + { emitIr: true, ffi: false }, + ); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + + expectUndefinedAmbient(result.binaryPath); + + const ir = JSON.stringify(JSON.parse(readFileSync(result.irPath!, "utf8"))); + expect(ir).toContain('"fn":"global.undefRead"'); + expect(ir).not.toContain('"kind":"ffiCall"'); +}); + +test.each([ + { + id: "alias", + name: "an alias read", + body: [ + "const alias = nativeScale;", + "console.log(alias(21));", + ], + }, + { + id: "call-property", + name: "a .call use", + body: ["console.log(nativeScale.call(null, 21));"], + }, + { + id: "parenthesized-callee", + name: "a parenthesized callee", + body: ["console.log((nativeScale)(21));"], + }, +])("does not widen $name into a native call", async ({ id, body }) => { + const { result } = await compileScaleFixture(`indirect-${id}`, body); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + + expectUndefinedAmbient(result.binaryPath); +}); + +test.each([ + { + id: "optional", + name: "an optional direct call", + call: "nativeScale?.(21)", + message: "direct, non-generic calls only", + }, + { + id: "spread", + name: "a spread direct call", + call: "nativeScale(...([21] as [number]))", + message: "spread arguments do not have a fixed native ABI", + }, + { + id: "arity", + name: "a wrong-arity direct call", + call: "nativeScale()", + message: "native ABI requires exactly 1", + suppressTypeScript: true, + }, +])("keeps the existing FFI diagnostic for $name", async ({ + id, + call, + message, + suppressTypeScript, +}) => { + const { entry, result } = await compileScaleFixture( + `call-diagnostic-${id}`, + [ + "function main(): void {", + ...(suppressTypeScript ? [" // @ts-ignore exercise the native arity diagnostic"] : []), + ` const result = ${call};`, + "}", + "main();", + ], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.code).toBe("SC5003"); + expect(result.diagnostics[0]?.message).toContain(message); + expect(result.diagnostics[0]?.loc.file).toBe(entry); + } +}); + test("manifest validation is strict and source-facing", () => { const path = join(cacheRoot, "invalid.json"); mkdirSync(cacheRoot, { recursive: true }); @@ -290,7 +454,8 @@ describe.each(["c", "llvm"] as const)("FFI binding identity, %s backend", (backe "declare function nativeScale(value: number): number;", "function localUse(): number {", " function nativeScale(value: number): number { return value + 1; }", - " return nativeScale(21);", + " const result = nativeScale(21);", + " return result;", "}", "console.log(localUse());", "", From d89404c3749ff6aaa532c0d45045940949367fa7 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Tue, 28 Jul 2026 17:50:28 -0300 Subject: [PATCH 3/3] fix(review): avoid redundant FFI symbol probes --- packages/compiler/src/frontend/lowering/lower-namespaces.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-namespaces.ts b/packages/compiler/src/frontend/lowering/lower-namespaces.ts index 303d3400f..8ad09e7dd 100644 --- a/packages/compiler/src/frontend/lowering/lower-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/lower-namespaces.ts @@ -371,7 +371,11 @@ export function ambientUndefVarRootOf(L: Lowerer, e: ts.Expression): ts.Identifi continue; } if (ts.isCallExpression(root) || ts.isNewExpression(root)) { - if (ts.isCallExpression(root) && ts.isIdentifier(root.expression)) { + if ( + ts.isCallExpression(root) && + ts.isIdentifier(root.expression) && + L.ffiImportsByName.has(root.expression.text) + ) { const symbol = L.resolveValueSymbol(root.expression); if (symbol && L.ownsValidatedFfiSymbol(root.expression.text, symbol)) { // The manifest supplies this exact ambient declaration. Stop at