diff --git a/docs/content/limitations/language-features/class-reflection.mdx b/docs/content/limitations/language-features/class-reflection.mdx index 4e2b9f299..f2b6dc2c8 100644 --- a/docs/content/limitations/language-features/class-reflection.mdx +++ b/docs/content/limitations/language-features/class-reflection.mdx @@ -1 +1 @@ -- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. They raise an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. +- **Class reflection:** `JSON.stringify` and `Object.keys` require every declared field to have a representation that can pass through runtime-checked storage. `JSON.stringify` writes Date fields as their ISO text and Map and Set fields as `{}`, as Node.js does, and refuses classes with RegExp or Promise fields with `SC1090`. `Object.keys` raises an error for classes with opaque fields such as native Maps. Class fields, inherited accessors, and added properties retain identity through untyped values. diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index a73c9d5e7..013b16e7c 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -8,6 +8,10 @@ import { isJsonStringifyDynamicType, BOOL, DYN, + type IrType, + classDynViewSupported, + isClassOwnEnumerableFieldName, + isDynTypedRefType, type IrExpr, STRING, type SrcLoc, @@ -157,6 +161,16 @@ export function lowerJsonMethodCall( (id) => lowerer.unions.get(id), ) ) { + const unreadable = classFieldWithoutJsonView(lowerer, value.type, new Set()); + if (unreadable !== null) { + lowerer.unsupported( + "SC1090", + argNode, + `JSON.stringify of '${lowerer.fmt(value.type)}' values (field '${unreadable.field}' of class ` + + `'${unreadable.className}' has type '${lowerer.fmt(unreadable.type)}', which the runtime JSON ` + + `traversal cannot ${unreadable.reason})`, + ); + } // Keep the original containers live while the runtime visits them: // a toJSON callback can mutate a later field, and shared/cyclic // references must retain their identities throughout traversal. @@ -276,6 +290,100 @@ function lowerOptionalStringifyRoot( return { kind: "call", callee: helper, args: [value], type: resultT, loc }; } +/** True when a class field type holds a RegExp or a Promise outside a + * nested class instance (whose own fields are checked separately): Node + * writes both as {}, and the runtime JSON traversal has no writer for them. */ +function holdsUnwritable(lowerer: Lowerer, t: IrType, visiting: Set): boolean { + switch (t.kind) { + case "regex": + case "promise": + return true; + case "array": + return holdsUnwritable(lowerer, t.elem, visiting); + case "record": { + if (visiting.has(t.shapeId)) return false; + visiting.add(t.shapeId); + const shape = lowerer.shapes.get(t.shapeId); + return ( + (shape?.fields ?? []).some((field) => holdsUnwritable(lowerer, field.type, visiting)) || + (shape?.indexValue !== undefined && holdsUnwritable(lowerer, shape.indexValue, visiting)) + ); + } + case "union": { + if (visiting.has(t.unionId)) return false; + visiting.add(t.unionId); + return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => + holdsUnwritable(lowerer, arm, visiting), + ); + } + default: + return false; + } +} + +/** The first class field reachable from a stringify input that the class's + * checked-dynamic property view cannot represent, or that holds a RegExp or + * a Promise, which the runtime JSON traversal cannot write. The traversal + * reads class instances through that view, so such a class is refused at + * compile time instead of throwing when the view is built or written. */ +function classFieldWithoutJsonView( + lowerer: Lowerer, + t: IrType, + visiting: Set, +): { className: string; field: string; type: IrType; reason: "read" | "write" } | null { + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + switch (t.kind) { + case "array": + return classFieldWithoutJsonView(lowerer, t.elem, visiting); + case "record": { + if (visiting.has(t.shapeId)) return null; + visiting.add(t.shapeId); + const shape = getRecord(t.shapeId); + for (const field of shape?.fields ?? []) { + const found = classFieldWithoutJsonView(lowerer, field.type, visiting); + if (found) return found; + } + return shape?.indexValue + ? classFieldWithoutJsonView(lowerer, shape.indexValue, visiting) + : null; + } + case "union": { + if (visiting.has(t.unionId)) return null; + visiting.add(t.unionId); + for (const arm of getUnion(t.unionId)?.arms ?? []) { + const found = classFieldWithoutJsonView(lowerer, arm, visiting); + if (found) return found; + } + return null; + } + case "object": { + if (!isDynTypedRefType(t) || visiting.has(t.className)) return null; + visiting.add(t.className); + const info = lowerer.classes.get(t.className); + for (const field of info?.def.fields ?? []) { + if (!isClassOwnEnumerableFieldName(field.name)) continue; + const unreadable = !classDynViewSupported([field], getRecord, getUnion); + if (unreadable || holdsUnwritable(lowerer, field.type, new Set())) { + const className = + info?.def.jsName ?? info?.decl?.name?.text ?? t.className.replace(/^%/, ""); + return { + className, + field: field.name, + type: field.type, + reason: unreadable ? "read" : "write", + }; + } + const found = classFieldWithoutJsonView(lowerer, field.type, visiting); + if (found) return found; + } + return null; + } + default: + return null; + } +} + /** 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); diff --git a/packages/compiler/src/ir/ir.test.ts b/packages/compiler/src/ir/ir.test.ts index 60ecc9ad0..52ef9b9c1 100644 --- a/packages/compiler/src/ir/ir.test.ts +++ b/packages/compiler/src/ir/ir.test.ts @@ -159,6 +159,17 @@ describe("checked records with opaque payloads", () => { isJsonStringifyDynamicType(arrayOf({ kind: "union", unionId: "optional" }), record, union), ).toBe(true); }); + + test("class instances stringify through the runtime JSON traversal only", () => { + const point: IrType = { kind: "object", className: "Point" }; + const failure: IrType = { kind: "object", className: "%Error" }; + expect(isJsonStringifyDynamicType(point, record, union)).toBe(true); + expect(isJsonStringifyDynamicType(arrayOf(point), record, union)).toBe(true); + expect(isJsonStringifyDynamicType(failure, record, union)).toBe(false); + expect(isJsonStringifySafeType(point, record, union)).toBe(false); + expect(isJsonSafeType(point, record, union)).toBe(false); + expect(canDynCheckTo(arrayOf(point), record, union)).toBe(true); + }); }); describe("native typed-array boundaries", () => { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index bcefc66ce..db4beca6b 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3730,9 +3730,10 @@ export function isJsonStringifySafeType( return isJsonSafeAt(t, getRecord, getUnion, true, false, new Set()); } -/** A JSON-shaped container with unknown payloads needs the runtime JSON - * traversal. Its declared structure can be boxed, but payloads may carry - * omitted values, toJSON methods, or cycles. This predicate does not widen +/** A JSON-shaped container with unknown payloads, or a class instance at + * any position, needs the runtime JSON traversal. Its declared structure can + * be boxed, but payloads may carry omitted values, toJSON methods, added + * properties, or cycles. This predicate does not widen * the type-directed serializer, checked-value boundary, or root-undefined * contract; callers must select the checked runtime traversal explicitly. */ export function isJsonStringifyDynamicType( @@ -3741,7 +3742,7 @@ export function isJsonStringifyDynamicType( getUnion: (unionId: string) => IrUnionDef | undefined, ): boolean { return ( - (t.kind === "record" || t.kind === "array" || t.kind === "union") && + (t.kind === "record" || t.kind === "array" || t.kind === "union" || isDynTypedRefType(t)) && isJsonSafeAt(t, getRecord, getUnion, true, false, new Set(), true) && !isJsonStringifySafeType(t, getRecord, getUnion) ); @@ -3851,8 +3852,12 @@ function isJsonSafeAt( return nativeFields && !stringify; case "bytes": return nativeFields; - case "func": + // Class instances stringify through the runtime JSON traversal, which + // reads their own enumerable properties and calls toJSON. case "object": + if (nativeFields && stringify && isDynTypedRefType(t)) return true; + return false; + case "func": // Class values stringify as "{}" husks in Node (own enumerable statics // aside — not representable type-directedly); rejected like Maps. case "classval": diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json new file mode 100644 index 000000000..93c106c07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6000-json-stringify-class-instances.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6000-json-stringify-class-instances.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json new file mode 100644 index 000000000..8ecb709f3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6001-json-stringify-js-class-instances.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6001-json-stringify-js-class-instances.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json new file mode 100644 index 000000000..ee3ed6bad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/json-class-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/json-class-fields.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 81da2bc66..0567d0b15 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -12818,8 +12818,15 @@ static ScrDyn *scr_native_set_pack(void *ptr) { ScrDyn *scr_dyn_native_set(ScrMap *value) { static const ScrDynHandleOps ops = { - "Set", &scr_map_retain_v, &scr_map_release_v, &scr_native_set_invoke, - &scr_native_set_get, &scr_weak_map_write, NULL, &scr_native_set_pack, + .cls = "Set", + .retain = &scr_map_retain_v, + .release = &scr_map_release_v, + .invoke = &scr_native_set_invoke, + .get = &scr_native_set_get, + .set = &scr_weak_map_write, + .iter_pack = &scr_native_set_pack, + /* Entries live in internal slots: JSON.stringify writes {}, as Node. */ + .json_empty_object = true, }; scr_dyn_handle_install(SCR_DYNH_SET, &ops); return scr_dyn_new_handle(value, SCR_DYNH_SET); @@ -12928,8 +12935,15 @@ static ScrDyn *scr_native_map_pack(void *ptr) { ScrDyn *scr_dyn_native_map(ScrMap *value) { static const ScrDynHandleOps ops = { - "Map", &scr_map_retain_v, &scr_map_release_v, &scr_native_map_invoke, - &scr_native_map_get, &scr_weak_map_write, NULL, &scr_native_map_pack, + .cls = "Map", + .retain = &scr_map_retain_v, + .release = &scr_map_release_v, + .invoke = &scr_native_map_invoke, + .get = &scr_native_map_get, + .set = &scr_weak_map_write, + .iter_pack = &scr_native_map_pack, + /* Entries live in internal slots: JSON.stringify writes {}, as Node. */ + .json_empty_object = true, }; scr_dyn_handle_install(SCR_DYNH_MAP, &ops); return scr_dyn_new_handle(value, SCR_DYNH_MAP); diff --git a/tests/corpus/6000-json-stringify-class-instances.ts b/tests/corpus/6000-json-stringify-class-instances.ts new file mode 100644 index 000000000..cc72a0b82 --- /dev/null +++ b/tests/corpus/6000-json-stringify-class-instances.ts @@ -0,0 +1,143 @@ +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + norm(): number { + return Math.hypot(this.x, this.y); + } +} + +class Account { + static count = 0; + #secret = "hidden"; + private balance = 10; + protected owner = "ada"; + label: string | undefined = undefined; + tags: string[] = ["a", "b"]; + get secret(): string { + return this.#secret; + } +} + +class Stamp { + at = 5; + toJSON(key: string): string { + return `stamp(${key})`; + } +} + +class Base { + kind = "base"; + toJSON(): { kind: string; via: string } { + return { kind: this.kind, via: "Base.toJSON" }; + } +} +class Child extends Base { + extra = 1; +} + +class Labeled extends Point { + name = "p"; +} + +class Box { + value: T; + constructor(value: T) { + this.value = value; + } +} + +class Registry { + entries = new Map([["a", 1]]); + count = 2; +} + +class Cycle { + name = "loop"; + next: Cycle | null = null; +} + +class Big { + n = 10n; +} + +const tag = Symbol("tag"); + +class Handlers { + name = "h"; + onEvent = (n: number): number => n + 1; + payload: unknown = { a: [1, "two", null] }; + [tag] = "symbol-keyed"; +} + +class Failure extends Error { + code = "E_FAIL"; +} + +class Empty {} + +class Tagged { + s = new Set([1, 2]); + count = 2; +} + +const p = new Point(3, 4); +console.log(JSON.stringify(p)); +console.log(JSON.stringify(new Account())); +console.log(JSON.stringify(new Stamp())); +console.log(JSON.stringify({ first: new Stamp(), list: [new Stamp()] })); +console.log(JSON.stringify(new Child())); +console.log(JSON.stringify(new Labeled(1, 2))); +console.log(JSON.stringify(new Box("text")), JSON.stringify(new Box(new Point(0, 1)))); +console.log(JSON.stringify(new Registry())); +console.log(JSON.stringify([p, null, new Point(5, 6)])); +console.log(JSON.stringify({ point: p, points: [p, p] }, null, 2)); +console.log(JSON.stringify(p, null, "--")); +console.log(JSON.stringify(p, ["x"])); +console.log(JSON.stringify(p, (key: string, value: unknown) => (key === "y" ? undefined : value))); + +const maybe: Point | null = Math.random() < 2 ? p : null; +console.log(JSON.stringify(maybe)); + +const loop = new Cycle(); +loop.next = loop; +try { + JSON.stringify(loop); + console.log("no error"); +} catch (e) { + console.log(e instanceof TypeError); +} + +try { + JSON.stringify(new Big()); + console.log("no error"); +} catch (e) { + console.log(e instanceof TypeError); +} + +p.x = 30; +console.log(JSON.stringify(p)); + +const h = new Handlers(); +console.log(JSON.stringify(h)); +console.log(JSON.stringify(new Empty())); +console.log(JSON.stringify(new Failure("boom"))); +h.payload = 7; +console.log(JSON.stringify({ handlers: [h] })); +const u: unknown = new Map([["a", 1]]); +console.log(JSON.stringify(new Tagged()), JSON.stringify(u)); + +// Date fields write their toJSON text, also in nested instances. +class Schedule { + name = "weekly"; + next = new Date(Date.UTC(2024, 0, 1)); +} +class Calendar { + title = "team"; + schedules: Schedule[] = [new Schedule()]; +} +console.log(JSON.stringify(new Schedule())); +console.log(JSON.stringify({ calendar: new Calendar() })); diff --git a/tests/corpus/6001-json-stringify-js-class-instances.js b/tests/corpus/6001-json-stringify-js-class-instances.js new file mode 100644 index 000000000..d270a632f --- /dev/null +++ b/tests/corpus/6001-json-stringify-js-class-instances.js @@ -0,0 +1,49 @@ +class Box { + constructor(v) { + this.v = v; + } +} + +class Plain {} + +class Late { + constructor() { + this.a = 1; + } + setB() { + this.b = 2; + } + toString() { + return "late"; + } +} + +class Shaped { + constructor(n) { + this.n = n; + } + toJSON() { + return { shaped: this.n }; + } +} + +const b = new Box(1); +console.log(JSON.stringify(b)); +b.extra = "e"; +console.log(JSON.stringify(b)); + +const q = new Plain(); +console.log(JSON.stringify(q)); +q.a = 1; +q.inner = new Box(2); +q.list = [new Box(3), null]; +console.log(JSON.stringify(q)); +console.log(JSON.stringify([q, b], null, 1)); + +const late = new Late(); +console.log(JSON.stringify(late)); +late.c = 3; +late.setB(); +console.log(JSON.stringify(late)); + +console.log(JSON.stringify({ s: new Shaped(4), arr: [new Shaped(5)] })); diff --git a/tests/diagnostics/json-class-fields.ts b/tests/diagnostics/json-class-fields.ts new file mode 100644 index 000000000..d4d3f8e73 --- /dev/null +++ b/tests/diagnostics/json-class-fields.ts @@ -0,0 +1,22 @@ +// JSON.stringify reads class instances through their checked-dynamic +// property view. A class whose declared field holds a RegExp or a Promise, +// which the runtime JSON traversal cannot write, is refused at compile +// time, naming the field. +class Pattern { + name = "digits"; + source = /\d+/g; +} + +class Job { + title = "fetch"; + pending = Promise.resolve(1); +} + +class Catalog { + title = "team"; + patterns: Pattern[] = [new Pattern()]; +} + +console.log(JSON.stringify(new Pattern())); +console.log(JSON.stringify(new Job())); +console.log(JSON.stringify({ catalog: new Catalog() })); diff --git a/tests/harness/__snapshots__/json-class-fields.ts.txt b/tests/harness/__snapshots__/json-class-fields.ts.txt new file mode 100644 index 000000000..5e6c30fdf --- /dev/null +++ b/tests/harness/__snapshots__/json-class-fields.ts.txt @@ -0,0 +1,20 @@ +json-class-fields.ts:20:28 - error SC1090: JSON.stringify of 'Pattern' values (field 'source' of class 'Pattern' has type 'RegExp', which the runtime JSON traversal cannot write) are not supported yet + + 19 | + 20 | console.log(JSON.stringify(new Pattern())); + | ^~~~~~~~~~~~~ + 21 | console.log(JSON.stringify(new Job())); + +json-class-fields.ts:21:28 - error SC1090: JSON.stringify of 'Job' values (field 'pending' of class 'Job' has type 'Promise', which the runtime JSON traversal cannot write) are not supported yet + + 20 | console.log(JSON.stringify(new Pattern())); + 21 | console.log(JSON.stringify(new Job())); + | ^~~~~~~~~ + 22 | console.log(JSON.stringify({ catalog: new Catalog() })); + +json-class-fields.ts:22:28 - error SC1090: JSON.stringify of '{ catalog: Catalog }' values (field 'source' of class 'Pattern' has type 'RegExp', which the runtime JSON traversal cannot write) are not supported yet + + 21 | console.log(JSON.stringify(new Job())); + 22 | console.log(JSON.stringify({ catalog: new Catalog() })); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~ + 23 | \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 1ba71d05f..48b392237 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2209,6 +2209,8 @@ "5995-array-dense-loop-boundaries.ts": 2596, "5996-rejections-between-macrotasks.cjs": 3034, "600-closures-basic.ts": 2349, + "6000-json-stringify-class-instances.ts": 7567, + "6001-json-stringify-js-class-instances.js": 4668, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, "6020-class-extends-function-constructor.js": 4300, @@ -5265,6 +5267,8 @@ "5995-array-dense-loop-boundaries.ts": 4171, "5996-rejections-between-macrotasks.cjs": 22172, "600-closures-basic.ts": 2534, + "6000-json-stringify-class-instances.ts": 12107, + "6001-json-stringify-js-class-instances.js": 7469, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, "6020-class-extends-function-constructor.js": 7500,