From bed63d3223403e73675f76d6a8a3a383e8532bce Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 25 Jul 2026 09:56:48 -0500 Subject: [PATCH 1/3] feat: add outbound native FFI - Add a strict versioned manifest and direct C ABI lowering for both backends. - Link declared native inputs through the CLI and compiler APIs with precise diagnostics. - Cover the ABI under plain and sanitized integration tests and document its contracts. --- README.md | 1 + docs/src/app/cli/page.mdx | 3 + docs/src/app/ffi/layout.tsx | 7 + docs/src/app/ffi/page.mdx | 155 ++++++++++++ docs/src/app/limitations/page.mdx | 1 + docs/src/lib/docs-navigation.ts | 1 + docs/src/lib/page-titles.ts | 1 + packages/cli/README.md | 2 + packages/cli/src/main.ts | 18 +- packages/compiler/src/backend/cc.ts | 29 ++- .../src/backend/emission/emit-exprs.ts | 46 ++++ .../compiler/src/backend/emission/emitter.ts | 32 +++ packages/compiler/src/backend/llvm/emitter.ts | 104 ++++++++ .../compiler/src/diagnostics/diagnostic.ts | 41 +++ packages/compiler/src/ffi/profile.ts | 235 ++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 112 ++++++++- .../compiler/src/frontend/lowering/lowerer.ts | 33 ++- packages/compiler/src/index.ts | 49 ++++ packages/compiler/src/ir/nodes.ts | 28 ++- packages/compiler/src/ir/serialize.ts | 2 +- packages/compiler/src/ir/validate.ts | 70 +++++- .../compiler/src/library/int-infer.test.ts | 2 +- packages/compiler/src/library/int-infer.ts | 1 + packages/compiler/test/emit-c.test.ts | 6 +- packages/compiler/test/fixtures/fib-ir.ts | 2 +- packages/compiler/test/ir.test.ts | 4 +- tests/ffi/main.ts | 17 ++ tests/ffi/native.c | 44 ++++ tests/ffi/profile.json | 16 ++ tests/harness/ffi.test.ts | 174 +++++++++++++ 30 files changed, 1211 insertions(+), 25 deletions(-) create mode 100644 docs/src/app/ffi/layout.tsx create mode 100644 docs/src/app/ffi/page.mdx create mode 100644 packages/compiler/src/ffi/profile.ts create mode 100644 tests/ffi/main.ts create mode 100644 tests/ffi/native.c create mode 100644 tests/ffi/profile.json create mode 100644 tests/harness/ffi.test.ts diff --git a/README.md b/README.md index fd46dfe8b..3949fd9db 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Measured on Apple M-series against the same workloads in Node, Go, Rust, and Zig ## Escape hatches, honestly priced - **`comptime(() => ...)`** runs TypeScript at build time (in an isolated VM inside the compiler) and bakes the result into the binary as a literal. +- **Native FFI (`--ffi`)** binds signature-only TypeScript declarations to direct C ABI calls and links manifest-declared archives, objects, and system libraries. The boundary is explicit and length-delimited; see the [Native FFI guide](https://scriptc.dev/ffi). - **`--dynamic`** embeds the engine for npm deps and `any` code. `scriptc coverage --dynamic` reports exactly which statements run where and what the remaining blockers are. Static stays the default: a binary never silently grows an engine. - **Checked casts** — `JSON.parse(...) as Config` inserts a runtime validation that throws a catchable error naming the offending path (`expected number at $.port, got string`). TypeScript's `as` is a promise; scriptc verifies it. diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx index 1b2a3c11e..5dc5c58ce 100644 --- a/docs/src/app/cli/page.mdx +++ b/docs/src/app/cli/page.mdx @@ -57,6 +57,9 @@ Analyzes the program without producing a binary and reports, statement by statem
--dynamic
Embed the dynamic engine (~620KB) so npm dependencies and any-typed code can run. Static stays the default — without this flag, dynamic-tier sites are per-site compile errors. See npm Dependencies.
+
--ffi <file>
+
Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See Native FFI.
+
--backend <c|llvm>
Code generator: llvm (default — emits LLVM IR text, compiled by the same clang) or c (the readable reference backend). Unset, the build tries LLVM and falls back to the C backend when the program is outside the LLVM tier — a one-line stderr note says so, and program behavior is identical either way. An explicit --backend llvm fails with a diagnostic instead of falling back (the debugging/CI pin); --backend c pins the reference backend.
diff --git a/docs/src/app/ffi/layout.tsx b/docs/src/app/ffi/layout.tsx new file mode 100644 index 000000000..5810f36b4 --- /dev/null +++ b/docs/src/app/ffi/layout.tsx @@ -0,0 +1,7 @@ +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata = pageMetadata("ffi"); + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/docs/src/app/ffi/page.mdx b/docs/src/app/ffi/page.mdx new file mode 100644 index 000000000..7ab594c60 --- /dev/null +++ b/docs/src/app/ffi/page.mdx @@ -0,0 +1,155 @@ +# Native FFI + +Outbound FFI lets statically compiled TypeScript call C ABI symbols directly. A strict JSON manifest connects a signature-only TypeScript declaration to a native symbol and supplies the archives or objects that resolve it at link time. There is no runtime symbol lookup and no JavaScript engine at the boundary. + +## A complete example + +Declare the native function in TypeScript. The declaration gives the type checker its ordinary source-level signature; it emits no JavaScript body. + +```ts:main.ts +declare function nativeScale(value: number): number; + +console.log(nativeScale(21)); +``` + +Implement a C ABI symbol with the matching native signature: + +```c:native.c +double native_scale(double value) { + return value * 2.0; +} +``` + +Bind the two names in an FFI manifest. Library paths are resolved relative to this file. + +```json:ffi.json +{ + "ffi_format": 1, + "functions": [ + { + "name": "nativeScale", + "symbol": "native_scale", + "params": ["f64"], + "returns": "f64" + } + ], + "libraries": ["./libnative.a"], + "system_libraries": [] +} +``` + +Build the native archive, then pass the manifest to scriptc: + +```console +$ clang -c native.c -o native.o +$ ar rcs libnative.a native.o +$ scriptc build main.ts --ffi ffi.json -o app +$ ./app +42 +``` + +The FFI binding applies only to a direct call of that exact declaration. A function with a body, an overload, a generic declaration, an alias such as `const f = nativeScale`, or a shadowing local does not silently become a native call. + +## ABI classes + +The manifest is the native ABI authority. TypeScript has only `number`, so its declaration cannot distinguish a double from an integer-width parameter. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Manifest classTypeScript typeC ABI type and behaviorParameterReturn
f64numberdoubleyesyes
boolbooleanuint8_t; inputs are 0 or 1, any nonzero return becomes trueyesyes
u8numberuint8_t; inputs use JavaScript's modulo conversionyesyes
u32numberuint32_t; inputs use ToUint32yesyes
i32numberint32_t; inputs use ToInt32yesyes
stringstringconst uint8_t *, size_t; UTF-8 bytes, length-delimitedyesno
bytesUint8Array or Bufferconst uint8_t *, size_t; raw bytes, length-delimitedyesno
voidvoidvoidnoyes
+ +String and byte pointers are borrowed only for the duration of the call. Native code must not mutate, free, or retain them. Strings may contain embedded NUL bytes, so always use the supplied length; an empty span may have a null pointer. Format 1 deliberately has no pointer, string, or byte return because those need an explicit ownership and allocator contract. + +For C++, export the symbol with `extern "C"` so it keeps the manifest's unmangled C name. + +## Manifest fields + +
+
ffi_format
+
Required. Must be 1.
+ +
functions
+
Required array. Every entry has exactly name, symbol, params, and returns. Binding names and symbols must be unique.
+ +
libraries
+
Optional array of archive or object paths. Relative paths are resolved from the manifest directory and appended after the generated program at link time.
+ +
system_libraries
+
Optional array of linker-neutral library names. For example, ["m"] is emitted as -lm.
+
+ +Unknown fields, invalid ABI classes, duplicate names, and signature mismatches fail the build with an `SC5xxx` diagnostic. The same manifest can be passed to `scriptc coverage` so native call sites count as statically compiled. + +## Boundary rules and current limits + +- Native calls are synchronous and must return normally. Do not unwind C++ exceptions or `longjmp` across the boundary. +- Native code is outside scriptc's exception, reference-counting, and sanitizer contracts. A bad pointer or mismatched C signature can still corrupt the process. +- There are no callbacks, variadic calls, struct-by-value arguments, owned pointer returns, or runtime `dlopen`/`dlsym` handles yet. +- The archive or object must match the build target. Cross-compilation does not translate native inputs. +- Outbound FFI is currently available for executable builds, not `scriptc build --lib`. diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index 54618d156..a9a4e42ea 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -84,5 +84,6 @@ const who = process.argv.length > 2 ? process.argv[2] : "world"; ## Tooling gaps - `scriptc run` does not forward extra CLI arguments to the program — `build` and invoke the binary directly. +- Native FFI is a direct, manifest-declared C ABI link surface. It does not yet support callbacks, variadic calls, structs by value, owned pointer/string/byte returns, runtime dynamic-library loading, or library-mode builds. See [Native FFI](/ffi). - Numbers are JS-exact f64 everywhere. Integer inference and ownership analysis — the systems-language performance ceiling — are roadmap, not shipped. - No Windows servers or `child_process` yet — see [Platform Support](/platforms). diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index c7d6ac4d0..c97a05e17 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -21,6 +21,7 @@ export const navSections: NavSection[] = [ items: [ { name: "Coverage Reports", href: "/coverage" }, { name: "npm Dependencies", href: "/dependencies" }, + { name: "Native FFI", href: "/ffi" }, { name: "Platform Support", href: "/platforms" }, ], }, diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index 8e6acb1e6..3d662cf26 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -5,6 +5,7 @@ export const PAGE_TITLES: Record = { cli: "CLI Reference", coverage: "Coverage Reports", dependencies: "npm Dependencies", + ffi: "Native FFI", platforms: "Platform Support", "how-it-works": "How It Works", limitations: "Limitations", diff --git a/packages/cli/README.md b/packages/cli/README.md index 27fa42369..5dd84c851 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -32,4 +32,6 @@ Requires clang on the PATH (Xcode Command Line Tools on macOS, `clang` package o No annotations, no dialect, no special stdlib: the same TypeScript you run on Node, type-checked by the real TypeScript compiler. Programs outside the static tier can opt into `--dynamic`, which embeds a small JavaScript engine (~620KB) for the parts that can't be static; everything else fails the build with a specific error code and usually a rewrite hint. +Native code can be called through an explicit, link-time C ABI manifest: declare the function signature in TypeScript, bind it to a C symbol, and build with `--ffi `. See the [Native FFI guide](https://scriptc.dev/ffi). + Docs: [scriptc.dev](https://scriptc.dev) diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index cacaf1b50..4219aeefe 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -37,6 +37,8 @@ Options: --emit-ir also write the IR as JSON next to the executable --sanitize build with ASan + runtime RC audit --dynamic embed the dynamic engine (adds ~620KB; static stays the default) + --ffi bind signature-only TypeScript declarations to native + C symbols and link the manifest's archives/libraries --npm-static compile the named npm packages' shipped JS statically as program modules (repeatable; "auto" opts in every eligible @@ -109,6 +111,7 @@ const CLI_OPTIONS = { "emit-ir": { type: "boolean", default: false }, sanitize: { type: "boolean", default: false }, dynamic: { type: "boolean", default: false }, + ffi: { type: "string" }, "npm-static": { type: "string", multiple: true }, "provenance-sources": { type: "boolean", default: false }, lib: { type: "boolean", default: false }, @@ -147,9 +150,9 @@ async function main(): Promise { if (inputArg) { fail("scriptc build --lib takes no input positional: the profile names the entry module"); } - if (values.dynamic || values.backend !== undefined || (values["npm-static"] ?? []).length > 0) { + if (values.dynamic || values.backend !== undefined || values.ffi !== undefined || (values["npm-static"] ?? []).length > 0) { fail( - "scriptc build --lib takes no --dynamic/--backend/--npm-static: the profile pins the emission, and npm imports are judged automatically — an eligible package compiles statically, an ineligible one refuses (library artifacts have no island tier)", + "scriptc build --lib takes no --dynamic/--backend/--npm-static/--ffi: the profile pins the emission, npm imports are judged automatically, and outbound FFI currently belongs to executable builds", ); } const profilePath = resolve(profileArg); @@ -177,6 +180,7 @@ async function main(): Promise { } if (!inputArg) fail(`missing input file\n\n${USAGE}`); const input = resolve(inputArg); + const ffiProfilePath = values.ffi !== undefined ? resolve(values.ffi) : undefined; const backend = values.backend; if (backend !== undefined && backend !== "c" && backend !== "llvm") { fail(`unknown backend "${backend}" (supported: c, llvm)\n\n${USAGE}`); @@ -210,7 +214,11 @@ async function main(): Promise { } if (command === "coverage") { - const { coverage, sourceTexts } = analyze(input, { dynamic: values.dynamic, ...(npmStatic !== undefined ? { npmStatic } : {}) }); + const { coverage, sourceTexts } = analyze(input, { + dynamic: values.dynamic, + ...(npmStatic !== undefined ? { npmStatic } : {}), + ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), + }); const color = process.stdout.isTTY ?? false; process.stdout.write(renderCoverage(coverage, { color, sourceTexts }) + "\n"); return coverage.preflightFailed ? 1 : 0; @@ -222,6 +230,9 @@ async function main(): Promise { const build = async (): Promise => { if (values["from-c"]) { + if (ffiProfilePath !== undefined) { + fail("--ffi is a TypeScript/JavaScript compiler feature and cannot be combined with --from-c"); + } await compileC({ cPath: input, outPath, sanitize: values.sanitize, dynamic: values.dynamic }); return outPath; } @@ -233,6 +244,7 @@ async function main(): Promise { dynamic: values.dynamic, ...(backend !== undefined ? { backend } : {}), ...(npmStatic !== undefined ? { npmStatic } : {}), + ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), }); if (!result.ok) { const color = process.stderr.isTTY ?? false; diff --git a/packages/compiler/src/backend/cc.ts b/packages/compiler/src/backend/cc.ts index e08a5e6dd..2c6db955b 100644 --- a/packages/compiler/src/backend/cc.ts +++ b/packages/compiler/src/backend/cc.ts @@ -37,6 +37,12 @@ export interface CcOptions { outPath: string; /** Build with ASan + the runtime RC audit (test/debug lane). */ sanitize?: boolean; + /** Additional native archives/objects, appended after the generated + * program TU so their symbols resolve outbound FFI calls. */ + linkInputs?: readonly string[]; + /** Driver-neutral system library names, emitted as `-l` after + * linkInputs. */ + systemLibraries?: readonly string[]; /** Embed the dynamic-island engine (--dynamic): compiles scr_island.c, * defines SCR_DYNAMIC, and links the cached libqjs.a. Off = the static * default, byte-identical to builds predating the flag. */ @@ -1185,6 +1191,8 @@ export async function compileC(opts: CcOptions): Promise { // command line cannot change by a byte. ...(opts.cPath.endsWith(".ll") ? ["-Wno-override-module"] : []), opts.cPath, + ...(opts.linkInputs ?? []), + ...(opts.systemLibraries ?? []).map((name) => `-l${name}`), "-o", opts.outPath, ]; const ccName = driver.argv.join(" "); @@ -1193,10 +1201,16 @@ export async function compileC(opts: CcOptions): Promise { await execFileAsync(driver.argv[0] ?? "clang", [...driver.argv.slice(1), ...args]); } catch (err) { const stderr = (err as { stderr?: string }).stderr ?? String(err); + const guidance = + (opts.linkInputs?.length ?? 0) > 0 || + (opts.systemLibraries?.length ?? 0) > 0 + ? "This build includes native FFI link inputs. Check that every symbol and system library exists, " + + "that archive/object ordering is correct, and that each input matches the selected target." + : `This is a scriptc bug (generated C should always compile) unless ` + + `${ccName} itself is missing/broken.`; throw new Error( `${ccName} failed compiling ${opts.cPath}.\n` + - `This is a scriptc bug (generated C should always compile) unless ` + - `${ccName} itself is missing/broken.\n\n${stderr}`, + `${guidance}\n\n${stderr}`, ); } }; @@ -1208,10 +1222,11 @@ export async function compileC(opts: CcOptions): Promise { return; } - const [cv, fingerprint, cBytes] = await Promise.all([ + const [cv, fingerprint, cBytes, linkBytes] = await Promise.all([ ccVersionOnce(driver.argv), runtimeFingerprint(rtDir), readFile(opts.cPath), + Promise.all((opts.linkInputs ?? []).map((path) => readFile(path))), ]); // The key sees the full command line with the two program-specific paths // normalized out (their CONTENT is what matters: the C bytes are hashed, @@ -1229,10 +1244,12 @@ export async function compileC(opts: CcOptions): Promise { .update(cv).update("\0") .update(fingerprint).update("\0") .update(identityArgs.join("\x1f")).update("\0") - .update(cBytes) + .update(cBytes); + for (const bytes of linkBytes) key.update("\0ffi-link\0").update(bytes); + const keyHex = key .digest("hex"); const binDir = join(root, "bin"); - const cachedBin = join(binDir, key); + const cachedBin = join(binDir, keyHex); try { // NEVER copy over outPath in place: overwriting an already-executed // signed binary invalidates the kernel's per-vnode code-signature cache @@ -1284,7 +1301,7 @@ export async function compileC(opts: CcOptions): Promise { await runClang(buildArgs((p) => objects?.get(p) ?? p)); try { await mkdir(binDir, { recursive: true }); - const tmp = join(binDir, `.tmp-${key.slice(0, 8)}-${process.pid}-${Math.random().toString(36).slice(2)}`); + const tmp = join(binDir, `.tmp-${keyHex.slice(0, 8)}-${process.pid}-${Math.random().toString(36).slice(2)}`); await copyFile(opts.outPath, tmp); await chmod(tmp, 0o755); await rename(tmp, cachedBin); diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index 9c71cc43a..cb472286d 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -1434,6 +1434,52 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { if (E.mayThrow.has(e.callee)) E.emitPendingCheck(); return t; } + case "ffiCall": { + const entry = E.ffiByName.get(e.import); + if (!entry) throw new Error(`emitter bug: unknown FFI import ${e.import}`); + const args = e.args.map((arg) => E.emitExpr(arg)); + const nativeArgs: string[] = []; + entry.params.forEach((cls, i) => { + const arg = args[i]!; + switch (cls) { + case "f64": + nativeArgs.push(arg.name); + break; + case "bool": + nativeArgs.push(`(uint8_t)(${arg.name} ? 1 : 0)`); + break; + case "u8": + nativeArgs.push(`(uint8_t)(uint32_t)scr_bit_ushr(${arg.name}, 0.0)`); + break; + case "u32": + nativeArgs.push(`(uint32_t)scr_bit_ushr(${arg.name}, 0.0)`); + break; + case "i32": + nativeArgs.push(`(int32_t)scr_bit_or(${arg.name}, 0.0)`); + break; + case "string": + nativeArgs.push(`(const uint8_t *)${arg.name}->data`, `${arg.name}->len`); + break; + case "bytes": + nativeArgs.push(`(const uint8_t *)${arg.name}->data`, `${arg.name}->len`); + break; + } + }); + const call = `${entry.symbol}(${nativeArgs.join(", ")})`; + switch (entry.returns) { + case "void": + E.line(`${call};${E.srcComment(e.loc)}`); + return { name: "", type: e.type }; + case "f64": + return E.newTemp(e.type, call); + case "bool": + return E.newTemp(e.type, `(${call} != 0)`); + case "u8": + case "u32": + case "i32": + return E.newTemp(e.type, `(double)${call}`); + } + } case "closure": { const target = E.fnByName.get(e.fnName); if (!target) throw new Error(`emitter bug: closure over unknown function ${e.fnName}`); diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index 076bd002d..195ef57b4 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -24,6 +24,7 @@ import type { IrGlobal, IrRecordShape, IrExpr, + IrFfiImport, IrFunction, IrLocal, IrModule, @@ -167,6 +168,8 @@ export class CEmitter { * programs with no async functions. */ usesTimers = false; readonly fnByName = new Map(); + /** Manifest-bound native imports, used by ffiCall emission. */ + readonly ffiByName = new Map(); readonly globalsById = new Map(); readonly unionsById = new Map(); /** Active optional-chain bind temps, by chain id (chainRecv reads). */ @@ -319,6 +322,7 @@ export class CEmitter { this.returnTypeByFn.set(fn.name, fn.returnType); this.fnByName.set(fn.name, fn); } + for (const entry of mod.ffiImports ?? []) this.ffiByName.set(entry.name, entry); const mt = computeMayThrow(mod); this.mayThrow = mt.fns; this.indirectMayThrow = mt.indirect; @@ -608,6 +612,34 @@ export class CEmitter { out.push(`static ${cDecl(g.type, mangleGlobal(g.id))}; /* ${g.name} */`); } if (globals.length > 0) out.push(""); + // Outbound native FFI declarations. string/bytes each expand from one + // scriptc value to a borrowed pointer+length pair at the C boundary. + for (const entry of this.mod.ffiImports ?? []) { + const params = entry.params.flatMap((cls): string[] => { + switch (cls) { + case "f64": + return ["double"]; + case "bool": + case "u8": + return ["uint8_t"]; + case "u32": + return ["uint32_t"]; + case "i32": + return ["int32_t"]; + case "string": + case "bytes": + return ["const uint8_t *", "size_t"]; + } + }); + const ret = + entry.returns === "f64" ? "double" + : entry.returns === "bool" || entry.returns === "u8" ? "uint8_t" + : entry.returns === "u32" ? "uint32_t" + : entry.returns === "i32" ? "int32_t" + : "void"; + out.push(`extern ${ret} ${entry.symbol}(${params.length > 0 ? params.join(", ") : "void"});`); + } + if ((this.mod.ffiImports?.length ?? 0) > 0) out.push(""); for (const fn of this.mod.functions) out.push(this.signature(fn) + ";"); // Class objects (classes as values): construct-thunk prototypes plus // the immortal statics that take their addresses — after the function diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6df195d23..a1a247c36 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -62,6 +62,7 @@ */ import type { IrExpr, + IrFfiImport, IrFunction, IrGlobal, IrLocal, @@ -885,6 +886,8 @@ class LlEmitter { private needsRetainBox = false; private readonly fnByName = new Map(); + /** Manifest-bound native imports, used by ffiCall emission. */ + private readonly ffiByName = new Map(); private readonly globalTypes = new Map(); /** May-throw analysis (the C emitter's computeMayThrow, shared): pending * checks are emitted only after calls that can actually raise. */ @@ -975,6 +978,7 @@ class LlEmitter { constructor(private readonly mod: IrModule) { for (const fn of mod.functions) this.fnByName.set(fn.name, fn); + for (const entry of mod.ffiImports ?? []) this.ffiByName.set(entry.name, entry); const mt = computeMayThrow(mod); this.mayThrow = mt.fns; this.indirectMayThrow = mt.indirect; @@ -4963,6 +4967,106 @@ class LlEmitter { if (this.mayThrow.has(e.callee)) this.emitPendingCheck(); return out; } + case "ffiCall": { + const entry = this.ffiByName.get(e.import); + if (!entry) throw new Error(`llvm emitter bug: unknown FFI import ${e.import}`); + const args = e.args.map((arg) => this.emitExpr(arg)); + const nativeArgs: string[] = []; + const nativeParamTypes: string[] = []; + entry.params.forEach((cls, i) => { + const arg = args[i]!; + switch (cls) { + case "f64": + nativeParamTypes.push("double"); + nativeArgs.push(`double ${arg.name}`); + break; + case "bool": { + const widened = B.tmp(); + B.line(`${widened} = zext i1 ${arg.name} to i8`); + nativeParamTypes.push("i8"); + nativeArgs.push(`i8 ${widened}`); + break; + } + case "u8": + case "u32": { + this.declare(`declare double @scr_bit_ushr(double, double)`); + const asDouble = B.tmp(); + const asU32 = B.tmp(); + B.line(`${asDouble} = call double @scr_bit_ushr(double ${arg.name}, double ${f64Lit(0)})`); + B.line(`${asU32} = fptoui double ${asDouble} to i32`); + if (cls === "u8") { + const asU8 = B.tmp(); + B.line(`${asU8} = trunc i32 ${asU32} to i8`); + nativeParamTypes.push("i8"); + nativeArgs.push(`i8 ${asU8}`); + } else { + nativeParamTypes.push("i32"); + nativeArgs.push(`i32 ${asU32}`); + } + break; + } + case "i32": { + this.declare(`declare double @scr_bit_or(double, double)`); + const asDouble = B.tmp(); + const asI32 = B.tmp(); + B.line(`${asDouble} = call double @scr_bit_or(double ${arg.name}, double ${f64Lit(0)})`); + B.line(`${asI32} = fptosi double ${asDouble} to i32`); + nativeParamTypes.push("i32"); + nativeArgs.push(`i32 ${asI32}`); + break; + } + case "string": { + const lenPtr = B.tmp(); + const len = B.tmp(); + const data = B.tmp(); + B.line(`${lenPtr} = getelementptr inbounds %ScrStr, ptr ${arg.name}, i64 0, i32 1`); + B.line(`${len} = load i64, ptr ${lenPtr}`); + B.line(`${data} = getelementptr inbounds i8, ptr ${arg.name}, i64 24`); + nativeParamTypes.push("ptr", "i64"); + nativeArgs.push(`ptr ${data}`, `i64 ${len}`); + break; + } + case "bytes": { + const lenPtr = B.tmp(); + const len = B.tmp(); + const dataPtr = B.tmp(); + const data = B.tmp(); + B.line(`${lenPtr} = getelementptr inbounds i8, ptr ${arg.name}, i64 8`); + B.line(`${len} = load i64, ptr ${lenPtr}`); + B.line(`${dataPtr} = getelementptr inbounds i8, ptr ${arg.name}, i64 24`); + B.line(`${data} = load ptr, ptr ${dataPtr}`); + nativeParamTypes.push("ptr", "i64"); + nativeArgs.push(`ptr ${data}`, `i64 ${len}`); + break; + } + } + }); + const retTy = + entry.returns === "f64" ? "double" + : entry.returns === "bool" || entry.returns === "u8" ? "i8" + : entry.returns === "u32" || entry.returns === "i32" ? "i32" + : "void"; + this.declare( + `declare ${retTy} @${entry.symbol}(${nativeParamTypes.join(", ")})`, + ); + const call = `call ${retTy} @${entry.symbol}(${nativeArgs.join(", ")})`; + if (entry.returns === "void") { + B.line(call); + return { name: "", type: e.type }; + } + const raw = B.tmp(); + B.line(`${raw} = ${call}`); + if (entry.returns === "f64") return { name: raw, type: e.type }; + if (entry.returns === "bool") { + const value = B.tmp(); + B.line(`${value} = icmp ne i8 ${raw}, 0`); + return { name: value, type: e.type }; + } + const value = B.tmp(); + const op = entry.returns === "i32" ? "sitofp" : "uitofp"; + B.line(`${value} = ${op} ${retTy} ${raw} to double`); + return { name: value, type: e.type }; + } case "new": { // Allocate (fields zeroed, vt stamped), then run the ctor. The // ctor owns and releases its `this` param like any callee, so it diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 28a7a7697..2676651e8 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -39,6 +39,10 @@ * (SC4022 — may-be-NaN or may-be-fractional at a declared * i64/u64 slot), and range (SC4023 — the proven interval does * not fit ±(2^53 − 1), or is negative at a u64 slot) + * SC5xxx native FFI: malformed manifests (SC5001), a configured + * binding that is not an ambient function declaration + * (SC5002), and a TypeScript signature that does not match its + * declared native ABI classes (SC5003) * SC9xxx internal compiler errors (still source-anchored) */ import type { SrcLoc } from "../ir/nodes.js"; @@ -62,6 +66,43 @@ export interface ScrDiagnostic { note?: string; } +/* ── SC5xxx: native FFI ───────────────────────────────────────────────── */ + +/** SC5001 — the outbound native-FFI manifest is malformed or unreadable. */ +export function ffiProfileDiag(detail: string, profilePath: string): ScrDiagnostic { + return { + code: "SC5001", + message: `FFI manifest invalid: ${detail}`, + loc: { file: profilePath, start: 0, end: 0 }, + }; +} + +/** SC5002 — a manifest binding must resolve to a signature-only ambient + * function. A function with a body is ordinary scriptc code and must never + * silently turn into a native call because its name happened to match. */ +export function ffiBindingDiag(name: string, detail: string, loc: SrcLoc): ScrDiagnostic { + return { + code: "SC5002", + message: `FFI binding '${name}' cannot be resolved: ${detail}`, + loc, + hint: + `declare it as a signature-only ambient function, for example ` + + `'declare function ${name}(...): ...'; scriptc only replaces direct calls of that exact binding`, + }; +} + +/** SC5003 — the ambient TypeScript signature and manifest ABI disagree. */ +export function ffiSignatureDiag(name: string, detail: string, loc: SrcLoc): ScrDiagnostic { + return { + code: "SC5003", + message: `FFI binding '${name}' has an incompatible signature: ${detail}`, + loc, + hint: + "FFI parameter classes: f64/u8/u32/i32 (TypeScript number), bool, string, and bytes " + + "(Uint8Array/Buffer); return classes: f64/u8/u32/i32, bool, and void", + }; +} + interface UnsupportedEntry { feature: string; milestone: Milestone; diff --git a/packages/compiler/src/ffi/profile.ts b/packages/compiler/src/ffi/profile.ts new file mode 100644 index 000000000..68cf8dd7a --- /dev/null +++ b/packages/compiler/src/ffi/profile.ts @@ -0,0 +1,235 @@ +/* Outbound native-FFI manifest. The manifest is configuration, never code: + * it binds signature-only TypeScript declarations to C ABI symbols and + * names the link inputs an executable build adds after its generated + * translation unit. + * + * Format 1: + * { + * "ffi_format": 1, + * "functions": [ + * { "name": "nativeScale", "symbol": "native_scale", + * "params": ["f64"], "returns": "f64" } + * ], + * "libraries": ["native/libnative.a"], + * "system_libraries": ["m"] + * } + * + * string and bytes parameters expand to the length-delimited C pair + * `(const uint8_t *, size_t)`. Their storage is borrowed for the duration + * of the call only. Return values deliberately stay scalar in format 1: + * pointer ownership needs an allocator/free contract, not a guess. */ +import { readFileSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { ffiProfileDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js"; + +export const FFI_PARAM_CLASSES = [ + "f64", + "bool", + "u8", + "u32", + "i32", + "string", + "bytes", +] as const; + +export const FFI_RETURN_CLASSES = ["f64", "bool", "u8", "u32", "i32", "void"] as const; + +export type FfiParamClass = (typeof FFI_PARAM_CLASSES)[number]; +export type FfiReturnClass = (typeof FFI_RETURN_CLASSES)[number]; + +export interface FfiFunction { + /** The signature-only TypeScript function declaration's binding name. */ + name: string; + /** The external C symbol linked into the executable. */ + symbol: string; + params: FfiParamClass[]; + returns: FfiReturnClass; +} + +export interface FfiProfile { + ffiFormat: 1; + functions: FfiFunction[]; + /** Absolute paths, resolved relative to the manifest. */ + libraries: string[]; + /** Driver-neutral names; the linker receives each as `-l`. */ + systemLibraries: string[]; +} + +class FfiProfileError extends Error { + constructor(readonly detail: string) { + super(detail); + } +} + +const C_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; +const TS_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const SYSTEM_LIBRARY = /^[A-Za-z0-9_+.-]+$/; + +function rejectUnknownKeys(obj: object, path: string, known: readonly string[]): void { + for (const key of Object.keys(obj)) { + if (!known.includes(key)) { + const field = path === "" ? key : `${path}.${key}`; + throw new FfiProfileError( + `unknown field '${field}' ` + + "(root and function keys are strict so an ABI typo cannot be ignored)", + ); + } + } +} + +function stringField(value: unknown, path: string): string { + if (typeof value !== "string") { + throw new FfiProfileError(`'${path}' must be a string`); + } + if (value === "") throw new FfiProfileError(`'${path}' must be non-empty`); + return value; +} + +function stringArray(value: unknown, path: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new FfiProfileError(`'${path}' must be an array`); + return value.map((entry, i) => stringField(entry, `${path}[${i}]`)); +} + +/** Parse and strictly validate one outbound native-FFI manifest. */ +export function loadFfiProfile( + profilePath: string, +): { ok: true; profile: FfiProfile } | { ok: false; diagnostics: ScrDiagnostic[] } { + const fail = (detail: string): { ok: false; diagnostics: ScrDiagnostic[] } => ({ + ok: false, + diagnostics: [ffiProfileDiag(detail, profilePath)], + }); + let bytes: Uint8Array; + try { + bytes = readFileSync(profilePath); + } catch (err) { + return fail(`cannot read manifest: ${(err as Error).message}`); + } + let raw: unknown; + try { + raw = JSON.parse(new TextDecoder().decode(bytes)); + } catch (err) { + return fail(`manifest is not valid JSON (${(err as Error).message})`); + } + try { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new FfiProfileError("manifest must be a JSON object"); + } + const root = raw as Record; + const format = root["ffi_format"]; + if (format !== 1) { + throw new FfiProfileError( + typeof format === "number" + ? `unsupported ffi_format ${format} (this scriptc reads format 1)` + : "'ffi_format' must be the number 1", + ); + } + rejectUnknownKeys(root, "", [ + "ffi_format", + "functions", + "libraries", + "system_libraries", + ]); + + const functionsRaw = root["functions"]; + if (!Array.isArray(functionsRaw)) { + throw new FfiProfileError("'functions' must be an array"); + } + const names = new Set(); + const symbols = new Set(); + const functions = functionsRaw.map((entry, i): FfiFunction => { + const path = `functions[${i}]`; + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new FfiProfileError(`'${path}' must be an object`); + } + rejectUnknownKeys(entry, path, ["name", "symbol", "params", "returns"]); + const row = entry as Record; + const name = stringField(row["name"], `${path}.name`); + if (!TS_IDENT.test(name)) { + throw new FfiProfileError(`'${path}.name' is not a plain TypeScript identifier: '${name}'`); + } + if (names.has(name)) { + throw new FfiProfileError(`function binding '${name}' is declared twice`); + } + names.add(name); + + const symbol = stringField(row["symbol"], `${path}.symbol`); + if (!C_IDENT.test(symbol)) { + throw new FfiProfileError(`'${path}.symbol' is not a C identifier: '${symbol}'`); + } + if (symbols.has(symbol)) { + throw new FfiProfileError(`native symbol '${symbol}' is declared twice`); + } + symbols.add(symbol); + + if (!Array.isArray(row["params"])) { + throw new FfiProfileError(`'${path}.params' must be an array`); + } + const params = row["params"].map((value, j) => { + if ( + typeof value !== "string" || + !(FFI_PARAM_CLASSES as readonly string[]).includes(value) + ) { + throw new FfiProfileError( + `'${path}.params[${j}]' must be one of ${FFI_PARAM_CLASSES.join("/")}, got ${JSON.stringify(value)}`, + ); + } + return value as FfiParamClass; + }); + const returns = stringField(row["returns"], `${path}.returns`); + if (!(FFI_RETURN_CLASSES as readonly string[]).includes(returns)) { + throw new FfiProfileError( + `'${path}.returns' must be one of ${FFI_RETURN_CLASSES.join("/")}, got '${returns}'`, + ); + } + return { name, symbol, params, returns: returns as FfiReturnClass }; + }); + + const libraries = stringArray(root["libraries"], "libraries").map((path) => + resolve(dirname(profilePath), path) + ); + const systemLibraries = stringArray( + root["system_libraries"], + "system_libraries", + ); + for (const [i, name] of systemLibraries.entries()) { + if (!SYSTEM_LIBRARY.test(name) || name.startsWith("-")) { + throw new FfiProfileError( + `'system_libraries[${i}]' is not a library name: '${name}'`, + ); + } + } + if (new Set(libraries).size !== libraries.length) { + throw new FfiProfileError("'libraries' contains a duplicate path"); + } + for (const [i, path] of libraries.entries()) { + try { + if (!statSync(path).isFile()) { + throw new FfiProfileError( + `'libraries[${i}]' does not name a file: '${path}'`, + ); + } + } catch (err) { + if (err instanceof FfiProfileError) throw err; + throw new FfiProfileError( + `'libraries[${i}]' cannot be read at '${path}': ${(err as Error).message}`, + ); + } + } + if (new Set(systemLibraries).size !== systemLibraries.length) { + throw new FfiProfileError("'system_libraries' contains a duplicate name"); + } + return { + ok: true, + profile: { + ffiFormat: 1, + functions, + libraries, + systemLibraries, + }, + }; + } catch (err) { + if (err instanceof FfiProfileError) return fail(err.detail); + throw err; + } +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index dc2a97f06..782bdb4ed 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -5,13 +5,13 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { lowerGenMethodCall } from "./lower-generators.js"; -import { BOOL, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, STRING, SYMBOL_T, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, funcOf, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/nodes.js"; +import { BOOL, BYTES_U8, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, STRING, SYMBOL_T, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, funcOf, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/nodes.js"; import { isJsSourceFile, locOf } from "../program.js"; import { isGenericCallableMemberType, typeKey } from "../types.js"; import { PoisonError, dynFallbackType, dynUndefinedExpr, importCallHandleType, jsFuncNameOf, newFnCtx, nodeThrowExpr } from "./lowerer.js"; import { enforceLibBoundary } from "./lib-boundary.js"; import { NARROW_FIRST, builtinFenceHintOf, builtinModuleFnOf } from "./surfaces.js"; -import { requiresDynamicDiag } from "../../diagnostics/diagnostic.js"; +import { ffiBindingDiag, ffiSignatureDiag, requiresDynamicDiag } from "../../diagnostics/diagnostic.js"; import { mixinFnShapeOf } from "./lower-mixins.js"; import { bufEncoding, dynStringReceiver, lowerArrayFromCall, lowerDynArrayFilterCall, lowerDynArrayFlatMapCall, lowerGroupByStaticCall, lowerIteratorHelperCall, lowerObjectAssignIndexShape, lowerObjectFromEntriesCall, lowerObjectIterOverIndexShape, lowerRegexMethodCall, lowerStringMethodCall, lowerTupleReadMethodCall } from "./lower-containers.js"; import { lowerChildStreamMethodCall, lowerCreateRequireCall, lowerDirentMethodCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerWatcherMethodCall } from "./lower-builtins.js"; @@ -2468,6 +2468,114 @@ export function islandPrimitiveExit(L: Lowerer, call: ts.CallExpression, result: return null; } +/** A manifest-bound call of a signature-only ambient declaration. This + * recognition deliberately runs before ambientUndefVarRootOf: without the + * manifest the exact same source keeps Node's ReferenceError semantics; + * with it, only the resolved declaration binding (never a shadowing + * function with a body) becomes a direct native call. */ +export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null { + if (!ts.isIdentifier(expr.expression)) return null; + const binding = L.ffiImportsByName.get(expr.expression.text); + if (binding === undefined) return null; + const loc = locOf(expr); + const bindingError = (detail: string): never => { + L.pushDiag(ffiBindingDiag(binding.name, detail, loc)); + throw new PoisonError(); + }; + const signatureError = (detail: string): never => { + L.pushDiag(ffiSignatureDiag(binding.name, detail, loc)); + throw new PoisonError(); + }; + if (expr.questionDotToken !== undefined || expr.typeArguments !== undefined) { + signatureError("native bindings support direct, non-generic calls only"); + } + const symbol = + L.resolveValueSymbol(expr.expression) ?? + bindingError("the call has no resolved TypeScript symbol"); + const declarations = L.checker.declarationsOf(symbol); + const functionDecls = declarations.filter(ts.isFunctionDeclaration); + if ( + functionDecls.length === 0 || + declarations.some((decl) => !ts.isFunctionDeclaration(decl)) || + functionDecls.some((decl) => decl.body !== undefined) + ) { + bindingError( + "the configured name does not resolve exclusively to signature-only function declarations", + ); + } + if (functionDecls.some((decl) => (decl.typeParameters?.length ?? 0) > 0)) { + signatureError("generic ambient declarations cannot describe one fixed C ABI"); + } + const signatures = L.checker.getCallSignatures(L.typeOf(expr.expression)); + if (signatures.length !== 1) { + signatureError( + `the ambient binding has ${signatures.length} call signatures; exactly one non-overloaded signature is required`, + ); + } + const signature = signatures[0]!; + const params = signature.getParameters(); + if (params.length !== binding.params.length) { + signatureError( + `the TypeScript declaration has ${params.length} parameter(s), but the manifest declares ${binding.params.length}`, + ); + } + if (expr.arguments.some(ts.isSpreadElement)) { + signatureError("spread arguments do not have a fixed native ABI"); + } + if (expr.arguments.length !== binding.params.length) { + signatureError( + `this call passes ${expr.arguments.length} argument(s), but the native ABI requires exactly ${binding.params.length}`, + ); + } + const typeForClass = ( + cls: (typeof binding.params)[number] | typeof binding.returns, + ): IrType => { + switch (cls) { + case "bool": + return BOOL; + case "string": + return STRING; + case "bytes": + return BYTES_U8; + case "void": + return VOID; + default: + return F64; + } + }; + const expectedParams = binding.params.map(typeForClass); + params.forEach((param, i) => { + const paramType = L.checker.getTypeOfSymbol(param); + const mapped = L.mapTypeOf(paramType); + const expected = expectedParams[i]!; + if (mapped === null || !typeEquals(mapped, expected)) { + signatureError( + `parameter ${i + 1} maps to '${mapped === null ? L.checker.typeToString(paramType) : L.fmt(mapped)}', ` + + `which does not fit manifest class '${binding.params[i]}'`, + ); + } + }); + const returnType = L.checker.getReturnTypeOfSignature(signature); + const declaredReturn = L.mapTypeOf(returnType); + const expectedReturn = typeForClass(binding.returns); + if (declaredReturn === null || !typeEquals(declaredReturn, expectedReturn)) { + signatureError( + `the return maps to '${declaredReturn === null ? L.checker.typeToString(returnType) : L.fmt(declaredReturn)}', ` + + `which does not fit manifest class '${binding.returns}'`, + ); + } + const args = expr.arguments.map((arg, i) => + L.lowerExprExpecting(arg, expectedParams[i]!) + ); + return { + kind: "ffiCall", + import: binding.name, + args, + type: expectedReturn, + loc, + }; + } + export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr { const loc = locOf(expr); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0091ff57b..43d6d8e84 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -38,6 +38,7 @@ import { import type { IrClassDef, IrExpr, + IrFfiImport, IrFunction, IrGlobal, IrLocal, @@ -89,7 +90,7 @@ import { CompoundOp, IslandFnEntry, boundaryIntoIslandMsg, boundaryOutOfIslandMs import { FileParts, splitFiles, collectProgram, collectNpmImports, collectJsonImports, moduleArtifacts, collectGlobals, declSymbolOf, defaultExportSymbolOf, lowerFileInit, lowerDefaultExport, buildMain, appendDynamicImportModules } from "./lower-modules.js"; import { ClassInfo, ClassIteratorInfo, GenericClassInfo, registerBuiltinErrorClasses, registerBuiltinEmitterClass, registerBuiltinStreamClasses, builtinErrorInfoOf, builtinEmitterInfoOf, builtinStreamInfoOf, analyzeClassDecoration, classIteratorDrainCall, classIteratorNextCall, classIteratorOf, classIteratorOpenCall, classIteratorRestDrainCall, classMemberNameOf, classValueRef, collectClassShape, exactClassOfReceiver, collectClassShapeInner, ctorAbiEquals, findMethodOn, findStaticOn, findGenericMethodOn, findGenericStaticOn, genericClassInstanceType, isSubclassOf, inHierarchy, overrideBelow, staticShadowBelow, upcastTo, lowerClassMembers, lowerClassCtor, lowerClassExpression, lowerClassExpressionInfo, lowerClassMethodMember, lowerClassValueProperty, lowerStaticMethod, throwingSetterFn, fieldInitStmts, lowerStaticFieldInits, lowerStaticFieldRead, lowerDerivedCtorBody, superCallStmt, lowerSuperMethodCall, superThisRef, lowerSuperAccessorRead, lowerSuperAccessorWrite, inheritsBuiltinErrorCtor, inheritsBuiltinEmitterCtor, errorMessageArg, lowerNew, accessorCall } from "./lower-classes.js"; import { MixinFnShape, mixinCallClassInfoOf, mixinIntersectionInstanceType } from "./lower-mixins.js"; -import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction } from "./lower-calls.js"; +import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction } from "./lower-calls.js"; import { lowerArrayMethodCall, lowerBufferStaticCall, lowerBytesMethodCall, lowerBytesNew, lowerMapMethodCall, lowerMapForEachCall, buildMapForEachFn, lowerRecordOvfCaptureHelper, lowerEnvToPairsHelper, lowerSetMethodCall, lowerSetForEachCall, buildSetForEachFn, lowerRegexMethodCall, lowerStringMethodCall } from "./lower-containers.js"; import { lowerStreamModuleCall } from "./lower-stream.js"; import { lowerEmitOverrideSpec, type EmitSpecCtx, type EmitSpecRequest } from "./lower-emitter.js"; @@ -344,6 +345,11 @@ export interface LowerOptions { * OUTSIDE the graph, so discovery seeds them alongside the init * bodies. Names are the entry file's unqualified declaration names. */ libRoots?: readonly string[]; + /** Outbound native FFI declarations from a validated format-1 manifest. + * Calls of their exact ambient TypeScript bindings lower to direct C ABI + * imports; without this option ambient declarations keep Node's ordinary + * ReferenceError behavior. */ + ffiImports?: readonly IrFfiImport[]; } /** The Lowerer's pass configuration (see lowerToIr). */ @@ -365,6 +371,8 @@ export interface LowererMode { * module init — Node refuses the whole graph before anything evaluates, * so nothing runs. */ startupCrash?: StartupCrash | null; + /** The build's outbound native FFI declarations. */ + ffiImports?: readonly IrFfiImport[]; } /** Build lowering runs in two passes over the same ts.Program: @@ -411,8 +419,17 @@ export function lowerToIr( ); }); } - const reachable = new Lowerer(program, entry, moduleOrder, dynamic, { targetPlatform }).discover(options.libRoots); - const emit = new Lowerer(program, entry, moduleOrder, dynamic, { reachable, targetPlatform, startupCrash }); + const ffiImports = options.ffiImports ?? []; + const reachable = new Lowerer(program, entry, moduleOrder, dynamic, { + targetPlatform, + ffiImports, + }).discover(options.libRoots); + const emit = new Lowerer(program, entry, moduleOrder, dynamic, { + reachable, + targetPlatform, + startupCrash, + ffiImports, + }); for (const d of dynamicCycleDiags) emit.pushDiag(d); const result = emit.run(); if (options.coverage !== true) return result; @@ -421,6 +438,7 @@ export function lowerToIr( remainder: true, alreadyFlushed: emit.flushedSymbols, targetPlatform, + ffiImports, }); const rem = remainder.run(); return { ...result, unreached: { diagnostics: rem.diagnostics, stats: rem.stats } }; @@ -1095,6 +1113,9 @@ export class Lowerer { readonly targetPlatform: string; /** LowererMode.startupCrash — buildMain opens %main with the throw. */ readonly startupCrash: StartupCrash | null; + /** Outbound native bindings by their source-level ambient name. */ + readonly ffiImports: readonly IrFfiImport[]; + readonly ffiImportsByName: ReadonlyMap; /** Symbols a POISONED declaration statement would have bound: the * declaration's own diagnostic is already recorded, and no local/global * registered, so later references fall through every resolution step — @@ -1147,6 +1168,8 @@ export class Lowerer { this.alreadyFlushed = mode.alreadyFlushed ?? new Set(); this.targetPlatform = mode.targetPlatform ?? process.platform; this.startupCrash = mode.startupCrash ?? null; + this.ffiImports = mode.ffiImports ?? []; + this.ffiImportsByName = new Map(this.ffiImports.map((entry) => [entry.name, entry])); this.checker = program.getTypeChecker(); this.typeCtx = { checker: this.checker, @@ -1804,7 +1827,7 @@ export class Lowerer { this.diags.length > 0 ? null : { - irVersion: 1, + irVersion: 2, sourceFile: this.entry.fileName, functions, classes: artifacts.classes, @@ -1813,6 +1836,7 @@ export class Lowerer { globals: this.globalsList, ...(this.npmEmbedded ? { embedded: this.npmEmbedded } : {}), entry: ENTRY_NAME, + ...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}), }; return { module, @@ -7031,6 +7055,7 @@ export class Lowerer { // have no lowering for a promise-returning ambient global, and // `import` is a keyword callee no identifier path matches). return ( + lowerFfiCall(this, expr) ?? lowerFetchCall(this, expr) ?? lowerDynamicImportCall(this, expr) ?? lowerCall(this, expr) diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index bbbb5cbb2..615a87eb2 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -29,6 +29,7 @@ import { resolveBareModule } from "./frontend/resolve.js"; import { isJsSourceFileName, isRelativeSpecifier } from "./frontend/shared.js"; import { lowerToIr, type LowerOptions, type LowerResult } from "./frontend/lowering/lowerer.js"; import type { CoverageInput, NpmStaticStatus } from "./coverage/report.js"; +import { loadFfiProfile, type FfiProfile } from "./ffi/profile.js"; export const VERSION = "0.0.1"; @@ -58,6 +59,15 @@ export { type LibParamClass, type LibReturnClass, } from "./library/profile.js"; +export { + loadFfiProfile, + FFI_PARAM_CLASSES, + FFI_RETURN_CLASSES, + type FfiFunction, + type FfiParamClass, + type FfiProfile, + type FfiReturnClass, +} from "./ffi/profile.js"; export { assembleTrapTeaching, TRAP_TEACHING_MARKER, @@ -122,6 +132,10 @@ export interface CompileOptions { * — never a silent misbuild. Off by default: nothing changes without * the flag. */ npmStatic?: readonly string[] | "auto"; + /** Outbound native FFI manifest. Its signature-only TypeScript bindings + * lower to direct C ABI calls, and its archive/system-library inputs are + * appended to the executable link. */ + ffiProfilePath?: string; } export type CompileResult = @@ -173,6 +187,8 @@ export interface AnalyzeOptions { * opted-in packages' JS as program modules and the coverage report * carries each package's static/fallback status. */ npmStatic?: readonly string[] | "auto"; + /** Analyze with the outbound native bindings from this FFI manifest. */ + ffiProfilePath?: string; } /* ── the frontend, one pipeline shape ─────────────────────────────────── @@ -508,6 +524,23 @@ function runFrontend(entryPath: string, npmStatic?: readonly string[] | "auto" | /** Analysis without codegen: how much of the program compiles statically. * Unlike compile(), lowering diagnostics are data here, not failure. */ export function analyze(entryPath: string, opts: AnalyzeOptions = {}): AnalyzeResult { + let ffi: FfiProfile | null = null; + if (opts.ffiProfilePath !== undefined) { + const loaded = loadFfiProfile(opts.ffiProfilePath); + if (!loaded.ok) { + return { + coverage: { + file: entryPath, + dynamic: opts.dynamic ?? false, + stats: { statementsTotal: 0, statementsFailed: 0, statementsIsland: 0, functionsSkipped: 0 }, + diagnostics: loaded.diagnostics, + preflightFailed: true, + }, + sourceTexts: new Map(), + }; + } + ffi = loaded.profile; + } const fe = runFrontend(entryPath, opts.npmStatic); try { const emptyStats = { statementsTotal: 0, statementsFailed: 0, statementsIsland: 0, functionsSkipped: 0 }; @@ -543,6 +576,7 @@ export function analyze(entryPath: string, opts: AnalyzeOptions = {}): AnalyzeRe dynamic: opts.dynamic ?? false, coverage: true, targetPlatform: buildTargetPlatform(), + ...(ffi !== null ? { ffiImports: ffi.functions } : {}), }); const provenance = provenanceSources(); return { @@ -575,6 +609,14 @@ export function analyze(entryPath: string, opts: AnalyzeOptions = {}): AnalyzeRe /** The whole pipeline: load → preflight → lower → validate → emit C → clang. */ export async function compile(entryPath: string, opts: CompileOptions): Promise { + let ffi: FfiProfile | null = null; + if (opts.ffiProfilePath !== undefined) { + const loaded = loadFfiProfile(opts.ffiProfilePath); + if (!loaded.ok) { + return { ok: false, diagnostics: loaded.diagnostics, sourceTexts: new Map() }; + } + ffi = loaded.profile; + } const fe = runFrontend(entryPath, opts.npmStatic); let lowered: LowerResult; let entryText: string; @@ -594,6 +636,7 @@ export async function compile(entryPath: string, opts: CompileOptions): Promise< lowered = fe.lower({ dynamic: opts.dynamic ?? false, targetPlatform: buildTargetPlatform(), + ...(ffi !== null ? { ffiImports: ffi.functions } : {}), }); } catch (e) { // The last-resort panic fence: an upstream tsgo panic that crossed a @@ -736,6 +779,12 @@ export async function compile(entryPath: string, opts: CompileOptions): Promise< // cc.ts also compiles it under the tls gate (scr_tls.c consults the // unit's default-set override for its trust anchors). tlsCa: moduleUsesTlsCa(lowered.module), + ...(ffi !== null + ? { + linkInputs: ffi.libraries, + systemLibraries: ffi.systemLibraries, + } + : {}), }); return { ok: true, diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index b880da52d..df9af5e1e 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -676,7 +676,7 @@ export function isRefCounted(t: IrType): boolean { export interface IrModule { /** Bumped on any breaking IR change; serialize.ts refuses mismatches. */ - irVersion: 1; + irVersion: 2; sourceFile: string; functions: IrFunction[]; /** Class shapes. Constructors and methods are ordinary module functions @@ -725,6 +725,11 @@ export interface IrModule { unions?: IrUnionDef[]; /** Name of the synthetic function holding top-level statements. */ entry: string; + /** Outbound native FFI declarations used by `ffiCall` expressions. + * These are link-time C ABI imports, not runtime dynamic-library handles: + * executable builds resolve their symbols from the manifest's archive + * and system-library inputs. Absent when the build has no FFI manifest. */ + ffiImports?: IrFfiImport[]; /** LIBRARY mode: the profile's resolved export map plus the * mode-provided symbol names, landed ON the IR so both backends emit the * external-linkage wrappers and entries from the same facts — the two @@ -733,6 +738,20 @@ export interface IrModule { lib?: IrLibSection; } +/** One outbound native FFI declaration, copied from the validated format-1 + * manifest onto the IR so both backends emit the same C ABI. `string` and + * `bytes` parameters each expand to `(const uint8_t *, size_t)`; their + * storage is borrowed only until the native call returns. Integer inputs + * use JavaScript's ToUint32/ToInt32 coercions before narrowing. */ +export interface IrFfiImport { + /** The signature-only ambient TypeScript binding name. */ + name: string; + /** The external C symbol. */ + symbol: string; + params: ("f64" | "bool" | "u8" | "u32" | "i32" | "string" | "bytes")[]; + returns: "f64" | "bool" | "u8" | "u32" | "i32" | "void"; +} + /** One export-map entry, resolved: the external ccc symbol, the IR * function it wraps, and the marshalling class of each parameter and the * return (already validated against the function's IR types — SC4003 ran @@ -4180,6 +4199,13 @@ export type IrExpr = } /** Call of a user function declared in this module, by name. */ | { kind: "call"; callee: string; args: IrExpr[]; type: IrType; loc: SrcLoc } + /** Direct call of a manifest-bound native C symbol. Arguments are + * borrowed (native code may inspect but never retain string/bytes + * pointers); scalar results return by value. The import's ABI signature + * lives once on IrModule.ffiImports and the validator checks this call + * against it. Native code is outside scriptc's exception protocol: it + * must return normally and must not retain or mutate borrowed storage. */ + | { kind: "ffiCall"; import: string; args: IrExpr[]; type: IrType; loc: SrcLoc } /** Closure creation: a function value over `fnName` (a module function), * capturing the listed boxed locals of the CREATING function (localIds, * in the callee's captures[] order). The result is owned (+1); the closure diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index 81a125520..63c108839 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -5,7 +5,7 @@ */ import type { IrModule } from "./nodes.js"; -export const IR_VERSION = 1 as const; +export const IR_VERSION = 2 as const; export function serializeModule(mod: IrModule): string { return JSON.stringify(mod, (_key, value) => { diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 3972a2ec1..5a7b75d07 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -1082,6 +1082,23 @@ function callSiteReturnType(fn: IrFunction): IrType { return fn.returnType; } +/** IR type carried by one native FFI marshalling class. Integer classes + * are represented as f64 inside scriptc and narrow only at the C edge. */ +function ffiClassType(cls: string): IrType { + switch (cls) { + case "bool": + return BOOL; + case "string": + return STRING; + case "bytes": + return BYTES_U8; + case "void": + return VOID; + default: + return F64; + } +} + export function validateModule(mod: IrModule): IrValidationError[] { const errors: IrValidationError[] = []; const functionsByName = new Map(); @@ -1094,6 +1111,19 @@ export function validateModule(mod: IrModule): IrValidationError[] { } functionsByName.set(fn.name, fn); } + const ffiByName = new Map[number]>(); + const ffiSymbols = new Set(); + const moduleLoc: SrcLoc = { file: mod.sourceFile, start: 0, end: 0 }; + for (const entry of mod.ffiImports ?? []) { + if (ffiByName.has(entry.name)) { + errors.push({ message: `duplicate FFI binding "${entry.name}"`, loc: moduleLoc }); + } + if (ffiSymbols.has(entry.symbol)) { + errors.push({ message: `duplicate FFI symbol "${entry.symbol}"`, loc: moduleLoc }); + } + ffiByName.set(entry.name, entry); + ffiSymbols.add(entry.symbol); + } // The lib section (library mode): every mapped function exists, is // synchronous, and its IR signature fits the declared marshalling // classes — the SC4xxx refusals ran before this landed on the module, so @@ -1442,7 +1472,16 @@ export function validateModule(mod: IrModule): IrValidationError[] { } } for (const fn of mod.functions) { - validateFunction(fn, functionsByName, classesByName, recordsById, unionsById, globalsById, errors); + validateFunction( + fn, + functionsByName, + ffiByName, + classesByName, + recordsById, + unionsById, + globalsById, + errors, + ); } return errors; } @@ -1450,6 +1489,7 @@ export function validateModule(mod: IrModule): IrValidationError[] { function validateFunction( fn: IrFunction, functions: Map, + ffiByName: Map[number]>, classes: Map, records: Map, unions: Map, @@ -2445,6 +2485,34 @@ function validateFunction( } break; } + case "ffiCall": { + for (const arg of e.args) checkExpr(arg); + const entry = ffiByName.get(e.import); + if (!entry) { + err(`FFI call to undeclared import "${e.import}"`, e.loc); + break; + } + if (entry.params.length !== e.args.length) { + err( + `FFI call ${e.import}: ${e.args.length} args, expected ${entry.params.length}`, + e.loc, + ); + } + e.args.forEach((arg, i) => { + const cls = entry.params[i]; + if (cls !== undefined) { + expectType(arg, ffiClassType(cls), `FFI call ${e.import} arg ${i}`); + } + }); + const expected = ffiClassType(entry.returns); + if (!typeEquals(e.type, expected)) { + err( + `FFI call ${e.import} type ${e.type.kind} != return class ${entry.returns}`, + e.loc, + ); + } + break; + } case "closure": { const target = functions.get(e.fnName); if (!target) { diff --git a/packages/compiler/src/library/int-infer.test.ts b/packages/compiler/src/library/int-infer.test.ts index 5d7e2ef93..a82b85971 100644 --- a/packages/compiler/src/library/int-infer.test.ts +++ b/packages/compiler/src/library/int-infer.test.ts @@ -58,7 +58,7 @@ const sink = (name: string): IrFunction => ({ /** A module holding the case function plus the two declared sinks. */ function caseModule(params: string[], locals: string[], body: IrStmt[]): IrModule { return { - irVersion: 1, + irVersion: 2, sourceFile: "corpus.ts", functions: [ sink("send"), diff --git a/packages/compiler/src/library/int-infer.ts b/packages/compiler/src/library/int-infer.ts index db38a5540..fa4a1457b 100644 --- a/packages/compiler/src/library/int-infer.ts +++ b/packages/compiler/src/library/int-infer.ts @@ -1134,6 +1134,7 @@ class FnAnalyzer { case "assignExpr": case "seqExpr": case "call": + case "ffiCall": case "callValue": case "new": case "newValue": diff --git a/packages/compiler/test/emit-c.test.ts b/packages/compiler/test/emit-c.test.ts index 0ada4b7ce..e83b8a688 100644 --- a/packages/compiler/test/emit-c.test.ts +++ b/packages/compiler/test/emit-c.test.ts @@ -35,7 +35,7 @@ test("strings: literals, concat in a loop, toString, RC-clean under audit", asyn // while (i < 3) { acc = acc + ("-" + i); i = i + 1; } // console.log(acc, acc === "x-0-1-2", "α∂" < "β"); const mod: IrModule = { - irVersion: 1, + irVersion: 2, sourceFile: "s.ts", entry: "__main", functions: [ @@ -113,7 +113,7 @@ test("short-circuit: right operand of && only evaluates when left is true", asyn // if (false && sideEffect()) {} ; if (true || sideEffect()) {} // console.log("done") const mod: IrModule = { - irVersion: 1, + irVersion: 2, sourceFile: "l.ts", entry: "__main", functions: [ @@ -161,7 +161,7 @@ test("string params: callee owns and releases; returns transfer ownership", asyn // function greet(who: string): string { return "hi " + who; } // console.log(greet("world")); const mod: IrModule = { - irVersion: 1, + irVersion: 2, sourceFile: "p.ts", entry: "__main", functions: [ diff --git a/packages/compiler/test/fixtures/fib-ir.ts b/packages/compiler/test/fixtures/fib-ir.ts index 48c638a64..a57ef6a9a 100644 --- a/packages/compiler/test/fixtures/fib-ir.ts +++ b/packages/compiler/test/fixtures/fib-ir.ts @@ -18,7 +18,7 @@ const n = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, lo const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); export const fibModule: IrModule = { - irVersion: 1, + irVersion: 2, sourceFile: "fib.ts", entry: "__main", functions: [ diff --git a/packages/compiler/test/ir.test.ts b/packages/compiler/test/ir.test.ts index cdd6e30b6..6515ddd15 100644 --- a/packages/compiler/test/ir.test.ts +++ b/packages/compiler/test/ir.test.ts @@ -16,7 +16,7 @@ test("fib module JSON round-trips", () => { test("validator rejects type mismatches and bad references", () => { const loc = { file: "t.ts", start: 0, end: 0 }; const bad: IrModule = { - irVersion: 1, + irVersion: 2, sourceFile: "t.ts", entry: "__main", functions: [ @@ -77,6 +77,6 @@ test("serializer round-trips ±Infinity and refuses NaN", () => { }); test("deserializer enforces IR version", () => { - const json = serializeModule(fibModule).replace('"irVersion": 1', '"irVersion": 99'); + const json = serializeModule(fibModule).replace('"irVersion": 2', '"irVersion": 99'); expect(() => deserializeModule(json)).toThrow(/version mismatch/); }); diff --git a/tests/ffi/main.ts b/tests/ffi/main.ts new file mode 100644 index 000000000..23eebb1cb --- /dev/null +++ b/tests/ffi/main.ts @@ -0,0 +1,17 @@ +declare function nativeScale(value: number): number; +declare function nativeInvert(value: boolean): boolean; +declare function nativeU8(value: number): number; +declare function nativeU32(value: number): number; +declare function nativeI32(value: number): number; +declare function nativeTextSum(value: string): number; +declare function nativeBytesSum(value: Uint8Array): number; +declare function nativeNote(value: number): void; +declare function nativeLastNote(): number; + +console.log(nativeScale(21)); +console.log(nativeInvert(false), nativeInvert(true)); +console.log(nativeU8(258), nativeU32(-1), nativeI32(4294967295)); +console.log(nativeTextSum("A\0é")); +console.log(nativeBytesSum(new Uint8Array([1, 2, 3]))); +nativeNote(12.5); +console.log(nativeLastNote()); diff --git a/tests/ffi/native.c b/tests/ffi/native.c new file mode 100644 index 000000000..ac67e5667 --- /dev/null +++ b/tests/ffi/native.c @@ -0,0 +1,44 @@ +#include +#include + +static double last_note; + +double sf_scale(double value) { + return value * 2.0; +} + +uint8_t sf_invert(uint8_t value) { + return value ? 0 : 1; +} + +uint8_t sf_u8(uint8_t value) { + return value; +} + +uint32_t sf_u32(uint32_t value) { + return value; +} + +int32_t sf_i32(int32_t value) { + return value; +} + +double sf_text_sum(const uint8_t *data, size_t len) { + double sum = 0; + for (size_t i = 0; i < len; i++) sum += data[i]; + return sum; +} + +double sf_bytes_sum(const uint8_t *data, size_t len) { + double sum = 0; + for (size_t i = 0; i < len; i++) sum += data[i]; + return sum; +} + +void sf_note(double value) { + last_note = value; +} + +double sf_last_note(void) { + return last_note; +} diff --git a/tests/ffi/profile.json b/tests/ffi/profile.json new file mode 100644 index 000000000..57a2c3da2 --- /dev/null +++ b/tests/ffi/profile.json @@ -0,0 +1,16 @@ +{ + "ffi_format": 1, + "functions": [ + { "name": "nativeScale", "symbol": "sf_scale", "params": ["f64"], "returns": "f64" }, + { "name": "nativeInvert", "symbol": "sf_invert", "params": ["bool"], "returns": "bool" }, + { "name": "nativeU8", "symbol": "sf_u8", "params": ["u8"], "returns": "u8" }, + { "name": "nativeU32", "symbol": "sf_u32", "params": ["u32"], "returns": "u32" }, + { "name": "nativeI32", "symbol": "sf_i32", "params": ["i32"], "returns": "i32" }, + { "name": "nativeTextSum", "symbol": "sf_text_sum", "params": ["string"], "returns": "f64" }, + { "name": "nativeBytesSum", "symbol": "sf_bytes_sum", "params": ["bytes"], "returns": "f64" }, + { "name": "nativeNote", "symbol": "sf_note", "params": ["f64"], "returns": "void" }, + { "name": "nativeLastNote", "symbol": "sf_last_note", "params": [], "returns": "f64" } + ], + "libraries": [], + "system_libraries": [] +} diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts new file mode 100644 index 000000000..576160316 --- /dev/null +++ b/tests/harness/ffi.test.ts @@ -0,0 +1,174 @@ +/* Outbound native FFI is an integration lane rather than a corpus case: + * Node has no equivalent static-link surface to differential-run. The same + * TypeScript and native archive run through BOTH scriptc backends, and the + * result bytes must match. The fixture covers every format-1 ABI class, + * integer coercion, embedded-NUL/UTF-8 string spans, byte spans, and a void + * call with observable native state. */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { compile, loadFfiProfile } from "@scriptc/compiler"; + +const repoRoot = join(import.meta.dirname, "../.."); +const fixtureRoot = join(repoRoot, "tests/ffi"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const flavor = sanitize ? "san" : "plain"; +const cacheRoot = join( + repoRoot, + "node_modules/.cache/scriptc-tests/ffi", + flavor, +); + +function nativeArchive(): string { + const outDir = join(cacheRoot, "native"); + mkdirSync(outDir, { recursive: true }); + const object = join(outDir, "native.o"); + const archive = join(outDir, "libnative.a"); + execFileSync("clang", [ + "-std=c11", + ...(sanitize ? ["-O1", "-fsanitize=address"] : ["-O2"]), + "-c", + join(fixtureRoot, "native.c"), + "-o", + object, + ]); + execFileSync("ar", ["rcs", archive, object]); + return archive; +} + +function manifest(archive: string): string { + const outDir = join(cacheRoot, "manifest"); + mkdirSync(outDir, { recursive: true }); + const profile = JSON.parse( + readFileSync(join(fixtureRoot, "profile.json"), "utf8"), + ) as { libraries: string[] }; + profile.libraries = [archive]; + const path = join(outDir, "profile.json"); + writeFileSync(path, JSON.stringify(profile, null, 2)); + return path; +} + +const expected = [ + "42", + "true false", + "2 4294967295 -1", + "429", + "6", + "12.5", + "", +].join("\n"); + +describe.each(["c", "llvm"] as const)("outbound native FFI, %s backend", (backend) => { + test("calls the manifest-bound archive across every v1 ABI class", async () => { + const outDir = join(cacheRoot, backend); + mkdirSync(outDir, { recursive: true }); + const result = await compile(join(fixtureRoot, "main.ts"), { + outDir, + outPath: join(outDir, "program"), + backend, + sanitize, + ffiProfilePath: manifest(nativeArchive()), + }); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + expect( + execFileSync(result.binaryPath, [], { encoding: "utf8" }), + ).toBe(expected); + }); +}); + +test("manifest validation is strict and source-facing", () => { + const path = join(cacheRoot, "invalid.json"); + mkdirSync(cacheRoot, { recursive: true }); + writeFileSync( + path, + JSON.stringify({ + ffi_format: 1, + functions: [ + { name: "f", symbol: "not-a-C-symbol", params: [], returns: "f64" }, + ], + }), + ); + const result = loadFfiProfile(path); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics[0]?.code).toBe("SC5001"); + expect(result.diagnostics[0]?.message).toContain("not a C identifier"); + } +}); + +test("manifest validation reports a missing native link input", () => { + const path = join(cacheRoot, "missing-library.json"); + mkdirSync(cacheRoot, { recursive: true }); + writeFileSync( + path, + JSON.stringify({ + ffi_format: 1, + functions: [], + libraries: ["does-not-exist.a"], + }), + ); + const result = loadFfiProfile(path); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics[0]?.code).toBe("SC5001"); + expect(result.diagnostics[0]?.message).toContain("cannot be read"); + } +}); + +test.each([ + { + name: "a TypeScript signature that disagrees with the manifest", + source: [ + "declare function nativeScale(value: string): number;", + "console.log(nativeScale('21'));", + "", + ].join("\n"), + code: "SC5003", + message: "parameter 1", + }, + { + name: "an ordinary function body with a configured name", + source: [ + "function nativeScale(value: number): number { return value; }", + "console.log(nativeScale(21));", + "", + ].join("\n"), + code: "SC5002", + message: "signature-only", + }, +])("rejects $name", async ({ source, code, message }) => { + const outDir = join(cacheRoot, `reject-${code}`); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, "main.ts"); + const profilePath = join(outDir, "profile.json"); + writeFileSync(entry, source); + writeFileSync( + profilePath, + JSON.stringify({ + ffi_format: 1, + functions: [ + { + name: "nativeScale", + symbol: "sf_scale", + params: ["f64"], + returns: "f64", + }, + ], + }), + ); + const result = await compile(entry, { + outDir, + outPath: join(outDir, "never"), + ffiProfilePath: profilePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics[0]?.code).toBe(code); + expect(result.diagnostics[0]?.message).toContain(message); + } +}); From 2098e0df6d812470927975d6485f19e633f6fc90 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 25 Jul 2026 10:57:54 -0500 Subject: [PATCH 2/3] fix: validate unused FFI bindings --- .../src/frontend/lowering/lower-calls.ts | 250 +++++++++++++----- .../compiler/src/frontend/lowering/lowerer.ts | 17 +- tests/harness/ffi.test.ts | 27 +- 3 files changed, 226 insertions(+), 68 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 782bdb4ed..bd24fe656 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -6,12 +6,14 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { lowerGenMethodCall } from "./lower-generators.js"; import { BOOL, BYTES_U8, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, STRING, SYMBOL_T, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, funcOf, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/nodes.js"; +import type { IrFfiImport } from "../../ir/nodes.js"; import { isJsSourceFile, locOf } from "../program.js"; import { isGenericCallableMemberType, typeKey } from "../types.js"; import { PoisonError, dynFallbackType, dynUndefinedExpr, importCallHandleType, jsFuncNameOf, newFnCtx, nodeThrowExpr } from "./lowerer.js"; import { enforceLibBoundary } from "./lib-boundary.js"; import { NARROW_FIRST, builtinFenceHintOf, builtinModuleFnOf } from "./surfaces.js"; import { ffiBindingDiag, ffiSignatureDiag, requiresDynamicDiag } from "../../diagnostics/diagnostic.js"; +import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import { mixinFnShapeOf } from "./lower-mixins.js"; import { bufEncoding, dynStringReceiver, lowerArrayFromCall, lowerDynArrayFilterCall, lowerDynArrayFlatMapCall, lowerGroupByStaticCall, lowerIteratorHelperCall, lowerObjectAssignIndexShape, lowerObjectFromEntriesCall, lowerObjectIterOverIndexShape, lowerRegexMethodCall, lowerStringMethodCall, lowerTupleReadMethodCall } from "./lower-containers.js"; import { lowerChildStreamMethodCall, lowerCreateRequireCall, lowerDirentMethodCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerWatcherMethodCall } from "./lower-builtins.js"; @@ -2468,6 +2470,173 @@ export function islandPrimitiveExit(L: Lowerer, call: ts.CallExpression, result: return null; } +function ffiTypeForClass( + cls: IrFfiImport["params"][number] | IrFfiImport["returns"], +): IrType { + switch (cls) { + case "bool": + return BOOL; + case "string": + return STRING; + case "bytes": + return BYTES_U8; + case "void": + return VOID; + default: + return F64; + } +} + +/** The declaration half of an outbound FFI binding. Kept independent of + * call-site argument checks so the whole manifest can be validated even + * when a configured function is never called. */ +function ffiDeclarationDiagnostic( + L: Lowerer, + binding: IrFfiImport, + symbol: ts.Symbol, + loc: SrcLoc, +): ScrDiagnostic | null { + const declarations = L.checker.declarationsOf(symbol); + const functionDecls = declarations.filter(ts.isFunctionDeclaration); + if ( + functionDecls.length === 0 || + declarations.some((decl) => !ts.isFunctionDeclaration(decl)) || + functionDecls.some((decl) => decl.body !== undefined) + ) { + return ffiBindingDiag( + binding.name, + "the configured name does not resolve exclusively to signature-only function declarations", + loc, + ); + } + if (functionDecls.some((decl) => (decl.typeParameters?.length ?? 0) > 0)) { + return ffiSignatureDiag( + binding.name, + "generic ambient declarations cannot describe one fixed C ABI", + loc, + ); + } + const signatures = L.checker.getCallSignatures(L.checker.getTypeOfSymbol(symbol)); + if (signatures.length !== 1) { + return ffiSignatureDiag( + binding.name, + `the ambient binding has ${signatures.length} call signatures; exactly one non-overloaded signature is required`, + loc, + ); + } + const signature = signatures[0]!; + const params = signature.getParameters(); + if (params.length !== binding.params.length) { + return ffiSignatureDiag( + binding.name, + `the TypeScript declaration has ${params.length} parameter(s), but the manifest declares ${binding.params.length}`, + loc, + ); + } + const expectedParams = binding.params.map(ffiTypeForClass); + for (let i = 0; i < params.length; i++) { + const paramType = L.checker.getTypeOfSymbol(params[i]!); + const mapped = L.mapTypeOf(paramType); + const expected = expectedParams[i]!; + if (mapped === null || !typeEquals(mapped, expected)) { + return ffiSignatureDiag( + binding.name, + `parameter ${i + 1} maps to '${mapped === null ? L.checker.typeToString(paramType) : L.fmt(mapped)}', ` + + `which does not fit manifest class '${binding.params[i]}'`, + loc, + ); + } + } + const returnType = L.checker.getReturnTypeOfSignature(signature); + const declaredReturn = L.mapTypeOf(returnType); + const expectedReturn = ffiTypeForClass(binding.returns); + if (declaredReturn === null || !typeEquals(declaredReturn, expectedReturn)) { + return ffiSignatureDiag( + binding.name, + `the return maps to '${declaredReturn === null ? L.checker.typeToString(returnType) : L.fmt(declaredReturn)}', ` + + `which does not fit manifest class '${binding.returns}'`, + loc, + ); + } + return null; +} + +export interface FfiValidationResult { + diagnostics: ScrDiagnostic[]; + symbolsByName: ReadonlyMap>; +} + +/** Resolve and validate every configured outbound binding before emit. + * Candidate declarations are signature-only functions bearing the manifest + * name anywhere in the program. Multiple scoped declarations are all native + * bindings under the existing name-based call surface, so every candidate + * must fit the one manifest ABI. */ +export function validateFfiImports(L: Lowerer): FfiValidationResult { + const diagnostics: ScrDiagnostic[] = []; + const symbolsByName = new Map>(); + const configuredNames = new Set(L.ffiImports.map((binding) => binding.name)); + const candidates = new Map>(); + + if (configuredNames.size === 0) return { diagnostics, symbolsByName }; + + for (const file of L.program.getSourceFiles()) { + ts.walkPreorder(file, (node) => { + if (ts.isFunctionDeclaration(node)) { + if ( + node.body === undefined && + node.name !== undefined && + configuredNames.has(node.name.text) + ) { + const symbol = L.checker.getSymbolAtLocation(node.name); + if (symbol !== undefined) { + let bySymbol = candidates.get(node.name.text); + if (bySymbol === undefined) { + bySymbol = new Map(); + candidates.set(node.name.text, bySymbol); + } + if (!bySymbol.has(symbol)) bySymbol.set(symbol, node); + } + } + return "skip"; + } + if (ts.isFunctionLike(node)) return "skip"; + }); + } + + for (const binding of L.ffiImports) { + const bySymbol = candidates.get(binding.name); + if (bySymbol === undefined || bySymbol.size === 0) { + diagnostics.push( + ffiBindingDiag( + binding.name, + "the program has no signature-only function declaration with this name", + { file: L.entry.fileName, start: 0, end: 0 }, + ), + ); + continue; + } + const validSymbols = new Set(); + let valid = true; + for (const [symbol, declaration] of bySymbol) { + const diagnostic = ffiDeclarationDiagnostic( + L, + binding, + symbol, + locOf(declaration), + ); + if (diagnostic === null) { + validSymbols.add(symbol); + } else { + diagnostics.push(diagnostic); + valid = false; + } + } + if (valid) symbolsByName.set(binding.name, validSymbols); + } + + return { diagnostics, symbolsByName }; +} + /** A manifest-bound call of a signature-only ambient declaration. This * recognition deliberately runs before ambientUndefVarRootOf: without the * manifest the exact same source keeps Node's ReferenceError semantics; @@ -2492,32 +2661,22 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null const symbol = L.resolveValueSymbol(expr.expression) ?? bindingError("the call has no resolved TypeScript symbol"); - const declarations = L.checker.declarationsOf(symbol); - const functionDecls = declarations.filter(ts.isFunctionDeclaration); - if ( - functionDecls.length === 0 || - declarations.some((decl) => !ts.isFunctionDeclaration(decl)) || - functionDecls.some((decl) => decl.body !== undefined) - ) { - bindingError( - "the configured name does not resolve exclusively to signature-only function declarations", - ); - } - if (functionDecls.some((decl) => (decl.typeParameters?.length ?? 0) > 0)) { - signatureError("generic ambient declarations cannot describe one fixed C ABI"); - } - const signatures = L.checker.getCallSignatures(L.typeOf(expr.expression)); - if (signatures.length !== 1) { - signatureError( - `the ambient binding has ${signatures.length} call signatures; exactly one non-overloaded signature is required`, - ); - } - const signature = signatures[0]!; - const params = signature.getParameters(); - if (params.length !== binding.params.length) { - signatureError( - `the TypeScript declaration has ${params.length} parameter(s), but the manifest declares ${binding.params.length}`, - ); + if (L.ffiBindingSymbols !== null) { + const validSymbols = L.ffiBindingSymbols.get(binding.name); + // 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)) { + bindingError( + "the configured name does not resolve exclusively to a validated signature-only function declaration", + ); + } + } else { + const diagnostic = ffiDeclarationDiagnostic(L, binding, symbol, loc); + if (diagnostic !== null) { + L.pushDiag(diagnostic); + throw new PoisonError(); + } } if (expr.arguments.some(ts.isSpreadElement)) { signatureError("spread arguments do not have a fixed native ABI"); @@ -2527,43 +2686,8 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null `this call passes ${expr.arguments.length} argument(s), but the native ABI requires exactly ${binding.params.length}`, ); } - const typeForClass = ( - cls: (typeof binding.params)[number] | typeof binding.returns, - ): IrType => { - switch (cls) { - case "bool": - return BOOL; - case "string": - return STRING; - case "bytes": - return BYTES_U8; - case "void": - return VOID; - default: - return F64; - } - }; - const expectedParams = binding.params.map(typeForClass); - params.forEach((param, i) => { - const paramType = L.checker.getTypeOfSymbol(param); - const mapped = L.mapTypeOf(paramType); - const expected = expectedParams[i]!; - if (mapped === null || !typeEquals(mapped, expected)) { - signatureError( - `parameter ${i + 1} maps to '${mapped === null ? L.checker.typeToString(paramType) : L.fmt(mapped)}', ` + - `which does not fit manifest class '${binding.params[i]}'`, - ); - } - }); - const returnType = L.checker.getReturnTypeOfSignature(signature); - const declaredReturn = L.mapTypeOf(returnType); - const expectedReturn = typeForClass(binding.returns); - if (declaredReturn === null || !typeEquals(declaredReturn, expectedReturn)) { - signatureError( - `the return maps to '${declaredReturn === null ? L.checker.typeToString(returnType) : L.fmt(declaredReturn)}', ` + - `which does not fit manifest class '${binding.returns}'`, - ); - } + const expectedParams = binding.params.map(ffiTypeForClass); + const expectedReturn = ffiTypeForClass(binding.returns); const args = expr.arguments.map((arg, i) => L.lowerExprExpecting(arg, expectedParams[i]!) ); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 43d6d8e84..b82a82cf9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -90,7 +90,7 @@ import { CompoundOp, IslandFnEntry, boundaryIntoIslandMsg, boundaryOutOfIslandMs import { FileParts, splitFiles, collectProgram, collectNpmImports, collectJsonImports, moduleArtifacts, collectGlobals, declSymbolOf, defaultExportSymbolOf, lowerFileInit, lowerDefaultExport, buildMain, appendDynamicImportModules } from "./lower-modules.js"; import { ClassInfo, ClassIteratorInfo, GenericClassInfo, registerBuiltinErrorClasses, registerBuiltinEmitterClass, registerBuiltinStreamClasses, builtinErrorInfoOf, builtinEmitterInfoOf, builtinStreamInfoOf, analyzeClassDecoration, classIteratorDrainCall, classIteratorNextCall, classIteratorOf, classIteratorOpenCall, classIteratorRestDrainCall, classMemberNameOf, classValueRef, collectClassShape, exactClassOfReceiver, collectClassShapeInner, ctorAbiEquals, findMethodOn, findStaticOn, findGenericMethodOn, findGenericStaticOn, genericClassInstanceType, isSubclassOf, inHierarchy, overrideBelow, staticShadowBelow, upcastTo, lowerClassMembers, lowerClassCtor, lowerClassExpression, lowerClassExpressionInfo, lowerClassMethodMember, lowerClassValueProperty, lowerStaticMethod, throwingSetterFn, fieldInitStmts, lowerStaticFieldInits, lowerStaticFieldRead, lowerDerivedCtorBody, superCallStmt, lowerSuperMethodCall, superThisRef, lowerSuperAccessorRead, lowerSuperAccessorWrite, inheritsBuiltinErrorCtor, inheritsBuiltinEmitterCtor, errorMessageArg, lowerNew, accessorCall } from "./lower-classes.js"; import { MixinFnShape, mixinCallClassInfoOf, mixinIntersectionInstanceType } from "./lower-mixins.js"; -import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction } from "./lower-calls.js"; +import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction, validateFfiImports } from "./lower-calls.js"; import { lowerArrayMethodCall, lowerBufferStaticCall, lowerBytesMethodCall, lowerBytesNew, lowerMapMethodCall, lowerMapForEachCall, buildMapForEachFn, lowerRecordOvfCaptureHelper, lowerEnvToPairsHelper, lowerSetMethodCall, lowerSetForEachCall, buildSetForEachFn, lowerRegexMethodCall, lowerStringMethodCall } from "./lower-containers.js"; import { lowerStreamModuleCall } from "./lower-stream.js"; import { lowerEmitOverrideSpec, type EmitSpecCtx, type EmitSpecRequest } from "./lower-emitter.js"; @@ -373,6 +373,9 @@ export interface LowererMode { startupCrash?: StartupCrash | null; /** The build's outbound native FFI declarations. */ ffiImports?: readonly IrFfiImport[]; + /** Program-validated ambient declaration symbols for each FFI name. + * Undefined in discovery's legacy call-local validation path. */ + ffiBindingSymbols?: ReadonlyMap>; } /** Build lowering runs in two passes over the same ts.Program: @@ -420,17 +423,21 @@ export function lowerToIr( }); } const ffiImports = options.ffiImports ?? []; - const reachable = new Lowerer(program, entry, moduleOrder, dynamic, { + const discovery = new Lowerer(program, entry, moduleOrder, dynamic, { targetPlatform, ffiImports, - }).discover(options.libRoots); + }); + const ffiValidation = validateFfiImports(discovery); + const reachable = discovery.discover(options.libRoots); const emit = new Lowerer(program, entry, moduleOrder, dynamic, { reachable, targetPlatform, startupCrash, ffiImports, + ffiBindingSymbols: ffiValidation.symbolsByName, }); for (const d of dynamicCycleDiags) emit.pushDiag(d); + for (const d of ffiValidation.diagnostics) emit.pushDiag(d); const result = emit.run(); if (options.coverage !== true) return result; const remainder = new Lowerer(program, entry, moduleOrder, dynamic, { @@ -439,6 +446,7 @@ export function lowerToIr( alreadyFlushed: emit.flushedSymbols, targetPlatform, ffiImports, + ffiBindingSymbols: ffiValidation.symbolsByName, }); const rem = remainder.run(); return { ...result, unreached: { diagnostics: rem.diagnostics, stats: rem.stats } }; @@ -1116,6 +1124,8 @@ export class Lowerer { /** Outbound native bindings by their source-level ambient name. */ readonly ffiImports: readonly IrFfiImport[]; readonly ffiImportsByName: ReadonlyMap; + /** Non-null after whole-program FFI declaration validation. */ + readonly ffiBindingSymbols: ReadonlyMap> | null; /** Symbols a POISONED declaration statement would have bound: the * declaration's own diagnostic is already recorded, and no local/global * registered, so later references fall through every resolution step — @@ -1170,6 +1180,7 @@ export class Lowerer { this.startupCrash = mode.startupCrash ?? null; this.ffiImports = mode.ffiImports ?? []; this.ffiImportsByName = new Map(this.ffiImports.map((entry) => [entry.name, entry])); + this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null; this.checker = program.getTypeChecker(); this.typeCtx = { checker: this.checker, diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts index 576160316..331a9861f 100644 --- a/tests/harness/ffi.test.ts +++ b/tests/harness/ffi.test.ts @@ -122,6 +122,7 @@ test("manifest validation reports a missing native link input", () => { test.each([ { + id: "called-signature", name: "a TypeScript signature that disagrees with the manifest", source: [ "declare function nativeScale(value: string): number;", @@ -132,6 +133,7 @@ test.each([ message: "parameter 1", }, { + id: "called-body", name: "an ordinary function body with a configured name", source: [ "function nativeScale(value: number): number { return value; }", @@ -141,8 +143,29 @@ test.each([ code: "SC5002", message: "signature-only", }, -])("rejects $name", async ({ source, code, message }) => { - const outDir = join(cacheRoot, `reject-${code}`); + { + id: "unused-signature", + name: "an unused TypeScript signature that disagrees with the manifest", + source: [ + "declare function nativeScale(value: string): number;", + "console.log('ok');", + "", + ].join("\n"), + code: "SC5003", + message: "parameter 1", + }, + { + id: "unused-missing", + name: "an unused manifest binding with no source declaration", + source: [ + "console.log('ok');", + "", + ].join("\n"), + code: "SC5002", + message: "signature-only", + }, +])("rejects $name", async ({ id, source, code, message }) => { + const outDir = join(cacheRoot, `reject-${id}`); mkdirSync(outDir, { recursive: true }); const entry = join(outDir, "main.ts"); const profilePath = join(outDir, "profile.json"); From 1a5bc86bc97e0c9cd95cf9fca9dd101e9dd2b615 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 25 Jul 2026 11:52:55 -0500 Subject: [PATCH 3/3] fix: harden outbound FFI validation --- packages/compiler/src/backend/cc.ts | 19 +- .../compiler/src/diagnostics/diagnostic.ts | 18 +- .../src/frontend/lowering/lower-calls.ts | 34 ++- .../compiler/src/frontend/lowering/lowerer.ts | 17 +- packages/compiler/src/index.ts | 207 ++++++++++-------- tests/harness/ffi.test.ts | 142 +++++++++++- 6 files changed, 338 insertions(+), 99 deletions(-) diff --git a/packages/compiler/src/backend/cc.ts b/packages/compiler/src/backend/cc.ts index 2c6db955b..09628066a 100644 --- a/packages/compiler/src/backend/cc.ts +++ b/packages/compiler/src/backend/cc.ts @@ -188,6 +188,21 @@ export interface CcOptions { tlsCa?: boolean; } +/** Structured compiler-driver failure. Most callers still let this surface + * as an internal build error; the TypeScript compiler pipeline recognizes it + * when an outbound FFI profile is active and turns user-controlled native + * link failures into SC5004. */ +export class CcCompileError extends Error { + constructor( + readonly driver: string, + readonly stderr: string, + message: string, + ) { + super(message); + this.name = "CcCompileError"; + } +} + export function runtimeSrcDir(): string { const require = createRequire(import.meta.url); return join(dirname(require.resolve("@scriptc/runtime/package.json")), "src"); @@ -1208,7 +1223,9 @@ export async function compileC(opts: CcOptions): Promise { "that archive/object ordering is correct, and that each input matches the selected target." : `This is a scriptc bug (generated C should always compile) unless ` + `${ccName} itself is missing/broken.`; - throw new Error( + throw new CcCompileError( + ccName, + stderr, `${ccName} failed compiling ${opts.cPath}.\n` + `${guidance}\n\n${stderr}`, ); diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 2676651e8..02b3246e7 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -42,7 +42,8 @@ * SC5xxx native FFI: malformed manifests (SC5001), a configured * binding that is not an ambient function declaration * (SC5002), and a TypeScript signature that does not match its - * declared native ABI classes (SC5003) + * declared native ABI classes (SC5003), and native toolchain + * failures while applying a valid profile (SC5004) * SC9xxx internal compiler errors (still source-anchored) */ import type { SrcLoc } from "../ir/nodes.js"; @@ -103,6 +104,21 @@ export function ffiSignatureDiag(name: string, detail: string, loc: SrcLoc): Scr }; } +/** SC5004 — the compiler driver could not build/link a valid outbound FFI + * profile. This is source-facing because missing symbols, incompatible + * archives, and unavailable system libraries are profile inputs rather than + * compiler crashes. */ +export function ffiNativeBuildDiag(detail: string, profilePath: string): ScrDiagnostic { + return { + code: "SC5004", + message: `FFI native build failed: ${detail}`, + loc: { file: profilePath, start: 0, end: 0 }, + hint: + "check that every native symbol and system library exists, archive/object ordering is correct, " + + "and each input matches the selected target", + }; +} + interface UnsupportedEntry { feature: string; milestone: Milestone; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index bd24fe656..bc059fde1 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -2536,6 +2536,17 @@ function ffiDeclarationDiagnostic( const expectedParams = binding.params.map(ffiTypeForClass); for (let i = 0; i < params.length; i++) { const paramType = L.checker.getTypeOfSymbol(params[i]!); + // `mapType` deliberately gives uninhabited value positions a cheap f64 + // slot because no TypeScript value can ever reach them. An FFI + // declaration is different: it is a callable external contract, so a + // `never` slot cannot truthfully describe any native parameter. + if ((paramType.flags & ts.TypeFlags.Never) !== 0) { + return ffiSignatureDiag( + binding.name, + `parameter ${i + 1} is 'never', an uninhabited TypeScript type that cannot describe a native ABI parameter`, + loc, + ); + } const mapped = L.mapTypeOf(paramType); const expected = expectedParams[i]!; if (mapped === null || !typeEquals(mapped, expected)) { @@ -2548,6 +2559,16 @@ function ffiDeclarationDiagnostic( } } const returnType = L.checker.getReturnTypeOfSignature(signature); + // A native function is allowed to return. Accepting `never` here would + // let tsc erase all control flow after the call while the linked function + // continues, making the generated program disagree with TypeScript. + if ((returnType.flags & ts.TypeFlags.Never) !== 0) { + return ffiSignatureDiag( + binding.name, + "the return type is 'never', but a native ABI return cannot uphold TypeScript's non-returning contract", + loc, + ); + } const declaredReturn = L.mapTypeOf(returnType); const expectedReturn = ffiTypeForClass(binding.returns); if (declaredReturn === null || !typeEquals(declaredReturn, expectedReturn)) { @@ -2655,9 +2676,6 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null L.pushDiag(ffiSignatureDiag(binding.name, detail, loc)); throw new PoisonError(); }; - if (expr.questionDotToken !== undefined || expr.typeArguments !== undefined) { - signatureError("native bindings support direct, non-generic calls only"); - } const symbol = L.resolveValueSymbol(expr.expression) ?? bindingError("the call has no resolved TypeScript symbol"); @@ -2667,9 +2685,10 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null // binding. Poison the statement without duplicating that diagnostic. if (validSymbols === undefined) throw new PoisonError(); if (!validSymbols.has(symbol)) { - bindingError( - "the configured name does not resolve exclusively to a validated signature-only function declaration", - ); + // 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. + return null; } } else { const diagnostic = ffiDeclarationDiagnostic(L, binding, symbol, loc); @@ -2678,6 +2697,9 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null throw new PoisonError(); } } + if (expr.questionDotToken !== undefined || expr.typeArguments !== undefined) { + signatureError("native bindings support direct, non-generic calls only"); + } if (expr.arguments.some(ts.isSpreadElement)) { signatureError("spread arguments do not have a fixed native ABI"); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b82a82cf9..dcd2999c2 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -423,11 +423,24 @@ export function lowerToIr( }); } const ffiImports = options.ffiImports ?? []; - const discovery = new Lowerer(program, entry, moduleOrder, dynamic, { + const validation = new Lowerer(program, entry, moduleOrder, dynamic, { targetPlatform, ffiImports, }); - const ffiValidation = validateFfiImports(discovery); + const ffiValidation = validateFfiImports(validation); + // Discovery must use the same exact-symbol ownership as emit. Otherwise a + // local function shadowing a configured ambient name is mistaken for FFI + // while computing reachability, even though emit would correctly lower it + // as ordinary TypeScript. FFI-free builds reuse the validation lowerer + // (validation is an immediate no-op there), retaining the historical + // two-pass construction cost. + const discovery = ffiImports.length === 0 + ? validation + : new Lowerer(program, entry, moduleOrder, dynamic, { + targetPlatform, + ffiImports, + ffiBindingSymbols: ffiValidation.symbolsByName, + }); const reachable = discovery.discover(options.libRoots); const emit = new Lowerer(program, entry, moduleOrder, dynamic, { reachable, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 615a87eb2..3894f1eeb 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1,9 +1,9 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; -import { compileC, compileLibArchive, resolveCc, targetPlatform } from "./backend/cc.js"; +import { CcCompileError, compileC, compileLibArchive, resolveCc, targetPlatform } from "./backend/cc.js"; import { emitModule } from "./backend/emission/emitter.js"; import { emitLlvmModule, LlvmUnsupportedError } from "./backend/llvm/emitter.js"; -import { checkerPanicDiag, libAsyncExportDiag, libAsyncSurfaceDiag, libExportUnresolvedDiag, libGenericExportDiag, libIntBoundaryDiag, libNpmIneligibleDiag, libUnmappableSignatureDiag, iceDiag, isCheckerPanic, LIB_INBOUND_BYTES_TRAP_CODE, LIB_RUNTIME_TRAP_CODES, type ScrDiagnostic } from "./diagnostics/diagnostic.js"; +import { checkerPanicDiag, ffiNativeBuildDiag, libAsyncExportDiag, libAsyncSurfaceDiag, libExportUnresolvedDiag, libGenericExportDiag, libIntBoundaryDiag, libNpmIneligibleDiag, libUnmappableSignatureDiag, iceDiag, isCheckerPanic, LIB_INBOUND_BYTES_TRAP_CODE, LIB_RUNTIME_TRAP_CODES, type ScrDiagnostic } from "./diagnostics/diagnostic.js"; import { checkLibraryIntegerSlots, classSeed, hasIntSlots, type FnIntSlots, type IntSlotConfig } from "./library/int-infer.js"; import { loadLibraryProfile, profileRemediation, profileTeaching, type LibraryProfile } from "./library/profile.js"; import { decorateLibraryRefusals, evaluateLibraryFences } from "./library/fence-eval.js"; @@ -160,6 +160,23 @@ function llvmRefusalDiag(err: LlvmUnsupportedError, entryPath: string): ScrDiagn }; } +/** Clang may print every warning from the generated/runtime translation + * units before the actionable linker failure. Keep the source diagnostic + * precise by starting at the first portable linker marker; if the driver + * supplied no recognizable marker, retain only its bounded tail. */ +function ffiNativeBuildDetail(err: CcCompileError): string { + const lines = err.stderr.trim().split(/\r?\n/); + const linkerMarker = lines.findIndex((line) => + /(?:Undefined symbols|undefined reference to|unresolved external symbol|duplicate symbol|library not found for|cannot find -l|unable to find library|file format not recognized|linker command failed|fatal error LNK|lld-link: error)/i.test(line) + ); + const relevant = linkerMarker >= 0 ? lines.slice(linkerMarker) : lines.slice(-40); + const output = relevant.join("\n").trim(); + return ( + `${err.driver} ${linkerMarker >= 0 ? "could not link the generated program" : "failed while building the generated program"}` + + (output.length > 0 ? `:\n${output}` : "") + ); +} + export interface AnalyzeResult { coverage: CoverageInput; sourceTexts: Map; @@ -701,91 +718,107 @@ export async function compile(entryPath: string, opts: CompileOptions): Promise< } await mkdir(dirname(opts.outPath), { recursive: true }); - await compileC({ - cPath, - outPath: opts.outPath, - sanitize: opts.sanitize ?? false, - dynamic: opts.dynamic ?? false, - // The link switch for scr_regex.c + libregexp: detected on the IR, so - // regex-free programs keep the historical (pinned) command line. - regex: moduleUsesRegex(lowered.module), - // The link switch for scr_fetch.c (the native bridge over scr_net + - // scr_tls + scr_http's client parser + zlib — cc.ts implies those - // units into the link): embedded npm code that references fetch gets - // the bridge; everything else keeps its exact link line. - fetch: moduleUsesFetch(lowered.module), - // The island's node:http/https client bridge: embedded graphs that - // import those builtins pull scr_net_island.c + the socket units. - netIsland: - moduleEmbedsBuiltin(lowered.module, "node:http") || - moduleEmbedsBuiltin(lowered.module, "node:https") || - moduleEmbedsBuiltin(lowered.module, "node:net") || - moduleEmbedsBuiltin(lowered.module, "node:tls"), - // The link switch for scr_zlib.c + libz: zlib.* libCalls on the IR, - // node:zlib in the embedded graph, or COMPRESSED embedded module text - // (emit-island.ts stores big npm sources as raw DEFLATE; the emitted - // main installs scr_zlib_inflate_exact on the same predicate). - zlib: moduleUsesZlib(lowered.module) || moduleEmbedsCompressedNpm(lowered.module), - // The link switch for scr_assert.c: assert.* libCalls on the IR (the - // regex switch also pulls it — scr_regex.c calls the assert helpers). - assert: moduleUsesAssert(lowered.module), - // The link switch for scr_inspect.c: insp.* libCalls on the IR. - inspect: moduleUsesInspect(lowered.module), - // The link switch for scr_dyn_invoke.c: dynInvoke nodes or - // dyn.defineProps libCalls on the IR. - dynInvoke: moduleUsesDynInvoke(lowered.module), - // The link switch for scr_dc.c: dc.* libCalls on the IR (the - // diagnostics_channel registry and pub/sub). - dc: moduleUsesDc(lowered.module), - // The link switch for scr_async_dyn.c: the checked-dynamic async - // surfaces (cc.ts also pulls it under the dynInvoke/dc gates). - dynAsync: moduleUsesDynAsync(lowered.module), - // The link switch for scr_events.c: process signal/exit listeners and - // the stdin event surface on the IR. - events: moduleUsesProcessEvents(lowered.module), - // The link switch for scr_events_emitter.c: the node:events - // EventEmitter surface on the IR (emitter.* libCalls or the - // %EventEmitter class def). - emitter: moduleUsesEmitter(lowered.module), - // The link switch for scr_symbol.c: sym.* libCalls or a symbol-kind - // type anywhere on the IR. - symbol: moduleUsesSymbol(lowered.module), - // The link switch for scr_url_params.c: sp.* libCalls, the - // url.searchParams getter, or a searchParams-kind type on the IR. - searchParams: moduleUsesSearchParams(lowered.module), - // The link switch for scr_qs.c: the qs.* libCalls that live there - // (parse/stringify/unescape; escape rides the always-linked encoder). - qs: moduleUsesQs(lowered.module), - // The link switch for scr_stream.c: the node:stream class surface on - // the IR (stream libCalls or the %Readable-family class defs). - stream: moduleUsesStream(lowered.module), - // The link switch for scr_net.c: net.* (or http.* — http rides on - // net) libCalls on the IR. - net: moduleUsesNet(lowered.module), - // The link switch for scr_http.c: http.* libCalls on the IR. - http: moduleUsesHttpServer(lowered.module), - http2: moduleUsesHttp2(lowered.module), - // The link switch for scr_dgram.c: dgram.* or dns.* libCalls on the IR. - dgram: moduleUsesDgram(lowered.module), - // The link switch for scr_watch.c: fs.watch/watcher.* libCalls on the IR. - watch: moduleUsesFsWatch(lowered.module), - // The link switch for scr_test.c: test.* libCalls on the IR. - nodeTest: moduleUsesNodeTest(lowered.module), - // The link switch for scr_tls.c + the vendored mbedTLS archive: - // tls.* or https.* libCalls on the IR. - tls: moduleUsesTls(lowered.module), - // The link switch for scr_tls_ca.c (the CA-store introspection unit - // — plain PEM bookkeeping, no mbedTLS): tlsca.* libCalls on the IR. - // cc.ts also compiles it under the tls gate (scr_tls.c consults the - // unit's default-set override for its trust anchors). - tlsCa: moduleUsesTlsCa(lowered.module), - ...(ffi !== null - ? { - linkInputs: ffi.libraries, - systemLibraries: ffi.systemLibraries, - } - : {}), - }); + try { + await compileC({ + cPath, + outPath: opts.outPath, + sanitize: opts.sanitize ?? false, + dynamic: opts.dynamic ?? false, + // The link switch for scr_regex.c + libregexp: detected on the IR, so + // regex-free programs keep the historical (pinned) command line. + regex: moduleUsesRegex(lowered.module), + // The link switch for scr_fetch.c (the native bridge over scr_net + + // scr_tls + scr_http's client parser + zlib — cc.ts implies those + // units into the link): embedded npm code that references fetch gets + // the bridge; everything else keeps its exact link line. + fetch: moduleUsesFetch(lowered.module), + // The island's node:http/https client bridge: embedded graphs that + // import those builtins pull scr_net_island.c + the socket units. + netIsland: + moduleEmbedsBuiltin(lowered.module, "node:http") || + moduleEmbedsBuiltin(lowered.module, "node:https") || + moduleEmbedsBuiltin(lowered.module, "node:net") || + moduleEmbedsBuiltin(lowered.module, "node:tls"), + // The link switch for scr_zlib.c + libz: zlib.* libCalls on the IR, + // node:zlib in the embedded graph, or COMPRESSED embedded module text + // (emit-island.ts stores big npm sources as raw DEFLATE; the emitted + // main installs scr_zlib_inflate_exact on the same predicate). + zlib: moduleUsesZlib(lowered.module) || moduleEmbedsCompressedNpm(lowered.module), + // The link switch for scr_assert.c: assert.* libCalls on the IR (the + // regex switch also pulls it — scr_regex.c calls the assert helpers). + assert: moduleUsesAssert(lowered.module), + // The link switch for scr_inspect.c: insp.* libCalls on the IR. + inspect: moduleUsesInspect(lowered.module), + // The link switch for scr_dyn_invoke.c: dynInvoke nodes or + // dyn.defineProps libCalls on the IR. + dynInvoke: moduleUsesDynInvoke(lowered.module), + // The link switch for scr_dc.c: dc.* libCalls on the IR (the + // diagnostics_channel registry and pub/sub). + dc: moduleUsesDc(lowered.module), + // The link switch for scr_async_dyn.c: the checked-dynamic async + // surfaces (cc.ts also pulls it under the dynInvoke/dc gates). + dynAsync: moduleUsesDynAsync(lowered.module), + // The link switch for scr_events.c: process signal/exit listeners and + // the stdin event surface on the IR. + events: moduleUsesProcessEvents(lowered.module), + // The link switch for scr_events_emitter.c: the node:events + // EventEmitter surface on the IR (emitter.* libCalls or the + // %EventEmitter class def). + emitter: moduleUsesEmitter(lowered.module), + // The link switch for scr_symbol.c: sym.* libCalls or a symbol-kind + // type anywhere on the IR. + symbol: moduleUsesSymbol(lowered.module), + // The link switch for scr_url_params.c: sp.* libCalls, the + // url.searchParams getter, or a searchParams-kind type on the IR. + searchParams: moduleUsesSearchParams(lowered.module), + // The link switch for scr_qs.c: the qs.* libCalls that live there + // (parse/stringify/unescape; escape rides the always-linked encoder). + qs: moduleUsesQs(lowered.module), + // The link switch for scr_stream.c: the node:stream class surface on + // the IR (stream libCalls or the %Readable-family class defs). + stream: moduleUsesStream(lowered.module), + // The link switch for scr_net.c: net.* (or http.* — http rides on + // net) libCalls on the IR. + net: moduleUsesNet(lowered.module), + // The link switch for scr_http.c: http.* libCalls on the IR. + http: moduleUsesHttpServer(lowered.module), + http2: moduleUsesHttp2(lowered.module), + // The link switch for scr_dgram.c: dgram.* or dns.* libCalls on the IR. + dgram: moduleUsesDgram(lowered.module), + // The link switch for scr_watch.c: fs.watch/watcher.* libCalls on the IR. + watch: moduleUsesFsWatch(lowered.module), + // The link switch for scr_test.c: test.* libCalls on the IR. + nodeTest: moduleUsesNodeTest(lowered.module), + // The link switch for scr_tls.c + the vendored mbedTLS archive: + // tls.* or https.* libCalls on the IR. + tls: moduleUsesTls(lowered.module), + // The link switch for scr_tls_ca.c (the CA-store introspection unit + // — plain PEM bookkeeping, no mbedTLS): tlsca.* libCalls on the IR. + // cc.ts also compiles it under the tls gate (scr_tls.c consults the + // unit's default-set override for its trust anchors). + tlsCa: moduleUsesTlsCa(lowered.module), + ...(ffi !== null + ? { + linkInputs: ffi.libraries, + systemLibraries: ffi.systemLibraries, + } + : {}), + }); + } catch (err) { + if (ffi !== null && err instanceof CcCompileError) { + return { + ok: false, + diagnostics: [ + ffiNativeBuildDiag( + ffiNativeBuildDetail(err), + opts.ffiProfilePath ?? entryPath, + ), + ], + sourceTexts, + }; + } + throw err; + } return { ok: true, binaryPath: opts.outPath, diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts index 331a9861f..644f103ea 100644 --- a/tests/harness/ffi.test.ts +++ b/tests/harness/ffi.test.ts @@ -4,8 +4,9 @@ * result bytes must match. The fixture covers every format-1 ABI class, * integer coercion, embedded-NUL/UTF-8 string spans, byte spans, and a void * call with observable native state. */ -import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { compile, loadFfiProfile } from "@scriptc/compiler"; @@ -164,6 +165,28 @@ test.each([ code: "SC5002", message: "signature-only", }, + { + id: "never-parameter", + name: "an uninhabited TypeScript parameter presented as a native ABI slot", + source: [ + "declare function nativeScale(value: never): number;", + "console.log('ok');", + "", + ].join("\n"), + code: "SC5003", + message: "parameter 1 is 'never'", + }, + { + id: "never-return", + name: "a TypeScript never return that the native function cannot uphold", + source: [ + "declare function nativeScale(value: number): never;", + "console.log('ok');", + "", + ].join("\n"), + code: "SC5003", + message: "return type is 'never'", + }, ])("rejects $name", async ({ id, source, code, message }) => { const outDir = join(cacheRoot, `reject-${id}`); mkdirSync(outDir, { recursive: true }); @@ -195,3 +218,118 @@ test.each([ expect(result.diagnostics[0]?.message).toContain(message); } }); + +describe.each(["c", "llvm"] as const)("FFI binding identity, %s backend", (backend) => { + test("a local same-named function remains ordinary TypeScript with Node-byte parity", async () => { + const outDir = join(cacheRoot, `shadow-${backend}`); + mkdirSync(outDir, { recursive: true }); + // Node 24 deliberately refuses its built-in TypeScript stripping for + // files below node_modules, where this suite keeps compiler artifacts. + // Keep the reference input in the OS temp tree and only outputs/cache in + // node_modules. + const sourceDir = mkdtempSync(join(tmpdir(), "scriptc-ffi-shadow-")); + const entry = join(sourceDir, "main.ts"); + const profilePath = join(outDir, "profile.json"); + try { + writeFileSync( + entry, + [ + "declare function nativeScale(value: number): number;", + "function localUse(): number {", + " function nativeScale(value: number): number { return value + 1; }", + " return nativeScale(21);", + "}", + "console.log(localUse());", + "", + ].join("\n"), + ); + writeFileSync( + profilePath, + JSON.stringify({ + ffi_format: 1, + functions: [ + { + name: "nativeScale", + symbol: "sf_scale", + params: ["f64"], + returns: "f64", + }, + ], + libraries: [nativeArchive()], + }), + ); + + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + backend, + sanitize, + ffiProfilePath: profilePath, + }); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + + const node = spawnSync(process.execPath, [entry], { encoding: "utf8" }); + const native = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect({ + stdout: native.stdout, + stderr: native.stderr, + status: native.status, + }).toEqual({ + stdout: node.stdout, + stderr: node.stderr, + status: node.status, + }); + } finally { + rmSync(sourceDir, { recursive: true, force: true }); + } + }); +}); + +test("a missing FFI symbol is an SC5004 diagnostic, not a rejected compile", async () => { + const outDir = join(cacheRoot, "missing-symbol"); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, "main.ts"); + const profilePath = join(outDir, "profile.json"); + const missingSymbol = "sf_symbol_that_does_not_exist"; + writeFileSync( + entry, + [ + "declare function nativeScale(value: number): number;", + "console.log(nativeScale(21));", + "", + ].join("\n"), + ); + writeFileSync( + profilePath, + JSON.stringify({ + ffi_format: 1, + functions: [ + { + name: "nativeScale", + symbol: missingSymbol, + params: ["f64"], + returns: "f64", + }, + ], + libraries: [nativeArchive()], + }), + ); + + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + sanitize, + ffiProfilePath: profilePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.code).toBe("SC5004"); + expect(result.diagnostics[0]?.message).toContain(missingSymbol); + expect(result.diagnostics[0]?.message).not.toContain("CcCompileError"); + } +});