Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/compiler/src/frontend/lowering/lower-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 48 additions & 34 deletions packages/compiler/src/frontend/lowering/lower-namespaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,50 +340,64 @@ 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
// resolveValueSymbol flushes the root's DEFERRED collection
// 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;
Expand Down
9 changes: 9 additions & 0 deletions packages/compiler/src/frontend/lowering/lowerer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
226 changes: 222 additions & 4 deletions tests/harness/ffi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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",
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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());",
"",
Expand Down