mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-30 07:14:39 +08:00
Merge pull request #1 from vercel-labs/ctate/outbound-ffi
feat: add outbound native FFI
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ Analyzes the program without producing a binary and reports, statement by statem
|
||||
<dt><code>--dynamic</code></dt>
|
||||
<dd>Embed the dynamic engine (~620KB) so npm dependencies and <code>any</code>-typed code can run. Static stays the default — without this flag, dynamic-tier sites are per-site compile errors. See <a href="/dependencies">npm Dependencies</a>.</dd>
|
||||
|
||||
<dt><code>--ffi <file></code></dt>
|
||||
<dd>Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See <a href="/ffi">Native FFI</a>.</dd>
|
||||
|
||||
<dt><code>--backend <c|llvm></code></dt>
|
||||
<dd>Code generator: <code>llvm</code> (default — emits LLVM IR text, compiled by the same clang) or <code>c</code> (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 <code>--backend llvm</code> fails with a diagnostic instead of falling back (the debugging/CI pin); <code>--backend c</code> pins the reference backend.</dd>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Manifest class</th>
|
||||
<th>TypeScript type</th>
|
||||
<th>C ABI type and behavior</th>
|
||||
<th>Parameter</th>
|
||||
<th>Return</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>f64</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td><code>double</code></td>
|
||||
<td>yes</td>
|
||||
<td>yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bool</code></td>
|
||||
<td><code>boolean</code></td>
|
||||
<td><code>uint8_t</code>; inputs are 0 or 1, any nonzero return becomes <code>true</code></td>
|
||||
<td>yes</td>
|
||||
<td>yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>u8</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td><code>uint8_t</code>; inputs use JavaScript's modulo conversion</td>
|
||||
<td>yes</td>
|
||||
<td>yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>u32</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td><code>uint32_t</code>; inputs use <code>ToUint32</code></td>
|
||||
<td>yes</td>
|
||||
<td>yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>i32</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td><code>int32_t</code>; inputs use <code>ToInt32</code></td>
|
||||
<td>yes</td>
|
||||
<td>yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>string</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td><code>const uint8_t *, size_t</code>; UTF-8 bytes, length-delimited</td>
|
||||
<td>yes</td>
|
||||
<td>no</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bytes</code></td>
|
||||
<td><code>Uint8Array</code> or <code>Buffer</code></td>
|
||||
<td><code>const uint8_t *, size_t</code>; raw bytes, length-delimited</td>
|
||||
<td>yes</td>
|
||||
<td>no</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>void</code></td>
|
||||
<td><code>void</code></td>
|
||||
<td><code>void</code></td>
|
||||
<td>no</td>
|
||||
<td>yes</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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
|
||||
|
||||
<dl>
|
||||
<dt><code>ffi_format</code></dt>
|
||||
<dd>Required. Must be <code>1</code>.</dd>
|
||||
|
||||
<dt><code>functions</code></dt>
|
||||
<dd>Required array. Every entry has exactly <code>name</code>, <code>symbol</code>, <code>params</code>, and <code>returns</code>. Binding names and symbols must be unique.</dd>
|
||||
|
||||
<dt><code>libraries</code></dt>
|
||||
<dd>Optional array of archive or object paths. Relative paths are resolved from the manifest directory and appended after the generated program at link time.</dd>
|
||||
|
||||
<dt><code>system_libraries</code></dt>
|
||||
<dd>Optional array of linker-neutral library names. For example, <code>["m"]</code> is emitted as <code>-lm</code>.</dd>
|
||||
</dl>
|
||||
|
||||
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`.
|
||||
@@ -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).
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
cli: "CLI Reference",
|
||||
coverage: "Coverage Reports",
|
||||
dependencies: "npm Dependencies",
|
||||
ffi: "Native FFI",
|
||||
platforms: "Platform Support",
|
||||
"how-it-works": "How It Works",
|
||||
limitations: "Limitations",
|
||||
|
||||
@@ -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 <manifest.json>`. See the [Native FFI guide](https://scriptc.dev/ffi).
|
||||
|
||||
Docs: [scriptc.dev](https://scriptc.dev)
|
||||
|
||||
@@ -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 <file> bind signature-only TypeScript declarations to native
|
||||
C symbols and link the manifest's archives/libraries
|
||||
--npm-static <pkg[,pkg…]|auto>
|
||||
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<number> {
|
||||
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<number> {
|
||||
}
|
||||
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<number> {
|
||||
}
|
||||
|
||||
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<number> {
|
||||
|
||||
const build = async (): Promise<string> => {
|
||||
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<number> {
|
||||
dynamic: values.dynamic,
|
||||
...(backend !== undefined ? { backend } : {}),
|
||||
...(npmStatic !== undefined ? { npmStatic } : {}),
|
||||
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
const color = process.stderr.isTTY ?? false;
|
||||
|
||||
@@ -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<name>` 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. */
|
||||
@@ -182,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");
|
||||
@@ -1185,6 +1206,8 @@ export async function compileC(opts: CcOptions): Promise<void> {
|
||||
// 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 +1216,18 @@ export async function compileC(opts: CcOptions): Promise<void> {
|
||||
await execFileAsync(driver.argv[0] ?? "clang", [...driver.argv.slice(1), ...args]);
|
||||
} catch (err) {
|
||||
const stderr = (err as { stderr?: string }).stderr ?? String(err);
|
||||
throw new Error(
|
||||
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 CcCompileError(
|
||||
ccName,
|
||||
stderr,
|
||||
`${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 +1239,11 @@ export async function compileC(opts: CcOptions): Promise<void> {
|
||||
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 +1261,12 @@ export async function compileC(opts: CcOptions): Promise<void> {
|
||||
.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 +1318,7 @@ export async function compileC(opts: CcOptions): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string, IrFunction>();
|
||||
/** Manifest-bound native imports, used by ffiCall emission. */
|
||||
readonly ffiByName = new Map<string, IrFfiImport>();
|
||||
readonly globalsById = new Map<string, IrGlobal>();
|
||||
readonly unionsById = new Map<string, IrUnionDef>();
|
||||
/** 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
|
||||
|
||||
@@ -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<string, IrFunction>();
|
||||
/** Manifest-bound native imports, used by ffiCall emission. */
|
||||
private readonly ffiByName = new Map<string, IrFfiImport>();
|
||||
private readonly globalTypes = new Map<string, IrType>();
|
||||
/** 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
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
* (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), and native toolchain
|
||||
* failures while applying a valid profile (SC5004)
|
||||
* SC9xxx internal compiler errors (still source-anchored)
|
||||
*/
|
||||
import type { SrcLoc } from "../ir/nodes.js";
|
||||
@@ -62,6 +67,58 @@ 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",
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -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<name>`. */
|
||||
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<string, unknown>;
|
||||
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<string>();
|
||||
const symbols = new Set<string>();
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,15 @@
|
||||
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 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 { requiresDynamicDiag } from "../../diagnostics/diagnostic.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,258 @@ 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]!);
|
||||
// `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)) {
|
||||
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);
|
||||
// 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)) {
|
||||
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<string, ReadonlySet<ts.Symbol>>;
|
||||
}
|
||||
|
||||
/** 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<string, ReadonlySet<ts.Symbol>>();
|
||||
const configuredNames = new Set(L.ffiImports.map((binding) => binding.name));
|
||||
const candidates = new Map<string, Map<ts.Symbol, ts.FunctionDeclaration>>();
|
||||
|
||||
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<ts.Symbol>();
|
||||
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;
|
||||
* 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();
|
||||
};
|
||||
const symbol =
|
||||
L.resolveValueSymbol(expr.expression) ??
|
||||
bindingError("the call has no resolved TypeScript symbol");
|
||||
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)) {
|
||||
// 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);
|
||||
if (diagnostic !== null) {
|
||||
L.pushDiag(diagnostic);
|
||||
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");
|
||||
}
|
||||
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 expectedParams = binding.params.map(ffiTypeForClass);
|
||||
const expectedReturn = ffiTypeForClass(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);
|
||||
|
||||
|
||||
@@ -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, 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";
|
||||
@@ -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,11 @@ 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[];
|
||||
/** Program-validated ambient declaration symbols for each FFI name.
|
||||
* Undefined in discovery's legacy call-local validation path. */
|
||||
ffiBindingSymbols?: ReadonlyMap<string, ReadonlySet<ts.Symbol>>;
|
||||
}
|
||||
|
||||
/** Build lowering runs in two passes over the same ts.Program:
|
||||
@@ -411,9 +422,35 @@ 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 validation = new Lowerer(program, entry, moduleOrder, dynamic, {
|
||||
targetPlatform,
|
||||
ffiImports,
|
||||
});
|
||||
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,
|
||||
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, {
|
||||
@@ -421,6 +458,8 @@ export function lowerToIr(
|
||||
remainder: true,
|
||||
alreadyFlushed: emit.flushedSymbols,
|
||||
targetPlatform,
|
||||
ffiImports,
|
||||
ffiBindingSymbols: ffiValidation.symbolsByName,
|
||||
});
|
||||
const rem = remainder.run();
|
||||
return { ...result, unreached: { diagnostics: rem.diagnostics, stats: rem.stats } };
|
||||
@@ -1095,6 +1134,11 @@ 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<string, IrFfiImport>;
|
||||
/** Non-null after whole-program FFI declaration validation. */
|
||||
readonly ffiBindingSymbols: ReadonlyMap<string, ReadonlySet<ts.Symbol>> | 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 —
|
||||
@@ -1147,6 +1191,9 @@ 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.ffiBindingSymbols = mode.ffiBindingSymbols ?? null;
|
||||
this.checker = program.getTypeChecker();
|
||||
this.typeCtx = {
|
||||
checker: this.checker,
|
||||
@@ -1804,7 +1851,7 @@ export class Lowerer {
|
||||
this.diags.length > 0
|
||||
? null
|
||||
: {
|
||||
irVersion: 1,
|
||||
irVersion: 2,
|
||||
sourceFile: this.entry.fileName,
|
||||
functions,
|
||||
classes: artifacts.classes,
|
||||
@@ -1813,6 +1860,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 +7079,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)
|
||||
|
||||
+163
-81
@@ -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";
|
||||
@@ -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 =
|
||||
@@ -146,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<string, string>;
|
||||
@@ -173,6 +204,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 +541,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 +593,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 +626,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<CompileResult> {
|
||||
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 +653,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
|
||||
@@ -658,85 +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),
|
||||
});
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, IrFunction>();
|
||||
@@ -1094,6 +1111,19 @@ export function validateModule(mod: IrModule): IrValidationError[] {
|
||||
}
|
||||
functionsByName.set(fn.name, fn);
|
||||
}
|
||||
const ffiByName = new Map<string, NonNullable<IrModule["ffiImports"]>[number]>();
|
||||
const ffiSymbols = new Set<string>();
|
||||
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<string, IrFunction>,
|
||||
ffiByName: Map<string, NonNullable<IrModule["ffiImports"]>[number]>,
|
||||
classes: Map<string, IrClassDef>,
|
||||
records: Map<string, IrRecordShape>,
|
||||
unions: Map<string, IrUnionDef>,
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1134,6 +1134,7 @@ class FnAnalyzer {
|
||||
case "assignExpr":
|
||||
case "seqExpr":
|
||||
case "call":
|
||||
case "ffiCall":
|
||||
case "callValue":
|
||||
case "new":
|
||||
case "newValue":
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+1
-1
@@ -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: [
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
@@ -0,0 +1,44 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/* 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, 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";
|
||||
|
||||
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([
|
||||
{
|
||||
id: "called-signature",
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "called-body",
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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 });
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user