mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
fix ffi call initializers (#268)
Co-authored-by: monteslu <423800+monteslu@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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é"));
|
||||
|
||||
@@ -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());",
|
||||
"",
|
||||
|
||||
Reference in New Issue
Block a user