fix ffi call initializers (#268)

Co-authored-by: monteslu <423800+monteslu@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-08-30 19:23:04 -05:00
committed by GitHub
co-authored by monteslu
parent 0b1738d51e
commit 4d9e2a2397
5 changed files with 162 additions and 4 deletions
@@ -335,9 +335,12 @@ export function ambientNsRootOf(lowerer: Lowerer, e: ts.Expression): ts.Identifi
* null/undefined AFTER a successful read; it cannot guard the root's own
* ReferenceError), calls and `new` (the callee evaluates before any
* argument), instantiation expressions, and tagged templates (the tag
* evaluates first). Callers lower the WHOLE expression to the root's
* throw, typed by the use site — and never lower the arguments, exactly
* the order Node dies in. Null for stdlib/@types roots (their own
* evaluates first). A manifest-validated direct FFI call is the narrow
* exception: its ambient declaration supplies a native implementation, so
* the call itself is not an undefined-root read and outer transparent chains
* retain that native result. Callers lower every other whole expression to
* the root's throw, typed by the use site — and never lower the arguments,
* exactly the order Node dies in. Null for stdlib/@types roots (their own
* chokepoints stand) and anything declared with a value. */
export function ambientUndefVarRootOf(lowerer: Lowerer, e: ts.Expression): ts.Identifier | null {
let root: ts.Expression = e;
@@ -357,6 +360,10 @@ export function ambientUndefVarRootOf(lowerer: Lowerer, e: ts.Expression): ts.Id
continue;
}
if (ts.isCallExpression(root) || ts.isNewExpression(root)) {
// A configured native binding owns this exact call node. Do not walk
// into its signature-only declaration: the call produces a native
// result, and normal call lowering must retain its ffiCall IR.
if (ts.isCallExpression(root) && lowerer.ownsFfiCall(root)) return null;
root = root.expression;
continue;
}
@@ -1709,6 +1709,28 @@ export class Lowerer {
return symbol;
}
/** Whether this exact direct call belongs to a manifest-validated native
* binding. Declaration classification uses this as a probe before normal
* expression lowering: resolving must not flush deferred diagnostics or
* trigger the merged-namespace fence just because it is asking ownership.
* Call lowering remains the authority for every ABI and call-shape
* diagnostic once this answers true. */
ownsFfiCall(expr: ts.CallExpression): boolean {
if (!ts.isIdentifier(expr.expression)) return false;
const binding = this.ffiImportsByName.get(expr.expression.text);
if (binding === undefined || this.ffiBindingSymbols === null) return false;
const validSymbols = this.ffiBindingSymbols.get(binding.name);
if (validSymbols === undefined) return false;
const wasCollecting = this.collecting;
this.collecting = true;
try {
const symbol = this.resolveValueSymbol(expr.expression);
return symbol !== null && validSymbols.has(symbol);
} finally {
this.collecting = wasCollecting;
}
}
/** The configured external host module owning an expression's runtime
* value, or null. Alias chains are followed to their declaration file so
* direct imports and local re-export facades classify identically. Type
+100
View File
@@ -0,0 +1,100 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
type IrRecord = Record<string, unknown>;
function recordsOf(value: unknown, out: IrRecord[] = []): IrRecord[] {
if (value === null || typeof value !== "object") return out;
if (Array.isArray(value)) {
for (const item of value) recordsOf(item, out);
return out;
}
const record = value as IrRecord;
out.push(record);
for (const child of Object.values(record)) recordsOf(child, out);
return out;
}
test("manifest-bound call initializers retain ffiCall IR and declaration storage", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-ffi-lowering-"));
dirs.push(dir);
const entry = join(dir, "main.ts");
const outDir = join(dir, ".scriptc");
const outPath = join(outDir, "main.ir.json");
const profilePath = join(dir, "profile.json");
await writeFile(
entry,
[
"declare function nativeNumber(value: number): number;",
"declare function nativeBoolean(value: boolean): boolean;",
"const moduleConst = nativeNumber(1);",
"let moduleLet = nativeBoolean(false);",
"var moduleVar = nativeNumber(2);",
"function localBindings() {",
" const localConst = nativeNumber(3);",
" let localLet = nativeBoolean(true);",
" var localVar = nativeNumber(4);",
" console.log(localConst, localLet, localVar);",
"}",
"localBindings();",
"",
].join("\n"),
);
await writeFile(
profilePath,
JSON.stringify({
ffi_format: 1,
functions: [
{ name: "nativeNumber", symbol: "sf_number", params: ["f64"], returns: "f64" },
{ name: "nativeBoolean", symbol: "sf_boolean", params: ["bool"], returns: "bool" },
],
libraries: [],
}),
);
const result = await compile(entry, { outDir, outPath, outputKind: "ir", ffiProfilePath: profilePath });
if (!result.ok) {
throw new Error(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n"));
}
const module = deserializeModule(await readFile(outPath, "utf8"));
expect(validateModule(module)).toEqual([]);
const expectedGlobals = ["moduleConst", "moduleLet", "moduleVar"];
const globals = module.globals ?? [];
expect(globals.map((global) => global.name)).toEqual(expect.arrayContaining(expectedGlobals));
const globalIds = new Set(
globals.filter((global) => expectedGlobals.includes(global.name)).map((global) => global.id),
);
const localFn = module.functions.find((fn) => fn.name.endsWith("localBindings"));
expect(localFn).toBeDefined();
const expectedLocals = ["localConst", "localLet", "localVar"];
const localIds = new Set(
localFn!.locals.filter((local) => expectedLocals.includes(local.name)).map((local) => local.id),
);
expect(localIds.size).toBe(expectedLocals.length);
const records = recordsOf(module);
const ffiInitializers = (ids: ReadonlySet<string>) => records.filter((record) =>
((record.kind === "assign" && typeof record.localId === "string" && ids.has(record.localId) &&
(record.value as IrRecord | undefined)?.kind === "ffiCall") ||
(record.kind === "varDecl" && typeof record.localId === "string" && ids.has(record.localId) &&
(record.init as IrRecord | undefined)?.kind === "ffiCall")),
);
expect(ffiInitializers(globalIds)).toHaveLength(expectedGlobals.length);
expect(ffiInitializers(localIds)).toHaveLength(expectedLocals.length);
const ffiCalls = records.filter((record) => record.kind === "ffiCall");
expect(ffiCalls).toHaveLength(6);
expect(records.some((record) => record.kind === "libCall" && record.fn === "global.undefRead")).toBe(false);
});
+14
View File
@@ -41,6 +41,20 @@ declare function nativeRetainedRawPump(value: number): void;
declare function nativeRetainedRawSetFlush(callback: (value: number) => void): void;
console.log(nativeScale(21));
const boundScale = nativeScale(2);
let boundInvert = nativeInvert(false);
var boundVarScale = nativeScale(3);
console.log(boundScale, boundInvert, boundVarScale);
function printFunctionBoundResults() {
const localScale = nativeScale(4);
let localInvert = nativeInvert(true);
var localVarScale = nativeScale(5);
console.log(localScale, localInvert, localVarScale);
}
printFunctionBoundResults();
console.log(nativeInvert(false), nativeInvert(true));
console.log(nativeU8(258), nativeU32(-1), nativeI32(4294967295));
console.log(nativeTextSum("A\0é"));
+16 -1
View File
@@ -55,6 +55,8 @@ function manifest(archive: string): string {
const expected = [
"42",
"4 true 6",
"8 false 10",
"true false",
"2 4294967295 -1",
"429",
@@ -619,6 +621,18 @@ test.each([
code: "SC5003",
message: "parameter 1",
},
{
id: "called-optional-initializer",
name: "an optional native call stored in an initializer",
source: [
"declare function nativeScale(value: number): number;",
"const stored = nativeScale?.(21);",
"console.log(stored);",
"",
].join("\n"),
code: "SC5003",
message: "direct, non-generic calls only",
},
{
id: "called-body",
name: "an ordinary function body with a configured name",
@@ -799,7 +813,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 stored = nativeScale(21);",
" return stored;",
"}",
"console.log(localUse());",
"",