feat(compiler): run IR serialization and dependency scans natively

- Compile production IR serialization and runtime dependency scans on both native backends
- Support native JSON callbacks and checked records with unknown payloads
- Verify native IR round trips and executable generation with focused sanitizer coverage
This commit is contained in:
Chris Tate
2026-09-27 15:34:38 -05:00
parent ffe5e27563
commit df5d06627f
30 changed files with 1453 additions and 59 deletions
+2
View File
@@ -68,6 +68,8 @@ A static-tier program otherwise produces byte-identical stdout and the same exit
**Runtime traps are not catchable.** User `throw` is fully catchable, and runtime failures Node models as exceptions (JSON parse errors, checked-cast failures, fs errors, regex errors) throw real error objects. Remaining hard traps, including typed-array bounds violations, abort the process.
**JSON callbacks have a native subset.** Function replacers and two-argument revivers run statically, including nested replacements, object-property deletion, and thrown exceptions. A replacer that omits the root returns `undefined`. Replacer property lists, reviver source contexts, and computed indentation remain unsupported; a reviver that deletes an array element throws because checked-dynamic arrays cannot represent holes. Callback values use the checked-dynamic boundary: typed records and arrays become snapshots, so callback mutations do not update the original typed containers, record fields retain declaration order, and typed Buffer values lose their Buffer brand when converted to bytes. JavaScript callbacks receive the holder as `this`; TypeScript callbacks that access a dynamic `this` remain unsupported.
**A lying cast on dynamic data throws instead of corrupting memory** — the headline divergence, and the point. `JSON.parse(s) as Config` with mismatched data throws a catchable error naming the offending path (`expected number at $.port, got string`) where JS would silently hand you garbage.
**Structural width subtyping copies.** A record flowing into a strict field-subset shape is copied, not aliased: mutations through the narrower reference are invisible to the original. Same stance at the dynamic boundary: values cross by copy, never by reference.
+5 -3
View File
@@ -35,9 +35,9 @@ interface ObjectConstructor {
/* `parse` returns `unknown`, not the lib's `any` — the dynamic boundary. A
* checked cast (`JSON.parse(s) as Config`) validates the value against the
* target type at runtime and THROWS on mismatch — the mechanism that makes
* trusting TS types sound at data boundaries. The lib's any-returning
* overload stays reachable only through the reviver form, which the
* lowerer rejects (as it does stringify's replacer/space parameters).
* trusting TS types sound at data boundaries. Reviver results use the same
* checked boundary. A function replacer can omit the root, so that form
* returns string | undefined even though the stock lib promises string.
*
* `stringify` takes `unknown`, not the lib's `any`: the same honest
* surface, but the parameter must not CONTEXTUALLY TYPE literal arguments
@@ -46,7 +46,9 @@ interface ObjectConstructor {
* type-directedly). */
interface JSON {
parse(text: string): unknown;
parse(text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown;
stringify(value: unknown): string;
stringify(value: unknown, replacer: (this: unknown, key: string, value: unknown) => unknown, space?: string | number): string | undefined;
}
/* The supported Promise construction shape: an executor whose resolve takes
+4
View File
@@ -4366,6 +4366,10 @@ function emitWebLibCall(state: LibCallState): Temp {
// and the typed dummy is a NULL promise the pending check
// abandons (releases are NULL-tolerant).
return finish(`(scr_jsval_cast_fail(${arg(0)}, ${arg(1)}), NULL)`);
case "json.parseReviver":
return finish(`scr_json_parse_reviver(${arg(0)}, ${arg(1)})`);
case "json.stringifyReplacer":
return finish(`scr_json_stringify_replacer(${arg(0)}, ${arg(1)}, ${arg(2)})`);
case "json.parse":
// Borrows the text; returns +1 on a fresh dyn, or throws a
// catchable SyntaxError-shaped string (may-throw seed set).
@@ -501,6 +501,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fetch.streamFrom": "scr_fetch_stream_from",
"fetch.readerRead": "scr_fetch_reader_read",
"json.parse": "scr_json_parse",
"json.parseReviver": "scr_json_parse_reviver",
"json.stringifyReplacer": "scr_json_stringify_replacer",
"dyn.keySet": "scr_dyn_key_set",
"dyn.iterPack": "scr_dyn_iter_pack",
"dyn.arrLen": "scr_dyn_arr_len",
@@ -4760,39 +4760,30 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr
return name;
}
/** `JSON.parse(text)` / `JSON.stringify(value)`.
* - parse → a may-throw `libCall` producing a dyn value (the runtime JSON
* dyn); malformed input throws a catchable SyntaxError-shaped string.
* The divergence override types the one-argument form `unknown`; the
* lib's reviver form typechecks (returning `any`) and is fenced here.
* - stringify → the type-DIRECTED `jsonStringify` node: the lib
* signature honestly says `any`, but lowering requires the argument's
* STATIC IR type to be JSON-safe — the backend emits a per-type
* serializer, never a dynamic walk, so dyn (and closures/class
* instances) are rejected here with a specific message. The
* `stringify(v, null, space)` pretty-print form compiles when the
* replacer is the literal null (or undefined) and the space is a
* LITERAL — Node's rules apply at compile time (numbers clamp to 0–10
* spaces, strings truncate to 10 code units) and the resolved indent
* rides the node to the backend's re-indenter. Function replacers and
* non-literal spaces stay fenced.
* Null when this isn't a JSON member call. */
/** JSON parse produces checked-dynamic data; a native reviver walks it
* bottom-up before any checked cast. Stringify without a callback keeps
* its type-directed fast path. A function replacer boxes the input and
* walks it before primitive normalization. Both paths share literal gap
* rules, and callback failures use ordinary native exception unwinding. */
export function lowerJsonMethodCall(lowerer: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken) return null;
const member = lowerer.stdlibGlobalMember(access, "JSON");
if (member === null) return null;
const loc = locOf(call);
if (member === "parse" && call.arguments.length !== 1) {
lowerer.noLowering(
"JSON.parse with a reviver",
call,
"parse to `unknown` and validate with a checked cast ('as T') instead",
);
if ((member === "parse" && (call.arguments.length < 1 || call.arguments.length > 2)) ||
(member === "stringify" && call.arguments.length > 3) ||
call.arguments.some(ts.isSpreadElement)) {
lowerer.noLowering(`JSON.${member} with extra or spread arguments`, call, "pass the JSON arguments explicitly");
}
if (member === "parse") {
const text = lowerer.lowerExprExpecting(call.arguments[0]!, STRING);
return { kind: "libCall", fn: "json.parse", args: [text], type: DYN, loc };
const reviver = call.arguments[1];
if (!reviver || jsonNullishArgument(lowerer, reviver)) {
return { kind: "libCall", fn: "json.parse", args: [text], type: DYN, loc };
}
const callback = lowerJsonCallback(lowerer, reviver, "reviver");
return { kind: "libCall", fn: "json.parseReviver", args: [text, callback], type: DYN, loc };
}
if (member === "stringify") {
const indent = stringifySpaceIndent(lowerer, call);
@@ -4800,7 +4791,36 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr
return lowerer.wrappedUndefined(lowerer.withUndefinedArm(STRING), loc)!;
}
const argNode = call.arguments[0]!;
const value = lowerer.lowerExpr(argNode);
let value = lowerer.lowerExpr(argNode);
const replacer = call.arguments[1];
if (replacer && !jsonNullishArgument(lowerer, replacer)) {
const callback = lowerJsonCallback(lowerer, replacer, "replacer");
if (value.type.kind === "undefinedT" || value.type.kind === "nullT") value = lowerer.coerceToExpected(value, DYN);
// Evaluate the original arguments before taking a typed snapshot.
// Creating the callback can itself mutate the input object.
const inputSlot = lowerer.declareHiddenLocal("%jsonInput", value.type);
const callbackSlot = lowerer.declareHiddenLocal("%jsonCallback", DYN);
const boxed = lowerer.coerceToExpected(varRef(inputSlot.id, value.type, loc), DYN);
if (boxed.type.kind !== "dyn") {
lowerer.unsupported("SC1090", argNode,
`JSON.stringify callback input of type '${lowerer.fmt(value.type)}' (the value must cross the checked-dynamic boundary)`);
}
const raw: IrExpr = {
kind: "libCall", fn: "json.stringifyReplacer",
args: [boxed, varRef(callbackSlot.id, DYN, loc), { kind: "strLit", value: indent, type: STRING, loc }],
type: DYN, loc,
};
// A replacer can omit even the root. Preserve actual undefined;
// an inferred binding adopts this union, while a required string
// consumer uses the ordinary checked optional-value boundary.
const result: IrExpr = { kind: "dynCheck", value: raw, type: lowerer.withUndefinedArm(STRING), loc };
return {
kind: "seqExpr", stmts: [
{ kind: "varDecl", localId: inputSlot.id, init: value, loc },
{ kind: "varDecl", localId: callbackSlot.id, init: callback, loc },
], result, type: result.type, loc,
};
}
const optionalString = lowerOptionalStringifyRoot(lowerer, value, indent, loc);
if (optionalString) return optionalString;
// An ISLAND value (`JSON.stringify(err)` on a package handle — the
@@ -4911,13 +4931,33 @@ function lowerOptionalStringifyRoot(lowerer: Lowerer, value: IrExpr, indent: str
return { kind: "call", callee: helper, args: [value], type: resultT, loc };
}
/** The compile-time indent of a `JSON.stringify(v[, replacer[, space]])`
* call, with Node's space rules applied: a number clamps to 0–10 spaces
* (ToInteger truncation), a string truncates to its first 10 code units,
* and null/undefined/0/"" mean compact ("" here). Only literal
* replacer/space spellings compile — the replacer must be `null` (or
* `undefined`), the space a numeric/string literal or `null`/`undefined`;
* everything else keeps the existing fence. */
/** Only explicit null/undefined select the no-callback path. */
function jsonNullishArgument(lowerer: Lowerer, node: ts.Expression): boolean {
if (ts.isParenthesizedExpression(node)) return jsonNullishArgument(lowerer, node.expression);
if (node.kind === ts.SyntaxKind.NullKeyword) return true;
if (!ts.isIdentifier(node) || node.text !== "undefined") return false;
const symbol = lowerer.checker.getSymbolAtLocation(node);
return symbol !== undefined && lowerer.checker.declarationsOf(symbol).every((decl) => lowerer.isStdlibFile(decl.getSourceFile()));
}
/** JSON callbacks cross the same checked native function boundary as other
* runtime callbacks. Reviver source contexts need parser source tracking;
* refuse signatures that request one until that protocol is implemented. */
function lowerJsonCallback(lowerer: Lowerer, node: ts.Expression, role: "replacer" | "reviver"): IrExpr {
const callback = lowerer.lowerExpr(node);
if (callback.type.kind === "func" && callback.type.params.length <= 2 &&
(role !== "reviver" || (!callback.type.rest && !callback.type.argumentsAll)) &&
canBoxFuncIntoDyn(callback.type, (id) => lowerer.shapes.get(id), (id) => lowerer.unions.get(id))) {
return { kind: "dynFrom", value: callback, type: DYN, loc: locOf(node) };
}
lowerer.noLowering(
`JSON ${role} of type '${lowerer.fmt(callback.type)}'`, node,
"use a native function taking key and value; replacer arrays and reviver source contexts are not supported yet",
);
}
/** Compile-time Node gap rules: numbers clamp to 0–10 spaces and strings
* truncate to ten UTF-16 code units. Callback validation is independent. */
function stringifySpaceIndent(lowerer: Lowerer, call: ts.CallExpression): string {
const fence = (): never =>
lowerer.noLowering(
@@ -4929,9 +4969,7 @@ function lowerOptionalStringifyRoot(lowerer: Lowerer, value: IrExpr, indent: str
const unwrap = (e: ts.Expression): ts.Expression =>
ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e;
const isUndefined = (e: ts.Expression): boolean =>
ts.isIdentifier(e) && e.text === "undefined";
const replacer = unwrap(call.arguments[1]!);
if (replacer.kind !== ts.SyntaxKind.NullKeyword && !isUndefined(replacer)) fence();
jsonNullishArgument(lowerer, e);
if (call.arguments.length === 2) return "";
const space = unwrap(call.arguments[2]!);
if (space.kind === ts.SyntaxKind.NullKeyword || isUndefined(space)) return "";
@@ -10,7 +10,7 @@ import { dirname, posix } from "node:path";
import { pathToFileURL } from "node:url";
import type { Lowerer } from "./lowerer.js";
import { wasiGuestPath } from "../../wasi-paths.js";
import { BIGINT_T, BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, IrExpr, IrFunction, IrJsOp, IrLocal, IrRecordShape, IrStmt, IrType, JSVAL, NULL_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_ERROR_CLASSES, SEARCH_PARAMS_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canAdaptDynFuncTo, canBoxFuncIntoDyn, funcOf, isDynTypedRefType, isJsonSafeType, isSupportedArrayElem, isUnitType, jsOpResultKind, shapeHasAccessorSlots, typeEquals, typeKey, unionFuncSetArmsOk } from "../../ir/ir.js";
import { BIGINT_T, BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, IrExpr, IrFunction, IrJsOp, IrLocal, IrRecordShape, IrStmt, IrType, JSVAL, NULL_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_ERROR_CLASSES, SEARCH_PARAMS_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canAdaptDynFuncTo, canDynCheckTo, canBoxFuncIntoDyn, funcOf, isDynTypedRefType, isJsonSafeType, isSupportedArrayElem, isUnitType, jsOpResultKind, shapeHasAccessorSlots, typeEquals, typeKey, unionFuncSetArmsOk } from "../../ir/ir.js";
import { cjsClassExprWholeExportOf, cjsExportAssignmentOf, cjsExportDiscardReason, isCjsExportTableLiteral, isCjsJsFile, isJsSourceFile, isModuleExportsAccess, isNodeEsmFile, locOf } from "../program.js";
import { ARRAY_METHODS, builtinConstLit, builtinFenceHintOf, builtinModuleConstOf, builtinModulesArrayLit, builtinModuleFnOf, COMPOUND_ASSIGN_OPS, CompoundOp, ISLAND_SURFACE, isChildSurfaceMember, MAP_METHODS, NARROW_FIRST, SET_METHODS, STRING_INDEX_METHODS, STR_METHODS, UNSUPPORTED_EXPR, sideEffectFreeOptionValue, stdlibGlobalNameOf } from "./surfaces.js";
import { UNSUPPORTED, blockedBindingUseDiag, requiresDynamicPackageDiag, unsupportedDiag } from "../../diagnostics/diagnostic.js";
@@ -4820,7 +4820,7 @@ export function lowerOptionalNumber(
let declared: IrType | null = null;
if (shape.indexValue) {
declared = shape.indexValue;
if (includeUndefined || lowerer.program.getCompilerOptions().noUncheckedIndexedAccess) {
if (declared.kind !== "dyn" && (includeUndefined || lowerer.program.getCompilerOptions().noUncheckedIndexedAccess)) {
declared = lowerer.withUndefinedArmOf(declared);
if (!declared) lowerer.badType(expr, lowerer.typeOf(expr));
}
@@ -5747,7 +5747,7 @@ export function lowerTemplate(lowerer: Lowerer, expr: ts.TemplateExpression): Ir
const target = lowerer.mapTypeOf(targetTs);
if (!target) lowerer.badType(expr.type, targetTs);
if (target.kind === "dyn") return inner; // `as unknown`: erasure
if (target.kind === "void" || !lowerer.jsonSafe(target)) {
if (target.kind === "void" || !canDynCheckTo(target, (id) => lowerer.shapes.get(id), (id) => lowerer.unions.get(id))) {
// Bare undefined-armed targets pass when every OTHER arm is
// JSON-safe: the checked-dynamic tree holds a first-class undefined value now
// (index-signature overflow reads produce it for missing keys), and
@@ -53,7 +53,7 @@ import type {
IrUnionDef,
SrcLoc,
} from "../../ir/ir.js";
import { arrayOf, BOOL, canAdaptDynFuncTo, canConvertToDyn, canCrossIslandBoundary, canExitIslandToType, canMarshalTypedFuncIntoIsland, DYN, DYN_HANDLE_KINDS, F64, isDynTypedRefType, isJsonSafeType, isJsonStringifySafeType, isUndefinedArmedUnion, isUnitType, JSVAL, NULL_T, RUNTIME_ERROR_CLASSES, STRING, typeEquals, UNDEFINED_T, VOID } from "../../ir/ir.js";
import { arrayOf, BOOL, canAdaptDynFuncTo, canDynCheckTo, canConvertToDyn, canCrossIslandBoundary, canExitIslandToType, canMarshalTypedFuncIntoIsland, DYN, DYN_HANDLE_KINDS, F64, isDynTypedRefType, isJsonSafeType, isJsonStringifySafeType, isUndefinedArmedUnion, isUnitType, JSVAL, NULL_T, RUNTIME_ERROR_CLASSES, STRING, typeEquals, UNDEFINED_T, VOID } from "../../ir/ir.js";
import { type DynamicImportResolution, type NpmBuiltinUse, type NpmLazyTrap } from "../npm.js";
import { provenanceActive } from "../provenance-registry.js";
import {
@@ -5290,7 +5290,7 @@ export class Lowerer {
expected.kind === "func" &&
canAdaptDynFuncTo(expected, (id) => this.shapes.get(id), (id) => this.unions.get(id));
const handleOk = DYN_HANDLE_KINDS.has(expected.kind);
if (this.jsonSafe(expected) || undefArmedOk || bytesOk || errorOk || classOk || funcOk || handleOk) {
if (canDynCheckTo(expected, (id) => this.shapes.get(id), (id) => this.unions.get(id)) || undefArmedOk || bytesOk || errorOk || classOk || funcOk || handleOk) {
return { kind: "dynCheck", value: expr, type: expected, loc: expr.loc };
}
return expr;
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { HANDLE_KINDS, POINTER_KINDS, STRING, arrayOf, typeEquals, typeKey } from "./ir.js";
import { DYN, F64, HANDLE_KINDS, canDynCheckTo, isIslandCallbackParamType, isJsonSafeType, isJsonStringifySafeType, type IrRecordShape, type IrType, type IrUnionDef, POINTER_KINDS, STRING, arrayOf, typeEquals, typeKey } from "./ir.js";
describe("IR kind sets", () => {
test("keeps procStream as the scalar handle exception", () => {
@@ -36,3 +36,43 @@ describe("IR kind sets", () => {
expect(typeKey(full)).toBe("func(string,arguments[])=>string");
});
});
describe("checked records with opaque payloads", () => {
const records = new Map<string, IrRecordShape>([
["payload", { id: "payload", fields: [{ name: "id", type: F64 }, { name: "value", type: DYN }] }],
["recursive", { id: "recursive", fields: [
{ name: "children", type: arrayOf({ kind: "record", shapeId: "recursive" }) },
{ name: "payload", type: DYN },
] }],
["tuple", { id: "tuple", tuple: true, fields: [{ name: "0", type: STRING }, { name: "1", type: DYN }] }],
["unsafe", { id: "unsafe", fields: [
{ name: "child", type: { kind: "record", shapeId: "unsafe" } },
{ name: "map", type: { kind: "map", key: STRING, value: F64 } },
] }],
]);
const unions = new Map<string, IrUnionDef>([
["optional", { id: "optional", arms: [{ kind: "record", shapeId: "payload" }, { kind: "undefinedT" }] }],
]);
const record = (id: string): IrRecordShape | undefined => records.get(id);
const union = (id: string): IrUnionDef | undefined => unions.get(id);
test.each(["payload", "recursive", "tuple"])("validates %s without declaring opaque slots JSON-safe", (id) => {
const type: IrType = { kind: "record", shapeId: id };
expect(canDynCheckTo(type, record, union)).toBe(true);
expect(isJsonSafeType(type, record, union)).toBe(false);
expect(isJsonStringifySafeType(type, record, union)).toBe(false);
expect(isIslandCallbackParamType(type, record, union)).toBe(false);
});
test("opaque arrays and optional records retain their actual payload representation", () => {
expect(canDynCheckTo(arrayOf(DYN), record, union)).toBe(true);
expect(canDynCheckTo({ kind: "union", unionId: "optional" }, record, union)).toBe(true);
});
test("recursive back-edges cannot hide an unsupported sibling", () => {
expect(canDynCheckTo({ kind: "record", shapeId: "unsafe" }, record, union)).toBe(false);
expect(canDynCheckTo({ kind: "record", shapeId: "missing" }, record, union)).toBe(false);
expect(canDynCheckTo({ kind: "union", unionId: "missing" }, record, union)).toBe(false);
});
});
+21 -9
View File
@@ -1950,8 +1950,9 @@ export type IrRegexIntrinsicMethod =
* union — every member has a signature in the validator's LIB_FN_SIGS and a
* scr_* implementation in the runtime (scr_lib.c / scr_json.c). fs.*
* failures and json.parse syntax errors THROW (catchable, via the runtime
* exception cell); process.* members never throw. JSON.stringify is NOT a
* libCall — it lowers to the type-directed `jsonStringify` node below.
* exception cell); process.* members never throw. JSON.stringify without a
* callback lowers to the type-directed `jsonStringify` node below; callback
* forms use the shared native JSON walker through libCall.
* island.eval (the internal __island_eval testing hook) exists only in
* --dynamic builds — the frontend rejects it otherwise, so backends may
* assume the island runtime is linked when they see it; island exceptions
@@ -2001,6 +2002,8 @@ export type IrLibFn =
* --dynamic only. */
| "island.castFail"
| "json.parse"
| "json.parseReviver"
| "json.stringifyReplacer"
/** Keyed WRITE on a dyn value — `h.onDone = cb` / `h["k"] = v` on a
* checked-dynamic object (args: receiver, key string, value — all
* borrowed; the runtime copies the key and retains the value in). An
@@ -5783,15 +5786,18 @@ function isJsonSafeAt(
stringify: boolean,
undefinedAllowed: boolean,
visiting: Set<string>,
dynFields = false,
): boolean {
if (HANDLE_KINDS.has(t.kind)) return false;
switch (t.kind) {
case "dyn":
return dynFields;
case "f64":
case "string":
case "bool":
return true;
case "array":
return isJsonSafeAt(t.elem, getRecord, getUnion, stringify, stringify, visiting);
return isJsonSafeAt(t.elem, getRecord, getUnion, stringify, stringify, visiting, dynFields);
case "record": {
const shape = getRecord(t.shapeId);
if (!shape) return false;
@@ -5800,7 +5806,7 @@ function isJsonSafeAt(
// short-circuits every `every` up the walk).
if (visiting.has(t.shapeId)) return true;
visiting.add(t.shapeId);
if (!shape.fields.every((f) => isJsonSafeAt(f.type, getRecord, getUnion, stringify, !shape.tuple || stringify, visiting))) {
if (!shape.fields.every((f) => isJsonSafeAt(f.type, getRecord, getUnion, stringify, !shape.tuple || stringify, visiting, dynFields))) {
return false;
}
// Overflow values sit in record-key position too: dyn is JSON-safe
@@ -5808,7 +5814,7 @@ function isJsonSafeAt(
// like any undefined-valued key), everything else follows the
// record-field rule.
if (shape.indexValue && shape.indexValue.kind !== "dyn") {
return isJsonSafeAt(shape.indexValue, getRecord, getUnion, stringify, true, visiting);
return isJsonSafeAt(shape.indexValue, getRecord, getUnion, stringify, true, visiting, dynFields);
}
return true;
}
@@ -5818,7 +5824,7 @@ function isJsonSafeAt(
const key = `${t.unionId}:${stringify}:${undefinedAllowed}`;
if (visiting.has(key)) return true; // the recursive knot, union-flavored
visiting.add(key);
return def.arms.every((a) => a.kind === "undefinedT" ? undefinedAllowed : isJsonSafeAt(a, getRecord, getUnion, stringify, undefinedAllowed, visiting));
return def.arms.every((a) => a.kind === "undefinedT" ? undefinedAllowed : isJsonSafeAt(a, getRecord, getUnion, stringify, undefinedAllowed, visiting, dynFields));
}
case "func":
case "object":
@@ -5848,7 +5854,6 @@ function isJsonSafeAt(
// Buffers as {type:"Buffer",data:[...]} in Node — neither shape is
// representable type-directedly; rejected like Maps.
case "bytes":
case "dyn":
case "jsval":
case "caught":
case "promise":
@@ -6177,14 +6182,19 @@ export function canDynCheckTo(
getRecord: (shapeId: string) => IrRecordShape | undefined,
getUnion: (unionId: string) => IrUnionDef | undefined,
): boolean {
if (isJsonSafeType(t, getRecord, getUnion)) return true;
// Unknown fields keep an owned dyn subtree; checking the surrounding
// record/array still validates its layout. This is broader than the
// stringify/island JSON domain, which cannot assume opaque slots are
// serializable. Backends already retain dyn fields and fill missing
// unknown record fields with the undefined value.
if (isJsonSafeAt(t, getRecord, getUnion, false, false, new Set(), true)) return true;
if (t.kind === "bytes" && t.elem === "u8") return true;
if (t.kind === "object" && t.className === "%Error") return true;
if (t.kind === "func") return canAdaptDynFuncTo(t, getRecord, getUnion);
if (DYN_HANDLE_KINDS.has(t.kind)) return true;
if (t.kind === "union") {
const def = getUnion(t.unionId);
return !!def && def.arms.every((a) => a.kind === "undefinedT" || isJsonSafeType(a, getRecord, getUnion));
return !!def && def.arms.every((a) => a.kind === "undefinedT" || isJsonSafeAt(a, getRecord, getUnion, false, false, new Set(), true));
}
return false;
}
@@ -7690,6 +7700,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"island.import",
"island.castFail",
"json.parse",
"json.parseReviver",
"json.stringifyReplacer",
"util.parseArgs",
// decodeURIComponent throws the spec's URIError on bad hex/invalid
// UTF-8 octets (encodeURIComponent never throws — see the IrLibFn doc).
+4 -2
View File
@@ -18,7 +18,7 @@ import type {
IrUnionDef,
SrcLoc,
} from "./ir.js";
import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isJsonStringifySafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./ir.js";
import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canDynCheckTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isJsonStringifySafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./ir.js";
import { BIGINT_T } from "./ir.js";
import { unionWideningTags } from "./analysis.js";
import { alwaysReturns } from "./control-flow.js";
@@ -109,6 +109,8 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
// checked in the libCall case, like error.new.
"island.castFail": { argTypes: [JSVAL, STRING], result: VOID },
"json.parse": { argTypes: [STRING], result: DYN },
"json.parseReviver": { argTypes: [STRING, DYN], result: DYN },
"json.stringifyReplacer": { argTypes: [DYN, DYN, STRING], result: DYN },
"dyn.keySet": { argTypes: [DYN, STRING, DYN], result: VOID },
"dyn.iterPack": { argTypes: [DYN, STRING], result: DYN },
"dyn.arrLen": { argTypes: [DYN], result: F64 },
@@ -5267,7 +5269,7 @@ function validateFunction(
// Runtime HANDLE targets unwrap the checked-dynamic tree's handle kind by tag (a
// retained reference, no copy — DYN_HANDLE_KINDS).
const handleOk = DYN_HANDLE_KINDS.has(e.type.kind);
if (!jsonOk(e.type) && !undefArmedOk && !bytesOk && !errorOk && !classOk && !funcOk && !handleOk) {
if (!canDynCheckTo(e.type, (id) => records.get(id), (id) => unions.get(id)) && !undefArmedOk && !bytesOk && !errorOk && !classOk && !funcOk && !handleOk) {
err(`dynCheck against non-JSON-representable type ${e.type.kind}`, e.loc);
}
break;
@@ -0,0 +1,100 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { analyze, compile } from "../src/index.js";
const temp = (): string => mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-json-callback-"));
// Unsupported forms must be named; accepting a third argument but never
// supplying context.source would silently change data recovery code.
test.each([
["replacer property list", `JSON.stringify({ n: 1 }, ["n"]);`, "JSON replacer"],
["reviver context", `JSON.parse("1", (key, value, context) => context.source);`, "JSON reviver"],
["reviver rest context", `JSON.parse("1", (...args) => args.length);`, "JSON reviver"],
["reviver arguments context", `JSON.parse("1", function () { return arguments[2].source; });`, "JSON reviver"],
["dynamic gap", `const gap = process.argv[2]; JSON.stringify({ n: 1 }, (key, value) => value, gap);`, "replacer/space"],
] as const)("JSON callback boundary: %s", (_name, source, message) => {
const dir = temp();
try {
const entry = join(dir, "main.js");
writeFileSync(entry, source);
const { coverage } = analyze(entry, { dynamic: false });
expect(coverage.preflightFailed).toBe(false);
expect([...coverage.diagnostics, ...coverage.runtimeFences].some((d) => d.code === "SC2020" && d.message.includes(message)),
JSON.stringify(coverage.diagnostics)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
for (const backend of ["c", "llvm"] as const) {
test(`JSON callback checked boundaries and recovery (${backend})`, async () => {
const dir = temp();
try {
const entry = join(dir, "main.ts");
writeFileSync(entry, `
export {};
// Dense dyn arrays must not misrepresent a deleted element as a
// present undefined. The runtime explicitly refuses that form.
try {
JSON.parse("[1,2]", (key: string, value: unknown): unknown => key === "0" ? undefined : value);
} catch (error) {
if (error instanceof Error) console.log(error.message);
}
// A checked cast still validates the result AFTER revival.
try {
const value = JSON.parse('{"n":1}', (key: string, value: unknown): unknown => key === "n" ? "wrong" : value) as { n: number };
console.log(value.n);
} catch (error) { console.log(error instanceof TypeError); }
// Typed callback parameters use checked adaptation, not a raw ABI
// cast of a JSON object into a number slot.
try {
JSON.stringify({ n: 1 }, (_key: string, value: number) => value + 1);
} catch (error) { console.log(error instanceof TypeError); }
// A required-string boundary must refuse a dropped root. Inferred
// bindings and explicitly optional results preserve undefined.
function required(): string { return JSON.stringify(1, () => undefined); }
try { console.log(required()); }
catch (error) { console.log(error instanceof TypeError); }
function optional(): string | undefined { return JSON.stringify(1, () => undefined); }
console.log(optional() === undefined);
function local(): void {
const result = JSON.stringify(1, () => undefined);
console.log(result === undefined, typeof result);
}
local();
for (let i = 0; i < 20; i++) {
try {
const record = JSON.parse('{"opaque":{"deep":[1,2]},"typed":"wrong"}') as { opaque: unknown; typed: number };
console.log(record.typed);
} catch (error) { if (i === 0) console.log(error instanceof TypeError); }
try {
const rows = JSON.parse('[{"opaque":1,"typed":2},{"opaque":3,"typed":false}]') as { opaque: unknown; typed: number }[];
console.log(rows.length);
} catch (error) { if (i === 0) console.log(error instanceof TypeError); }
}
const recovered = JSON.parse('{"n":1}', (_key: string, value: unknown) => value) as { n: number };
console.log(recovered.n);
`);
const built = await compile(entry, {
outDir: dir, outPath: join(dir, process.platform === "win32" ? "program.exe" : "program"),
backend, dynamic: false, sanitize: process.env["SCRIPTC_SAN"] === "1",
});
if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
expect(built.backend).toBe(backend);
const result = spawnSync(built.binaryPath, [], { timeout: 30_000 });
expect(result.error).toBeUndefined();
expect(result.signal).toBeNull();
expect(result.status, result.stderr.toString()).toBe(0);
expect(result.stdout.toString()).toBe([
"JSON.parse reviver deleting array elements is not supported yet",
"true", "true", "true", "true", "true undefined", "true", "true", "1", "",
].join("\n"));
expect(result.stderr.toString()).toBe("");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}
@@ -7427,6 +7427,60 @@
],
"diags": []
},
"<repo>/tests/corpus/3094-json-replacer-traversal.ts": {
"order": [
"<repo>/tests/corpus/3094-json-replacer-traversal.ts"
],
"diags": []
},
"<repo>/tests/corpus/3095-json-reviver-traversal.ts": {
"order": [
"<repo>/tests/corpus/3095-json-reviver-traversal.ts"
],
"diags": []
},
"<repo>/tests/corpus/3096-json-callback-nonfinite.ts": {
"order": [
"<repo>/tests/corpus/3096-json-callback-nonfinite.ts"
],
"diags": []
},
"<repo>/tests/corpus/3097-json-callback-reentrancy.ts": {
"order": [
"<repo>/tests/corpus/3097-json-callback-reentrancy.ts"
],
"diags": []
},
"<repo>/tests/corpus/3098-json-callback-space.ts": {
"order": [
"<repo>/tests/corpus/3098-json-callback-space.ts"
],
"diags": []
},
"<repo>/tests/corpus/3099-json-callback-functions.ts": {
"order": [
"<repo>/tests/corpus/3099-json-callback-functions.ts"
],
"diags": []
},
"<repo>/tests/corpus/3100-json-callback-holder.js": {
"order": [
"<repo>/tests/corpus/3100-json-callback-holder.js"
],
"diags": []
},
"<repo>/tests/corpus/3101-json-callback-bytes.ts": {
"order": [
"<repo>/tests/corpus/3101-json-callback-bytes.ts"
],
"diags": []
},
"<repo>/tests/corpus/3102-json-unknown-fields.ts": {
"order": [
"<repo>/tests/corpus/3102-json-unknown-fields.ts"
],
"diags": []
},
"<repo>/tests/corpus/400-fib.ts": {
"order": [
"<repo>/tests/corpus/400-fib.ts"
+258
View File
@@ -2779,6 +2779,264 @@ ScrDyn *scr_json_parse(ScrStr *text) {
return d;
}
/* ── Native JSON callback walks ──────────────────────────────────────
* Keep these walks separate from the type-directed fast serializer. The
* replacer observes the original value BEFORE number normalization, and
* a reviver observes children AFTER their replacements. Neither protocol
* can be implemented as a callback over already-serialized JSON text.
*
* Each frame owns its value and its key snapshot. A callback may mutate
* siblings, recurse into JSON, or throw; no pointer into an object's
* reallocatable entry storage survives a callback. Receiver binding is
* scoped to the call and unwound even when an exception is pending. */
static ScrDyn *scr_json_callback(const ScrDyn *callback, const ScrDyn *holder,
const ScrStr *key, ScrDyn *value) {
ScrDyn *name = scr_dyn_new_str((ScrStr *)key);
ScrDyn *args[] = { name, value };
scr_dyn_this_push_dyn(holder);
ScrDyn *result = scr_dyn_call(callback, args, 2, "JSON callback");
scr_dyn_this_pop();
scr_dyn_release(name);
return result;
}
static bool scr_json_callback_depth(size_t depth) {
if (depth <= SCR_JSON_MAX_DEPTH) return true;
const char *message = "Maximum call stack size exceeded";
scr_throw_error_msg(SCR_ERR_RANGE, message, strlen(message));
return false;
}
static ScrStr *scr_json_index_key(size_t index) {
char bytes[32];
int length = snprintf(bytes, sizeof bytes, "%zu", index);
return scr_str_new(bytes, (size_t)length);
}
/* Keys passed here come from our own index formatter or own-key walk. */
static ScrDyn *scr_json_member(const ScrDyn *holder, const ScrStr *key) {
if (holder->kind == SCR_DYN_ARR || holder->kind == SCR_DYN_BYTES) {
size_t index = 0;
for (size_t i = 0; i < key->len; i++) index = index * 10 + (size_t)(key->data[i] - '0');
if (holder->kind == SCR_DYN_BYTES) {
return index < holder->v.bytes->len ? scr_dyn_new_num(scr_bytes_get(holder->v.bytes, (double)index))
: scr_dyn_retain(scr_dyn_undefined());
}
return scr_dyn_retain(index < holder->v.arr.len ? holder->v.arr.items[index] : scr_dyn_undefined());
}
ScrDyn *value = scr_dyn_obj_get(holder, key->data, key->len);
return scr_dyn_retain(value ? value : scr_dyn_undefined());
}
static void scr_json_delete_member(ScrDyn *object, const ScrStr *key) {
for (size_t i = 0; i < object->v.obj.len; i++) {
ScrDynEntry *entry = &object->v.obj.entries[i];
if (entry->key_len != key->len || memcmp(entry->key, key->data, key->len) != 0) continue;
free(entry->key);
scr_dyn_release(entry->value);
memmove(entry, entry + 1, (object->v.obj.len - i - 1) * sizeof *entry);
object->v.obj.len--;
return;
}
}
static ScrDyn *scr_json_revive(ScrDyn *holder, const ScrStr *key,
const ScrDyn *reviver, size_t depth) {
if (!scr_json_callback_depth(depth)) return NULL;
ScrDyn *value = scr_json_member(holder, key);
if (value->kind == SCR_DYN_ARR) {
/* Capture length once, but read each member at the time it is visited. */
size_t length = value->v.arr.len;
for (size_t i = 0; i < length; i++) {
ScrStr *index = scr_json_index_key(i);
ScrDyn *replacement = scr_json_revive(value, index, reviver, depth + 1);
scr_str_release(index);
if (!replacement) { scr_dyn_release(value); return NULL; }
if (replacement->kind == SCR_DYN_UNDEF) {
/* The checked-dynamic array ABI is dense. Do not pretend that an
* undefined element is a hole: hasOwn/keys would be observably wrong. */
const char *message = "JSON.parse reviver deleting array elements is not supported yet";
scr_throw_error_msg(SCR_ERR_ERROR, message, strlen(message));
scr_dyn_release(replacement);
scr_dyn_release(value);
return NULL;
}
while (value->v.arr.len <= i) scr_dyn_arr_push(value, scr_dyn_retain(scr_dyn_undefined()));
scr_dyn_release(value->v.arr.items[i]);
value->v.arr.items[i] = replacement;
}
} else if (value->kind == SCR_DYN_OBJ) {
ScrDyn *keys = scr_dyn_obj_keys(value);
for (size_t i = 0; i < keys->v.arr.len; i++) {
const ScrStr *name = keys->v.arr.items[i]->v.str;
ScrDyn *replacement = scr_json_revive(value, name, reviver, depth + 1);
if (!replacement) { scr_dyn_release(keys); scr_dyn_release(value); return NULL; }
if (replacement->kind == SCR_DYN_UNDEF) {
scr_json_delete_member(value, name);
scr_dyn_release(replacement);
} else {
scr_dyn_obj_set(value, name->data, name->len, replacement);
}
}
scr_dyn_release(keys);
}
ScrDyn *result = scr_json_callback(reviver, holder, key, value);
scr_dyn_release(value);
return result;
}
ScrDyn *scr_json_parse_reviver(ScrStr *text, const ScrDyn *reviver) {
ScrDyn *parsed = scr_json_parse(text);
if (!parsed) return NULL; /* No callback runs on invalid input. */
ScrDyn *holder = scr_dyn_new_obj();
scr_dyn_obj_set(holder, "", 0, parsed);
ScrStr *key = scr_str_new("", 0);
ScrDyn *result = scr_json_revive(holder, key, reviver, 0);
scr_str_release(key);
scr_dyn_release(holder);
return result;
}
/* Buffer's toJSON happens before the replacer. The returned data object
* is owned by this walk; a replacer-returned Buffer is never sent here. */
static ScrDyn *scr_json_buffer_view(const ScrDyn *value) {
ScrDyn *view = scr_dyn_new_obj();
const ScrBytes *bytes = value->v.bytes;
ScrStr *name = scr_str_new("Buffer", 6);
scr_dyn_obj_set(view, "type", 4, scr_dyn_new_str(name));
scr_str_release(name);
ScrDyn *data = scr_dyn_new_arr();
for (size_t i = 0; i < bytes->len; i++) scr_dyn_arr_push(data, scr_dyn_new_num(scr_bytes_get(bytes, (double)i)));
scr_dyn_obj_set(view, "data", 4, data);
return view;
}
static ScrDyn *scr_json_replace(const ScrDyn *holder, const ScrStr *key, const ScrDyn *replacer) {
ScrDyn *value = scr_json_member(holder, key);
if (value->kind == SCR_DYN_TYPED_REF) {
ScrDyn *view = scr_dyn_typed_ref_materialize(value);
scr_dyn_release(value);
value = view;
}
if (value->kind == SCR_DYN_BYTES && value->buffer) {
ScrDyn *view = scr_json_buffer_view(value);
scr_dyn_release(value);
value = view;
} else if (value->kind == SCR_DYN_OBJ) {
ScrDyn *method = scr_dyn_obj_get(value, "toJSON", 6);
if (method && method->kind == SCR_DYN_FUNC) {
/* Retain the callable too: it can replace its own property. */
scr_dyn_retain(method);
ScrDyn *name = scr_dyn_new_str((ScrStr *)key);
ScrDyn *args[] = { name };
scr_dyn_this_push_dyn(value);
ScrDyn *converted = scr_dyn_call(method, args, 1, "toJSON");
scr_dyn_this_pop();
scr_dyn_release(method);
scr_dyn_release(name);
scr_dyn_release(value);
if (!converted) return NULL;
value = converted;
}
}
ScrDyn *result = scr_json_callback(replacer, holder, key, value);
scr_dyn_release(value);
return result;
}
static bool scr_json_omitted(const ScrDyn *value) {
return value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_FUNC;
}
static void scr_json_gap(ScrJsonBuf *buffer, const ScrStr *gap, size_t depth) {
if (!gap->len) return;
scr_jb_putc(buffer, '\n');
for (size_t i = 0; i < depth; i++) scr_jb_write(buffer, gap->data, gap->len);
}
static bool scr_json_replaced_write(ScrJsonBuf *buffer, const ScrDyn *value,
const ScrDyn *replacer, const ScrStr *gap, size_t depth) {
if (!scr_json_callback_depth(depth)) return false;
if (value->kind == SCR_DYN_TYPED_REF) {
ScrDyn *view = scr_dyn_typed_ref_materialize(value);
bool ok = scr_json_replaced_write(buffer, view, replacer, gap, depth);
scr_dyn_release(view);
return ok;
}
if (value->kind != SCR_DYN_OBJ && value->kind != SCR_DYN_ARR && value->kind != SCR_DYN_BYTES) {
/* Engine objects need an engine callback bridge, not an opaque JSON
* splice: that would silently skip all their children. */
if (value->kind == SCR_DYN_JSVAL) {
const char *message = "JSON replacers over engine-held values are not supported yet";
scr_throw_error_msg(SCR_ERR_ERROR, message, strlen(message));
return false;
}
scr_dyn_json_write(buffer, value);
return !scr_exc_pending();
}
bool array = value->kind == SCR_DYN_ARR;
if (!scr_jb_enter(buffer, value, array)) return false;
scr_jb_putc(buffer, array ? '[' : '{');
ScrDyn *keys = array ? NULL : scr_dyn_obj_keys(value);
size_t length = array ? value->v.arr.len : keys->v.arr.len;
bool first = true;
bool ok = true;
for (size_t i = 0; i < length; i++) {
ScrStr *key = array ? scr_json_index_key(i) : scr_str_retain(keys->v.arr.items[i]->v.str);
if (array) scr_jb_edge_idx(buffer, i);
else scr_jb_edge_key(buffer, key);
ScrDyn *child = scr_json_replace(value, key, replacer);
if (!child) { scr_str_release(key); ok = false; break; }
if (!array && scr_json_omitted(child)) {
scr_dyn_release(child);
scr_str_release(key);
continue;
}
if (!first) scr_jb_putc(buffer, ',');
first = false;
scr_json_gap(buffer, gap, depth + 1);
if (!array) {
scr_jb_put_json_str(buffer, key);
scr_jb_putc(buffer, ':');
if (gap->len) scr_jb_putc(buffer, ' ');
}
if (scr_json_omitted(child)) scr_jb_puts(buffer, "null");
else ok = scr_json_replaced_write(buffer, child, replacer, gap, depth + 1);
scr_dyn_release(child);
scr_str_release(key);
if (!ok) break;
}
scr_dyn_release(keys);
scr_jb_leave(buffer);
if (!ok) return false;
if (!first) scr_json_gap(buffer, gap, depth);
scr_jb_putc(buffer, array ? ']' : '}');
return true;
}
ScrDyn *scr_json_stringify_replacer(const ScrDyn *value, const ScrDyn *replacer, const ScrStr *gap) {
ScrDyn *holder = scr_dyn_new_obj();
scr_dyn_obj_set(holder, "", 0, scr_dyn_retain((ScrDyn *)value));
ScrStr *key = scr_str_new("", 0);
ScrDyn *replaced = scr_json_replace(holder, key, replacer);
scr_str_release(key);
scr_dyn_release(holder);
if (!replaced) return NULL;
if (scr_json_omitted(replaced)) {
scr_dyn_release(replaced);
return scr_dyn_retain(scr_dyn_undefined());
}
ScrJsonBuf buffer;
scr_jb_init(&buffer);
bool ok = scr_json_replaced_write(&buffer, replaced, replacer, gap, 0);
scr_dyn_release(replaced);
if (!ok) { scr_jb_dispose(&buffer); return NULL; }
ScrStr *text = scr_jb_finish(&buffer);
ScrDyn *result = scr_dyn_new_str(text);
scr_str_release(text);
return result;
}
/* Untyped RC adapters (box/promise/exception-cell currency). */
void *scr_dyn_retain_v(void *d) { return scr_dyn_retain((ScrDyn *)d); }
void scr_dyn_release_v(void *d) { scr_dyn_release((ScrDyn *)d); }
+7
View File
@@ -3471,6 +3471,13 @@ void scr_dyn_release(ScrDyn *d); /* releases the tree recursively; NULL-tolerant
* compiler-emitted pending checks — json.parse is in the may-throw seed). */
ScrDyn *scr_json_parse(ScrStr *text);
/* Native JSON callbacks. All inputs borrowed, result owned (+1), NULL on
* pending exception. Stringify returns a dyn string OR actual undefined
* when the replacer omits the root. gap has already applied space rules. */
ScrDyn *scr_json_parse_reviver(ScrStr *text, const ScrDyn *reviver);
ScrDyn *scr_json_stringify_replacer(const ScrDyn *value, const ScrDyn *replacer, const ScrStr *gap);
/* BORROWED member lookup on a SCR_DYN_OBJ; NULL when the key is absent. */
ScrDyn *scr_dyn_obj_get(const ScrDyn *d, const char *key, size_t key_len);
/* Literal discriminator tests used before selecting a typed record layout.
+240
View File
@@ -60,6 +60,244 @@ static bool dyn_str_is(const ScrDyn *d, const char *want) {
return d && d->kind == SCR_DYN_STR && str_is(d->v.str, want);
}
/* Low-level callback contracts that typed source does not expose: cyclic
* dyn graphs, receiver lifetime during mutation, and runtime refusal paths.
* Every temporary remains under the existing ASan and RC audit harness. */
static size_t callback_calls;
static int callback_mode;
static ScrDyn *callback_shared;
static ScrDyn *json_test_callback(ScrClosure *closure, ScrDyn *const *args, size_t argc) {
(void)closure;
callback_calls++;
check(argc == 2, "JSON callback receives key and value");
check(args[0]->kind == SCR_DYN_STR, "JSON callback key is a string");
ScrDyn *holder = scr_dyn_this_get();
check(holder->kind == SCR_DYN_OBJ || holder->kind == SCR_DYN_ARR || holder->kind == SCR_DYN_BYTES, "JSON callback holder bound");
const ScrStr *key = args[0]->v.str;
ScrDyn *value = args[1];
if (callback_mode == 7 && (str_is(key, "0") || str_is(key, "1"))) {
check(holder == callback_shared, "returned bytes remain the holder for indexed callbacks");
}
if (callback_mode == 1 && key->len) {
scr_dyn_release(holder);
return scr_dyn_retain(scr_dyn_undefined());
}
if (callback_mode == 2 && str_is(key, "a")) {
/* Reallocating the holder's entries must not invalidate traversal. */
for (size_t i = 0; i < 80; i++) {
char name[32];
int length = snprintf(name, sizeof name, "new%zu", i);
scr_dyn_obj_set(holder, name, (size_t)length, scr_dyn_new_num((double)i));
}
scr_dyn_obj_set(holder, "b", 1, scr_dyn_new_num(9));
}
if (callback_mode == 3 && key->len) {
scr_throw_error_msg(SCR_ERR_TYPE, "callback", 8);
scr_dyn_release(holder);
return NULL;
}
if (callback_mode == 4 && str_is(key, "a")) {
/* Release the original parent edge while its value is borrowed by the
* callback. The walker must still own that original value. */
scr_dyn_obj_set(holder, "a", 1, scr_dyn_new_null());
check(value->kind == SCR_DYN_OBJ, "replaced child survives callback");
}
if (callback_mode == 5) {
ScrDyn *replacement = scr_dyn_new_obj();
scr_dyn_obj_set(replacement, "child", 5, scr_dyn_new_null());
scr_dyn_release(holder);
return replacement;
}
if ((callback_mode == 6 && key->len) || (callback_mode == 7 && str_is(key, "replace"))) {
scr_dyn_release(holder);
return scr_dyn_retain(callback_shared);
}
scr_dyn_release(holder);
return scr_dyn_retain(value);
}
static ScrDyn *json_test_to_json(ScrClosure *closure, ScrDyn *const *args, size_t argc) {
(void)closure;
check(argc == 1, "toJSON receives only the property key");
check(dyn_str_is(args[0], "nested"), "toJSON receives containing key");
ScrDyn *holder = scr_dyn_this_get();
check(holder == callback_shared, "toJSON receiver is the value");
/* Drop the only owning property reference to this callable while it is
* executing; the invocation must retain its closure independently. */
scr_dyn_obj_set(holder, "toJSON", 6, scr_dyn_new_null());
scr_dyn_release(holder);
return scr_dyn_new_num(42);
}
static ScrDyn *json_test_func(ScrDynThunk thunk) {
return scr_dyn_new_func(scr_closure_new(NULL, 0), thunk, 2, "JSON test callback", "json_test");
}
static ScrDyn *json_test_stringify(ScrDyn *value, ScrDyn *callback, const char *gap) {
ScrStr *indent = S(gap);
ScrDyn *out = scr_json_stringify_replacer(value, callback, indent);
scr_str_release(indent);
return out;
}
static ScrDyn *json_test_parse(const char *text, ScrDyn *callback) {
ScrStr *input = S(text);
ScrDyn *out = scr_json_parse_reviver(input, callback);
scr_str_release(input);
return out;
}
static void json_callback_tests(void) {
ScrDyn *callback = json_test_func(json_test_callback);
callback_mode = 0;
callback_calls = 0;
ScrDyn *value = parse_ok("{\"10\":10,\"2\":2,\"a\":[1,true,null]}", "callback input");
ScrDyn *out = json_test_stringify(value, callback, " ");
check(dyn_str_is(out, "{\n \"2\": 2,\n \"10\": 10,\n \"a\": [\n 1,\n true,\n null\n ]\n}"), "replacer pretty output");
check(callback_calls == 7, "replacer invokes once per property plus root");
scr_dyn_release(out);
scr_dyn_release(value);
callback_mode = 1;
value = parse_ok("{\"a\":1,\"b\":2}", "omission input");
out = json_test_stringify(value, callback, " ");
check(dyn_str_is(out, "{}"), "all omitted properties have no blank lines");
scr_dyn_release(out);
scr_dyn_release(value);
out = json_test_parse("{\"a\":1,\"b\":2}", callback);
check(out && out->kind == SCR_DYN_OBJ && out->v.obj.len == 0, "reviver deletes actual object properties");
scr_dyn_release(out);
out = json_test_parse("[1]", callback);
check(!out && scr_exc_pending(), "sparse reviver result refuses instead of returning a dense undefined");
scr_exc_clear();
callback_mode = 2;
callback_calls = 0;
value = parse_ok("{\"a\":1,\"b\":2}", "mutation input");
out = json_test_stringify(value, callback, "");
check(dyn_str_is(out, "{\"a\":1,\"b\":9}"), "replacer snapshots keys but reads current values");
check(callback_calls == 3, "replacer skips keys added during traversal");
scr_dyn_release(out);
scr_dyn_release(value);
callback_calls = 0;
out = json_test_parse("{\"a\":1,\"b\":2}", callback);
check(out && scr_dyn_obj_get(out, "b", 1)->v.num == 9, "reviver reads changed sibling");
check(out && out->v.obj.len == 82, "reviver retains new properties without visiting them");
check(callback_calls == 3, "reviver key snapshot survives realloc");
scr_dyn_release(out);
callback_mode = 4;
value = parse_ok("{\"a\":{\"x\":1}}", "parent overwrite input");
out = json_test_stringify(value, callback, "");
check(dyn_str_is(out, "{\"a\":{\"x\":1}}"), "replacer owns value after edge replacement");
check(scr_dyn_obj_get(value, "a", 1)->kind == SCR_DYN_NULL, "replacer mutation reaches holder");
scr_dyn_release(out);
scr_dyn_release(value);
out = json_test_parse("{\"a\":{\"x\":1}}", callback);
check(out && scr_dyn_obj_get(out, "a", 1)->kind == SCR_DYN_OBJ, "reviver returned value wins over holder assignment");
scr_dyn_release(out);
callback_mode = 0;
callback_shared = scr_dyn_new_obj();
scr_dyn_obj_set(callback_shared, "toJSON", 6, json_test_func(json_test_to_json));
value = scr_dyn_new_obj();
scr_dyn_obj_set(value, "nested", 6, scr_dyn_retain(callback_shared));
out = json_test_stringify(value, callback, "");
check(dyn_str_is(out, "{\"nested\":42}"), "toJSON precedes replacer and owns its callable");
scr_dyn_release(out);
scr_dyn_release(value);
scr_dyn_release(callback_shared);
callback_shared = NULL;
/* Shared subtrees are not cycles. A true back-edge throws only after
* the replacer gets its chance to replace that edge. Break it manually
* after the test: dyn graphs do not use the typed cycle collector. */
value = scr_dyn_new_obj();
ScrDyn *shared = parse_ok("{\"n\":1}", "shared subtree");
scr_dyn_obj_set(value, "a", 1, scr_dyn_retain(shared));
scr_dyn_obj_set(value, "b", 1, scr_dyn_retain(shared));
out = json_test_stringify(value, callback, "");
check(dyn_str_is(out, "{\"a\":{\"n\":1},\"b\":{\"n\":1}}"), "DAG is not circular");
scr_dyn_release(out);
scr_dyn_release(shared);
scr_dyn_release(value);
value = scr_dyn_new_obj();
scr_dyn_obj_set(value, "self", 4, scr_dyn_retain(value));
callback_calls = 0;
out = json_test_stringify(value, callback, "");
check(!out && scr_exc_pending(), "cyclic replacer output throws");
check(callback_calls == 2, "replacer observes cyclic edge before cycle error");
scr_exc_clear();
callback_mode = 1;
out = json_test_stringify(value, callback, "");
check(dyn_str_is(out, "{}"), "replacer may remove a cycle");
scr_dyn_release(out);
scr_dyn_obj_set(value, "self", 4, scr_dyn_new_null());
scr_dyn_release(value);
callback_mode = 5;
value = scr_dyn_new_null();
out = json_test_stringify(value, callback, "");
check(!out && scr_exc_pending(), "ever-growing replacements hit a catchable depth limit");
scr_exc_clear();
scr_dyn_release(value);
callback_mode = 6;
callback_shared = scr_dyn_new_num(INFINITY);
out = json_test_parse("{\"n\":0}", callback);
check(out && scr_dyn_obj_get(out, "n", 1) == callback_shared, "reviver retains returned shared value");
scr_dyn_release(out);
scr_dyn_release(callback_shared);
callback_shared = NULL;
/* Branded buffers can enter the dyn tree from native stream callbacks.
* Their pre-replacer toJSON differs from a buffer returned BY a replacer. */
const uint8_t bytes[] = { 5, 6 };
ScrBytes *storage = scr_bytes_from_data(bytes, sizeof bytes);
callback_shared = scr_dyn_new_buffer_copy(storage);
scr_bytes_release(storage);
callback_mode = 0;
value = scr_dyn_new_obj();
scr_dyn_obj_set(value, "buffer", 6, scr_dyn_retain(callback_shared));
out = json_test_stringify(value, callback, "");
check(dyn_str_is(out, "{\"buffer\":{\"type\":\"Buffer\",\"data\":[5,6]}}"), "branded buffer toJSON precedes callback");
scr_dyn_release(out);
scr_dyn_release(value);
callback_mode = 7;
value = parse_ok("{\"replace\":0}", "buffer replacement input");
out = json_test_stringify(value, callback, "");
check(dyn_str_is(out, "{\"replace\":{\"0\":5,\"1\":6}}"), "returned buffer does not rerun toJSON");
scr_dyn_release(out);
scr_dyn_release(value);
scr_dyn_release(callback_shared);
callback_shared = NULL;
for (size_t i = 0; i < 100; i++) {
callback_mode = 3;
out = json_test_parse("{\"a\":[1,2],\"b\":3}", callback);
check(!out && scr_exc_pending(), "reviver exception abandons all walk frames");
scr_exc_clear();
value = parse_ok("{\"a\":[1,2],\"b\":3}", "throw input");
out = json_test_stringify(value, callback, " ");
check(!out && scr_exc_pending(), "replacer exception abandons output and walk frames");
scr_exc_clear();
scr_dyn_release(value);
ScrDyn *receiver = scr_dyn_this_get();
check(receiver->kind == SCR_DYN_UNDEF, "receiver stack restored after exception");
scr_dyn_release(receiver);
}
callback_mode = 0;
callback_calls = 0;
out = json_test_parse("{\"a\":1,}", callback);
check(!out && scr_exc_pending() && callback_calls == 0, "parse error never runs reviver");
scr_exc_clear();
out = json_test_parse("17", callback);
check(out && out->kind == SCR_DYN_NUM && out->v.num == 17, "callback recovery after parse error");
scr_dyn_release(out);
scr_dyn_release(callback);
}
int main(void) {
scr_init();
@@ -227,6 +465,8 @@ int main(void) {
scr_str_release(out);
}
json_callback_tests();
printf("%d/%d checks passed\n", checks - failures, checks);
return failures == 0 ? 0 : 1;
}
@@ -0,0 +1,30 @@
export {};
const trace: string[] = [];
const source: unknown = JSON.parse('{"10":10,"2":2,"nested":{"keep":1,"omit":2},"items":[3,4],"empty":{}}');
const json = JSON.stringify(source, (key: string, value: unknown): unknown => {
trace.push(key + ":" + typeof value);
if (key === "omit") return undefined;
if (typeof value === "number") return value * 10;
return value;
}, 2);
console.log(json);
console.log(trace.join("|"));
console.log(JSON.stringify(source));
// The callback runs on the replacement's children, not on the replaced
// primitive a second time. A freshly returned record is traversed too.
console.log(JSON.stringify({ value: 7 }, (key: string, value: unknown): unknown => {
if (key === "value") return { wrapped: value, missing: undefined };
return value;
}));
// Omission rules differ for an object field, an array element and root.
function omit(key: string, value: unknown): unknown {
return key === "1" || key === "drop" ? undefined : value;
}
console.log(JSON.stringify({ keep: null, drop: 1, list: [1, 2, 3] }, omit));
const absent = JSON.stringify({ present: true }, () => undefined);
console.log(absent === undefined, typeof absent);
const text = JSON.stringify(12, (_key: string, value: unknown) => value);
console.log(text === undefined ? "absent" : text.toUpperCase());
@@ -0,0 +1,30 @@
export {};
const trace: string[] = [];
const result: unknown = JSON.parse('{"10":10,"2":2,"nested":{"keep":1,"omit":2},"items":[3,4],"empty":{}}',
(key: string, value: unknown): unknown => {
trace.push(key + ":" + typeof value);
if (key === "omit") return undefined;
if (typeof value === "number") return value * 10;
return value;
});
console.log(JSON.stringify(result));
console.log(trace.join("|"));
console.log(JSON.stringify(JSON.parse('{"a":1,"a":2,"__proto__":{"safe":true}}', (_key: string, value: unknown) => value)));
// Root replacement is observable even for scalar input.
console.log(JSON.stringify(JSON.parse("17", () => ({ answer: 42 }))));
console.log(JSON.parse("17", () => undefined) === undefined);
console.log(JSON.parse("false", () => "root"));
console.log(JSON.stringify(JSON.parse('{"a":{"b":1}}', (key: string, value: unknown): unknown => {
if (key === "a") return [8, 9];
return value;
})));
let calls = 0;
try {
JSON.parse('{"x":}', () => { calls++; return 1; });
} catch (error) {
console.log(error instanceof SyntaxError, calls);
}
console.log(JSON.stringify(JSON.parse('{"x":3}', undefined)));
@@ -0,0 +1,28 @@
export {};
function encode(_key: string, value: unknown): unknown {
if (typeof value === "number") {
if (Number.isNaN(value)) return { number: "nan" };
if (!Number.isFinite(value)) return { number: value > 0 ? "inf" : "-inf" };
if (Object.is(value, -0)) return { number: "-0" };
}
return value;
}
function decode(_key: string, value: unknown): unknown {
if (typeof value === "object" && value !== null && "number" in value) {
const tag = (value as { number: string }).number;
if (tag === "nan") return NaN;
if (tag === "inf") return Infinity;
if (tag === "-inf") return -Infinity;
if (tag === "-0") return -0;
}
return value;
}
const values = [0, -0, Infinity, -Infinity, NaN, 1.25, 1e300];
const encoded = JSON.stringify({ values }, encode, 2);
if (encoded === undefined) throw new Error("missing document");
console.log(encoded);
const decoded = JSON.parse(encoded, decode) as { values: number[] };
for (const value of decoded.values) console.log(String(value), Object.is(value, -0), Number.isNaN(value));
console.log(JSON.stringify(decoded, encode) === JSON.stringify({ values }, encode));
console.log(JSON.stringify([NaN, Infinity, -Infinity, -0], (_key: string, value: unknown) => value));
@@ -0,0 +1,60 @@
export {};
let calls = 0;
const factor = 3;
function multiply(_key: string, value: unknown): unknown {
calls++;
return typeof value === "number" ? value * factor : value;
}
const outer = JSON.stringify({ first: 1, second: 2 }, (key: string, value: unknown): unknown => {
if (key === "first") {
const inner = JSON.stringify({ n: 4 }, multiply);
if (inner === undefined) throw new Error("inner omitted");
return JSON.parse(inner, multiply);
}
return value;
});
console.log(outer, calls);
// A callback exception must not leave a receiver or traversal frame active.
for (let attempt = 0; attempt < 20; attempt++) {
const seen: string[] = [];
try {
JSON.stringify({ before: 1, fail: [2], after: 3 }, (key: string, value: unknown): unknown => {
seen.push(key);
if (key === "0") throw new TypeError("replacer failed");
return value;
});
} catch (error) {
if (attempt === 0) console.log(error instanceof TypeError, seen.join("|"));
}
try {
JSON.parse('{"before":1,"fail":[2],"after":3}', (key: string, value: unknown): unknown => {
if (key === "0") throw new RangeError("reviver failed");
return value;
});
} catch (error) {
if (attempt === 0) console.log(error instanceof RangeError);
}
const ok = JSON.parse('{"n":2}', multiply) as { n: number };
if (ok.n !== 6) throw new Error("corrupted callback state");
}
console.log("recovered", calls);
// toJSON runs before the replacer for a property. Its exception must skip
// that property's callback and release both the receiver and callable.
for (let attempt = 0; attempt < 10; attempt++) {
const seen: string[] = [];
const input = {
child: { toJSON: () => { throw new TypeError("toJSON failed"); } },
};
try {
JSON.stringify(input, (key: string, value: unknown): unknown => {
seen.push(key === "" ? "root" : key);
return value;
});
} catch (error) {
if (attempt === 0) console.log(error instanceof TypeError, seen.join("|"));
}
}
console.log(JSON.stringify({ n: 4 }, multiply));
+17
View File
@@ -0,0 +1,17 @@
export {};
const value = { a: [1, { b: true }], empty: {}, list: [], drop: 7 };
function identity(key: string, value: unknown): unknown {
return key === "drop" ? undefined : value;
}
console.log(JSON.stringify(value, identity));
console.log(JSON.stringify(value, identity, 0));
console.log(JSON.stringify(value, identity, -3));
console.log(JSON.stringify(value, identity, 2.8));
console.log(JSON.stringify(value, identity, 20));
console.log(JSON.stringify(value, identity, "abcdefghijk"));
console.log(JSON.stringify(value, identity, "\t"));
console.log(JSON.stringify(value, identity, undefined));
console.log(JSON.stringify({ a: 1 }, () => undefined, 2) === undefined);
console.log(JSON.stringify({ a: 1 }, () => "escaped\n\t\"\\", 2));
console.log(JSON.stringify({ omit: 1 }, (key: string, value: unknown): unknown => key === "omit" ? undefined : value, 2));
@@ -0,0 +1,42 @@
export {};
function replacement(): number { return 7; }
function filter(key: string, value: unknown): unknown {
if (key === "a" || key === "0") return replacement;
return value;
}
console.log(JSON.stringify({ a: 1, b: [2, 3] }, filter));
console.log(JSON.stringify(4, () => replacement) === undefined);
console.log(JSON.stringify(undefined, () => "defined"));
console.log(JSON.stringify(replacement, () => 8));
// Named functions and closures use the same native callback ABI. Fewer
// declared parameters are allowed; ignored arguments still exist at call.
function rootName(key: string): string { return key === "" ? "root" : "child"; }
console.log(JSON.stringify({ a: 2 }, rootName));
console.log(JSON.parse("0", () => 9));
const prefix = "key:";
console.log(JSON.parse("false", (key: string) => prefix + key));
const withMethod = { x: 1, toJSON: (key: string) => ({ key, converted: 2 }) };
console.log(JSON.stringify({ nested: withMethod }, (_key: string, value: unknown) => value));
console.log(JSON.stringify(withMethod, (_key: string, value: unknown) => value));
// Argument evaluation completes before conversion into the callback tree.
const mutable = { n: 1 };
function makeCallback(): (key: string, value: unknown) => unknown {
mutable.n = 9;
return (_key: string, value: unknown) => value;
}
console.log(JSON.stringify(mutable, makeCallback()));
function shadowed(undefined: (key: string, value: unknown) => unknown): void {
console.log(JSON.stringify(1, undefined));
console.log(JSON.parse("1", undefined));
}
shadowed(() => 8);
// The result of toJSON is passed straight to the replacer. Its own
// toJSON property is an ordinary function-valued field, not another hook.
const once = { toJSON: () => ({ value: 3, toJSON: () => { throw new Error("must not recur"); } }) };
console.log(JSON.stringify(once, (_key: string, value: unknown) => value));
+40
View File
@@ -0,0 +1,40 @@
// Checked-dynamic holders keep identity, and each key is read at visit
// time. Added keys are not visited by an already-started object walk.
const input = JSON.parse('{"a":1,"b":2}');
const seen = [];
const output = JSON.stringify(input, function (key, value) {
seen.push(key + ":" + (this[key] === value));
if (key === "a") {
this.b = 20;
this.newKey = 30;
}
return value;
});
console.log(output, seen.join("|"), input.b, input.newKey);
const visits = [];
const revived = JSON.parse('{"a":1,"b":2}', function (key, value) {
visits.push(key + ":" + (this[key] === value));
if (key === "a") {
this.b = 40;
this.extra = 50;
}
return typeof value === "number" ? value + 1 : value;
});
console.log(JSON.stringify(revived), visits.join("|"));
const nested = JSON.parse('{"x":{"a":1,"b":2}}');
console.log(JSON.stringify(nested, function (key, value) {
if (key === "a") {
const old = this;
JSON.parse('{"inner":1}', function (innerKey, innerValue) {
if (innerKey === "inner") this.inner = 9;
return innerValue;
});
console.log(this === old, this.b);
}
return value;
}));
console.log(JSON.stringify(JSON.parse("[1,2]", null)));
console.log(JSON.stringify({ n: 1 }, (key, value) => value, null));
+14
View File
@@ -0,0 +1,14 @@
export {};
const trace: string[] = [];
function observe(key: string, value: unknown): unknown {
trace.push(key + ":" + typeof value);
return value;
}
console.log(JSON.stringify({ bytes: new Uint8Array([3, 4]) }, observe));
console.log(trace.join("|"));
console.log(JSON.stringify({ replace: 0 }, (key: string, value: unknown): unknown => {
return key === "replace" ? new Uint8Array([5, 6]) : value;
}));
console.log(JSON.stringify(0, (key: string, value: unknown): unknown => key === "" ? new Uint8Array([7, 8]) : value));
console.log(JSON.stringify(new Uint8Array([]), observe, 2));
+36
View File
@@ -0,0 +1,36 @@
export {};
interface Node {
kind: string;
value?: unknown;
nested?: { payload: unknown };
children: Node[];
}
const root = JSON.parse('{"kind":"root","value":{"count":2},"nested":{"payload":[1,null,true]},"children":[{"kind":"leaf","children":[]}]}') as Node;
console.log(root.kind, JSON.stringify(root.value));
console.log(JSON.stringify(root.nested?.payload));
for (const child of root.children) console.log(child.kind, child.value === undefined, child.nested === undefined);
function visit(input: unknown): string[] {
if (input === null || typeof input !== "object") return [];
if (Array.isArray(input)) return input.flatMap(visit);
const node = input as { kind?: unknown; value?: unknown };
const names: string[] = [];
if (typeof node.kind === "string") names.push(node.kind);
for (const key of Object.keys(input)) {
const child = (input as Record<string, unknown>)[key];
names.push(...visit(child));
}
return names;
}
console.log(visit(root).join("|"));
// Opaque fields retain the subtree, including functions that a reviver
// returns. Validation checks the surrounding layout and typed siblings.
function add(value: number): number { return value + 1; }
const revived = JSON.parse('{"kind":"callable","value":0,"children":[]}',
(key: string, value: unknown): unknown => key === "value" ? add : value) as Node;
console.log(revived.kind, (revived.value as (value: number) => number)(5));
const payload = JSON.parse('{"id":7,"opaque":{"x":1}}') as { id: number; opaque: unknown };
const again = payload.opaque as { x: number };
console.log(payload.id, again.x);
+6 -3
View File
@@ -1,6 +1,7 @@
import { boolLit, countedFor, numLit, strLit, varRef } from "../../../packages/compiler/src/ir/build.js";
import { endsWithJump, isStableReceiverOperand, matchStringSelfConcat, streamTypedRefEligible, undefinedArmTag } from "../../../packages/compiler/src/ir/analysis.js";
import { F64, STRING, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt, type IrUnionDef, type SrcLoc } from "../../../packages/compiler/src/ir/ir.js";
import { IR_VERSION, deserializeModule, serializeModule } from "../../../packages/compiler/src/ir/serialize.js";
import { validateModule } from "../../../packages/compiler/src/ir/validate.js";
const loc: SrcLoc = { file: "native-generated.ts", start: 0, end: 1 };
@@ -65,7 +66,9 @@ const fn: IrFunction = {
],
body, loc,
};
const mod: IrModule = { irVersion: 12, sourceFile: loc.file, functions: [fn], entry: "main" };
const errors = validateModule(mod);
const mod: IrModule = { irVersion: IR_VERSION, sourceFile: loc.file, functions: [fn], entry: "main" };
const serialized = serializeModule(mod);
const roundTripped = deserializeModule(serialized);
const errors = validateModule(roundTripped);
if (errors.length > 0) throw new Error(JSON.stringify(errors));
console.log(JSON.stringify(mod));
console.log(serializeModule(roundTripped));
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { deserializeModule } from "../../../packages/compiler/src/ir/serialize.js";
import {
moduleUsesRegex, moduleUsesCopying, moduleUsesLegacyTextDecoder,
moduleUsesFileHandle, moduleUsesFetch, moduleUsesProcessEvents,
moduleUsesEmitter, moduleUsesStream, moduleUsesZlib, moduleUsesDc,
moduleUsesAssert, moduleUsesDynInvoke, moduleUsesDynAsync,
moduleUsesInspect, moduleUsesChildProcess, moduleUsesNet,
moduleUsesSymbol, moduleUsesBigInt, moduleUsesSearchParams, moduleUsesQs,
moduleUsesParseArgs, moduleUsesFsWatch, moduleUsesNodeTest,
moduleUsesDgram, moduleUsesHttpServer, moduleUsesHttp2, moduleUsesTls, moduleUsesTlsCa,
} from "../../../packages/compiler/src/ir/ir.js";
const mod = deserializeModule(readFileSync(process.argv[2]!, "utf8"));
console.log(JSON.stringify({
regex: moduleUsesRegex(mod), copying: moduleUsesCopying(mod),
legacyTextDecoder: moduleUsesLegacyTextDecoder(mod), fileHandle: moduleUsesFileHandle(mod),
fetch: moduleUsesFetch(mod), processEvents: moduleUsesProcessEvents(mod),
emitter: moduleUsesEmitter(mod), stream: moduleUsesStream(mod),
zlib: moduleUsesZlib(mod), dc: moduleUsesDc(mod), assert: moduleUsesAssert(mod),
dynInvoke: moduleUsesDynInvoke(mod), dynAsync: moduleUsesDynAsync(mod),
inspect: moduleUsesInspect(mod), childProcess: moduleUsesChildProcess(mod), net: moduleUsesNet(mod),
symbol: moduleUsesSymbol(mod), bigint: moduleUsesBigInt(mod), searchParams: moduleUsesSearchParams(mod),
qs: moduleUsesQs(mod), parseArgs: moduleUsesParseArgs(mod), fsWatch: moduleUsesFsWatch(mod),
nodeTest: moduleUsesNodeTest(mod), dgram: moduleUsesDgram(mod), http: moduleUsesHttpServer(mod),
http2: moduleUsesHttp2(mod), tls: moduleUsesTls(mod), tlsCa: moduleUsesTlsCa(mod),
}));
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { deserializeModule, serializeModule } from "../../../packages/compiler/src/ir/serialize.js";
import { validateModule } from "../../../packages/compiler/src/ir/validate.js";
try {
const mod = deserializeModule(readFileSync(process.argv[2]!, "utf8"));
const errors = validateModule(mod);
if (errors.length > 0) {
console.log(JSON.stringify(errors));
process.exitCode = 1;
} else {
// Exercise the serializer's refusal through a real number literal,
// without placing an invalid non-JSON number in the input document.
if (process.argv[3] === "nan") {
const statement = mod.functions[0]!.body[0];
if (statement?.kind === "exprStmt" && statement.expr.kind === "numLit") statement.expr.value = NaN;
}
console.log(serializeModule(mod));
}
} catch (error) {
if (error instanceof Error) console.log(error.name + ": " + error.message);
else console.log("unexpected thrown value");
process.exitCode = 1;
}
+1 -1
View File
@@ -30,7 +30,7 @@ json-dyn.ts:42:3 - error SC1090: 'unknown'-typed class fields are not supported
| ^~~~
43 | }
json-dyn.ts:46:7 - error SC2007: values of type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature
json-dyn.ts:46:7 - error SC2007: values of type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; (text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature
45 | const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585)
46 | const parseRef = JSON.parse;
@@ -0,0 +1,146 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { analyze, compile, deserializeModule, serializeModule } from "@scriptc/compiler";
import { IR_VERSION } from "../../packages/compiler/src/ir/serialize.js";
import { F64, STRING, VOID, arrayOf, type IrExpr, type IrLibFn, type IrModule, type IrType } from "../../packages/compiler/src/ir/ir.js";
import { strLit } from "../../packages/compiler/src/ir/build.js";
const root = fileURLToPath(new URL("../..", import.meta.url));
const entry = join(root, "tests/fixtures/self-hosting/runtime-features.ts");
const loc = { file: "runtime-features.ts", start: 0, end: 1 };
const empty = (): IrModule => ({
irVersion: IR_VERSION, sourceFile: loc.file, entry: "main",
functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [], loc }],
});
const lib = (fn: IrLibFn): IrExpr => ({ kind: "libCall", fn, args: [], type: VOID, loc });
// Dependency detection deliberately examines structure without executing
// calls or validating their signatures. These trees isolate a link feature
// even when running that API would require sockets, files, or a test loop.
const featureCalls: readonly [string, IrLibFn][] = [
["legacyTextDecoder", "text.decodeLegacy"],
["fetch", "fetch.start"],
["processEvents", "process.onExit"],
["emitter", "emitter.getMax"],
["stream", "readable.read"],
["zlib", "zlib.gzipSync"],
["dc", "dc.channel"],
["assert", "assert.ok"],
["dynInvoke", "dyn.defineProps"],
["dynAsync", "async.awaitDyn"],
["inspect", "insp.f64"],
["childProcess", "cp.spawnSync"],
["net", "net.createServer"],
["symbol", "sym.for"],
["bigint", "bigint.parse"],
["searchParams", "sp.new"],
["qs", "qs.parse"],
["parseArgs", "util.parseArgs"],
["fsWatch", "fs.watch"],
["nodeTest", "test.register"],
["dgram", "dgram.createSocket"],
["http", "http.createServer"],
["http2", "http2.connect"],
["tls", "tls.connect"],
["tlsCa", "tlsca.get"],
];
test("the production runtime dependency scanners lower entirely statically", () => {
const { coverage } = analyze(entry, { dynamic: false });
expect(coverage.preflightFailed).toBe(false);
expect(coverage.stats.statementsTotal).toBeGreaterThan(500);
expect(coverage.stats.statementsFailed).toBe(0);
expect(coverage.stats.statementsIsland).toBe(0);
expect(coverage.stats.functionsSkipped).toBe(0);
});
for (const backend of ["c", "llvm"] as const) {
test(`self-hosting runtime dependency detection (${backend})`, async () => {
const dir = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-features-"));
try {
const built = await compile(entry, {
outDir: dir, outPath: join(dir, process.platform === "win32" ? "features.exe" : "features"),
backend, dynamic: false, optimization: "dev", sanitize: process.env["SCRIPTC_SAN"] === "1",
});
if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
expect(built.backend).toBe(backend);
const check = (name: string, module: IrModule, positive: string[] = [], negative: string[] = []): void => {
const input = join(dir, "input.json");
writeFileSync(input, serializeModule(module));
const options = { cwd: root, timeout: 30_000, maxBuffer: 1024 * 1024 };
const oracle = spawnSync(process.execPath, ["--import", "tsx", entry, input], options);
const native = spawnSync(built.binaryPath, [input], options);
for (const result of [oracle, native]) {
expect(result.error, name).toBeUndefined();
expect(result.signal, name).toBeNull();
expect(result.status, `${name}: ${result.stderr}`).toBe(0);
}
expect(native.stdout, name).toEqual(oracle.stdout);
expect(native.stderr, name).toEqual(oracle.stderr);
const features = JSON.parse(native.stdout.toString()) as Record<string, boolean>;
for (const feature of positive) expect(features[feature], `${name}: ${feature}`).toBe(true);
for (const feature of negative) expect(features[feature], `${name}: ${feature}`).toBe(false);
};
const none = featureCalls.map(([feature]) => feature).concat(["regex", "copying", "fileHandle"]);
check("empty module", empty(), [], none);
for (const [feature, fn] of featureCalls) {
const module = empty();
// Put the use inside an uncalled function and multiple nested
// bodies. Link requirements must not depend on entry execution.
module.functions.push({
name: "callback", params: [], locals: [], returnType: VOID, loc,
body: [{ kind: "block", body: [{ kind: "exprStmt", expr: lib(fn), loc }], loc }],
});
check(fn, module, [feature]);
}
const regex = empty();
regex.functions[0]!.body.push({ kind: "exprStmt", expr: {
kind: "strIntrinsic", method: "toLowerCase", receiver: strLit("UPPER", loc), args: [], type: STRING, loc,
}, loc });
check("case conversion links regex Unicode tables", regex, ["regex"]);
const copying = empty();
copying.functions[0]!.body.push({ kind: "exprStmt", expr: {
kind: "arrIntrinsic", method: "toReversed", receiver: { kind: "arrayLit", elems: [], type: arrayOf(F64), loc }, args: [], type: arrayOf(F64), loc,
}, loc });
check("copying intrinsic", copying, ["copying"]);
const handle = empty();
const handleType: IrType = { kind: "promise", inner: { kind: "fileHandle" } };
handle.functions[0]!.locals.push({ id: "handle.0", name: "handle", mutable: false, type: handleType });
check("nested result type", handle, ["fileHandle"]);
const strings = empty();
strings.functions[0]!.body.push({ kind: "exprStmt", expr: strLit("fetch.start regexLit tls.connect bigint.parse", loc), loc });
check("ordinary string data is not IR", strings, [], none);
const embedded = empty();
embedded.embedded = {
modules: [{ key: "package", source: "/* fetch() */", format: "esm" }], edges: [],
};
check("embedded text is not a capability fact", embedded, [], ["fetch", "zlib"]);
embedded.embedded.modules[0]!.usesFetch = true;
check("embedded fetch fact survives serialization", embedded, ["fetch"]);
embedded.embedded.edges.push({ from: "package", specifier: "node:zlib", to: "node:zlib", kind: "import" });
check("embedded builtin edge survives serialization", embedded, ["fetch", "zlib"]);
// Audit genuine frontend output too, including closure tables,
// recursive types, metadata and JSON callback helper functions.
for (const [source, feature] of [
["tests/corpus/3097-json-callback-reentrancy.ts", ""],
["tests/corpus/1002-json-parse-cast.ts", ""],
["tests/corpus/2975-bigint-default-radix.ts", "bigint"],
["tests/corpus/2678-util-parseargs.ts", "parseArgs"],
["tests/corpus/2557-tls-ca-store.ts", "tlsCa"],
]) {
const path = join(dir, "emitted.json");
const emitted = await compile(join(root, source!), { outDir: dir, outPath: path, outputKind: "ir", dynamic: false });
if (!emitted.ok) throw new Error(emitted.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
check(source!, deserializeModule(readFileSync(path, "utf8")), feature ? [feature] : []);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}
@@ -0,0 +1,136 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { analyze, compile, compileC, deserializeModule, emitCModule, serializeModule, validateModule } from "@scriptc/compiler";
import { IR_VERSION } from "../../packages/compiler/src/ir/serialize.js";
import { F64, VOID, type IrModule } from "../../packages/compiler/src/ir/ir.js";
import { numLit } from "../../packages/compiler/src/ir/build.js";
import { validatorCases } from "./self-hosting-validator-cases.js";
const root = fileURLToPath(new URL("../..", import.meta.url));
const entry = join(root, "tests/fixtures/self-hosting/serialize.ts");
const runOptions = { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 };
function numericModule(): IrModule {
const loc = { file: "native-numbers.ts", start: 0, end: 1 };
const numbers = [Infinity, -Infinity, -0, 0, 0.1 + 0.2, Number.MAX_VALUE, Number.MIN_VALUE];
return {
irVersion: IR_VERSION, sourceFile: loc.file, entry: "main",
functions: [{
name: "main", params: [], returnType: VOID, locals: [], loc,
body: [
// The fixture's failure mode changes this first literal to NaN.
{ kind: "exprStmt", expr: numLit(0, loc), loc },
{ kind: "exprStmt", expr: { kind: "intrinsic", name: "console.log", args: numbers.map((n) => numLit(n, loc)), type: VOID, loc }, loc },
{ kind: "exprStmt", expr: { kind: "intrinsic", name: "console.log", args: [{
kind: "bin", op: "/", left: numLit(1, loc), right: numLit(-0, loc), type: F64, loc,
}], type: VOID, loc }, loc },
{ kind: "return", value: null, loc },
],
}],
};
}
test("the production IR serialization and validation pipeline lowers entirely statically", () => {
const { coverage } = analyze(entry, { dynamic: false });
expect(coverage.preflightFailed).toBe(false);
expect(coverage.stats.statementsTotal).toBeGreaterThan(3000);
expect(coverage.stats.statementsFailed).toBe(0);
expect(coverage.stats.statementsIsland).toBe(0);
expect(coverage.stats.functionsSkipped).toBe(0);
});
for (const backend of ["c", "llvm"] as const) {
test(`self-hosting serialization: ${backend} round-trips IR and produces a working program`, async () => {
const dir = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-serialize-"));
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const executable = (name: string): string => join(dir, name + (process.platform === "win32" ? ".exe" : ""));
try {
const built = await compile(entry, {
outDir: dir, outPath: executable("serializer"), backend, dynamic: false, optimization: "dev", sanitize,
});
if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
expect(built.backend).toBe(backend);
const run = (input: string, args: string[] = []) => {
const path = join(dir, "input.json");
writeFileSync(path, input);
const oracle = spawnSync(process.execPath, ["--import", "tsx", entry, path, ...args], runOptions);
const native = spawnSync(built.binaryPath, [path, ...args], runOptions);
for (const result of [oracle, native]) {
expect(result.error).toBeUndefined();
expect(result.signal, result.stderr.toString()).toBeNull();
expect(result.stderr.toString()).toBe("");
}
expect(native.status).toBe(oracle.status);
return { oracle, native };
};
const roundTrip = (module: IrModule): IrModule => {
const { oracle, native } = run(serializeModule(module));
expect(native.status, native.stdout.toString()).toBe(0);
// Native records use declaration order; JSON member order is not
// part of the IR format. Compare all payloads after decoding the
// production sentinel protocol, including the sign of zero.
const actual = deserializeModule(native.stdout.toString());
expect(actual).toEqual(deserializeModule(oracle.stdout.toString()));
expect(actual).toEqual(module);
expect(validateModule(actual)).toEqual([]);
const again = run(native.stdout.toString());
expect(again.native.stdout).toEqual(native.stdout);
return actual;
};
const numeric = roundTrip(numericModule());
const print = numeric.functions[0]!.body[1]!;
expect(print.kind).toBe("exprStmt");
if (print.kind !== "exprStmt" || print.expr.kind !== "intrinsic") throw new Error("numeric IR changed");
const third = print.expr.args[2]!;
expect(third.kind).toBe("numLit");
if (third.kind !== "numLit") throw new Error("literal changed");
expect(Object.is(third.value, -0)).toBe(true);
const cPath = join(dir, "numbers.c");
writeFileSync(cPath, emitCModule(numeric));
await compileC({ cPath, outPath: executable("numbers"), sanitize });
const program = spawnSync(executable("numbers"), [], runOptions);
expect(program.error).toBeUndefined();
expect(program.signal).toBeNull();
expect(program.status, program.stderr.toString()).toBe(0);
expect(program.stdout.toString()).toBe("Infinity -Infinity -0 0 0.30000000000000004 1.7976931348623157e+308 5e-324\n-Infinity\n");
expect(program.stderr.toString()).toBe("");
for (const item of validatorCases()) {
if (!item.diagnostic) roundTrip(item.module);
else {
const { oracle, native } = run(serializeModule(item.module));
expect(native.status).toBe(1);
expect(native.stdout).toEqual(oracle.stdout);
expect(native.stdout.toString()).toContain(item.diagnostic);
}
}
for (const source of [
"tests/corpus/3086-error-constructor-options.ts",
"tests/corpus/3089-array-find-narrowing.ts",
"tests/corpus/3091-json-recursive-discriminants.ts",
"tests/corpus/3094-json-replacer-traversal.ts",
]) {
const path = join(dir, "emitted.json");
const emitted = await compile(join(root, source), { outDir: dir, outPath: path, outputKind: "ir", dynamic: false });
if (!emitted.ok) throw new Error(emitted.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
roundTrip(deserializeModule(readFileSync(path, "utf8")));
}
const previous = { ...numericModule(), irVersion: IR_VERSION - 1 };
const mismatch = run(serializeModule(previous));
expect(mismatch.native.status).toBe(1);
expect(mismatch.native.stdout).toEqual(mismatch.oracle.stdout);
expect(mismatch.native.stdout.toString()).toContain("IR version mismatch");
const nan = run(serializeModule(numericModule()), ["nan"]);
expect(nan.native.status).toBe(1);
expect(nan.native.stdout).toEqual(nan.oracle.stdout);
expect(nan.native.stdout.toString()).toContain("IR contains NaN; refusing to serialize");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}