diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index c7a62194..435de2a8 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -1486,16 +1486,19 @@ export class CEmitter { * assigns later, a constructor branch skips it, a base constructor's * virtual call reads a derived field before super() returns. Node reads * `undefined` there; a NULL payload pointer would be a segfault (union - * fields) or a silent nothing (jsval fields). Undefined-armed unions get - * the interned immortal unit instance (free; releases skip it); jsval - * (`any`) fields get an engine undefined cell — such classes exist only - * in --dynamic builds, and the field's release balances it. Empty for + * fields) or a silent nothing (jsval/dyn fields). Undefined-armed unions + * get the interned immortal unit instance (free; releases skip it); jsval + * (`any`) fields get an engine undefined cell, while dyn (`unknown`) + * fields get the checked-dynamic immortal undefined singleton. Empty for * every type that cannot hold undefined (tsc's SPI guards those) and for * record shapes' construction paths, which write every field. */ undefFieldInitLineC(name: string, t: IrType): string[] { if (t.kind === "jsval") { return [` o->${mangleField(name)} = scr_jsval_undefined(); /* ${name} starts undefined */`]; } + if (t.kind === "dyn") { + return [` o->${mangleField(name)} = scr_dyn_retain(scr_dyn_undefined()); /* ${name} starts undefined */`]; + } const tag = this.undefinedArmTag(t); if (tag < 0 || t.kind !== "union") return []; return [` o->${mangleField(name)} = ${this.unitInstanceRef(t.unionId, tag)}; /* ${name} starts undefined */`]; diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index ee6ccb73..cbf1b189 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -213,16 +213,18 @@ export interface ClassHost extends ShapeHost { /** The newFn initialization stores for fields whose type ADMITS undefined * (undefFieldInitLineC's LLVM twin): undefined-armed union fields start - * at the interned unit instance; jsval fields (an `any` class field under - * --dynamic) start at the engine's undefined cell. */ + * at the interned unit instance; jsval (`any`) fields start at the engine's + * undefined cell, while dyn (`unknown`) fields start at the checked-dynamic + * immortal undefined singleton. */ function undefFieldInits(host: ClassHost, meta: LlClassMeta): string[] { const out: string[] = []; meta.def.fields.forEach((f, i) => { const { index } = classFieldIndex(meta, f.name); - if (f.type.kind === "jsval") { - host.declare(`declare ptr @scr_jsval_undefined()`); + if (f.type.kind === "jsval" || f.type.kind === "dyn") { + const fn = f.type.kind === "jsval" ? "scr_jsval_undefined" : "scr_dyn_undefined"; + host.declare(`declare ptr @${fn}()`); out.push( - ` %ufv${i} = call ptr @scr_jsval_undefined()`, + ` %ufv${i} = call ptr @${fn}()`, ` %uf${i} = getelementptr inbounds %${mangleClassStruct(meta.def.name)}, ptr %o, i64 0, i32 ${index}`, ` store ptr %ufv${i}, ptr %uf${i} ; ${f.name} starts undefined`, ); diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 7536ccaa..8132a4de 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1229,9 +1229,6 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration ) { const type = L.irTypeOf(member.name); if (type.kind === "void") L.badType(member.name, L.typeOf(member.name)); - if (type.kind === "dyn") { - L.unsupported("SC1090", member.name, "'unknown'-typed static fields"); - } staticFields.push({ name: member.name.text, type, @@ -1473,11 +1470,9 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration // the ordinary undefined-armed union machinery. const type = L.irTypeOf(member.name); if (type.kind === "void") L.badType(member.name, L.typeOf(member.name)); - // dyn stays out of class fields (KEEP NARROW; record - // fields and array elements are unmappable via mapType already). - if (type.kind === "dyn") { - L.unsupported("SC1090", member.name, "'unknown'-typed class fields"); - } + // `unknown` fields use the same checked-dynamic dyn kind as + // unknown locals/params. Allocation initializes them to the dyn + // undefined singleton before field initializers run. if (fields.has(member.name.text)) { // REDECLARING an inherited field: Node [[Define]]s the OWN // property again when THIS class's field initializers run @@ -1588,10 +1583,8 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration const shape = L.paramShape(p); const type = shape.bodyType ?? shape.type; if (type.kind === "void") L.badType(p.name, L.typeOf(p.name)); - // The class-field dyn rule verbatim (KEEP NARROW). - if (type.kind === "dyn") { - L.unsupported("SC1090", p.name, "'unknown'-typed class fields"); - } + // Unknown parameter properties use the ordinary dyn parameter + // ABI and assign into the dyn class slot after super(). // `override x` (and any same-named inherited member) would // redeclare a base slot — the declared-field rule verbatim. if (fields.has(name)) { diff --git a/tests/diagnostics/json-dyn.ts b/tests/diagnostics/json-dyn.ts index 96cd37c6..edd5cf01 100644 --- a/tests/diagnostics/json-dyn.ts +++ b/tests/diagnostics/json-dyn.ts @@ -35,7 +35,7 @@ function localCapture(): () => number { return () => local as number; } class Holder { - data: unknown = JSON.parse("{}"); + data: unknown = JSON.parse("{}"); // unknown class fields compile as dyn storage now — no fence } const anything: any = 5; // checker-`any` bindings ride the checked-dynamic tree now — no fence const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) @@ -49,7 +49,6 @@ function mkMaybe(): string | undefined { return undefined; } const stringifyUndef = JSON.stringify(mkMaybe()); - // Reached: unreached bodies never lower, so their rejections only exist // when something on the entry path uses them. localCapture(); diff --git a/tests/harness/__snapshots__/json-dyn.ts.txt b/tests/harness/__snapshots__/json-dyn.ts.txt index 9246e8da..8bfee8ce 100644 --- a/tests/harness/__snapshots__/json-dyn.ts.txt +++ b/tests/harness/__snapshots__/json-dyn.ts.txt @@ -39,13 +39,6 @@ json-dyn.ts:28:19 - error SC1090: a checked cast of 'unknown' to 'Point' (a dyna | ^~~~~~~~~~~~~~~~~~~~~~~~~ 29 | // (casts of unknown to ADAPTABLE function types compile now — the kind -json-dyn.ts:38:3 - error SC1090: 'unknown'-typed class fields are not supported yet - - 37 | class Holder { - 38 | data: unknown = JSON.parse("{}"); - | ^~~~ - 39 | } - json-dyn.ts:42: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 41 | const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) @@ -67,11 +60,4 @@ json-dyn.ts:51:39 - error SC1090: JSON.stringify of 'string | undefined' values 50 | } 51 | const stringifyUndef = JSON.stringify(mkMaybe()); | ^~~~~~~~~ - 52 | - -json-dyn.ts:59:1 - error SC1090: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet - - 58 | // them relevant; these references are what makes them count. - 59 | new Holder(); - | ^~~~~~~~~~~~ - 60 | \ No newline at end of file + 52 | // Reached: unreached bodies never lower, so their rejections only exist \ No newline at end of file diff --git a/tests/harness/unknown-fields.test.ts b/tests/harness/unknown-fields.test.ts new file mode 100644 index 00000000..0aba265f --- /dev/null +++ b/tests/harness/unknown-fields.test.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const cacheDir = join(tmpdir(), "scriptc-unknown-fields-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +interface RunResult { + stdout: Buffer; + stderr: Buffer; + exitCode: number; +} + +async function run(cmd: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "buffer" }); + return { stdout, stderr, exitCode: 0 }; + } catch (err) { + if ( + typeof err !== "object" || err === null || + !("code" in err) || typeof err.code !== "number" || + !("stdout" in err) || !Buffer.isBuffer(err.stdout) || + !("stderr" in err) || !Buffer.isBuffer(err.stderr) + ) { + throw err; + } + return { stdout: err.stdout, stderr: err.stderr, exitCode: err.code }; + } +} + +async function compileAndCompare(name: string, source: string, backend: "c" | "llvm"): Promise { + const key = createHash("sha256") + .update(source) + .update(backend) + .update(sanitize ? "san" : "plain") + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, key); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, `${name}.ts`); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize, + backend, + }); + if (!result.ok) { + throw new Error( + "unknown-fields program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const [nodeResult, nativeResult] = await Promise.all([ + run("node", ["--experimental-transform-types", "--disable-warning=ExperimentalWarning", file]), + run(result.binaryPath, []), + ]); + expect(nativeResult.stdout).toEqual(nodeResult.stdout); + expect(nativeResult.stderr).toEqual(nodeResult.stderr); + expect(nativeResult.exitCode).toBe(nodeResult.exitCode); +} + +const source = `class Base { + inherited: unknown; +} + +class Holder extends Base { + value: unknown; + initialized: unknown = { count: 3 }; + static current: unknown = "ready"; + + constructor(public argument: unknown) { + super(); + } +} + +const h = new Holder(42); +console.log(h.inherited === undefined); +h.inherited = "from base"; +console.log(h.inherited === "from base"); +console.log(h.value === undefined, h.argument === 42); +if (typeof h.initialized === "object" && h.initialized !== null && "count" in h.initialized) { + console.log((h.initialized as { count: number }).count); +} +console.log(typeof Holder.current, Holder.current); +h.value = ["a", "b"]; +if (Array.isArray(h.value)) console.log(h.value.length, h.value[1]); +Holder.current = false; +console.log(Holder.current === false); +`; + +describe.each(["c", "llvm"] as const)( + `unknown-typed class fields, %s backend${sanitize ? " (sanitized)" : ""}`, + (backend) => { + test("matches Node", async () => { + await compileAndCompare(`${backend}-backend`, source, backend); + }); + }, +);