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..8ad09e7dd 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,57 @@ 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) && + 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 + // 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..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,21 +38,72 @@ function nativeArchive(): string { object, ]); execFileSync("ar", ["rcs", archive, object]); - return archive; + cachedNativeArchive = archive; + return cachedNativeArchive; } -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)); 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", @@ -82,6 +136,169 @@ 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("preserves exact calls across binding and early-probe contexts", async () => { + const { result } = await compileScaleFixture( + `binding-initializer-${backend}`, + [ + "const moduleResult = nativeScale(2);", + "function main(): void {", + " 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();", + ], + { backend, 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: [ + "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.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 }); @@ -237,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());", "",