From 250097dd32d49cd70daaebe6681256fb69203b9d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 13:57:35 -0700 Subject: [PATCH 01/34] Represent unions of records as the record pointer itself A union of two or more record arms and at most two unit arms (the discriminated unions of object literals behind Result, token, shape and AST types) now holds the record pointer instead of a reference-counted %ScrUnion box, like nullable unions already do. NULL and the existing null sentinel encode the unit arms. Every record shape that is an arm of such a union carries its module-wide shape number in a hidden i32 after the reference count; the arm of a value is that word mapped through a per-union tag helper, which LLVM folds into the consumer's switch. Records share one retain; release and trace dispatch on the arm, and every arm of a union with a traced arm carries a collector header. Wraps move the record, retypes between such unions reuse the pointer, and equality is identity. Record field indices come from the shape's field base. Unions over records that are also arms of runtime-inspected unions (Map keys, Set elements), library builds, lifetime-region programs and debug builds keep boxes. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/dyn.ts | 20 ++- packages/compiler/src/backend/llvm/emitter.ts | 47 ++++-- .../src/backend/llvm/expr-callbacks.ts | 12 +- .../compiler/src/backend/llvm/expr-control.ts | 4 +- .../compiler/src/backend/llvm/expr-records.ts | 14 +- .../src/backend/llvm/expr-stream-bridges.ts | 11 +- .../compiler/src/backend/llvm/lib-datagram.ts | 4 +- .../src/backend/llvm/lib-filesystem.ts | 7 +- .../compiler/src/backend/llvm/lib-lookup.ts | 2 +- .../compiler/src/backend/llvm/lib-network.ts | 2 +- .../src/backend/llvm/nullable-unions.test.ts | 83 ++++++++++ .../src/backend/llvm/nullable-unions.ts | 156 ++++++++++++++++-- packages/compiler/src/backend/llvm/publish.ts | 23 ++- packages/compiler/src/backend/llvm/regions.ts | 9 +- packages/compiler/src/backend/llvm/shapes.ts | 143 +++++++++++++++- .../src/backend/llvm/stack-objects.ts | 12 ++ .../compiler/src/backend/llvm/union-repr.ts | 36 +++- .../src/backend/llvm/union-retype.test.ts | 19 ++- packages/compiler/src/backend/llvm/walkers.ts | 8 +- .../corpus/5930-record-union-pointers.ts.json | 6 + .../5931-record-union-unit-arms.ts.json | 6 + .../corpus/5932-record-union-trees.ts.json | 6 + .../5933-record-union-conversions.ts.json | 6 + .../5934-record-union-set-members.ts.json | 6 + tests/corpus/5930-record-union-pointers.ts | 79 +++++++++ tests/corpus/5931-record-union-unit-arms.ts | 67 ++++++++ tests/corpus/5932-record-union-trees.ts | 77 +++++++++ tests/corpus/5933-record-union-conversions.ts | 78 +++++++++ tests/corpus/5934-record-union-set-members.ts | 25 +++ 29 files changed, 886 insertions(+), 82 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/nullable-unions.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-record-union-pointers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-record-union-unit-arms.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5932-record-union-trees.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-record-union-conversions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5934-record-union-set-members.ts.json create mode 100644 tests/corpus/5930-record-union-pointers.ts create mode 100644 tests/corpus/5931-record-union-unit-arms.ts create mode 100644 tests/corpus/5932-record-union-trees.ts create mode 100644 tests/corpus/5933-record-union-conversions.ts create mode 100644 tests/corpus/5934-record-union-set-members.ts diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index b85ca0062..845633148 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1179,12 +1179,13 @@ export class LlDyn { `llvm emitter bug: JSON schema of unknown shape ${t.shapeId}`, ); const struct = mangleRecordStruct(t.shapeId); - const index = new Map(shape.fields.map((f, i) => [f.name, i + 1])); + const base = host.nullableUnions.fieldBase(t.shapeId); + const index = new Map(shape.fields.map((f, i) => [f.name, i + base])); // The runtime names kept-source keys by this index (record-sources.ts). const order = recordSourceFieldOrder(shape); const fieldTy = `{ ptr, ${S}, ${S}, ptr }`; const entries = order.map((fieldName) => { - const field = shape.fields[index.get(fieldName)! - 1]!; + const field = shape.fields[index.get(fieldName)! - base]!; const sub = this.jsonSchema(field.type); const offset = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i64 0, i32 ${index.get(fieldName)}) to ${S})`; return `${fieldTy} { ptr ${host.cstr(fieldName)}, ${S} ${Buffer.byteLength(fieldName, "utf8")}, ${S} ${offset}, ptr @${sub} }`; @@ -1421,18 +1422,19 @@ export class LlDyn { B.line(`call void @scr_arr_outline(ptr ${cached})`); B.line(`call void @scr_arr_outline(ptr ${checked})`); } + const base = t.kind === "record" ? host.nullableUnions.fieldBase(t.shapeId) : 1; const members = t.kind === "record" ? [ ...shape!.fields.map((field, index) => ({ - index: index + 1, + index: index + base, type: llFieldType(field.type, host.inlineUnions.record(field.type)), name: field.name, })), ...(shape!.indexValue ? [ { - index: shape!.fields.length + 1, + index: shape!.fields.length + base, type: "ptr" as const, name: "[key: string] overflow", }, @@ -1693,7 +1695,8 @@ export class LlDyn { `llvm emitter bug: dynCheck of unknown shape ${t.shapeId}`, ); const struct = mangleRecordStruct(t.shapeId); - const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1])); + const base = host.nullableUnions.fieldBase(t.shapeId); + const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + base])); const releaseR = (): void => { B.line(`call void ${releaseSym(host, t)}(ptr %r0)`); }; @@ -1870,7 +1873,7 @@ export class LlDyn { const ovfp = B.tmp(); const ovf = B.tmp(); B.line( - `${ovfp} = getelementptr inbounds %${struct}, ptr %r0, i64 0, i32 ${shape.fields.length + 1}`, + `${ovfp} = getelementptr inbounds %${struct}, ptr %r0, i64 0, i32 ${shape.fields.length + base}`, ); B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`); if (shape.fields.length === 0) { @@ -2443,7 +2446,8 @@ export class LlDyn { if (!shape) throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`); const struct = mangleRecordStruct(t.shapeId); - const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1])); + const base = host.nullableUnions.fieldBase(t.shapeId); + const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + base])); const loadFieldOf = (fname: string, ft: IrType): string => { const p = B.tmp(); B.line( @@ -2567,7 +2571,7 @@ export class LlDyn { const ovfp = B.tmp(); const ovf = B.tmp(); B.line( - `${ovfp} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 ${shape.fields.length + 1}`, + `${ovfp} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 ${shape.fields.length + base}`, ); B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`); const ks = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 09bf4979c..a9e840837 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -373,6 +373,7 @@ import { elemAccess, emitInlineRcHelpers, emitNullableRetainWrappers, + emitShapeUnionHelpers, FN_ATTRS, inlineRcDecls, type InlineRcMode, @@ -1071,7 +1072,7 @@ export class LlEmitter { expr: (expr) => !(expr.kind === "libCall" && expr.fn === "error.stack"), }), ); - this.nullableUnions = new NullableUnions(mod); + this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined); this.debug = options.debugSources === undefined ? null @@ -1165,8 +1166,10 @@ export class LlEmitter { // Optional array reads also cover nullable unions: emitLocalArrayRead // then yields the (borrowed or owned) element pointer, not a stack box. // Arrays of such a union are read the same way. - this.optionalArrayReads = new OptionalArrayReads(this.fnByName, this.unionsById, (id) => - this.nullableUnions.has(id), + this.optionalArrayReads = new OptionalArrayReads( + this.fnByName, + this.unionsById, + (id) => this.nullableUnions.single(id) !== null, ); this.unionSources = { unions: this.boxedUnionsById, @@ -1219,7 +1222,8 @@ export class LlEmitter { (type) => this.plainReference(type) && type.kind !== "string" && - (type.kind !== "union" || this.nullableUnions.get(type.unionId)?.arm.kind !== "string"), + (type.kind !== "union" || + this.nullableUnions.single(type.unionId)?.arm.kind !== "string"), this.walkBorrowsByFunction.get(fn.name) ?? new Set(), ); if (rebound.size === 0) continue; @@ -1270,6 +1274,15 @@ export class LlEmitter { const traced = computeTraced(mod); this.tracedShapes = traced.shapes; this.tracedUnions = traced.unions; + // A shape union's value is whichever record it holds, and the collector + // visits it as that record: when one arm carries a collector header, + // every arm must (nullable-unions.ts). + for (const union of this.nullableUnions.allShapeUnions()) + if ( + this.tracedUnions.has(union.unionId) || + union.arms.some((arm) => this.tracedShapes.has(`record:${arm.shapeId}`)) + ) + for (const arm of union.arms) this.tracedShapes.add(`record:${arm.shapeId}`); this.shapeHost = { sizeType: this.sizeType, cycleBufOffset: this.cycleBufOffset, @@ -3551,6 +3564,7 @@ export class LlEmitter { return [ ...late, ...emitNullableRetainWrappers(this.shapeHost), + ...emitShapeUnionHelpers(this.shapeHost), ...emitInlineRcHelpers(this.shapeHost), ]; } @@ -5527,7 +5541,11 @@ export class LlEmitter { const nullable = this.nullableUnions.get(unionId); if (nullable) { // The owned payload IS the nullable union's value. - if (tag !== nullable.refTag) + if ( + nullable.repr === "single" + ? tag !== nullable.refTag + : !nullable.arms.some((arm) => arm.tag === tag) + ) throw new InternalCompilerError( `llvm emitter bug: payload wrap into unit arm ${tag} of ${unionId}`, ); @@ -5632,7 +5650,7 @@ export class LlEmitter { const index = shape.fields.findIndex((entry) => entry.name === field); if (index < 0) throw new InternalCompilerError(`llvm emitter bug: missing union field ${field}`); - layout = "record:"; + layout = this.nullableUnions.fieldBase(arm.shapeId) === 2 ? "record:tagged;" : "record:"; for (let i = 0; i <= index; i++) { const type = shape.fields[i]!.type; layout += `${llFieldType(type, this.inlineUnions.record(type))};`; @@ -5668,7 +5686,7 @@ export class LlEmitter { ); const p = this.B.tmp(); this.B.line( - `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${idx + 1}`, + `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${idx + this.nullableUnions.fieldBase(shapeId)}`, ); const type = shape.fields[idx]!.type; return { ptr: p, type, inline: this.inlineUnions.record(type) }; @@ -5682,7 +5700,7 @@ export class LlEmitter { const p = this.B.tmp(); const v = this.B.tmp(); this.B.line( - `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${shape.fields.length + 1}`, + `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${shape.fields.length + this.nullableUnions.fieldBase(shapeId)}`, ); this.B.line(`${v} = load ptr, ptr ${p}`); return v; @@ -9564,7 +9582,12 @@ export class LlEmitter { unionsById: this.unionsById, recordsById: this.recordsById, inlineUnions: this.inlineUnions, - nullableArm: (t) => this.nullableUnions.of(t)?.arm ?? null, + nullableArm: (t) => { + const nullable = this.nullableUnions.of(t); + return nullable?.repr === "single" ? nullable.arm : null; + }, + shapeUnion: (t) => this.nullableUnions.of(t)?.repr === "shapes", + fieldBase: (shapeId) => this.nullableUnions.fieldBase(shapeId), classInfo: (className) => { const meta = this.classMetaOf(className); return { @@ -10280,7 +10303,11 @@ export class LlEmitter { const nullable = this.nullableUnions.get(e.unionId); if (!nullable) return null; if (isUnitType(e.value.type)) return e.value.kind === "unitLit" ? "unit" : null; - return e.tag === nullable.refTag && isRefCounted(e.value.type) ? "ref" : null; + const refArm = + nullable.repr === "single" + ? e.tag === nullable.refTag + : nullable.arms.some((arm) => arm.tag === e.tag); + return refArm && isRefCounted(e.value.type) ? "ref" : null; } materializeSplitLocal(localId: string): void { diff --git a/packages/compiler/src/backend/llvm/expr-callbacks.ts b/packages/compiler/src/backend/llvm/expr-callbacks.ts index e5560f14f..d71df333a 100644 --- a/packages/compiler/src/backend/llvm/expr-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-callbacks.ts @@ -182,6 +182,7 @@ export function genResultThunkFor( sym = mangleGenResThunk(host.resolveThunks.size); host.resolveThunks.set(key, sym); const shape = host.recordsById.get(recT.shapeId); + const base = host.nullableUnions.fieldBase(recT.shapeId); const valueT = shape?.fields.find((f) => f.name === "value")?.type; if (!shape || !valueT) throw new InternalCompilerError("llvm emitter bug: genResume record lacks its value field"); @@ -242,7 +243,7 @@ export function genResultThunkFor( ` %r = call ptr @${mangleRecordNew(recT.shapeId)}()`, ` %d = call zeroext i1 @scr_gen_done(ptr %g)`, ` %dz = zext i1 %d to i8`, - ` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdxD + 1}`, + ` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdxD + base}`, ` store i8 %dz, ptr %dp`, ` %has = call zeroext i1 @scr_gen_out_has(ptr %g)`, ` br i1 %has, label %take, label %undefv`, @@ -259,7 +260,7 @@ export function genResultThunkFor( ` br label %join`, `join:`, ` %v = load ptr, ptr %vslot`, - ` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdxD + 1}`, + ` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdxD + base}`, ` store ptr %v, ptr %vp`, ` ret ptr %r`, `}`, @@ -293,7 +294,7 @@ export function genResultThunkFor( ` %r = call ptr @${mangleRecordNew(recT.shapeId)}()`, ` %d = call zeroext i1 @scr_gen_done(ptr %g)`, ` %dz = zext i1 %d to i8`, - ` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdx + 1}`, + ` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdx + base}`, ` store i8 %dz, ptr %dp`, ` br i1 %d, label %doneb, label %susp`, ]; @@ -410,7 +411,7 @@ export function genResultThunkFor( ` br label %join`, `join:`, ` %v = load ptr, ptr %vslot`, - ` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdx + 1}`, + ` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdx + base}`, // An inline value field copies the box's bits and drops the box. ...(host.inlineUnions.record(valueT) ? [ @@ -1092,7 +1093,8 @@ export function settledStoreFor( return { tag, arm }; }; const fieldIndex = (arm: IrType & { kind: "record" }, field: string): number => - host.recordsById.get(arm.shapeId)!.fields.findIndex((f) => f.name === field) + 1; + host.recordsById.get(arm.shapeId)!.fields.findIndex((f) => f.name === field) + + host.nullableUnions.fieldBase(arm.shapeId); const fulfilled = armOf("value"); const rejected = armOf("reason"); const valueT = host.recordsById diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 298ea0199..fd4bc8787 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -330,7 +330,7 @@ export function emitControlExpr( ); B.line(`store ${ty} ${t}, ptr ${slot}`); if (host.mayThrow.has(e.retag)) host.emitPendingCheck(); - } else if (host.nullableUnions.has(e.left.type.unionId)) { + } else if (host.nullableUnions.single(e.left.type.unionId)) { // The owned nullable pointer IS the owned arm value. B.line(`store ${ty} ${l.name}, ptr ${slot}`); } else { @@ -440,7 +440,7 @@ export function emitControlExpr( host.emitBranchInto(slot, e.right); B.br(lj); B.startBlock(lv); - if (typeEquals(e.type, e.left.type) || host.nullableUnions.has(def.id)) { + if (typeEquals(e.type, e.left.type) || host.nullableUnions.single(def.id)) { // Pass-through, or a nullable pointer that IS its owned arm value. B.line(`store ${ty} ${l.name}, ptr ${slot}`); } else { diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 4b03dbad2..1d1717613 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -62,9 +62,17 @@ export function emitUnionRetype( const from = host.nullableUnions.get(e.fromId); const to = host.nullableUnions.get(e.unionId); if (from && to) { - // Equal encodings make the pointer itself the target value. - if (from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag) - return host.own({ name: value.name, type: e.type }); + // Equal encodings make the pointer itself the target value. A shape + // union derives its record arms from the record, so only the unit + // encodings must agree. + const same = + from.repr === "single" && to.repr === "single" + ? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag + : from.repr === "shapes" && + to.repr === "shapes" && + from.unitTag === to.unitTag && + from.nullTag === to.nullTag; + if (same) return host.own({ name: value.name, type: e.type }); return host.own({ name: rebuildRetype(host, value.name, e), type: e.type }); } if (from || to) return host.own({ name: rebuildRetype(host, value.name, e), type: e.type }); diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 55fa363cd..76ef35358 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -500,16 +500,17 @@ export function streamTypedRefCommitAdapter( ` br i1 %missing, label %done, label %swap`, `swap:`, ]; + const base = host.nullableUnions.fieldBase(t.shapeId); const members = [ ...shape.fields.map((field, index) => ({ - index: index + 1, + index: index + base, type: llFieldType(field.type, host.inlineUnions.record(field.type)), name: field.name, })), ...(shape.indexValue ? [ { - index: shape.fields.length + 1, + index: shape.fields.length + base, type: "ptr" as const, name: "[key: string] overflow", }, @@ -943,7 +944,7 @@ export function streamTypedRefMaterializeAdapter( B.line(`${out} = call ptr @scr_dyn_new_arr()`); const fields = [...shape.fields].sort((a, b) => Number(a.name) - Number(b.name)); for (const field of fields) { - const index = shape.fields.indexOf(field) + 1; + const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId); const fieldPtr = B.tmp(); B.line( `${fieldPtr} = getelementptr inbounds %${mangleRecordStruct(t.shapeId)}, ptr %p, i64 0, i32 ${index}`, @@ -980,7 +981,7 @@ export function streamTypedRefMaterializeAdapter( liveRecordOps(host, t, snapshot, fields); } for (const field of fields) { - const index = shape.fields.indexOf(field) + 1; + const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId); const fieldPtr = B.tmp(); B.line( `${fieldPtr} = getelementptr inbounds %${mangleRecordStruct(t.shapeId)}, ptr %p, i64 0, i32 ${index}`, @@ -1202,7 +1203,7 @@ function liveRecordOps( const names = `${snapshot}_names`; const ops = `${snapshot}_rops`; const loadField = (B: BlockBuilder, field: IrRecordShape["fields"][number]): string => { - const index = shape.fields.indexOf(field) + 1; + const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId); const fieldPtr = B.tmp(); B.line(`${fieldPtr} = getelementptr inbounds %${struct}, ptr %p, i64 0, i32 ${index}`); return loadStructField( diff --git a/packages/compiler/src/backend/llvm/lib-datagram.ts b/packages/compiler/src/backend/llvm/lib-datagram.ts index 1553ecf4f..72de03830 100644 --- a/packages/compiler/src/backend/llvm/lib-datagram.ts +++ b/packages/compiler/src/backend/llvm/lib-datagram.ts @@ -29,7 +29,7 @@ function messageAdapter(host: LlvmEmitterContext, type: IrType): string { const isString = field === "address" || field === "family"; if (isString) lines.push(` %${field}.owned = call ptr @scr_str_retain_v(ptr %${field})`); lines.push( - ` %${field}.slot = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr %record, i64 0, i32 ${index + 1}`, + ` %${field}.slot = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr %record, i64 0, i32 ${index + host.nullableUnions.fieldBase(type.shapeId)}`, ` store ${isString ? "ptr" : "double"} %${field}${isString ? ".owned" : ""}, ptr %${field}.slot`, ); } @@ -167,7 +167,7 @@ export function emitDatagramLibCall(host: LlvmEmitterContext, e: LibCallExpr): L ); const slot = host.B.tmp(); host.B.line( - `${slot} = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr ${record}, i64 0, i32 ${index + 1}`, + `${slot} = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr ${record}, i64 0, i32 ${index + host.nullableUnions.fieldBase(type.shapeId)}`, ); host.B.line(`store ${ty} ${value}, ptr ${slot}`); } diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 1083c69d5..bcb65c8fa 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -910,11 +910,8 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll scopeid.inline, ); } - const rc = vAdapters(host.shapeHost, t); - const rowU = B.tmp(); - B.line( - `${rowU} = call ptr @scr_union_new_ref(i32 ${tag}, ptr ${r}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, t)})`, - ); + // The row (+1) moves into its union arm (a shape union is the row). + const rowU = host.unionNewOwned(infoT.unionId, tag, { name: r, type: t }); B.line(`store ptr ${rowU}, ptr ${rowSlot}`); B.br(lRow); }; diff --git a/packages/compiler/src/backend/llvm/lib-lookup.ts b/packages/compiler/src/backend/llvm/lib-lookup.ts index 9fc23cb94..a6e76e4e0 100644 --- a/packages/compiler/src/backend/llvm/lib-lookup.ts +++ b/packages/compiler/src/backend/llvm/lib-lookup.ts @@ -65,7 +65,7 @@ export function emitLookupConnect(host: LlvmEmitterContext, e: LibCallExpr): LlV " br i1 %more, label %body, label %finish", "body:", " %record = call ptr @scr_arr_get_ref(ptr %addresses, double %index)", - ` %addressp = getelementptr inbounds %${struct}, ptr %record, i64 0, i32 ${field + 1}`, + ` %addressp = getelementptr inbounds %${struct}, ptr %record, i64 0, i32 ${field + host.nullableUnions.fieldBase(addresses.elem.shapeId)}`, " %address = load ptr, ptr %addressp", " %owned.address = call ptr @scr_str_retain_v(ptr %address)", " call double @scr_arr_push_ref(ptr %ips, ptr %owned.address)", diff --git a/packages/compiler/src/backend/llvm/lib-network.ts b/packages/compiler/src/backend/llvm/lib-network.ts index 3ac9fe050..322479a85 100644 --- a/packages/compiler/src/backend/llvm/lib-network.ts +++ b/packages/compiler/src/backend/llvm/lib-network.ts @@ -168,7 +168,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) const i = shape.fields.findIndex((f) => f.name === name); if (i < 0) throw new InternalCompilerError(`llvm emitter bug: net.serverAddress record lacks ${name}`); - return i + 1; + return i + host.nullableUnions.fieldBase(recT.shapeId); }; const store = (name: string, ty: string, v: string): void => { const p = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/nullable-unions.test.ts b/packages/compiler/src/backend/llvm/nullable-unions.test.ts new file mode 100644 index 000000000..46bd688e7 --- /dev/null +++ b/packages/compiler/src/backend/llvm/nullable-unions.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "vitest"; +import { + F64, + NULL_T, + UNDEFINED_T, + type IrModule, + type IrType, + type IrUnionDef, +} from "../../ir/ir.js"; +import { NullableUnions } from "./nullable-unions.js"; + +const loc = { file: "nullable-unions.ts", start: 0, end: 0 }; +const a: IrType = { kind: "record", shapeId: "a" }; +const b: IrType = { kind: "record", shapeId: "b" }; +const c: IrType = { kind: "record", shapeId: "c" }; + +function module(unions: IrUnionDef[], extra: Partial = {}): IrModule { + return { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + records: ["a", "b", "c"].map((id) => ({ id, fields: [{ name: id, type: F64 }] })), + unions, + functions: [], + ...extra, + }; +} + +test("unions of two or more records become shape unions", () => { + const unions = new NullableUnions( + module([ + { id: "ab", arms: [a, b] }, + { id: "abu", arms: [a, UNDEFINED_T, b] }, + { id: "abnu", arms: [a, b, NULL_T, UNDEFINED_T] }, + { id: "af", arms: [a, F64] }, + { id: "au", arms: [a, UNDEFINED_T] }, + ]), + ); + expect(unions.shapes("ab")).toMatchObject({ unitTag: -1, nullTag: -1 }); + expect(unions.shapes("abu")).toMatchObject({ unitTag: 1, nullTag: -1 }); + expect(unions.shapes("abnu")).toMatchObject({ unitTag: 3, nullTag: 2 }); + expect(unions.shapes("abnu")?.arms).toEqual([ + { tag: 0, shapeId: "a" }, + { tag: 1, shapeId: "b" }, + ]); + // A scalar arm keeps the box; one record arm is a single-arm nullable. + expect(unions.get("af")).toBeNull(); + expect(unions.single("au")).not.toBeNull(); + expect(unions.shapes("au")).toBeNull(); + // Tagged shapes number from 2 and move their fields after the word. + expect([unions.shapeNumber("a"), unions.shapeNumber("b"), unions.shapeNumber("c")]).toEqual([ + 2, 3, 0, + ]); + expect([unions.fieldBase("a"), unions.fieldBase("c")]).toEqual([2, 1]); + expect(unions.boxedUnions(new Map([["af", { id: "af", arms: [a, F64] }]])).size).toBe(1); +}); + +test("records of runtime-inspected unions keep every union over them boxed", () => { + const set: IrType = { kind: "set", elem: { kind: "union", unionId: "ab" } }; + const unions = new NullableUnions( + module( + [ + { id: "ab", arms: [a, b] }, + { id: "abu", arms: [a, b, UNDEFINED_T] }, + { id: "bc", arms: [b, c] }, + { id: "ac", arms: [a, c] }, + ], + { globals: [{ id: "%g.s", name: "s", type: set, mutable: false }] }, + ), + ); + expect(unions.get("ab")).toBeNull(); + expect(unions.get("abu")).toBeNull(); + expect(unions.get("bc")).toBeNull(); + expect(unions.get("ac")).toBeNull(); + expect(unions.shapeNumber("c")).toBe(0); +}); + +test("library, lifetime-region and debug builds keep record unions boxed", () => { + const unions = [{ id: "ab", arms: [a, b] }]; + expect(new NullableUnions(module(unions), false).get("ab")).toBeNull(); + expect(new NullableUnions(module(unions, { lifetimeRegions: true })).get("ab")).toBeNull(); + expect(new NullableUnions(module(unions)).shapes("ab")).not.toBeNull(); +}); diff --git a/packages/compiler/src/backend/llvm/nullable-unions.ts b/packages/compiler/src/backend/llvm/nullable-unions.ts index b1032f9d4..8729e9649 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.ts @@ -17,6 +17,7 @@ import { everyModuleNode } from "../../ir/traverse.js"; * every container, field, capture, parameter, and return slot holds the * plain pointer. Every other union keeps the tagged `%ScrUnion` box. */ export interface NullableUnion { + repr: "single"; unionId: string; refTag: number; /** The unit arm NULL encodes. */ @@ -24,7 +25,57 @@ export interface NullableUnion { /** The `null` arm of a two-unit-arm union, encoded by NULLABLE_NULL; * -1 when the union has one unit arm. */ nullTag: number; - arm: NullableRefArm; + /** One of NullableRefArm. Typed as the plain IrType so the self-hosted + * frontend keeps IrType's structurally identical arms one record shape + * (a PointerUnion arm holding the narrowed intersection would split them). */ + arm: IrType; +} + +/** A union of two or more record arms and at most two unit arms (`A | B`, + * `A | B | undefined`, discriminated unions of object literals) whose + * VALUES are the record pointer itself, like a NullableUnion: NULL is the + * unit arm (`undefined` when both exist, NULLABLE_NULL is then `null`). + * Every record shape that is an arm of such a union is a TAGGED shape: its + * struct carries the shape's module-wide number in a hidden `i32` word + * right after the reference count (struct index 1), stored by every + * allocation, so the arm of a pointer is that word mapped through the + * union's arm table (the per-union `@sc_stag.` helper). Records share + * one retain; release and trace dispatch on the arm. */ +export interface ShapeUnion { + repr: "shapes"; + unionId: string; + /** The unit arm NULL encodes; -1 when the union has no unit arm. */ + unitTag: number; + /** The `null` arm of a two-unit-arm union, encoded by NULLABLE_NULL; + * -1 otherwise. */ + nullTag: number; + /** The record arms in tag order. */ + arms: readonly { tag: number; shapeId: string }[]; +} + +/** A union whose values are plain pointers (no `%ScrUnion` box). */ +export type PointerUnion = NullableUnion | ShapeUnion; + +/** The struct index of a tagged record shape's hidden shape-number word. */ +export const SHAPE_WORD_INDEX = 1; + +/** The LLVM type through which any tagged record's shape word is read + * (the reference count, then the word). */ +export function shapeWordHeader(sizeType: "i32" | "i64"): string { + return `{ ${sizeType}, i32 }`; +} + +/** The per-union helper mapping a shape-union value to its tag. */ +export function shapeTagFn(unionId: string): string { + return `@sc_stag.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; +} + +/** The per-union release and trace dispatchers of a shape union. */ +export function shapeReleaseFn(unionId: string): string { + return `@sc_srelease.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; +} +export function shapeTraceFn(unionId: string): string { + return `@sc_strace.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; } /** The module-scope immortal sentinel standing for `null` in a @@ -48,44 +99,98 @@ export type NullableRefArm = | (IrType & { kind: "array" | "map" | "set" }); /** The arm is a JS object: always truthy, compared by identity. */ -export function isObjectArm(nullable: NullableUnion): boolean { - return nullable.arm.kind !== "string"; +export function isObjectArm(nullable: PointerUnion): boolean { + return nullable.repr === "shapes" || nullable.arm.kind !== "string"; } /** The representation decision for every union of one module. It is a * pure function of the module, so serialized-IR and in-memory emission * agree. */ export class NullableUnions { - private readonly byId = new Map(); + private readonly byId = new Map(); + /** Tagged record shapes (arms of a shape union) by their module-wide + * shape number (2 and up: 0 and 1 are the unit encodings' pseudo + * numbers, never stored). */ + private readonly shapeNumbers = new Map(); /** NULL-skipping retain wrappers requested so far: symbol → inner retain * call target. The emitter defines each once. */ readonly retainWrappers = new Map(); /** Some nullable union encodes `null` with NULLABLE_NULL. */ readonly usesNullSentinel: boolean = false; - constructor(mod: IrModule) { + /** `shapes`: allow shape unions (debug builds keep boxes, whose + * debug-info layouts describe every arm). */ + constructor(mod: IrModule, shapes = true) { const emitted = new Set(); for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name); const records = new Set(); for (const shape of mod.records ?? []) records.add(shape.id); const boxed = boxedUnionIds(mod); + // Shape unions stay out of library builds (their host ABI marshals + // boxes) and of programs with lifetime regions (whose walks report a + // reference slot by its type's release entry point). + const allowShapes = shapes && mod.lib === undefined && mod.lifetimeRegions !== true; + // A record that is an arm of a boxed union never becomes a shape + // union's arm: conversions between unions over the same records then + // keep one representation. + const boxedRecords = new Set(); + for (const def of mod.unions ?? []) + if (boxed.has(def.id)) + for (const arm of def.arms) if (arm.kind === "record") boxedRecords.add(arm.shapeId); for (const def of mod.unions ?? []) { if (boxed.has(def.id)) continue; - const nullable = nullableShape(def, emitted, records); + const nullable = + nullableShape(def, emitted, records) ?? + (allowShapes ? shapeUnion(def, records, boxedRecords) : null); if (!nullable) continue; this.byId.set(def.id, nullable); if (nullable.nullTag >= 0) this.usesNullSentinel = true; + if (nullable.repr === "shapes") + for (const arm of nullable.arms) + if (!this.shapeNumbers.has(arm.shapeId)) + this.shapeNumbers.set(arm.shapeId, this.shapeNumbers.size + 2); } } - get(unionId: string): NullableUnion | null { + get(unionId: string): PointerUnion | null { return this.byId.get(unionId) ?? null; } - of(type: IrType): NullableUnion | null { + of(type: IrType): PointerUnion | null { return type.kind === "union" ? this.get(type.unionId) : null; } + /** The single-reference-arm form only (shape unions answer null). */ + single(unionId: string): NullableUnion | null { + const nullable = this.byId.get(unionId); + return nullable?.repr === "single" ? nullable : null; + } + + /** The shape-union form only. */ + shapes(unionId: string): ShapeUnion | null { + const nullable = this.byId.get(unionId); + return nullable?.repr === "shapes" ? nullable : null; + } + + /** Every shape union, in module order. */ + allShapeUnions(): ShapeUnion[] { + const out: ShapeUnion[] = []; + for (const nullable of this.byId.values()) if (nullable.repr === "shapes") out.push(nullable); + return out; + } + + /** The module-wide number a tagged record shape stores in its shape + * word, or 0 for shapes that carry none. */ + shapeNumber(shapeId: string): number { + return this.shapeNumbers.get(shapeId) ?? 0; + } + + /** The struct index of a record shape's first declared field: 2 for + * tagged shapes (after the shape word), 1 otherwise. */ + fieldBase(shapeId: string): number { + return this.shapeNumbers.has(shapeId) ? 2 : 1; + } + has(unionId: string): boolean { return this.byId.has(unionId); } @@ -94,10 +199,11 @@ export class NullableUnions { return this.byId.size; } - /** The reference arm of every nullable union, by union id. */ + /** The reference arm of every single-arm nullable union, by union id. */ armTypes(): Map { const result = new Map(); - for (const [id, nullable] of this.byId) result.set(id, nullable.arm); + for (const [id, nullable] of this.byId) + if (nullable.repr === "single") result.set(id, nullable.arm); return result; } @@ -129,7 +235,7 @@ function nullableShape( if (unitTag < 0 || nullTag < 0) return null; } const arm = def.arms[refTag]!; - const base = { unionId: def.id, refTag, unitTag, nullTag }; + const base = { repr: "single" as const, unionId: def.id, refTag, unitTag, nullTag }; if (arm.kind === "object" && emittedClasses.has(arm.className)) return { ...base, arm: { kind: "object", className: arm.className } }; if (arm.kind === "record" && records.has(arm.shapeId)) @@ -140,6 +246,34 @@ function nullableShape( return null; } +/** Two or more emitted record arms and at most two unit arms (both: + * undefined and null). */ +function shapeUnion( + def: IrUnionDef, + records: ReadonlySet, + boxedRecords: ReadonlySet, +): ShapeUnion | null { + const units = def.arms.filter(isUnitType).length; + if (units > 2 || def.arms.length - units < 2) return null; + const arms: { tag: number; shapeId: string }[] = []; + for (let tag = 0; tag < def.arms.length; tag++) { + const arm = def.arms[tag]!; + if (isUnitType(arm)) continue; + if (arm.kind !== "record" || !records.has(arm.shapeId) || boxedRecords.has(arm.shapeId)) + return null; + arms.push({ tag, shapeId: arm.shapeId }); + } + let unitTag = -1, + nullTag = -1; + if (units === 1) unitTag = def.arms.findIndex(isUnitType); + else if (units === 2) { + unitTag = def.arms.findIndex((arm) => arm.kind === "undefinedT"); + nullTag = def.arms.findIndex((arm) => arm.kind === "nullT"); + if (unitTag < 0 || nullTag < 0) return null; + } + return { repr: "shapes", unionId: def.id, unitTag, nullTag, arms }; +} + /** Unions the runtime itself inspects as tagged boxes keep that * representation: Map keys and Set elements hash and compare union keys by * tag and payload inside the C map. */ diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts index c884339af..4f807424b 100644 --- a/packages/compiler/src/backend/llvm/publish.ts +++ b/packages/compiler/src/backend/llvm/publish.ts @@ -25,6 +25,7 @@ import type { IrClassDef, IrRecordShape, IrType, IrUnionDef, SrcLoc } from "../. import { mapOf, STRING, typeKey } from "../../ir/ir.js"; import { mangleClassStruct, mangleRecordStruct } from "../mangle.js"; import { FN_ATTRS } from "./shapes.js"; +import { shapeTagFn } from "./nullable-unions.js"; import type { InlineUnionFields } from "./inline-unions.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -44,6 +45,10 @@ export interface PublishHost { readonly inlineUnions: InlineUnionFields; /** The nullable-pointer representation of a union, when it has one. */ nullableArm(t: IrType): IrType | null; + /** The union's values are tagged record pointers (nullable-unions.ts). */ + shapeUnion(t: IrType): boolean; + /** The struct index of a record shape's first field. */ + fieldBase(shapeId: string): number; /** Class layout: hierarchy membership, preorder interval, field slots * (with their storage types), runtime roots. */ classInfo(className: string): PublishClassInfo; @@ -210,6 +215,19 @@ export class PublishWalkers { if (def.arms.some((arm) => scalarRefusal(arm))) throw new LlvmUnsupportedError(`publish of ${typeKey(t)}`, loc); const live = arms.flatMap((a) => (a.fn === null ? [] : [{ tag: a.tag, fn: a.fn }])); + if (this.host.shapeUnion(t)) { + // The value is the record itself (or a unit encoding): no box to + // mark; the arm's walker marks and walks the record. + const lines = [head, `entry:`]; + lines.push( + ` %tag = call i32 ${shapeTagFn(t.unionId)}(ptr %o)`, + ` switch i32 %tag, label %done [ ${live.map((a) => `i32 ${a.tag}, label %arm${a.tag}`).join(" ")} ]`, + ); + for (const a of live) + lines.push(`arm${a.tag}:`, ` call void ${a.fn}(ptr %o, ptr %ctx)`, ` br label %done`); + lines.push(`done:`, ` ret void`, `}`); + return lines; + } const lines = [ head, `entry:`, @@ -240,17 +258,18 @@ export class PublishWalkers { const shape = this.host.recordsById.get(t.shapeId); if (!shape) throw new LlvmUnsupportedError(`publish of an unknown record ${t.shapeId}`, loc); + const base = this.host.fieldBase(shape.id); const members = [ ...shape.fields.flatMap((f, i) => this.host.inlineUnions.record(f.type) ? [] - : [{ name: f.name, index: i + 1, type: f.type }], + : [{ name: f.name, index: i + base, type: f.type }], ), ...(shape.indexValue ? [ { name: "[key: string]", - index: shape.fields.length + 1, + index: shape.fields.length + base, type: mapOf(STRING, shape.indexValue), }, ] diff --git a/packages/compiler/src/backend/llvm/regions.ts b/packages/compiler/src/backend/llvm/regions.ts index 41f67c0cb..b789c644d 100644 --- a/packages/compiler/src/backend/llvm/regions.ts +++ b/packages/compiler/src/backend/llvm/regions.ts @@ -101,8 +101,8 @@ export class LifetimeRegions { /** Can a value of type `t` hold (or own something that holds) a * reference to a ProgramLifetime instance the walk can explain? */ private reaches(t: IrType): boolean { - const arm = this.host.nullableUnions.of(t)?.arm; - if (arm !== undefined) return this.reaches(arm); + const nullable = this.host.nullableUnions.of(t); + if (nullable?.repr === "single") return this.reaches(nullable.arm); switch (t.kind) { case "object": return this.isLifetime(t.className) || this.reach.get(`object:${t.className}`) === true; @@ -287,7 +287,8 @@ export class LifetimeRegions { * ProgramLifetime instance). */ private reportLines(struct: string, f: Field, i: number): string[] { if (!isRefCounted(f.type) || !this.reaches(f.type)) return []; - const arm = this.host.nullableUnions.of(f.type)?.arm ?? f.type; + const nullable = this.host.nullableUnions.of(f.type); + const arm = nullable?.repr === "single" ? nullable.arm : f.type; const load = [ ` %f${i} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`, ` %v${i} = load ptr, ptr %f${i}`, @@ -411,7 +412,7 @@ export class LifetimeRegions { ...this.reportBody( `define internal void ${walk}(ptr %o, ptr %w) ${FN_ATTRS} { ; region walk record ${shape.id}`, mangleRecordStruct(shape.id), - rcMembers(shape, this.host.inlineUnions), + rcMembers(shape, this.host.inlineUnions, this.host.nullableUnions.fieldBase(shape.id)), ), ); rows.push(`{ ptr, ptr } { ptr @${mangleRecordRelease(shape.id)}, ptr ${walk} }`); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 23565eff4..162b36425 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -31,7 +31,16 @@ import { import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js"; import type { BlockBuilder } from "./blocks.js"; import { SCR_ARR_FIELD, llvmCommentText } from "./common.js"; -import type { NullableUnions } from "./nullable-unions.js"; +import { + NULLABLE_NULL, + SHAPE_WORD_INDEX, + shapeReleaseFn, + shapeTagFn, + shapeTraceFn, + shapeWordHeader, + type NullableUnions, + type ShapeUnion, +} from "./nullable-unions.js"; import { INLINE_UNION_FIELD, loadInlineUnion, @@ -129,6 +138,7 @@ export { computeTraced } from "../cycle-analysis.js"; * `_v`-shaped. */ export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release: string } { const nullable = host.nullableUnions.of(t); + if (nullable?.repr === "shapes") return shapeUnionRc(nullable); if (nullable) { const arm = vAdapters(host, nullable.arm); return { retain: nullableRetainSym(host, nullable.arm), release: arm.release }; @@ -184,6 +194,7 @@ export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release * Families with a mirrored fast path call the inline helper instead. */ export function retainSym(host: ShapeHost, t: IrType): string { const nullable = host.nullableUnions.of(t); + if (nullable?.repr === "shapes") return shapeUnionRc(nullable).retain; if (nullable) return nullableRetainSym(host, nullable.arm); const stem = runtimeRcStem(t); if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "retain") !== null) { @@ -195,6 +206,99 @@ export function retainSym(host: ShapeHost, t: IrType): string { return vAdapters(host, t).retain; } +/** A shape union's RC entry points: every record retain is the same + * NULL- and immortal-skipping increment (the null sentinel is immortal), + * so the first arm's serves; release dispatches on the arm. */ +function shapeUnionRc(union: ShapeUnion): { retain: string; release: string } { + return { + retain: `@${mangleRecordRetain(union.arms[0]!.shapeId)}`, + release: shapeReleaseFn(union.unionId), + }; +} + +/** The tag, release and trace helpers of every shape union + * (nullable-unions.ts). The tag helper maps a value to its arm: NULL and + * the null sentinel are the unit arms, anything else a tagged record whose + * shape word names its arm (the switch folds to a lookup table). */ +export function emitShapeUnionHelpers(host: ShapeHost): string[] { + const out: string[] = []; + const S = host.sizeType; + for (const union of host.nullableUnions.allShapeUnions()) { + const header = shapeWordHeader(S); + const tagFn = shapeTagFn(union.unionId); + const cases = union.arms + .map((arm) => `i32 ${host.nullableUnions.shapeNumber(arm.shapeId)}, label %a${arm.tag}`) + .join(" "); + out.push( + `define internal i32 ${tagFn}(ptr %u) alwaysinline ${FN_ATTRS} { ; shape union tag`, + `entry:`, + ` %isnull = icmp eq ptr %u, null`, + ` br i1 %isnull, label %unit, label %ref`, + `unit:`, + ` ret i32 ${union.unitTag}`, + `ref:`, + ); + if (union.nullTag >= 0) + out.push( + ` %isnullarm = icmp eq ptr %u, ${NULLABLE_NULL}`, + ` br i1 %isnullarm, label %nullarm, label %rec`, + `nullarm:`, + ` ret i32 ${union.nullTag}`, + `rec:`, + ); + out.push( + ` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`, + ` %w = load i32, ptr %wp`, + ` switch i32 %w, label %bad [ ${cases} ]`, + ...union.arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]), + `bad:`, + ` ret i32 -1`, + `}`, + ``, + ); + // release: unit encodings are NULL or immortal; each arm releases + // through its own (NULL- and immortal-tolerant) helper. + out.push( + `define internal void ${shapeReleaseFn(union.unionId)}(ptr %u) ${FN_ATTRS} { ; shape union release`, + `entry:`, + ` %t = call i32 ${tagFn}(ptr %u)`, + ` switch i32 %t, label %done [ ${union.arms.map((arm) => `i32 ${arm.tag}, label %a${arm.tag}`).join(" ")} ]`, + ...union.arms.flatMap((arm) => [ + `a${arm.tag}:`, + ` call void @${mangleRecordRelease(arm.shapeId)}(ptr %u)`, + ` br label %done`, + ]), + `done:`, + ` ret void`, + `}`, + ``, + ); + if (shapeUnionTraced(host, union)) + out.push( + `define internal void ${shapeTraceFn(union.unionId)}(ptr %u, ptr %visit, ptr %ctx) ${FN_ATTRS} { ; shape union trace`, + `entry:`, + ` %t = call i32 ${tagFn}(ptr %u)`, + ` switch i32 %t, label %done [ ${union.arms.map((arm) => `i32 ${arm.tag}, label %a${arm.tag}`).join(" ")} ]`, + ...union.arms.flatMap((arm) => [ + `a${arm.tag}:`, + ` call void @${mangleRecordTrace(arm.shapeId)}(ptr %u, ptr %visit, ptr %ctx)`, + ` br label %done`, + ]), + `done:`, + ` ret void`, + `}`, + ``, + ); + } + return out; +} + +/** A shape union's arms carry collector headers (all or none: the emitter + * traces every arm of a union with one traced arm). */ +function shapeUnionTraced(host: ShapeHost, union: ShapeUnion): boolean { + return union.arms.some((arm) => host.tracedShapes.has(`record:${arm.shapeId}`)); +} + /** The retain entry point of a nullable union's value: the arm's own when * it tolerates NULL (emitted class and record helpers), otherwise a * NULL-skipping wrapper around it (the null sentinel and literals are @@ -233,6 +337,7 @@ export function emitNullableRetainWrappers(host: ShapeHost): string[] { * Families with a mirrored fast path call the inline helper instead. */ export function releaseSym(host: ShapeHost, t: IrType): string { const nullable = host.nullableUnions.of(t); + if (nullable?.repr === "shapes") return shapeUnionRc(nullable).release; if (nullable) return releaseSym(host, nullable.arm); const stem = runtimeRcStem(t); if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "release") !== null) { @@ -643,6 +748,8 @@ export function traceAdapter(host: ShapeHost, t: IrType): string | null { return "@scr_promise_trace_v"; case "union": { const nullable = host.nullableUnions.get(t.unionId); + if (nullable?.repr === "shapes") + return shapeUnionTraced(host, nullable) ? shapeTraceFn(nullable.unionId) : null; if (nullable) return traceAdapter(host, nullable.arm); if (!host.tracedUnions.has(t.unionId)) return null; host.declare(`declare void @scr_union_trace_v(ptr, ptr, ptr)`); @@ -945,15 +1052,17 @@ export function mapValKindNum(value: IrType): number { export function rcMembers( shape: IrRecordShape, inline: InlineUnionFields, + /** The struct index of the first field (2 on tagged shapes). */ + base = 1, ): { index: number; type: IrType; name: string }[] { return [ ...shape.fields.flatMap((f, i) => - inline.record(f.type) ? [] : [{ index: i + 1, type: f.type, name: f.name }], + inline.record(f.type) ? [] : [{ index: i + base, type: f.type, name: f.name }], ), ...(shape.indexValue ? [ { - index: shape.fields.length + 1, + index: shape.fields.length + base, type: mapOf(STRING, shape.indexValue), name: "[key: string] overflow", }, @@ -1107,7 +1216,12 @@ export function directDestroyTarget( } function directDestroyKey(host: ShapeHost, type: IrType): string | null { - const t = host.nullableUnions.of(type)?.arm ?? type; + const nullable = host.nullableUnions.of(type); + let t = type; + if (nullable !== null) { + if (nullable.repr === "shapes") return null; + t = nullable.arm; + } if (t.kind === "record") return `record:${t.shapeId}`; return t.kind === "object" ? `object:${t.className}` : null; } @@ -1338,8 +1452,12 @@ export function emitRecordShapes( for (const shape of records) { const struct = mangleRecordStruct(shape.id); - const fieldTys = shape.fields.map((f) => llFieldType(f.type, host.inlineUnions.record(f.type))); + const fieldTys: string[] = shape.fields.map((f) => + llFieldType(f.type, host.inlineUnions.record(f.type)), + ); if (shape.indexValue) fieldTys.push("ptr"); // the overflow ScrMap * + // A tagged shape (an arm of a shape union) carries its shape number. + if (host.nullableUnions.shapeNumber(shape.id) !== 0) fieldTys.unshift("i32"); typeDefs.push( `%${struct} = type { ${host.sizeType}${fieldTys.length ? ", " + fieldTys.join(", ") : ""} } ` + `; record ${shape.id} { ${shape.fields.map((f) => llvmCommentText(f.name)).join("; ")}${shape.indexValue ? "; [key: string]" : ""} }`, @@ -1352,7 +1470,7 @@ export function emitRecordShapes( const candidates: [string, DirectDestroy][] = []; const memberTypes: IrType[] = []; for (const shape of records) { - const members = rcMembers(shape, host.inlineUnions); + const members = rcMembers(shape, host.inlineUnions, host.nullableUnions.fieldBase(shape.id)); for (const m of members) memberTypes.push(m.type); if (!members.some((m) => isRefCounted(m.type) && needsBoundedRelease(m.type))) continue; const release = mangleRecordRelease(shape.id); @@ -1370,7 +1488,9 @@ export function emitRecordShapes( for (const shape of records) { const struct = mangleRecordStruct(shape.id); const traced = host.tracedShapes.has(`record:${shape.id}`); - const members = rcMembers(shape, host.inlineUnions); + const base = host.nullableUnions.fieldBase(shape.id); + const shapeNumber = host.nullableUnions.shapeNumber(shape.id); + const members = rcMembers(shape, host.inlineUnions, base); const refMembers = members.filter((m) => isRefCounted(m.type)); const sizeOf = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 1) to ${host.sizeType})`; @@ -1439,6 +1559,11 @@ export function emitRecordShapes( ); nw.push(...emitObjectAlloc(host, sizeOf, traced ? { type: cycType } : null)); nw.push(` store ${host.sizeType} 1, ptr %o`); + if (shapeNumber !== 0) + nw.push( + ` %shp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${SHAPE_WORD_INDEX}`, + ` store i32 ${shapeNumber}, ptr %shp ; shape number`, + ); if (shape.indexValue) { // The overflow map (string-keyed): value handling is type-directed // exactly like shapes.ts's overflowNewC. @@ -1449,7 +1574,7 @@ export function emitRecordShapes( const trace = valKind === 2 ? traceArg(host, v) : "null"; nw.push( ` %ovf = call ptr @scr_map_new(i32 1, i32 ${valKind}, ptr ${host.mapOps(["null", "null", "null", rc.retain, rc.release, trace, "null"])})`, - ` %ovfp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${shape.fields.length + 1}`, + ` %ovfp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${shape.fields.length + base}`, ` store ptr %ovf, ptr %ovfp`, ); } @@ -1465,7 +1590,7 @@ export function emitRecordShapes( ]; let i = 0; for (const field of shape.fields) { - const index = i + 1; + const index = i + base; const inline = host.inlineUnions.record(field.type); const fieldTy = llFieldType(field.type, inline); clone.push( diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts index c03c7fb44..3b9f97aae 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -14,6 +14,7 @@ import type { CallLifetimes } from "./call-lifetimes.js"; import { emitCallExpr, emitConstruction } from "./expr-calls.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { emitRecordLitFields } from "./expr-primitives.js"; +import { SHAPE_WORD_INDEX } from "./nullable-unions.js"; export type StackObjectSource = IrExpr & { kind: "recordLit" | "new" | "call" }; @@ -159,6 +160,17 @@ export function emitStackObject( } } B.line(`store ${host.sizeType} -1, ptr ${slot}`); + if (type.kind === "record") { + // A tagged shape's word names it to every shape union's tag read. + const shapeNumber = host.nullableUnions.shapeNumber(type.shapeId); + if (shapeNumber !== 0) { + const p = B.tmp(); + B.line( + `${p} = getelementptr inbounds ${struct}, ptr ${slot}, i64 0, i32 ${SHAPE_WORD_INDEX}`, + ); + B.line(`store i32 ${shapeNumber}, ptr ${p}`); + } + } if (e.kind === "call") { // The entry writes the result into the slot it receives last. emitCallExpr( diff --git a/packages/compiler/src/backend/llvm/union-repr.ts b/packages/compiler/src/backend/llvm/union-repr.ts index 415aeb851..959f9e51e 100644 --- a/packages/compiler/src/backend/llvm/union-repr.ts +++ b/packages/compiler/src/backend/llvm/union-repr.ts @@ -2,14 +2,19 @@ * helper generators (walkers, dyn). A tagged union is a `%ScrUnion` box; a * nullable union (nullable-unions.ts) is the arm pointer itself: NULL for * its NULL-encoded unit arm and, in `C | null | undefined`, the immortal - * NULLABLE_NULL sentinel for `null`. Every structural read of a union value + * NULLABLE_NULL sentinel for `null`. A shape union's value is likewise the + * record pointer; its tag comes from the record's shape word. Every structural read of a union value * goes through here. */ import type { BlockBuilder } from "./blocks.js"; -import { NULLABLE_NULL, type NullableUnion } from "./nullable-unions.js"; +import { NULLABLE_NULL, shapeTagFn, type PointerUnion } from "./nullable-unions.js"; /** The i32 tag of a union value. */ -export function emitUnionTag(B: BlockBuilder, nullable: NullableUnion | null, u: string): string { +export function emitUnionTag(B: BlockBuilder, nullable: PointerUnion | null, u: string): string { const t = B.tmp(); + if (nullable?.repr === "shapes") { + B.line(`${t} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`); + return t; + } if (nullable) { const isNull = B.tmp(); B.line(`${isNull} = icmp eq ptr ${u}, null`); @@ -32,8 +37,13 @@ export function emitUnionTag(B: BlockBuilder, nullable: NullableUnion | null, u: /** i1: a nullable union value holds its reference arm (neither NULL nor * the `null` sentinel). */ -export function emitNullablePresent(B: BlockBuilder, nullable: NullableUnion, u: string): string { +export function emitNullablePresent(B: BlockBuilder, nullable: PointerUnion, u: string): string { const present = B.tmp(); + if (nullable.repr === "shapes" && nullable.unitTag < 0) { + // No unit arm: every value is a record (NULL only as a pending dummy). + B.line(`${present} = icmp ne ptr ${u}, null`); + return present; + } B.line(`${present} = icmp ne ptr ${u}, null`); if (nullable.nullTag < 0) return present; const notNullArm = B.tmp(), @@ -46,12 +56,18 @@ export function emitNullablePresent(B: BlockBuilder, nullable: NullableUnion, u: /** i1: a nullable union value holds the arm `tag` (`negated`: does not). */ export function emitNullableIsTag( B: BlockBuilder, - nullable: NullableUnion, + nullable: PointerUnion, u: string, tag: number, negated: boolean, ): string { - if (tag === nullable.refTag) { + if (nullable.repr === "shapes" && tag !== nullable.unitTag && tag !== nullable.nullTag) { + const actual = emitUnionTag(B, nullable, u), + t = B.tmp(); + B.line(`${t} = icmp ${negated ? "ne" : "eq"} i32 ${actual}, ${tag}`); + return t; + } + if (nullable.repr === "single" && tag === nullable.refTag) { const present = emitNullablePresent(B, nullable, u); if (!negated) return present; const t = B.tmp(); @@ -65,7 +81,7 @@ export function emitNullableIsTag( } /** The BORROWED payload pointer of a reference arm. */ -export function emitUnionPeek(B: BlockBuilder, nullable: NullableUnion | null, u: string): string { +export function emitUnionPeek(B: BlockBuilder, nullable: PointerUnion | null, u: string): string { if (nullable) return u; const p = B.tmp(); const t = B.tmp(); @@ -76,7 +92,9 @@ export function emitUnionPeek(B: BlockBuilder, nullable: NullableUnion | null, u /** Text-template forms for helpers written as raw LLVM lines: `%out` * receives the tag / payload of `u`. */ -export function unionTagLines(nullable: NullableUnion | null, u: string, out: string): string[] { +export function unionTagLines(nullable: PointerUnion | null, u: string, out: string): string[] { + if (nullable?.repr === "shapes") + return [` ${out} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`]; if (nullable && nullable.nullTag < 0) return [ ` ${out}.isnull = icmp eq ptr ${u}, null`, @@ -95,7 +113,7 @@ export function unionTagLines(nullable: NullableUnion | null, u: string, out: st ]; } -export function unionPeekLines(nullable: NullableUnion | null, u: string, out: string): string[] { +export function unionPeekLines(nullable: PointerUnion | null, u: string, out: string): string[] { // A plain copy of the pointer (NULL included) under the template's name. if (nullable) return [` ${out} = getelementptr i8, ptr ${u}, i64 0`]; return [ diff --git a/packages/compiler/src/backend/llvm/union-retype.test.ts b/packages/compiler/src/backend/llvm/union-retype.test.ts index eca6adcd6..0f0abdb68 100644 --- a/packages/compiler/src/backend/llvm/union-retype.test.ts +++ b/packages/compiler/src/backend/llvm/union-retype.test.ts @@ -183,7 +183,9 @@ test("a nullable-pointer source rebuilds the target box", () => { expect(retag).not.toContain("retype.share"); }); -async function llvmFor(source: string): Promise<{ module: IrModule; ir: string }> { +/** `boxed`: keep every union a tagged box (record unions otherwise become + * shape unions, which retype without any box). */ +async function llvmFor(source: string, boxed = true): Promise<{ module: IrModule; ir: string }> { const dir = await mkdtemp(join(tmpdir(), "scriptc-union-retype-")); try { const entry = join(dir, "main.ts"), @@ -194,7 +196,7 @@ async function llvmFor(source: string): Promise<{ module: IrModule; ir: string } throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); const module = deserializeModule(await readFile(output, "utf8")); expect(validateModule(module)).toEqual([]); - return { module, ir: emitLlvmModule(module) }; + return { module, ir: emitLlvmModule(boxed ? keepUnionsBoxed(module) : module) }; } finally { await rm(dir, { recursive: true, force: true }); } @@ -253,3 +255,16 @@ console.log(leafValue(parsed)); expect(stored).not.toBeNull(); expect(ir).toContain(`load i8, ptr ${stored![1]}`); }); + +test("record unions retype as the record pointer itself", async () => { + const { module, ir } = await llvmFor(VISITOR, false); + const helpers = module.functions.filter((fn) => fn.name.startsWith("%union.retag.")); + expect(helpers.length).toBeGreaterThan(0); + for (const helper of helpers) { + const symbol = `sc_bf__x25_union_retag_${helper.name.slice("%union.retag.".length)}`; + const retag = body(ir, symbol); + expect(retag).not.toMatch(/@scr_union_new|retype\.share|sc_retype_guard/); + } + // No record of the visitor is ever boxed. + expect(ir).not.toMatch(/call ptr @scr_union_new_ref/); +}); diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index e75e1679b..d0593313f 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -287,7 +287,8 @@ export class LlWalkers { throw new InternalCompilerError( `llvm emitter bug: jsonStringify of unknown shape ${shapeId}`, ); - const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1])); + const fieldBase = this.host.nullableUnions.fieldBase(shapeId); + const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + fieldBase])); if (recordMayKeepSource(shape)) this.emitRecordSourceWriter(B, shapeId); // CYCLE-CAPABLE shapes bracket the walk with the circular-detection // stack; edge labels stamp before members whose walk can re-enter — @@ -436,7 +437,8 @@ export class LlWalkers { throw new InternalCompilerError(`llvm emitter bug: field writer of unknown shape ${shapeId}`); const name = `sc_jwf_${this.recordFieldWriters.size}`; this.recordFieldWriters.set(shapeId, name); - const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1])); + const fieldBase = this.host.nullableUnions.fieldBase(shapeId); + const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + fieldBase])); const byName = new Map(shape.fields.map((f) => [f.name, f])); const cyclic = traceAdapter(this.host, { kind: "record", shapeId }) !== null; const order = recordSourceFieldOrder(shape); @@ -529,7 +531,7 @@ export class LlWalkers { const ovfp = B.tmp(); const ovf = B.tmp(); B.line( - `${ovfp} = getelementptr inbounds %${mangleRecordStruct(shape.id)}, ptr %v, i64 0, i32 ${shape.fields.length + 1}`, + `${ovfp} = getelementptr inbounds %${mangleRecordStruct(shape.id)}, ptr %v, i64 0, i32 ${shape.fields.length + this.host.nullableUnions.fieldBase(shape.id)}`, ); B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`); host.declare(`declare ptr @scr_map_keys_js_order(ptr)`); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-record-union-pointers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-record-union-pointers.ts.json new file mode 100644 index 000000000..e6e3632f5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-record-union-pointers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5930-record-union-pointers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-record-union-unit-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-record-union-unit-arms.ts.json new file mode 100644 index 000000000..53d531568 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-record-union-unit-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5931-record-union-unit-arms.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5932-record-union-trees.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5932-record-union-trees.ts.json new file mode 100644 index 000000000..ee4281193 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5932-record-union-trees.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5932-record-union-trees.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-record-union-conversions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-record-union-conversions.ts.json new file mode 100644 index 000000000..a1fcec317 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-record-union-conversions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5933-record-union-conversions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5934-record-union-set-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5934-record-union-set-members.ts.json new file mode 100644 index 000000000..98b0ac1f4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5934-record-union-set-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5934-record-union-set-members.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5930-record-union-pointers.ts b/tests/corpus/5930-record-union-pointers.ts new file mode 100644 index 000000000..01882d730 --- /dev/null +++ b/tests/corpus/5930-record-union-pointers.ts @@ -0,0 +1,79 @@ +// Unions whose arms are all object literals (discriminated unions) hold the +// record itself: construction, narrowing, storage in arrays, maps, fields +// and closures, identity, and printing behave exactly as in Node. +type Shape = + | { kind: "circle"; r: number } + | { kind: "rect"; w: number; h: number } + | { kind: "tri"; b: number; h: number; label: string }; + +function area(s: Shape): number { + switch (s.kind) { + case "circle": + return Math.round(3.14159 * s.r * s.r); + case "rect": + return s.w * s.h; + case "tri": + return 0.5 * s.b * s.h; + } +} + +function make(i: number): Shape { + if (i % 3 === 0) return { kind: "circle", r: i }; + if (i % 3 === 1) return { kind: "rect", w: i, h: i + 1 }; + return { kind: "tri", b: i, h: 2, label: "t" + i }; +} + +const shapes: Shape[] = []; +for (let i = 0; i < 9; i++) shapes.push(make(i)); +let total = 0; +for (const s of shapes) total += area(s); +console.log("total", total); +console.log(shapes.map((s) => s.kind).join(",")); +console.log(shapes.filter((s) => s.kind === "rect").length); + +// Identity: the same record compares equal, a structurally equal one does not. +const first = shapes[0]!; +const again: Shape = shapes[0]!; +const copy: Shape = { kind: "circle", r: 0 }; +console.log(first === again, first === copy, shapes.indexOf(again), shapes.includes(copy)); + +// Mutation through one alias is visible through the union value. +const rect = { kind: "rect" as const, w: 2, h: 3 }; +const asShape: Shape = rect; +rect.w = 10; +console.log(area(asShape), asShape === rect); + +// Fields, maps and closures holding union values. +interface Holder { + current: Shape; + history: Shape[]; +} +const holder: Holder = { current: make(1), history: [] }; +for (let i = 0; i < 4; i++) { + holder.history.push(holder.current); + holder.current = make(i + 5); +} +console.log(holder.current.kind, holder.history.map(area).join(" ")); + +const byName = new Map(); +for (const s of shapes) byName.set(s.kind + area(s), s); +console.log(byName.size, byName.get("rect2")?.kind, byName.get("missing") === undefined); + +const pick = (k: Shape["kind"]): (() => Shape | undefined) => { + const found = shapes.find((s) => s.kind === k); + return () => found; +}; +const getTri = pick("tri"); +const tri = getTri(); +console.log(tri !== undefined && tri.kind === "tri" ? tri.label : "none"); + +// Printing and serialization see the plain objects. +console.log(shapes.slice(0, 3)); +console.log(JSON.stringify(shapes.slice(0, 3))); +const second = shapes[2]!; +console.log(`${String(shapes[1]!.kind)} ${"label" in second} ${"label" in shapes[1]!}`); + +// Spread copies and sorting. +const grown = shapes.map((s): Shape => (s.kind === "circle" ? { ...s, r: s.r + 1 } : s)); +grown.sort((a, b) => area(a) - area(b)); +console.log(grown.map((s) => `${s.kind}:${area(s)}`).join(" ")); diff --git a/tests/corpus/5931-record-union-unit-arms.ts b/tests/corpus/5931-record-union-unit-arms.ts new file mode 100644 index 000000000..1ade8a0a3 --- /dev/null +++ b/tests/corpus/5931-record-union-unit-arms.ts @@ -0,0 +1,67 @@ +// Record unions with undefined and null arms: NULL and the null sentinel +// encode the unit arms, optional properties keep their absent state, and +// narrowing, nullish operators and equality agree with Node. +type Ok = { ok: true; value: number }; +type Err = { ok: false; error: string }; +type Res = Ok | Err; + +function parse(text: string): Res { + const n = Number(text); + return Number.isNaN(n) ? { ok: false, error: `bad ${text}` } : { ok: true, value: n }; +} + +function lookup(table: Res[], i: number): Res | undefined { + return i < table.length ? table[i] : undefined; +} + +function maybe(i: number): Res | null | undefined { + if (i % 3 === 0) return null; + if (i % 3 === 1) return undefined; + return parse(String(i)); +} + +const table = ["1", "x", "3"].map(parse); +for (let i = 0; i < 4; i++) { + const r = lookup(table, i); + if (r === undefined) console.log(i, "missing"); + else if (r.ok) console.log(i, "value", r.value); + else console.log(i, "error", r.error); +} + +for (let i = 0; i < 6; i++) { + const m = maybe(i); + const tag = m === null ? "null" : m === undefined ? "undefined" : m.ok ? "ok" : "err"; + console.log(i, tag, m == null, m ?? "default", typeof m); +} + +// Optional properties of a record-union type. +interface Slot { + name: string; + result?: Res; + last?: Res | null; +} +const slots: Slot[] = [ + { name: "a" }, + { name: "b", result: parse("7") }, + { name: "c", result: parse("q"), last: null }, +]; +for (const s of slots) { + console.log(s.name, "result" in s, s.result?.ok, s.last === null, s.last === undefined); +} +console.log(JSON.stringify(slots)); +slots[0]!.result = table[1]; +slots[1]!.result = undefined; +console.log(slots.map((s) => (s.result === undefined ? "-" : s.result.ok ? "ok" : "err")).join("")); + +// Equality across arms and unit encodings. +const a: Res | undefined = table[0]; +const b: Res | undefined = undefined; +const c: Res | null = null; +console.log(a === table[0], a === b, b === undefined, c === null, (c as Res | null) === table[2]); + +// Unions of records reaching unknown and back. +const values: unknown[] = [table[0], table[1], undefined, null]; +for (const v of values) { + if (v === null || v === undefined) console.log("unit", v); + else console.log("object", JSON.stringify(v)); +} diff --git a/tests/corpus/5932-record-union-trees.ts b/tests/corpus/5932-record-union-trees.ts new file mode 100644 index 000000000..060cadc4b --- /dev/null +++ b/tests/corpus/5932-record-union-trees.ts @@ -0,0 +1,77 @@ +// A recursive discriminated union (an expression tree) whose leaf arm holds +// no references: every arm shares the cycle collector's header, so trees +// can be built, rewritten, shared and dropped in cycles. +type Expr = + | { kind: "num"; value: number } + | { kind: "neg"; arg: Expr } + | { kind: "add"; left: Expr; right: Expr } + | { kind: "ref"; target: { expr: Expr | null } }; + +function evaluate(e: Expr): number { + switch (e.kind) { + case "num": + return e.value; + case "neg": + return -evaluate(e.arg); + case "add": + return evaluate(e.left) + evaluate(e.right); + case "ref": + return e.target.expr === null ? 0 : evaluate(e.target.expr); + } +} + +function build(depth: number, seed: number): Expr { + if (depth === 0) return { kind: "num", value: seed % 10 }; + if (seed % 4 === 0) return { kind: "neg", arg: build(depth - 1, seed + 1) }; + return { kind: "add", left: build(depth - 1, seed * 3 + 1), right: build(depth - 1, seed + 7) }; +} + +function simplify(e: Expr): Expr { + switch (e.kind) { + case "neg": { + const inner = simplify(e.arg); + return inner.kind === "neg" ? inner.arg : inner.kind === "num" ? { kind: "num", value: -inner.value } : { kind: "neg", arg: inner }; + } + case "add": { + const l = simplify(e.left); + const r = simplify(e.right); + if (l.kind === "num" && r.kind === "num") return { kind: "num", value: l.value + r.value }; + return l === e.left && r === e.right ? e : { kind: "add", left: l, right: r }; + } + default: + return e; + } +} + +function show(e: Expr): string { + switch (e.kind) { + case "num": + return String(e.value); + case "neg": + return `-(${show(e.arg)})`; + case "add": + return `(${show(e.left)} + ${show(e.right)})`; + case "ref": + return "ref"; + } +} + +let checksum = 0; +for (let round = 0; round < 50; round++) { + const tree = build(6, round); + const simple = simplify(tree); + checksum += evaluate(tree) * 3 + evaluate(simple); + // A cycle through a record arm: the tree refers back to itself. + const target: { expr: Expr | null } = { expr: null }; + const loop: Expr = { kind: "add", left: tree, right: { kind: "ref", target } }; + target.expr = { kind: "num", value: round }; + checksum += evaluate(loop); + target.expr = loop.left; +} +console.log(checksum); +console.log(show(simplify(build(3, 5)))); +console.log(show(build(2, 4)), evaluate(build(4, 2))); + +const shared: Expr = { kind: "num", value: 4 }; +const twice: Expr = { kind: "add", left: shared, right: shared }; +console.log(evaluate(twice), twice.kind === "add" && twice.left === twice.right); diff --git a/tests/corpus/5933-record-union-conversions.ts b/tests/corpus/5933-record-union-conversions.ts new file mode 100644 index 000000000..80b411a9b --- /dev/null +++ b/tests/corpus/5933-record-union-conversions.ts @@ -0,0 +1,78 @@ +// Record unions meet other representations: widening into unions with +// string and number arms, narrowing back, `in` tests on undiscriminated +// arms, generic helpers, class fields and promises. +type Point = { x: number; y: number }; +type Named = { name: string }; +type Item = Point | Named; +type Wide = Item | string | number; + +function describe(i: Item): string { + return "x" in i ? `point(${i.x},${i.y})` : `named(${i.name})`; +} + +function widen(i: Item): Wide { + return i; +} + +function narrow(w: Wide): Item | undefined { + if (typeof w === "string" || typeof w === "number") return undefined; + return w; +} + +const items: Item[] = [{ x: 1, y: 2 }, { name: "a" }, { x: 3, y: 4 }]; +const wide: Wide[] = items.map(widen); +wide.push("text", 42); +for (const w of wide) { + const back = narrow(w); + console.log(typeof w, back === undefined ? "-" : describe(back), back !== undefined && items.includes(back)); +} + +// Generic helpers instantiate over the union. +function firstWhere(xs: T[], pred: (x: T) => boolean): T | undefined { + for (const x of xs) if (pred(x)) return x; + return undefined; +} +const named = firstWhere(items, (i) => "name" in i); +console.log(named === items[1], named !== undefined ? describe(named) : "none"); +const pair: [Item, Item] = [items[2]!, items[0]!]; +const [p0, p1] = pair; +console.log(describe(p0), describe(p1)); + +// Class fields and methods. +class Cursor { + private at: Item; + readonly seen: Item[] = []; + constructor(start: Item) { + this.at = start; + } + move(next: Item): Item { + const prev = this.at; + this.seen.push(prev); + this.at = next; + return prev; + } + get current(): Item { + return this.at; + } +} +const cursor = new Cursor(items[0]!); +for (const i of items) cursor.move(i); +console.log(describe(cursor.current), cursor.seen.map(describe).join(" ")); + +// Promises carry union payloads. +async function load(i: number): Promise { + await null; + return items[i % items.length]!; +} +async function main(): Promise { + const loaded: Item[] = []; + for (const i of [0, 1, 5]) loaded.push(await load(i)); + console.log(loaded.map(describe).join(" "), loaded[0] === items[0]); + const slow = new Promise((resolve) => setTimeout(() => resolve({ x: 9, y: 9 }), 5)); + const raced = await Promise.race([load(1), slow]); + console.log(describe(raced)); + const pending: Promise[] = [load(2), load(0)]; + const all = await Promise.all(pending); + console.log(all.map(describe).join(" "), all[1] === items[0]); +} +main().then(() => console.log("done")); diff --git a/tests/corpus/5934-record-union-set-members.ts b/tests/corpus/5934-record-union-set-members.ts new file mode 100644 index 000000000..46f838870 --- /dev/null +++ b/tests/corpus/5934-record-union-set-members.ts @@ -0,0 +1,25 @@ +// A record union used as Set elements and Map keys keeps the runtime's +// tagged representation; other unions over the same records convert to and +// from it without changing identity semantics. +type Point = { x: number; y: number }; +type Named = { name: string }; +type Item = Point | Named; + +const items: Item[] = [{ x: 1, y: 2 }, { name: "a" }, { x: 3, y: 4 }]; +const marked = new Set(); +marked.add(items[0]!); +marked.add(items[0]!); +marked.add({ x: 1, y: 2 }); +console.log(marked.size, marked.has(items[0]!), marked.has(items[1]!)); + +const counts = new Map(); +for (const i of [...items, items[0]!, items[2]!]) counts.set(i, (counts.get(i) ?? 0) + 1); +console.log([...counts.values()].join(",")); + +function maybeItem(i: number): Item | undefined { + return i < items.length ? items[i] : undefined; +} +for (let i = 0; i < 4; i++) { + const m = maybeItem(i); + console.log(i, m === undefined ? "none" : "x" in m ? m.x : m.name, m !== undefined && marked.has(m)); +} From d1639298ff7d393fbf127fb07be294464736886a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:39:16 -0700 Subject: [PATCH 02/34] Erase only `import type` edges, following Node's type stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node runs TypeScript with verbatimModuleSyntax semantics: `import type` and `export type … from` vanish, while `import { type A }` and `export { type A } from` keep an empty clause and still load their module. - Package selection skipped nothing: `import type { X } from "type-fest"` selected type-fest and failed with SC2013, and type imports pulled whole packages into the compile. Erased edges no longer select packages. - Files reached only through erased edges are no longer program modules, so their imports (a `node:vm` import, say) no longer fence the build. - Inline type-only clauses are real module edges again (their modules' side effects ran under Node but not in the binary); a cycle closed by one binds nothing and is admitted like a side-effect import. - Interfaces, type aliases and type literals from an installed package's declarations map structurally, so values the program builds with a type-only package's types compile. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/dependencies.mdx | 2 + .../compiler/src/frontend/npm-static-prune.ts | 11 +- packages/compiler/src/frontend/pipeline.ts | 5 + packages/compiler/src/frontend/program.ts | 83 +++++----- packages/compiler/src/frontend/type-mapper.ts | 60 ++++++-- .../src/frontend/type-only-imports.ts | 143 ++++++++++++++++++ .../6120-type-only-import-graph/flags.ts | 2 + .../6120-type-only-import-graph/main.ts | 21 +++ .../6120-type-only-import-graph/mixed.ts | 8 + .../6120-type-only-import-graph/models.ts | 4 + .../node_modules/types-only-pkg/index.d.ts | 6 + .../node_modules/types-only-pkg/package.json | 5 + .../6120-type-only-import-graph/payload.ts | 4 + .../6120-type-only-import-graph/reexports.ts | 2 + .../6120-type-only-import-graph/tsconfig.json | 5 + .../6120-type-only-import-graph/uses-vm.ts | 4 + tests/corpus/6121-type-specifier-edges/a.ts | 10 ++ tests/corpus/6121-type-specifier-edges/b.ts | 7 + .../config-barrel.ts | 2 + .../6121-type-specifier-edges/config.ts | 4 + .../corpus/6121-type-specifier-edges/main.ts | 10 ++ .../node_modules/@types/twinned/index.d.ts | 5 + .../node_modules/@types/twinned/package.json | 5 + .../npm-static/node_modules/twinned/index.js | 9 ++ .../node_modules/twinned/package.json | 5 + tests/fixtures/npm-static/twinned-cli.ts | 5 + tests/fixtures/npm-static/type-only-cli.ts | 7 + tests/harness/npm-static.test.ts | 15 ++ 28 files changed, 382 insertions(+), 67 deletions(-) create mode 100644 packages/compiler/src/frontend/type-only-imports.ts create mode 100644 tests/corpus/6120-type-only-import-graph/flags.ts create mode 100644 tests/corpus/6120-type-only-import-graph/main.ts create mode 100644 tests/corpus/6120-type-only-import-graph/mixed.ts create mode 100644 tests/corpus/6120-type-only-import-graph/models.ts create mode 100644 tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/index.d.ts create mode 100644 tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/package.json create mode 100644 tests/corpus/6120-type-only-import-graph/payload.ts create mode 100644 tests/corpus/6120-type-only-import-graph/reexports.ts create mode 100644 tests/corpus/6120-type-only-import-graph/tsconfig.json create mode 100644 tests/corpus/6120-type-only-import-graph/uses-vm.ts create mode 100644 tests/corpus/6121-type-specifier-edges/a.ts create mode 100644 tests/corpus/6121-type-specifier-edges/b.ts create mode 100644 tests/corpus/6121-type-specifier-edges/config-barrel.ts create mode 100644 tests/corpus/6121-type-specifier-edges/config.ts create mode 100644 tests/corpus/6121-type-specifier-edges/main.ts create mode 100644 tests/fixtures/npm-static/node_modules/@types/twinned/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/@types/twinned/package.json create mode 100644 tests/fixtures/npm-static/node_modules/twinned/index.js create mode 100644 tests/fixtures/npm-static/node_modules/twinned/package.json create mode 100644 tests/fixtures/npm-static/twinned-cli.ts create mode 100644 tests/fixtures/npm-static/type-only-cli.ts diff --git a/docs/content/docs/dependencies.mdx b/docs/content/docs/dependencies.mdx index f93952221..80414ef2a 100644 --- a/docs/content/docs/dependencies.mdx +++ b/docs/content/docs/dependencies.mdx @@ -46,6 +46,8 @@ Your callback and the commander implementation both compile to native code. No e scriptc finds packages in `node_modules` with Node.js's resolution rules, including the export conditions in `package.json`, and compiles their dependencies too. TypeScript workspace packages compile as part of your program, including `.js` import specifiers that resolve to `.ts` sources. +Type-only imports follow Node.js's type stripping. `import type` and `export type … from` statements are erased, so a package or module named only by them supplies types and isn't compiled or loaded; it can even be a package with no JavaScript, such as `type-fest`. A clause whose specifiers are all marked `type`, such as `import { type Options } from "./config.ts"`, still loads its module, as it does under Node.js. + ## Which packages compile scriptc compiles a package automatically when it: diff --git a/packages/compiler/src/frontend/npm-static-prune.ts b/packages/compiler/src/frontend/npm-static-prune.ts index c6c1f9caf..2627e69e2 100644 --- a/packages/compiler/src/frontend/npm-static-prune.ts +++ b/packages/compiler/src/frontend/npm-static-prune.ts @@ -10,6 +10,7 @@ import { nearestPackageType, resolveBareModule } from "./resolve.js"; import { npmStaticPackageOfPath } from "./npm-static.js"; import { trackedReadFile } from "./input-tracker.js"; import { npmPackageNameOf } from "./workspace-registry.js"; +import { typeOnlyNamedImport } from "./type-only-imports.js"; export function isPrunedNpmReexport(program: ts.Program, stmt: ts.ExportDeclaration): boolean { return program.analysis.prunedNpmReexports.has(stmt); @@ -232,7 +233,9 @@ export function planNpmStaticReexports( (clause.namedBindings !== undefined && (ts.isNamespaceImport(clause.namedBindings) || (ts.isNamedImports(clause.namedBindings) && - clause.namedBindings.elements.length === 0))) + clause.namedBindings.elements.length === 0))) || + // `import { type A }` still loads the module, like `import {}`. + typeOnlyNamedImport(stmt) ) { request(dep, null); } else { @@ -244,12 +247,10 @@ export function planNpmStaticReexports( } } } else if (ts.isExportDeclaration(stmt)) { + // `export { type A } from` still loads the module under Node's + // type stripping; only `export type … from` erases. if ( stmt.isTypeOnly || - (stmt.exportClause !== undefined && - ts.isNamedExports(stmt.exportClause) && - stmt.exportClause.elements.length > 0 && - stmt.exportClause.elements.every((element) => element.isTypeOnly)) || stmt.moduleSpecifier === undefined || !ts.isStringLiteral(stmt.moduleSpecifier) ) diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 2ec9553f6..944877d58 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -28,6 +28,7 @@ import type { FrontendServices } from "./services.js"; import { isImportDeclaration, isExportDeclaration, isStringLiteral } from "./ts7/adapter.js"; import { CONTRACT_TYPES_MODULE } from "./contract-types.js"; import { THREADS_MODULE } from "./threads-import.js"; +import { erasedTypeOnlyImport } from "./type-only-imports.js"; import { EXPERIMENTAL_MODULE } from "./experimental-import.js"; export interface ProgramLoadOptions { @@ -107,6 +108,10 @@ function detectAutoPackages( stmt.moduleSpecifier && isStringLiteral(stmt.moduleSpecifier) ) { + // `import type` and `export type … from` erase under Node's type + // stripping: Node never loads the package, so it is no dependency + // to compile (its declarations still type the program). + if (isImportDeclaration(stmt) ? erasedTypeOnlyImport(stmt) : stmt.isTypeOnly) continue; edges.push({ spec: stmt.moduleSpecifier.text, loc: locOf(stmt) }); } else if (mode === "lib") { // CJS packages spell their dep edges as top-level requires; the diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 3a4ec1647..c805d47a7 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -119,6 +119,11 @@ import { import { trackedFileExists, trackedReadFile, trackedRealpath } from "./input-tracker.js"; import { forkTargetPaths } from "./fork-target.js"; import { workerTargetPaths } from "./worker-target.js"; +import { + erasedTypeOnlyImport, + typeOnlyNamedImport, + typeOnlyReachedFiles, +} from "./type-only-imports.js"; import { moduleSourceCandidates } from "./module-source-candidates.js"; import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js"; @@ -1642,7 +1647,7 @@ function insideFunctionLike7(node: ts.Node): boolean { function nodeEsmSyntaxMarker7(sf: ts.SourceFile): ts.Node | null { for (const stmt of sf.statements) { if (ts.isImportDeclaration(stmt) && !erasedTypeOnlyImport(stmt)) return stmt; - if (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly && !erasedTypeOnlyReexport(stmt)) { + if (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly) { return stmt; } if (ts.isExportAssignment(stmt)) return stmt; @@ -2314,6 +2319,7 @@ export function makeCycleAdmission( const clause = e.stmt.importClause; if ( clause === undefined || + typeOnlyNamedImport(e.stmt) || (clause.name === undefined && (clause.namedBindings === undefined || (ts.isNamespaceImport(clause.namedBindings) && @@ -3579,21 +3585,41 @@ function preflight7(load: LoadResult): { npmStaticPackageOfPath(sf.fileName) !== null) && !unselectedPackageJsFile(sf.fileName), ); + // Files reached only through `import type` / `export type … from` are + // checker input, never program modules: Node does not load them, so + // their imports and syntax must not fence the program. + const resolveRuntimeEdge = ( + sf: ts.SourceFile, + spec: string, + resolutionKind?: "import" | "require", + ): ts.SourceFile | null => + resolveImport7(program, sf, spec, resolutionKind) ?? + npmStaticDepSf7(program, sf, spec, resolutionKind); + const typeOnlyFiles = typeOnlyReachedFiles( + program, + entry, + programFiles, + () => [ + ...forkTargetPaths(program, program.getImplementationSourceFiles()), + ...workerTargetPaths(program, program.getImplementationSourceFiles()), + ], + resolveRuntimeEdge, + ); + const runtimeFiles = + typeOnlyFiles.size === 0 ? programFiles : programFiles.filter((sf) => !typeOnlyFiles.has(sf)); const userFiles = npmStaticActive() ? planNpmStaticReexports( program, entry, - programFiles, + runtimeFiles, [ ...createRequireProgramRoots7(program), ...forkTargetPaths(program, program.getImplementationSourceFiles()), ...workerTargetPaths(program, program.getImplementationSourceFiles()), ], - (sf, spec, resolutionKind) => - resolveImport7(program, sf, spec, resolutionKind) ?? - npmStaticDepSf7(program, sf, spec, resolutionKind), + resolveRuntimeEdge, ) - : programFiles; + : runtimeFiles; program.getTypeChecker().prefetchSourceFileStructures(userFiles); // Unselected node_modules JS is not native program source even when @@ -3718,8 +3744,7 @@ function preflight7(load: LoadResult): { continue; } if (ts.isExportDeclaration(stmt)) { - if (stmt.isTypeOnly || erasedTypeOnlyReexport(stmt) || isPrunedNpmReexport(program, stmt)) - continue; + if (stmt.isTypeOnly || isPrunedNpmReexport(program, stmt)) continue; if (!stmt.moduleSpecifier) continue; const fromSpec = ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : ""; if (load.externalTypes.has(fromSpec)) { @@ -4954,42 +4979,6 @@ export function isCheckedJsSourceFile(sf: ts.SourceFile, projectCheckJs: boolean return checked; } -/** `import type …` declarations and named clauses whose every element is - * `type`-qualified are ERASED by tsc at emit — Node's module graph has no - * edge there, so module order, cycle detection, init calls, and the - * startup-refusal probe must not count them either (zod v4's core modules - * are "cyclic" only through `import type * as ns` namespaces — 27 phantom - * SC1016 sites against 1 real value-level cycle; and a type-only import - * of a module Node cannot resolve is NOT a startup crash — Node never - * resolves it). Originated as the provenance-sources pilot's flag-gated - * prototype (SCRIPTC_TYPE_ONLY_EDGES), promoted to always-on with the - * full-suite differential green. Side-effect imports (no clause) stay - * real edges. */ -function erasedTypeOnlyImport(stmt: ts.ImportDeclaration): boolean { - const clause = stmt.importClause; - if (clause === undefined) return false; // side-effect import: a real edge - if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return true; - return ( - clause.name === undefined && - clause.namedBindings !== undefined && - ts.isNamedImports(clause.namedBindings) && - clause.namedBindings.elements.length > 0 && - clause.namedBindings.elements.every((e) => e.isTypeOnly) - ); -} - -/** The re-export twin: `export { type X } from "./m"` with every element - * `type`-qualified erases at emit exactly like `export type { X } from` - * (which stmt.isTypeOnly already covers) — no module edge. */ -function erasedTypeOnlyReexport(stmt: ts.ExportDeclaration): boolean { - return ( - stmt.exportClause !== undefined && - ts.isNamedExports(stmt.exportClause) && - stmt.exportClause.elements.length > 0 && - stmt.exportClause.elements.every((e) => e.isTypeOnly) - ); -} - export function orderedImportsOf( program: ts.Program, sf: ts.SourceFile, @@ -4997,11 +4986,7 @@ export function orderedImportsOf( const out: { stmt: ts.Statement; dep: ts.SourceFile | null }[] = []; for (const stmt of sf.statements) { if (ts.isExportDeclaration(stmt) && isPrunedNpmReexport(program, stmt)) continue; - if ( - ts.isExportDeclaration(stmt) && - (stmt.isTypeOnly || erasedTypeOnlyReexport(stmt) || !stmt.moduleSpecifier) - ) - continue; + if (ts.isExportDeclaration(stmt) && (stmt.isTypeOnly || !stmt.moduleSpecifier)) continue; if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt)) continue; if (ts.isImportDeclaration(stmt) && erasedTypeOnlyImport(stmt)) continue; if (!stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 54fb8f70e..7570a6ce0 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1656,16 +1656,38 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // Declaration-only package types have no compiled implementation. // Primitive and container types retain their structural mappings; types // owned by the standard library or explicit host mappings are handled - // by their dedicated native rules. - const npmSym = widened.getAliasSymbol() ?? widened.getSymbol(); - const npmDecls = npmSym ? checker.declarationsOf(npmSym) : undefined; + // by their dedicated native rules. Pure type shapes — interfaces, type + // aliases, and object or mapped type literals with no value declaration + // merged in — describe data, not an implementation: a program value of + // such a type is one the program built itself (`import type` from a + // types-only package), so it maps structurally like a local declaration. + // Values the package itself would produce stay refused at their import. + const packageOnly = (sym: ts.Symbol | undefined): readonly ts.Node[] | null => { + const decls = sym ? checker.declarationsOf(sym) : undefined; + return decls && + decls.length > 0 && + decls.every((d) => { + const sf = d.getSourceFile(); + return sf.isDeclarationFile && !ctx.isStdlibFile(sf) && !ctx.isExternalTypeFile(sf); + }) + ? decls + : null; + }; + const pureTypeShape = (decls: readonly ts.Node[] | null): boolean => + decls === null || + decls.every( + (d) => + packageTypeFile(d.getSourceFile(), ctx) && + (ts.isInterfaceDeclaration(d) || + ts.isTypeAliasDeclaration(d) || + ts.isTypeLiteralNode(d) || + ts.isMappedTypeNode(d)), + ); + const aliasDecls = packageOnly(widened.getAliasSymbol()); + const ownDecls = packageOnly(widened.getSymbol()); if ( - npmDecls && - npmDecls.length > 0 && - npmDecls.every((d) => { - const sf = d.getSourceFile(); - return sf.isDeclarationFile && !ctx.isStdlibFile(sf) && !ctx.isExternalTypeFile(sf); - }) + (aliasDecls !== null || (widened.getAliasSymbol() === undefined && ownDecls !== null)) && + !(pureTypeShape(aliasDecls) && pureTypeShape(ownDecls)) ) { return null; } @@ -5167,6 +5189,17 @@ function isMappedShape(t: ts.Type): boolean { ); } +/** A declaration file an installed package ships (its own or an @types + * twin), outside the standard library and @types/node: the type surface + * of a package the program does not compile. Its pure type shapes — + * interfaces, aliases, type literals — describe data a program can build + * itself (`import type` from a types-only package such as type-fest). */ +function packageTypeFile(sf: ts.SourceFile, ctx: Pick): boolean { + return ( + sf.isDeclarationFile && /[\\/]node_modules[\\/]/.test(sf.fileName) && !ctx.isStdlibFile(sf) + ); +} + /** The record path's provenance fence. Declared shapes (object literals, * interfaces, type literals) must come from user code or an explicitly * mapped external type surface, never an arbitrary .d.ts — the empty @@ -5205,13 +5238,14 @@ function recordProvenanceOk(t: ts.Type, ctx: TypeMapperCtx): boolean { .every( (decl) => !decl.getSourceFile().isDeclarationFile || - ctx.isExternalTypeFile(decl.getSourceFile()), + ctx.isExternalTypeFile(decl.getSourceFile()) || + packageTypeFile(decl.getSourceFile(), ctx), ), ); } return !decls.some((d) => { const sf = d.getSourceFile(); - return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf); + return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf) && !packageTypeFile(sf, ctx); }); } @@ -5668,7 +5702,7 @@ function mapRecordTypeInner( computed && checker.declarationsOf(p).some((d) => { const sf = d.getSourceFile(); - return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf); + return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf) && !packageTypeFile(sf, ctx); }) ) { return null; @@ -5995,7 +6029,7 @@ export function describeRecordMemberBlocker(widened: ts.Type, ctx: TypeMapperCtx computed && checker.declarationsOf(p).some((d) => { const sf = d.getSourceFile(); - return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf); + return sf.isDeclarationFile && !ctx.isExternalTypeFile(sf) && !packageTypeFile(sf, ctx); }) ) { return null; diff --git a/packages/compiler/src/frontend/type-only-imports.ts b/packages/compiler/src/frontend/type-only-imports.ts new file mode 100644 index 000000000..003d4ccf7 --- /dev/null +++ b/packages/compiler/src/frontend/type-only-imports.ts @@ -0,0 +1,143 @@ +/** Type-only module edges under Node's type stripping. + * + * Node runs TypeScript by erasing types in place, with the semantics of + * `verbatimModuleSyntax`: `import type …` and `export type … from` vanish, + * but `import { type A } from "./m"` and `export { type A } from "./m"` + * keep an empty clause (`import {} from "./m"`) and still load and + * evaluate `./m`. Only the erased forms are absent from Node's module + * graph. */ +import * as ts from "./ts7/adapter.js"; +import { moduleSourceCandidates } from "./module-source-candidates.js"; + +/** `import type …` (default, named, or namespace): erased by Node, so + * module order, cycle detection, init calls, package selection, and the + * startup-refusal probe must not count it (zod v4's core modules are + * "cyclic" only through `import type * as ns` namespaces, and a + * type-only import of a module Node cannot resolve is not a startup + * crash). Side-effect imports and inline `type` specifiers stay real + * edges. */ +export function erasedTypeOnlyImport(stmt: ts.ImportDeclaration): boolean { + return stmt.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword; +} + +/** `import { type A, type B } from "./m"`: a real module edge that binds + * nothing at runtime, exactly like a side-effect import. */ +export function typeOnlyNamedImport(stmt: ts.ImportDeclaration): boolean { + const clause = stmt.importClause; + return ( + clause !== undefined && + clause.phaseModifier !== ts.SyntaxKind.TypeKeyword && + clause.name === undefined && + clause.namedBindings !== undefined && + ts.isNamedImports(clause.namedBindings) && + clause.namedBindings.elements.length > 0 && + clause.namedBindings.elements.every((e) => e.isTypeOnly) + ); +} + +/** One module reference of a program file: its specifier and whether + * Node erases it. */ +interface ModuleReference { + spec: string; + erased: boolean; + kind?: "import" | "require"; +} + +function declarationReferencesOf(sf: ts.SourceFile): ModuleReference[] { + const refs: ModuleReference[] = []; + for (const stmt of sf.statements) { + if (ts.isImportDeclaration(stmt)) { + if (ts.isStringLiteral(stmt.moduleSpecifier)) + refs.push({ spec: stmt.moduleSpecifier.text, erased: erasedTypeOnlyImport(stmt) }); + } else if (ts.isExportDeclaration(stmt)) { + if (stmt.moduleSpecifier !== undefined && ts.isStringLiteral(stmt.moduleSpecifier)) + refs.push({ spec: stmt.moduleSpecifier.text, erased: stmt.isTypeOnly }); + } else if ( + ts.isImportEqualsDeclaration(stmt) && + ts.isExternalModuleReference(stmt.moduleReference) && + ts.isStringLiteralLike(stmt.moduleReference.expression) + ) { + refs.push({ + spec: stmt.moduleReference.expression.text, + erased: stmt.isTypeOnly, + kind: "require", + }); + } + } + return refs; +} + +/** Every call with a leading string literal counts as a loading edge — + * require(), import(), createRequire'd require functions, and loader + * helpers alike. Over-counting only keeps a file in the graph. */ +function callReferencesOf(program: ts.Program, sf: ts.SourceFile): ModuleReference[] { + const refs: ModuleReference[] = []; + for (const node of moduleSourceCandidates(program, sf).calls) { + const arg = node.arguments[0]; + if (arg === undefined || !ts.isStringLiteralLike(arg)) continue; + refs.push({ + spec: arg.text, + erased: false, + kind: node.expression.kind === ts.SyntaxKind.ImportKeyword ? "import" : "require", + }); + } + return refs; +} + +/** The program files Node never loads because the only references that + * reach them are erased type-only forms (directly or through other such + * files). A file referenced by nothing at all (a program root, or a file + * reached through a reference this walk does not model, such as an + * `import("./m")` type query) keeps its place, as do `extraRoots` (fork + * and worker targets). Empty when no erased reference resolves to a + * program file — the common case pays one statement scan per file. */ +export function typeOnlyReachedFiles( + program: ts.Program, + entry: ts.SourceFile, + files: readonly ts.SourceFile[], + extraRoots: () => readonly string[], + resolveEdge: ( + from: ts.SourceFile, + spec: string, + resolutionKind?: "import" | "require", + ) => ts.SourceFile | null, +): Set { + const available = new Set(files); + const declarationRefs = new Map(files.map((sf) => [sf, declarationReferencesOf(sf)] as const)); + const target = (sf: ts.SourceFile, ref: ModuleReference): ts.SourceFile | null => { + const dep = resolveEdge(sf, ref.spec, ref.kind); + return dep === null || dep === sf || !available.has(dep) ? null : dep; + }; + if ( + !files.some((sf) => + declarationRefs.get(sf)!.some((ref) => ref.erased && target(sf, ref) !== null), + ) + ) + return new Set(); + const valueEdges = new Map(); + const referenced = new Set(); + for (const sf of files) { + const deps: ts.SourceFile[] = []; + valueEdges.set(sf, deps); + for (const ref of [...declarationRefs.get(sf)!, ...callReferencesOf(program, sf)]) { + const dep = target(sf, ref); + if (dep === null) continue; + referenced.add(dep); + if (!ref.erased) deps.push(dep); + } + } + const roots = files.filter((sf) => sf === entry || !referenced.has(sf)); + for (const path of extraRoots()) { + const sf = program.getSourceFile(path); + if (sf !== undefined && available.has(sf)) roots.push(sf); + } + const reached = new Set(); + const queue = [...roots]; + while (queue.length > 0) { + const sf = queue.pop()!; + if (reached.has(sf)) continue; + reached.add(sf); + for (const dep of valueEdges.get(sf) ?? []) if (!reached.has(dep)) queue.push(dep); + } + return new Set(files.filter((sf) => !reached.has(sf))); +} diff --git a/tests/corpus/6120-type-only-import-graph/flags.ts b/tests/corpus/6120-type-only-import-graph/flags.ts new file mode 100644 index 000000000..5af639c0a --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/flags.ts @@ -0,0 +1,2 @@ +export type Flag = boolean; +console.log("flags evaluated"); diff --git a/tests/corpus/6120-type-only-import-graph/main.ts b/tests/corpus/6120-type-only-import-graph/main.ts new file mode 100644 index 000000000..251ce6809 --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/main.ts @@ -0,0 +1,21 @@ +// Node's type stripping erases `import type` and `export type … from`, so +// the modules and packages they name are never loaded: a package with no +// runtime entry, a module importing a builtin scriptc does not compile, and +// modules with load-time output. An inline `type` specifier keeps an empty +// import clause and still evaluates its module, as does a mixed clause. +import type { Frozen, Shape } from "types-only-pkg"; +import type { Label } from "./uses-vm.ts"; +import type * as Models from "./models.ts"; +import type { Reexported } from "./reexports.ts"; +import { type Mode, describe } from "./mixed.ts"; +import { type Flag } from "./flags.ts"; + +const shape: Shape = { width: 3, height: 4 }; +const frozen: Frozen = { width: 5, height: 6 }; +const label: Label = "area"; +const model: Models.Model = { id: 1 }; +const payload: Reexported = { ok: true }; +const mode: Mode = "fast"; +const flag: Flag = true; +console.log(label, shape.width * shape.height, model.id, payload.ok); +console.log(describe(mode), flag, frozen.width + frozen.height); diff --git a/tests/corpus/6120-type-only-import-graph/mixed.ts b/tests/corpus/6120-type-only-import-graph/mixed.ts new file mode 100644 index 000000000..ec5a08bbb --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/mixed.ts @@ -0,0 +1,8 @@ +export type { Payload } from "./payload.ts"; + +export type Mode = "fast" | "slow"; + +export function describe(mode: Mode): string { + return `mode=${mode}`; +} +console.log("mixed evaluated"); diff --git a/tests/corpus/6120-type-only-import-graph/models.ts b/tests/corpus/6120-type-only-import-graph/models.ts new file mode 100644 index 000000000..195cf9a17 --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/models.ts @@ -0,0 +1,4 @@ +export interface Model { + id: number; +} +console.log("models evaluated"); diff --git a/tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/index.d.ts b/tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/index.d.ts new file mode 100644 index 000000000..e9c1b5497 --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/index.d.ts @@ -0,0 +1,6 @@ +export interface Shape { + width: number; + height: number; +} + +export type Frozen = { readonly [K in keyof T]: T[K] }; diff --git a/tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/package.json b/tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/package.json new file mode 100644 index 000000000..c2be952b9 --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/node_modules/types-only-pkg/package.json @@ -0,0 +1,5 @@ +{ + "name": "types-only-pkg", + "version": "1.0.0", + "types": "./index.d.ts" +} diff --git a/tests/corpus/6120-type-only-import-graph/payload.ts b/tests/corpus/6120-type-only-import-graph/payload.ts new file mode 100644 index 000000000..beb97c5ac --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/payload.ts @@ -0,0 +1,4 @@ +export interface Payload { + ok: boolean; +} +console.log("payload evaluated"); diff --git a/tests/corpus/6120-type-only-import-graph/reexports.ts b/tests/corpus/6120-type-only-import-graph/reexports.ts new file mode 100644 index 000000000..52e86f294 --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/reexports.ts @@ -0,0 +1,2 @@ +export type { Payload as Reexported } from "./payload.ts"; +console.log("reexports evaluated"); diff --git a/tests/corpus/6120-type-only-import-graph/tsconfig.json b/tests/corpus/6120-type-only-import-graph/tsconfig.json new file mode 100644 index 000000000..aee0ec940 --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "strict": true + } +} diff --git a/tests/corpus/6120-type-only-import-graph/uses-vm.ts b/tests/corpus/6120-type-only-import-graph/uses-vm.ts new file mode 100644 index 000000000..bed6646bf --- /dev/null +++ b/tests/corpus/6120-type-only-import-graph/uses-vm.ts @@ -0,0 +1,4 @@ +import * as vm from "node:vm"; + +export type Label = string; +console.log("uses-vm evaluated", vm.runInThisContext("1 + 1")); diff --git a/tests/corpus/6121-type-specifier-edges/a.ts b/tests/corpus/6121-type-specifier-edges/a.ts new file mode 100644 index 000000000..59d6385da --- /dev/null +++ b/tests/corpus/6121-type-specifier-edges/a.ts @@ -0,0 +1,10 @@ +import { b } from "./b.ts"; + +export interface AInfo { + tag: string; +} + +export function start(): string { + return `a+${b()}`; +} +console.log("a evaluated"); diff --git a/tests/corpus/6121-type-specifier-edges/b.ts b/tests/corpus/6121-type-specifier-edges/b.ts new file mode 100644 index 000000000..a71f62def --- /dev/null +++ b/tests/corpus/6121-type-specifier-edges/b.ts @@ -0,0 +1,7 @@ +import { type AInfo } from "./a.ts"; + +export function b(): string { + const info: AInfo = { tag: "b" }; + return info.tag; +} +console.log("b evaluated"); diff --git a/tests/corpus/6121-type-specifier-edges/config-barrel.ts b/tests/corpus/6121-type-specifier-edges/config-barrel.ts new file mode 100644 index 000000000..dc461f654 --- /dev/null +++ b/tests/corpus/6121-type-specifier-edges/config-barrel.ts @@ -0,0 +1,2 @@ +export { type Config } from "./config.ts"; +console.log("barrel evaluated"); diff --git a/tests/corpus/6121-type-specifier-edges/config.ts b/tests/corpus/6121-type-specifier-edges/config.ts new file mode 100644 index 000000000..1a42a9093 --- /dev/null +++ b/tests/corpus/6121-type-specifier-edges/config.ts @@ -0,0 +1,4 @@ +export interface Config { + name: string; +} +console.log("config evaluated"); diff --git a/tests/corpus/6121-type-specifier-edges/main.ts b/tests/corpus/6121-type-specifier-edges/main.ts new file mode 100644 index 000000000..23855b470 --- /dev/null +++ b/tests/corpus/6121-type-specifier-edges/main.ts @@ -0,0 +1,10 @@ +// An inline `type` specifier keeps its module request under Node's type +// stripping: `export { type X } from` and `import { type X } from` both +// evaluate the named module, once, in source order. A cycle closed only by +// such an import binds nothing at runtime and runs like a side-effect +// import. +import { type Config } from "./config-barrel.ts"; +import { start } from "./a.ts"; + +const config: Config = { name: "demo" }; +console.log("main", config.name, start()); diff --git a/tests/fixtures/npm-static/node_modules/@types/twinned/index.d.ts b/tests/fixtures/npm-static/node_modules/@types/twinned/index.d.ts new file mode 100644 index 000000000..ef7c0d6c6 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/@types/twinned/index.d.ts @@ -0,0 +1,5 @@ +export interface Greeting { + text: string; +} + +export default function wait(text: string): string; diff --git a/tests/fixtures/npm-static/node_modules/@types/twinned/package.json b/tests/fixtures/npm-static/node_modules/@types/twinned/package.json new file mode 100644 index 000000000..2bdbbfa6e --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/@types/twinned/package.json @@ -0,0 +1,5 @@ +{ + "name": "@types/twinned", + "version": "1.0.0", + "types": "index.d.ts" +} diff --git a/tests/fixtures/npm-static/node_modules/twinned/index.js b/tests/fixtures/npm-static/node_modules/twinned/index.js new file mode 100644 index 000000000..0aa0a79f0 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/twinned/index.js @@ -0,0 +1,9 @@ +// A CommonJS package without its own declarations: its types live in the +// separate @types/twinned package, and it depends on the untyped ms. +'use strict'; + +const ms = require('ms'); + +module.exports = function wait(text) { + return 'twinned:' + text + ':' + ms(90000); +}; diff --git a/tests/fixtures/npm-static/node_modules/twinned/package.json b/tests/fixtures/npm-static/node_modules/twinned/package.json new file mode 100644 index 000000000..14cce5ab2 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/twinned/package.json @@ -0,0 +1,5 @@ +{ + "name": "twinned", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/twinned-cli.ts b/tests/fixtures/npm-static/twinned-cli.ts new file mode 100644 index 000000000..e24383184 --- /dev/null +++ b/tests/fixtures/npm-static/twinned-cli.ts @@ -0,0 +1,5 @@ +// twinned's declarations come from @types/twinned; the package itself and +// its untyped ms dependency ship JavaScript only. +import wait from "twinned"; + +console.log(wait("ready")); diff --git a/tests/fixtures/npm-static/type-only-cli.ts b/tests/fixtures/npm-static/type-only-cli.ts new file mode 100644 index 000000000..9fd332331 --- /dev/null +++ b/tests/fixtures/npm-static/type-only-cli.ts @@ -0,0 +1,7 @@ +// `import type` erases under Node's type stripping: twinned is never +// loaded, so no package is selected or refused, and its declared +// interface types a value the program builds itself. +import type { Greeting } from "twinned"; + +const greeting: Greeting = { text: "hello" }; +console.log(greeting.text); diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 9324d1c7f..398c35b8a 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -791,6 +791,21 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 120_000); + test("type-only imports select no package", async () => { + const entry = join(pilotRoot, "type-only-cli.ts"); + const { coverage } = analyze(entry); + expect(coverage.npmStatic ?? []).toEqual([]); + expect(coverage.preflightFailed).toBe(false); + expect(coverage.diagnostics).toEqual([]); + const binary = await buildStatic(entry, []); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, 180_000); + test("ms supports invalid duration strings without runtime fences", () => { const { coverage } = analyze(join(pilotRoot, "ms-invalid-cli.ts"), { npmStatic: ["ms"] }); expect(coverage.npmStatic).toEqual([{ package: "ms", status: "static" }]); From e79380ce17172766b583dc616c43f98d52bd7492 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:39:21 -0700 Subject: [PATCH 03/34] Name the runtime package in npm refusals and add transitive --npm-static - A package typed only by an @types twin was judged under the twin's name and refused with "its import resolves to '@types/x/index.d.ts', not to runtime JavaScript". Selection now resolves the runtime package, and the refusal names it with the --npm-static option that compiles it anyway. - Refusals the declaration and shipped-JS heuristics make carry the same remedy as a diagnostic hint; a dependency of a named package suggests the transitive form. - `--npm-static=pkg...` (and `"pkg..."` in a library profile) selects the package and every package its files import, nested require() calls included, without the eligibility heuristics. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/cli.mdx | 2 +- docs/content/docs/dependencies.mdx | 4 +- packages/compiler/src/cli/usage.ts | 3 +- packages/compiler/src/coverage/report.ts | 2 + .../compiler/src/diagnostics/diagnostic.ts | 11 +- packages/compiler/src/executable/prepare.ts | 1 + packages/compiler/src/frontend/analysis.ts | 1 + packages/compiler/src/frontend/pipeline.ts | 134 +++++++++++++++--- tests/harness/npm-static.test.ts | 47 ++++++ 9 files changed, 179 insertions(+), 26 deletions(-) diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index a6f75c6d3..3180d1fa4 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -119,7 +119,7 @@ Verifies and loads the installed precompiled runtime objects and vendored librar
On Linux, when the program calls process.exit(), hand the release of its memory to a helper process so the parent sees the exit sooner. The kernel otherwise frees the whole address space before the exit is reported, which takes tens of milliseconds per gigabyte. Opt in only where the trade-offs are acceptable: for up to about 100 ms after the exit, the helper keeps the executable mapped (overwriting the binary in place fails with "text file busy"), its memory stays charged to the cgroup, and the teardown CPU time no longer appears in the parent's resource usage. Valid only for executable builds; it has no effect on other platforms.
--npm-static <pkg[,pkg…]|auto>
-
Name npm packages to compile even if they don't ship their own type declarations (repeatable). auto uses the default automatic selection. Packages that can't compile produce diagnostics. See npm Dependencies.
+
Name npm packages to compile even if they don't ship their own type declarations (repeatable). A trailing ..., as in fs-extra..., also names every package that package depends on. auto uses the default automatic selection. Packages that can't compile produce diagnostics. See npm Dependencies.
--provenance-sources
Experimental. Compile npm dependencies from the source at the commit their provenance attestation names. Packages without a usable attestation compile from their installed files.
diff --git a/docs/content/docs/dependencies.mdx b/docs/content/docs/dependencies.mdx index 80414ef2a..c611fa1d5 100644 --- a/docs/content/docs/dependencies.mdx +++ b/docs/content/docs/dependencies.mdx @@ -55,7 +55,7 @@ scriptc compiles a package automatically when it: - ships its own type declarations, and - ships readable, unminified JavaScript. -A package that doesn't meet these requirements, or that uses something scriptc can't compile, gets a package-specific diagnostic that explains why. The usual fixes are to switch to a version or alternative package that compiles, or to replace the call with code you write yourself. +A package that doesn't meet these requirements, or that uses something scriptc can't compile, gets a package-specific diagnostic that explains why. The usual fixes are to switch to a version or alternative package that compiles, or to replace the call with code you write yourself. A package whose declarations come from a separate `@types/` package doesn't ship its own; its diagnostic names the package and the `--npm-static` option that compiles it anyway. @@ -69,7 +69,7 @@ Some packages rely on separately installed declarations, such as an `@types/` pa $ scriptc build app.ts --npm-static=three -o app >/dev/null ``` -Naming a package skips the declaration requirement above, but the package still has to compile. `--npm-static=auto` uses the default automatic selection. The option accepts a comma-separated list and can be repeated. +Naming a package skips the declaration requirement above, but the package still has to compile. The option selects only the packages it names: a dependency of a named package that doesn't ship its own declarations is still refused. Add a trailing `...` to select a package together with every package it depends on, for example `--npm-static=fs-extra...`. `--npm-static=auto` uses the default automatic selection. The option accepts a comma-separated list and can be repeated. ## Unsupported code inside a package diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index a0388374b..cf099b5fd 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -57,7 +57,8 @@ Options: compile the named npm packages' shipped JS statically as program modules (repeatable; "auto" opts in every eligible direct import: own .d.ts, unminified JS, no build-transform - markers). Packages that fail preflight are refused with a + markers; "pkg..." also names every package pkg depends + on). Packages that fail preflight are refused with a diagnostic. Eligible dependencies compile automatically. --provenance-sources EXPERIMENTAL: compile npm dependencies from their diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 9d0776d9a..e8d81ac01 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -61,6 +61,8 @@ export interface NpmStaticStatus { status: "static" | "refused"; /** The first refusal reason for a refused package. */ detail?: string; + /** How to act on the refusal, when there is a specific remedy. */ + hint?: string; } const GREEN = "\x1b[32m"; diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 9a7b4a37a..1902f6b18 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -877,13 +877,20 @@ export function unsupportedPackageValueDiag(pkg: string, loc: SrcLoc): ScrDiagno ); } -export function npmStaticRefusalDiag(pkg: string, detail: string, loc: SrcLoc): ScrDiagnostic { +export function npmStaticRefusalDiag( + pkg: string, + detail: string, + loc: SrcLoc, + hint?: string, +): ScrDiagnostic { return { code: "SC2013", message: `cannot compile npm package '${pkg}' statically: ${detail}`, loc, milestone: "later", - hint: "use a dependency with supported source, or replace the unsupported operation; scriptc has no interpreter fallback", + hint: + hint ?? + "use a dependency with supported source, or replace the unsupported operation; scriptc has no interpreter fallback", }; } diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index f6012dd2a..ead13afb8 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -59,6 +59,7 @@ export function prepareExecutableModule( entry.package, entry.detail ?? "its static compilation was refused", fe.npmImportSites.get(entry.package) ?? { file: entryPath, start: 0, end: 0 }, + entry.hint, ), ), ); diff --git a/packages/compiler/src/frontend/analysis.ts b/packages/compiler/src/frontend/analysis.ts index b38a267fa..8435710c2 100644 --- a/packages/compiler/src/frontend/analysis.ts +++ b/packages/compiler/src/frontend/analysis.ts @@ -58,6 +58,7 @@ export function analyzeWithFrontend( entry.package, entry.detail ?? "its static compilation was refused", fe.npmImportSites.get(entry.package) ?? { file: entryPath, start: 0, end: 0 }, + entry.hint, ), ), ]; diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 944877d58..20df75c9b 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -25,7 +25,15 @@ import { isJsSourceFileName } from "./tsc-codes.js"; import { isRelativeSpecifier, packageNameOfSpecifier } from "./workspace-registry.js"; import { lowerToIr, type LowerOptions, type LowerResult } from "./lowering/lowerer.js"; import type { FrontendServices } from "./services.js"; -import { isImportDeclaration, isExportDeclaration, isStringLiteral } from "./ts7/adapter.js"; +import { + isIdentifier, + isImportDeclaration, + isExportDeclaration, + isStringLiteral, + isStringLiteralLike, + SyntaxKind, +} from "./ts7/adapter.js"; +import { moduleSourceCandidates } from "./module-source-candidates.js"; import { CONTRACT_TYPES_MODULE } from "./contract-types.js"; import { THREADS_MODULE } from "./threads-import.js"; import { erasedTypeOnlyImport } from "./type-only-imports.js"; @@ -92,15 +100,36 @@ function detectAutoPackages( mode: "auto" | "lib" = "auto", judged?: Set, sites?: Map, + transitive: Set = new Set(), ): string[] { + const chosen: string[] = []; // package → the resolved types file AND the file whose import found it: // the runtime-JS probe below must resolve from the SAME importing file, // or a package visible only to a nested package.json realm (a pnpm // monorepo's packages/*/node_modules, unreachable from the entry's own // walk-up) answers "no runtime JS" for perfectly ordinary installs. - const seen = new Map(); - for (const sf of [...load.moduleOrder, load.entry]) { + const seen = new Map< + string, + { typesFile: string; fromFile: string; spec: string; owner: string | null } + >(); + // A transitive package's files outside the module order (reached through + // nested require() calls the graph walk does not model) still name its + // dependencies. + const scanned = new Set([...load.moduleOrder, load.entry]); + const transitiveFiles = + transitive.size === 0 + ? [] + : load.program.getImplementationSourceFiles().filter((sf) => { + const pkg = npmStaticPackageOfPath(sf.fileName); + return !scanned.has(sf) && pkg !== null && transitive.has(pkg); + }); + for (const sf of [...scanned, ...transitiveFiles]) { if (mode === "auto" && sf.fileName.includes("/node_modules/")) continue; + // `--npm-static=pkg...` opts pkg's whole dependency closure in: every + // package its compiled files import is selected the same explicit way, + // without the declaration eligibility bar. + const owner = npmStaticPackageOfPath(sf.fileName); + const viaTransitive = owner !== null && transitive.has(owner); const edges: { spec: string; loc: SrcLoc }[] = []; for (const stmt of sf.statements) { if ( @@ -113,12 +142,26 @@ function detectAutoPackages( // to compile (its declarations still type the program). if (isImportDeclaration(stmt) ? erasedTypeOnlyImport(stmt) : stmt.isTypeOnly) continue; edges.push({ spec: stmt.moduleSpecifier.text, loc: locOf(stmt) }); - } else if (mode === "lib") { + } else if (mode === "lib" && !viaTransitive) { // CJS packages spell their dep edges as top-level requires; the // import-statement scan alone would miss every one of them. for (const req of requiresOf(stmt)) edges.push({ spec: req.spec, loc: locOf(req.node) }); } } + // Transitive selection counts every literal require() and import() + // anywhere in the package, nested ones included. + if (viaTransitive) { + for (const call of moduleSourceCandidates(load.program, sf).calls) { + const arg = call.arguments[0]; + if ( + arg !== undefined && + isStringLiteralLike(arg) && + (call.expression.kind === SyntaxKind.ImportKeyword || + (isIdentifier(call.expression) && call.expression.text === "require")) + ) + edges.push({ spec: arg.text, loc: locOf(call) }); + } + } for (const { spec, loc } of edges) { if (load.externalTypes?.has(spec)) continue; // scriptc's own packages are not npm dependencies: contract types are @@ -142,28 +185,49 @@ function detectAutoPackages( if (judged?.has(packageNameOfSpecifier(spec))) continue; const npm = resolveNpmImport(sf.fileName, spec); if (npm !== null && isNodeTypesPath(npm.typesFile)) continue; - if (npm === null) { - if (mode !== "lib") continue; - const js = resolveBareModule(sf.fileName, spec, "js-only"); - if (js === null || judged!.has(js.packageName)) continue; - judged!.add(js.packageName); - sites!.set(js.packageName, loc); + // The runtime package Node loads. Types-first resolution answers an + // @types twin's declarations for a package that ships none, but + // selection and every refusal name the package that has the code. + const js = + mode === "lib" && (npm === null || npm.packageName.startsWith("@types/")) + ? resolveBareModule(sf.fileName, spec, "js-only") + : null; + const runtimePkg = js !== null && js.packageName !== npm?.packageName ? js.packageName : null; + if (npm === null || runtimePkg !== null) { + if (mode !== "lib" || runtimePkg === null || judged!.has(runtimePkg)) continue; + judged!.add(runtimePkg); + sites!.set(runtimePkg, loc); + if (viaTransitive) { + transitive.add(runtimePkg); + chosen.push(runtimePkg); + continue; + } statuses.push({ - package: js.packageName, + package: runtimePkg, status: "refused", - detail: "it ships no own .d.ts declaration surface", + detail: + npm === null + ? "it ships no own .d.ts declaration surface" + : `its declared types come from the separate ${npm.packageName} package, not the package itself`, + hint: npmStaticOptInHint(runtimePkg, owner), }); continue; } if (judged?.has(npm.packageName)) continue; + if (viaTransitive) { + judged!.add(npm.packageName); + sites!.set(npm.packageName, loc); + transitive.add(npm.packageName); + chosen.push(npm.packageName); + continue; + } if (!seen.has(npm.packageName)) { - seen.set(npm.packageName, { typesFile: npm.typesFile, fromFile: sf.fileName, spec }); + seen.set(npm.packageName, { typesFile: npm.typesFile, fromFile: sf.fileName, spec, owner }); sites?.set(npm.packageName, loc); } } } - const chosen: string[] = []; - for (const [pkg, { typesFile, fromFile, spec }] of seen) { + for (const [pkg, { typesFile, fromFile, spec, owner }] of seen) { judged?.add(pkg); // A package without a root entry (no "main", "exports" or index file) // can still serve the subpath the program imports. @@ -180,11 +244,23 @@ function detectAutoPackages( package: pkg, status: "refused", detail: mode === "lib" ? reason : `auto: ${reason}`, + // Naming a package bypasses the declaration and shipped-JS + // heuristics; it cannot supply JavaScript that does not resolve. + ...(mode === "lib" && jsEntry !== null ? { hint: npmStaticOptInHint(pkg, owner) } : {}), }); } return chosen; } +/** How to compile a package the declaration bar refused anyway: name it, + * or (for a dependency of an opted-in package) opt in that package's + * whole dependency closure. */ +function npmStaticOptInHint(pkg: string, importer: string | null): string { + return importer === null + ? `to compile it anyway, name it with --npm-static=${pkg} (or --npm-static=${pkg}... to include its dependencies)` + : `to compile it anyway, name it with --npm-static=${pkg}, or use --npm-static=${importer}... to include all of ${importer}'s dependencies`; +} + /** The opted-in packages a consumer-anchored tsc message NAMES: module * specifiers in `Module '"spec"'` phrasings, and resolved file paths in * `import("…")` type spellings — the two ways the checker points at an @@ -232,9 +308,23 @@ export function runFrontend( // selections bypass eligibility heuristics, but never compilation checks. const explicit: readonly string[] = npmStatic !== undefined && typeof npmStatic !== "string" ? npmStatic : []; - return loadFrontend(entryPath, trackedLoader, "lib", externalTypes, [ - ...new Set([...libraryNpmStatic, ...explicit]), - ]); + // A trailing "..." (`--npm-static=fs-extra...`) selects the package and + // its whole dependency closure, which the library fixpoint discovers. + const transitive = new Set(); + const names = [...libraryNpmStatic, ...explicit].map((name) => { + if (!name.endsWith("...") || name.length === 3) return name; + const base = name.slice(0, -3); + transitive.add(base); + return base; + }); + return loadFrontend( + entryPath, + trackedLoader, + "lib", + externalTypes, + [...new Set(names)], + transitive, + ); } catch (error) { for (const load of active) { // Preserve the original failure and attempt every remaining cleanup. @@ -254,6 +344,7 @@ function loadFrontend( npmStatic?: readonly string[] | "auto" | "lib", externalTypes?: Readonly>, libraryNpmStatic: readonly string[] = [], + transitive: Set = new Set(), ): Frontend { // Resolver package/workspace metadata is intentionally shared across the // several load attempts of ONE auto-detection fixpoint, but never across @@ -275,7 +366,10 @@ function loadFrontend( const scoutPreflight = checkPreflight(scout); requested = npmStatic === "lib" - ? [...libraryNpmStatic, ...detectAutoPackages(scout, statuses, "lib", judged, npmSites)] + ? [ + ...libraryNpmStatic, + ...detectAutoPackages(scout, statuses, "lib", judged, npmSites, transitive), + ] : detectAutoPackages(scout, statuses); // With no package to opt in, the scout already IS the final frontend: // same roots, resolution posture, preflight, and module order. Retain it @@ -333,7 +427,7 @@ function loadFrontend( // at least one new package for good). if (npmStatic === "lib") { for (;;) { - const grown = detectAutoPackages(load, statuses, "lib", judged, npmSites); + const grown = detectAutoPackages(load, statuses, "lib", judged, npmSites, transitive); if (grown.length === 0) break; requested = [...requested, ...grown]; load.dispose(); diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 398c35b8a..e144f428b 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -787,10 +787,56 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { package: "ms", status: "refused", detail: "it ships no own .d.ts declaration surface", + hint: "to compile it anyway, name it with --npm-static=ms (or --npm-static=ms... to include its dependencies)", }, ]); }, 120_000); + // Types-first resolution answers @types/twinned, but the refusal names the + // package that has the code and the flag that compiles it. + test("an @types-typed package is refused under its runtime name with the opt-in remedy", () => { + const { coverage } = analyze(join(pilotRoot, "twinned-cli.ts")); + expect(coverage.npmStatic).toEqual([ + { + package: "twinned", + status: "refused", + detail: + "its declared types come from the separate @types/twinned package, not the package itself", + hint: "to compile it anyway, name it with --npm-static=twinned (or --npm-static=twinned... to include its dependencies)", + }, + ]); + const diagnostic = coverage.diagnostics.find((d) => d.code === "SC2013"); + expect(diagnostic?.message).toContain("'twinned'"); + expect(diagnostic?.hint).toContain("--npm-static=twinned"); + }, 120_000); + + test("a named package's untyped dependency points at the transitive opt-in", () => { + const { coverage } = analyze(join(pilotRoot, "twinned-cli.ts"), { npmStatic: ["twinned"] }); + expect(coverage.npmStatic).toContainEqual({ + package: "ms", + status: "refused", + detail: "it ships no own .d.ts declaration surface", + hint: "to compile it anyway, name it with --npm-static=ms, or use --npm-static=twinned... to include all of twinned's dependencies", + }); + }, 120_000); + + test("--npm-static=pkg... compiles the package's dependency closure", async () => { + const entry = join(pilotRoot, "twinned-cli.ts"); + const { coverage } = analyze(entry, { npmStatic: ["twinned..."] }); + expect(coverage.npmStatic).toEqual([ + { package: "twinned", status: "static" }, + { package: "ms", status: "static" }, + ]); + expect(coverage.preflightFailed).toBe(false); + const binary = await buildStatic(entry, ["twinned..."]); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, 180_000); + test("type-only imports select no package", async () => { const entry = join(pilotRoot, "type-only-cli.ts"); const { coverage } = analyze(entry); @@ -937,6 +983,7 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { package: "typegapped", status: "refused", detail: "it ships no own .d.ts declaration surface", + hint: "to compile it anyway, name it with --npm-static=typegapped (or --npm-static=typegapped... to include its dependencies)", }, { package: "escape-string-regexp", status: "static" }, ]); From e2c7eac01db6b7b3ae00f54b7bd9776748d4879e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 21:01:15 -0700 Subject: [PATCH 04/34] Record order baselines for the type-only import fixtures Co-Authored-By: Claude Opus 5.5 --- .../corpus/6120-type-only-import-graph/main.ts.json | 8 ++++++++ .../corpus/6121-type-specifier-edges/main.ts.json | 10 ++++++++++ 2 files changed, 18 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6120-type-only-import-graph/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6121-type-specifier-edges/main.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6120-type-only-import-graph/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6120-type-only-import-graph/main.ts.json new file mode 100644 index 000000000..979391c1c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6120-type-only-import-graph/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/6120-type-only-import-graph/mixed.ts", + "/tests/corpus/6120-type-only-import-graph/flags.ts", + "/tests/corpus/6120-type-only-import-graph/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6121-type-specifier-edges/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6121-type-specifier-edges/main.ts.json new file mode 100644 index 000000000..c0f3f653c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6121-type-specifier-edges/main.ts.json @@ -0,0 +1,10 @@ +{ + "order": [ + "/tests/corpus/6121-type-specifier-edges/config.ts", + "/tests/corpus/6121-type-specifier-edges/config-barrel.ts", + "/tests/corpus/6121-type-specifier-edges/b.ts", + "/tests/corpus/6121-type-specifier-edges/a.ts", + "/tests/corpus/6121-type-specifier-edges/main.ts" + ], + "diags": [] +} From b4e25cdba0708b1b2a4aacbd499ec3ff21871c8c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 21:01:15 -0700 Subject: [PATCH 05/34] Leave an @types twin with no runtime package to the declaration bar in transitive selection Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/pipeline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 20df75c9b..c769ed99e 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -214,7 +214,9 @@ function detectAutoPackages( continue; } if (judged?.has(npm.packageName)) continue; - if (viaTransitive) { + // An @types twin with no runtime package installed has nothing to + // compile (Node cannot load it either). + if (viaTransitive && !npm.packageName.startsWith("@types/")) { judged!.add(npm.packageName); sites!.set(npm.packageName, loc); transitive.add(npm.packageName); From 1c19d6613df8a52d64e4536724d84f6d7eaeec1f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 21:01:16 -0700 Subject: [PATCH 06/34] Compile the deprecated punycode and constants builtin modules - node:punycode: encode, decode, toASCII, toUnicode, ucs2.decode, ucs2.encode and version, ported from the punycode.js 2.1.0 Node bundles (scr_url.c), with its RangeErrors. DEP0040 follows Node: a static ES import reports before any module evaluates and before the program can register a 'warning' listener; require() reports on the next tick, once for both spellings, not when a dependency under node_modules loads the module first, and not under process.noDeprecation. - node:constants: the frozen object Node builds from os.constants' dlopen, errno, priority and signals groups, fs.constants (node_constants.cc order and guards) and crypto.constants (scr_os.c); its bindings read it as a checked-dynamic value. These unblock tr46 (whatwg-url, node-fetch@2) and graceful-fs (fs-extra). Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 2 + .../builtin-apis/deprecated-modules.mdx | 1 + .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 48 +-- .../generated/node-v24-backlog.json | 116 +++--- .../generated/node-v24-internal.json | 76 +++- internal/compatibility/static-support.json | 1 + .../ambient/scriptc-node-fallback.d.ts | 33 ++ .../compiler/src/backend/llvm/lib-dispatch.ts | 2 + .../compiler/src/backend/llvm/lib-shared.ts | 8 + .../compiler/src/frontend/builtin-modules.ts | 2 + .../frontend/lowering/lower-builtin-values.ts | 32 ++ .../src/frontend/lowering/lower-exprs.ts | 14 + .../src/frontend/lowering/lower-modules.ts | 1 + .../src/frontend/lowering/lower-stmts.ts | 4 +- .../compiler/src/frontend/lowering/lowerer.ts | 77 ++++ .../src/frontend/lowering/surfaces.ts | 17 + packages/compiler/src/ir/builtin-effects.ts | 7 + .../compiler/src/ir/builtin-signatures.ts | 17 + packages/compiler/src/ir/runtime-features.ts | 2 + packages/compiler/surface-manifest.json | 57 +++ .../corpus/6122-punycode-conversions.ts.json | 6 + .../6124-punycode-require-warnings.cjs.json | 6 + .../corpus/6125-constants-module.cjs.json | 6 + .../6126-punycode-dependency/main.cjs.json | 18 + packages/runtime/src/scr_async.h | 5 + packages/runtime/src/scr_async_dyn.c | 21 ++ packages/runtime/src/scr_os.c | 194 ++++++++++ packages/runtime/src/scr_os.h | 4 + packages/runtime/src/scr_url.c | 339 ++++++++++++++++++ packages/runtime/src/scr_url.h | 9 + tests/corpus/6122-punycode-conversions.ts | 45 +++ .../corpus/6124-punycode-require-warnings.cjs | 32 ++ tests/corpus/6125-constants-module.cjs | 26 ++ .../corpus/6126-punycode-dependency/main.cjs | 12 + .../node_modules/puny-dep/index.d.ts | 2 + .../node_modules/puny-dep/index.js | 8 + .../node_modules/puny-dep/package.json | 5 + 38 files changed, 1169 insertions(+), 88 deletions(-) create mode 100644 docs/content/limitations/builtin-apis/deprecated-modules.mdx create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6122-punycode-conversions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6124-punycode-require-warnings.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6125-constants-module.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json create mode 100644 tests/corpus/6122-punycode-conversions.ts create mode 100644 tests/corpus/6124-punycode-require-warnings.cjs create mode 100644 tests/corpus/6125-constants-module.cjs create mode 100644 tests/corpus/6126-punycode-dependency/main.cjs create mode 100644 tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.d.ts create mode 100644 tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.js create mode 100644 tests/corpus/6126-punycode-dependency/node_modules/puny-dep/package.json diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index ed2c5b1ec..a66b2ffd6 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -74,6 +74,8 @@ title: "Limitations" ../limitations/builtin-apis/legacy-url-api.mdx +../limitations/builtin-apis/deprecated-modules.mdx + ../limitations/builtin-apis/filesystem-flags.mdx ../limitations/builtin-apis/copy.mdx diff --git a/docs/content/limitations/builtin-apis/deprecated-modules.mdx b/docs/content/limitations/builtin-apis/deprecated-modules.mdx new file mode 100644 index 000000000..bb630b8c3 --- /dev/null +++ b/docs/content/limitations/builtin-apis/deprecated-modules.mdx @@ -0,0 +1 @@ +- **Deprecated `punycode` and `constants` modules:** `punycode.encode()`, `decode()`, `toASCII()`, `toUnicode()`, `ucs2.decode()`, `ucs2.encode()`, and `version` compile, with the `DEP0040` warning Node.js reports when the module loads. A `require("punycode")` made from inside `node_modules` doesn't warn; a `require()` used inside a larger expression, such as `require("punycode").toASCII(name)`, doesn't warn either. The `punycode.ucs2` object itself is unsupported as a value. The `constants` module is the frozen object Node.js builds from `os.constants` and `fs.constants`, plus the `crypto.constants` of Node.js 24.22.0. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 93149c51c..a063052f3 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "a1ff929d49885bd8c92e", + "artifactVersion": "7b345b86e2e5f616a7a0", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 352c211b5..62eb8283e 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -584,9 +584,9 @@ "apiEntries": 9, "static": { "supported": 0, - "partial": 0, + "partial": 8, "refused": 0, - "not-implemented": 9, + "not-implemented": 1, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -58489,7 +58489,7 @@ "inherited": false }, "static": { - "status": "not-implemented", + "status": "partial", "detail": "Derived from the exact API rows in this chapter.", "verification": "derived" } @@ -58512,9 +58512,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -58535,9 +58535,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -58558,9 +58558,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -58581,9 +58581,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -58627,9 +58627,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -58650,9 +58650,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -58673,9 +58673,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index fff784adc..c4ce78314 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1091, - "verify-gap": 2238, + "audit-partial": 1098, + "verify-gap": 2231, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -58534,12 +58534,16 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:punycode", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.punycode.decode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] } } }, @@ -58559,12 +58563,16 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:punycode", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.punycode.encode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] } } }, @@ -58584,12 +58592,16 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:punycode", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.punycode.toASCII", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] } } }, @@ -58609,12 +58621,16 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:punycode", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.punycode.toUnicode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] } } }, @@ -58659,12 +58675,16 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:punycode", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.punycode.ucs2.decode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] } } }, @@ -58684,12 +58704,16 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:punycode", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.punycode.ucs2.encode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] } } }, @@ -58709,12 +58733,16 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:punycode", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.punycode.version", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 559bd48ca..58ffc3399 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -611,9 +611,9 @@ "apiEntries": 9, "static": { "supported": 0, - "partial": 0, + "partial": 8, "refused": 0, - "not-implemented": 9, + "not-implemented": 1, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -81908,8 +81908,13 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "derived:descendants" + "status": "partial", + "evidence": "derived:descendants", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "chapter" }, @@ -81931,8 +81936,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:punycode" + "status": "partial", + "evidence": "surface-manifest:node-builtin.punycode.decode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "exact" }, @@ -81954,8 +81964,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:punycode" + "status": "partial", + "evidence": "surface-manifest:node-builtin.punycode.encode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "exact" }, @@ -81977,8 +81992,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:punycode" + "status": "partial", + "evidence": "surface-manifest:node-builtin.punycode.toASCII", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "exact" }, @@ -82000,8 +82020,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:punycode" + "status": "partial", + "evidence": "surface-manifest:node-builtin.punycode.toUnicode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "exact" }, @@ -82046,8 +82071,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:punycode" + "status": "partial", + "evidence": "surface-manifest:node-builtin.punycode.ucs2.decode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "exact" }, @@ -82069,8 +82099,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:punycode" + "status": "partial", + "evidence": "surface-manifest:node-builtin.punycode.ucs2.encode", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "exact" }, @@ -82092,8 +82127,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:punycode" + "status": "partial", + "evidence": "surface-manifest:node-builtin.punycode.version", + "tests": [ + "tests/corpus/6122-punycode-conversions.ts", + "tests/corpus/6124-punycode-require-warnings.cjs", + "tests/corpus/6126-punycode-dependency/main.cjs" + ] }, "anchorSource": "chapter" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index d68011393..5332ddd4c 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -146,6 +146,7 @@ "node-builtin.process.rows": ["tests/corpus/2992-stream-rows.ts", "tests/harness/terminal-geometry.test.ts"], "node-builtin.process.columns": ["tests/corpus/1448-stream-columns.ts", "tests/harness/terminal-geometry.test.ts"], "node-builtin.process.exitCode": ["tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", "tests/corpus/2944-process-exit-code-wrap.ts"], + "node-builtin.punycode": ["tests/corpus/6122-punycode-conversions.ts", "tests/corpus/6124-punycode-require-warnings.cjs", "tests/corpus/6126-punycode-dependency/main.cjs"], "node-builtin.querystring": ["tests/corpus/957-builtins-namespace.ts", "tests/corpus/2917-builtin-callable-widths.ts"], "node-builtin.readline": ["tests/corpus/1475-readline-closed-stdin.ts"], "node-builtin.stream": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1746-stream-for-await.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/2629-stream-consumers.ts", "tests/corpus/2861-readable-iterator-close.ts"], diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index d87172f61..2c53bb523 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3643,6 +3643,39 @@ declare module "node:querystring" { export * from "querystring"; } +/* node:punycode (deprecated, DEP0040) — punycode.js 2.1.0: decode and + * toUnicode throw its RangeErrors on malformed input; ucs2 converts + * between strings and UTF-16 code point arrays. */ +declare module "punycode" { + export function decode(string: string): string; + export function encode(string: string): string; + export function toUnicode(domain: string): string; + export function toASCII(domain: string): string; + export namespace ucs2 { + function decode(string: string): number[]; + function encode(codePoints: readonly number[]): string; + } + export const version: string; +} +declare module "node:punycode" { + export * from "punycode"; +} + +/* node:constants (deprecated, DEP0008) — one frozen object: os.constants' + * dlopen, errno, priority and signals groups, fs.constants, and + * crypto.constants. A platform's missing constant reads as undefined. */ +declare module "constants" { + const constants: { readonly [name: string]: number } & { + readonly defaultCoreCipherList: string; + readonly defaultCipherList: string; + }; + export = constants; +} +declare module "node:constants" { + import constants = require("constants"); + export = constants; +} + /* node:readline — the question/close slice: createInterface over exactly * { input: process.stdin, output: process.stdout }, question(query, cb) * (the query writes to stdout, the callback gets the next line's text), diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 50804bf5a..92dbaea76 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -680,6 +680,8 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "events": case "blob": case "record": + case "punycode": + case "constants": return host.emitGenericLibCall(e); case "test": return emitTestLibCall(host, e); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9232cbd03..700044da2 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -192,6 +192,7 @@ export const LIB_FN_SYMS: Record = { "os.cpusModel": "scr_os_cpus_model", "os.cpusField": "scr_os_cpus_field", "os.constants": "scr_os_constants", + "constants.object": "scr_node_constants", "os.release": "scr_os_release", "os.userName": "scr_os_user_name", "os.userShell": "scr_os_user_shell", @@ -631,6 +632,13 @@ export const LIB_FN_SYMS: Record = { "url.legacyFormat": "scr_url_legacy_format", "url.legacyResolve": "scr_url_legacy_resolve", "url.domainToASCII": "scr_url_domain_to_ascii", + "punycode.decode": "scr_punycode_decode", + "punycode.encode": "scr_punycode_encode", + "punycode.toASCII": "scr_punycode_to_ascii", + "punycode.toUnicode": "scr_punycode_to_unicode", + "punycode.ucs2Decode": "scr_punycode_ucs2_decode", + "punycode.ucs2Encode": "scr_str_from_code_point", + "punycode.warn": "scr_punycode_warn", "url.domainToUnicode": "scr_url_domain_to_unicode", "url.pathToFileURL": "scr_url_from_path", "url.pathToFileURLPlatform": "scr_url_from_path_platform", diff --git a/packages/compiler/src/frontend/builtin-modules.ts b/packages/compiler/src/frontend/builtin-modules.ts index b45dce6d7..3a1acb934 100644 --- a/packages/compiler/src/frontend/builtin-modules.ts +++ b/packages/compiler/src/frontend/builtin-modules.ts @@ -42,6 +42,8 @@ export const SUPPORTED_BUILTIN_MODULES = [ "module", "console", "process", + "punycode", + "constants", ] as const; /** Builtins Node itself serves ONLY under the node: prefix — diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index a9df9e662..c05430ad3 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -23,6 +23,7 @@ import { builtinModulesArrayLit, stdlibGlobalNameOf, } from "./surfaces.js"; +import { CRYPTO_CONSTANTS } from "./crypto-tables.js"; const PROTOTYPE_METHODS: Record = { Date: ["getTime", "valueOf", "toISOString", "toJSON"], @@ -680,3 +681,34 @@ export function lowerBuiltinLoaderValue(lowerer: Lowerer, loc: SrcLoc): IrExpr { loc, }; } + +/** The deprecated node:constants module object: the runtime merges + * os.constants' groups and fs.constants with crypto.constants, which a + * lifted function answers from the baked table the first time the object + * is built. */ +export function builtinConstantsObject(lowerer: Lowerer, loc: SrcLoc): IrExpr { + const name = "%builtin.constants.crypto"; + if (!lowerer.builtinCallableValueFns.has(name)) { + lowerer.builtinCallableValueFns.set(name, name); + const fields = Object.entries(CRYPTO_CONSTANTS).map(([key, value]) => ({ + key: strLit(key, loc), + value: box(builtinConstLit(value, loc)), + })); + lowerer.liftedFns.push({ + name, + params: [], + returnType: DYN, + locals: [], + body: [{ kind: "return", value: { kind: "dynObjLit", fields, type: DYN, loc }, loc }], + loc, + }); + } + const table: IrExpr = { + kind: "closure", + fnName: name, + captures: [], + type: { kind: "func", params: [], ret: DYN }, + loc, + }; + return { kind: "libCall", fn: "constants.object", args: [box(table)], type: DYN, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 08e12518b..88a4918d8 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -216,6 +216,7 @@ import { } from "./class-prototypes.js"; import { isClassCallback } from "./class-callbacks.js"; import { + builtinConstantsObject, builtinPrototypeMethod, lowerArrayIsArrayValue, lowerCheckedPredicateValue, @@ -1164,6 +1165,17 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (bi.module === "os" && bi.member === "constants") { return { kind: "libCall", fn: "os.constants", args: [], type: DYN, loc }; } + // `import { O_RDONLY } from "constants"`: the member of the frozen + // module object. + if (bi.module === "constants") { + return { + kind: "dynKeyGet", + value: builtinConstantsObject(lowerer, loc), + key: { kind: "strLit", value: bi.member, type: STRING, loc }, + type: DYN, + loc, + }; + } // tls.rootCertificates: a runtime-valued module constant (the // cached bundled-CA array) — the one member read that lowers // to a libCall instead of a baked literal. @@ -1206,6 +1218,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // their access sites only. The CommonJS namespace binding // (`const lib = require("./lib.js")`) gets the same fence. const builtinNamespace = lowerer.builtinNamespaceModuleOf(expr); + if (builtinNamespace === "constants") return builtinConstantsObject(lowerer, loc); if (builtinNamespace !== null) { const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNamespace}` }; return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc }; @@ -1495,6 +1508,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { return { kind: "moduleNsRef", moduleId: moduleNs.moduleId, type: moduleNs, loc }; } const builtinNs = lowerer.builtinNamespaceModuleOf(expr); + if (builtinNs === "constants") return builtinConstantsObject(lowerer, loc); if (builtinNs !== null) { const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNs}` }; return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc }; diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index d18c4f547..b2f76cd24 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2656,6 +2656,7 @@ export function lowerFileInit( loc: loc0, }); } + if (sf === lowerer.entry) header.push(...lowerer.punycodeLinkWarning()); for (const { stmt, dep } of orderedImportsOf(lowerer.program, sf)) { const npm = lowerer.npmInitActions.get(sf)?.get(stmt); if (npm !== undefined && npm.length > 0) { diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 367c147f8..27f4169c1 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1557,7 +1557,9 @@ export function lowerVarStatement(lowerer: Lowerer, stmt: ts.VariableStatement): // Relative requires keep the fence: their module-init-at-position // semantics needs storage this model doesn't represent. if (canonicalBuiltinModule(spec) !== null) { - return []; + return canonicalBuiltinModule(spec) === "punycode" + ? [lowerer.punycodeRequireWarning(decl)] + : []; } lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3217802ad..8e6f64143 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -128,6 +128,7 @@ import { resolve } from "node:path"; import { bindingSource } from "../binding-source.js"; import { tsgoPath } from "../dts-paths.js"; import * as ts from "../ts7/adapter.js"; +import { erasedTypeOnlyImport } from "../type-only-imports.js"; import { literalValues } from "../literal-values.js"; import { discriminantOwners, literalUnionArm, type UnionLiteral } from "../union-discriminants.js"; import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; @@ -3745,6 +3746,65 @@ export class Lowerer { for (const fp of parts) if (!indexOf.has(fp.sf)) visit(fp.sf); } + /** node:punycode's load at a require() call: the module body reports + * DEP0040 when it first runs, unless isInsideNodeModules() finds the + * requiring frame in a dependency, and process.emitWarning delivers the + * report on the next tick. Later loads are cache hits either way. */ + punycodeRequireWarning(node: ts.Node): IrStmt { + const loc = locOf(node); + const fromDependency = /[\\/]node_modules[\\/]/.test(node.getSourceFile().fileName); + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "punycode.warn", + args: [ + { kind: "boolLit", value: false, type: BOOL, loc }, + { kind: "boolLit", value: !fromDependency, type: BOOL, loc }, + ], + type: VOID, + loc, + }, + loc, + }; + } + + /** node:punycode's DEP0040 for a static ES import anywhere in the module + * graph: Node links builtins before any module evaluates, so the report + * comes first, before the program can register a 'warning' listener — + * whichever file imports it (a dependency's ES import has no requiring + * frame either). Prepended to the entry's %init. */ + punycodeLinkWarning(): IrStmt[] { + const imports = this.moduleOrder.some((sf) => + sf.statements.some( + (stmt) => + ((ts.isImportDeclaration(stmt) && !erasedTypeOnlyImport(stmt)) || + (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly)) && + stmt.moduleSpecifier !== undefined && + ts.isStringLiteral(stmt.moduleSpecifier) && + canonicalBuiltinModule(stmt.moduleSpecifier.text) === "punycode", + ), + ); + if (!imports) return []; + const loc: SrcLoc = { file: this.entry.fileName, start: 0, end: 0 }; + return [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "punycode.warn", + args: [ + { kind: "boolLit", value: true, type: BOOL, loc }, + { kind: "boolLit", value: true, type: BOOL, loc }, + ], + type: VOID, + loc, + }, + loc, + }, + ]; + } + /** The lowering of a CommonJS `require("./local")` occurrence: a call of * the required module's run-once %init at exactly this statement's * position — Node's inline evaluation, with the guard supplying the @@ -3754,6 +3814,7 @@ export class Lowerer { * their external dependencies by name). Null for everything else * (builtins load nothing; the rest kept its preflight fence). */ requireInitStmt(spec: string, node: ts.Node): IrStmt | null { + if (canonicalBuiltinModule(spec) === "punycode") return this.punycodeRequireWarning(node); // Project requires resolve within the program; another bare require can // be a program-module edge when it names an opted-in --npm-static package // (one package requiring another — the resolution answered its shipped @@ -11286,7 +11347,23 @@ export class Lowerer { return { module: "timers/promises", member: `scheduler.${access.name.text}` }; } } + // punycode.ucs2.decode / encode: methods of the module's ucs2 object, + // dispatched as module members. + if (access.name.text === "decode" || access.name.text === "encode") { + const owner = access.expression; + const imported = ts.isIdentifier(owner) + ? this.builtinImportOf(owner) + : ts.isPropertyAccessExpression(owner) + ? this.builtinMemberOf(owner) + : null; + if (imported?.module === "punycode" && imported.member === "ucs2") { + return { module: "punycode", member: `ucs2.${access.name.text}` }; + } + } const module = this.builtinNamespaceModuleOf(access.expression); + // node:constants is one checked-dynamic object: its members read + // through the object itself (builtinConstantsObject), not the tables. + if (module === "constants") return null; if (module !== null) return { module, member: access.name.text }; // The events module object IS the EventEmitter class, so the class's // defaultMaxListeners is the module property. diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 4c74e8b6a..72d6c09f8 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1801,6 +1801,22 @@ export const BUILTIN_MODULE_FNS: Record< valueParams: [{ mode: "required", type: STRING }], }, }, + // node:punycode (deprecated): punycode.js 2.1.0's conversions over + // strings (scr_url.c). ucs2.decode/encode are members of the module's + // ucs2 object, dispatched as module members (builtinMemberOf). The + // DEP0040 report rides the module's load sites (punycodeLoadWarning). + punycode: { + decode: { fn: "punycode.decode", params: [STRING], result: STRING }, + encode: { fn: "punycode.encode", params: [STRING], result: STRING }, + toASCII: { fn: "punycode.toASCII", params: [STRING], result: STRING }, + toUnicode: { fn: "punycode.toUnicode", params: [STRING], result: STRING }, + "ucs2.decode": { fn: "punycode.ucs2Decode", params: [STRING], result: arrayOf(F64) }, + "ucs2.encode": { fn: "punycode.ucs2Encode", params: [arrayOf(F64)], result: STRING }, + }, + // node:constants (deprecated) is one frozen object (Object.assign of + // os.constants' groups, fs.constants and crypto.constants): bindings of + // the module read it as a checked-dynamic value (builtinConstantsObject). + constants: {}, // node:readline: createInterface's options are entirely special-cased // (exactly { input: process.stdin, output?: process.stdout } — see // lowerBuiltinModuleCall); the entry carries the canonical shape. The @@ -1930,6 +1946,7 @@ export const BUILTIN_MODULE_CONSTS: Record< os: { EOL: "\n", devNull: "/dev/null" }, worker_threads: { isMainThread: true, threadId: 0 }, cluster: { isPrimary: true, isMaster: true, isWorker: false }, + punycode: { version: "2.1.0" }, // zlib's module-level copies of its constants (every member outside // BROTLI_*, Node's own loop over zlib.constants). zlib: Object.fromEntries( diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 2381413f8..ee5ba8c7c 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -466,6 +466,13 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "str.encodeUri", "qs.escape", "qs.stringify", + // node:punycode's RangeErrors (encode/toASCII only past 32-bit deltas) + // and String.fromCodePoint's for ucs2.encode. + "punycode.decode", + "punycode.encode", + "punycode.toASCII", + "punycode.toUnicode", + "punycode.ucs2Encode", // The base64 globals: atob/btoa throw the catchable DOMException // InvalidCharacterError on malformed input; the zero-argument form // always throws Node's TypeError [ERR_MISSING_ARGS]. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 5e219ecf6..e9f7778bc 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -559,6 +559,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** os.constants: the per-thread null-prototype constants object (dlopen, * errno, signals, priority, UV_UDP_REUSEADDR). Never throws. */ "os.constants": { argTypes: [], result: DYN }, + /** The deprecated node:constants object (scr_os.c): the os groups and + * fs.constants merged with the borrowed baked crypto.constants table, + * frozen, one per thread. */ + "constants.object": { argTypes: [DYN], result: DYN }, /** umask(2): arg < 0 reads without setting (umask has no read-only form * — set 0, restore); otherwise sets and answers the PREVIOUS mask. * Never throws. */ @@ -856,6 +860,19 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "url.legacyResolve": { argTypes: [DYN, DYN, BOOL], result: STRING }, "url.domainToASCII": { argTypes: [DYN, BOOL], result: STRING }, "url.domainToUnicode": { argTypes: [DYN, BOOL], result: STRING }, + /** node:punycode (scr_url.c): punycode.js 2.1.0's string conversions; + * decode/toUnicode (and, past 32-bit deltas, encode/toASCII) throw the + * module's RangeErrors, ucs2Encode String.fromCodePoint's. warn marks the + * module's first load (scr_async_dyn.c) and reports DEP0040 then unless + * the second argument is false: at once for a static ES import, on the + * next tick for a require() call. */ + "punycode.decode": { argTypes: [STRING], result: STRING }, + "punycode.encode": { argTypes: [STRING], result: STRING }, + "punycode.toASCII": { argTypes: [STRING], result: STRING }, + "punycode.toUnicode": { argTypes: [STRING], result: STRING }, + "punycode.ucs2Decode": { argTypes: [STRING], result: arrayOf(F64) }, + "punycode.ucs2Encode": { argTypes: [arrayOf(F64)], result: STRING }, + "punycode.warn": { argTypes: [BOOL, BOOL], result: VOID }, "url.fileURLToPathBuffer": { argTypes: [DYN, DYN], result: BYTES_U8 }, "url.pathToFileURLChecked": { argTypes: [DYN, DYN], result: URL_T }, "url.fileURLToPathStr": { argTypes: [STRING], result: STRING }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 80a9c9b57..ba97670ce 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -43,6 +43,8 @@ export interface RuntimeFeatures { } const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ + // DEP0040 rides the dyn-async unit's warning machinery. + "punycode.warn", "fs.callbackValue", "fs.callbackCall", "fs.callbackGlob", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 63efde378..3ee171669 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -466,6 +466,13 @@ "status": "static", "note": "recognized module (bare and node:-prefixed specifiers)" }, + { + "id": "node-builtin.constants", + "kind": "node-builtin", + "name": "constants", + "status": "static", + "note": "recognized module (bare and node:-prefixed specifiers)" + }, { "id": "node-builtin.crypto", "kind": "node-builtin", @@ -2095,6 +2102,56 @@ "status": "static", "note": "shared version dictionary containing node and openssl; other components are absent unless defined by the program" }, + { + "id": "node-builtin.punycode", + "kind": "node-builtin", + "name": "punycode", + "status": "static", + "note": "recognized module (bare and node:-prefixed specifiers)" + }, + { + "id": "node-builtin.punycode.decode", + "kind": "node-builtin", + "name": "punycode.decode", + "status": "static" + }, + { + "id": "node-builtin.punycode.encode", + "kind": "node-builtin", + "name": "punycode.encode", + "status": "static" + }, + { + "id": "node-builtin.punycode.toASCII", + "kind": "node-builtin", + "name": "punycode.toASCII", + "status": "static" + }, + { + "id": "node-builtin.punycode.toUnicode", + "kind": "node-builtin", + "name": "punycode.toUnicode", + "status": "static" + }, + { + "id": "node-builtin.punycode.ucs2.decode", + "kind": "node-builtin", + "name": "punycode.ucs2.decode", + "status": "static" + }, + { + "id": "node-builtin.punycode.ucs2.encode", + "kind": "node-builtin", + "name": "punycode.ucs2.encode", + "status": "static" + }, + { + "id": "node-builtin.punycode.version", + "kind": "node-builtin", + "name": "punycode.version", + "status": "static", + "note": "constant value read" + }, { "id": "node-builtin.querystring", "kind": "node-builtin", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6122-punycode-conversions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6122-punycode-conversions.ts.json new file mode 100644 index 000000000..b15e4fe13 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6122-punycode-conversions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6122-punycode-conversions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6124-punycode-require-warnings.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6124-punycode-require-warnings.cjs.json new file mode 100644 index 000000000..dfd4aa912 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6124-punycode-require-warnings.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6124-punycode-require-warnings.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6125-constants-module.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6125-constants-module.cjs.json new file mode 100644 index 000000000..ef8c7f2bf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6125-constants-module.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6125-constants-module.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json new file mode 100644 index 000000000..5de83361a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/corpus/6126-punycode-dependency/main.cjs" + ], + "diags": [ + { + "code": "SC1010", + "message": "the 'puny-dep' module is not supported yet", + "loc": { + "file": "/tests/corpus/6126-punycode-dependency/main.cjs", + "start": 347, + "end": 376 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + } + ] +} diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index a391d2a26..f41949f6e 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -212,6 +212,11 @@ void scr_process_on_warning(ScrDyn *fn); void scr_process_off_warning(ScrDyn *fn); void scr_process_emit_warning(ScrDyn *args); void scr_emit_warning(const char *name, const char *code, ScrStr *message); +/* node:punycode's first load (scr_async_dyn.c): later calls are module + * cache hits. The first reports DEP0040 when `report` (false for a require + * made from node_modules) — at once for a static ES import, which Node + * links before any module evaluates, otherwise on the next tick. */ +void scr_punycode_warn(bool immediate, bool report); /* scr_emit_warning once a 'warning' listener exists (scr_lib.c; set by the * listener registration): units that cannot link the dyn-async unit route * their warnings through it, and print the default report otherwise. */ diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index a14ae6e6a..7a27f16f3 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -813,6 +813,27 @@ void scr_warning_emit_deferred(ScrDyn *w) { scr_warning_defer(w, scr_warning_tick); } +static SCR_TL bool scr_punycode_loaded = false; + +void scr_punycode_warn(bool immediate, bool report) { + if (scr_punycode_loaded) return; + scr_punycode_loaded = true; + if (!report) return; + static const char text[] = + "The `punycode` module is deprecated. Please use a userland alternative instead."; + ScrStr *message = scr_str_new(text, sizeof text - 1); + ScrStr *code = scr_str_new("DEP0040", 7); + ScrDyn *w = scr_warning_new(message, "DeprecationWarning", code); + if (immediate) { + if (!scr_process_deprecation_flag_on(0)) scr_warning_dispatch(w); + } else { + scr_warning_emit_deferred(w); + } + scr_dyn_release(w); + scr_str_release(code); + scr_str_release(message); +} + /* The console members' warnings. Node's emitWarning emits on * process.nextTick, so these queue in order and each rides one raw tick * on the shared tick queue; any left at exit are dropped with it. */ diff --git a/packages/runtime/src/scr_os.c b/packages/runtime/src/scr_os.c index 0bc50fb3f..0df57d2e1 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -27,6 +27,8 @@ #include #include #endif +#include +#include #ifdef __linux__ #include #endif @@ -1286,3 +1288,195 @@ ScrDyn *scr_os_constants(void) { scr_atexit(scr_os_constants_clear); return scr_dyn_retain(os); } + +/* ── node:constants (deprecated, DEP0008) ────────────────────────────── + * Node's lib/constants.js: Object.assign({}, os.constants.dlopen, errno, + * priority, signals, fs.constants, crypto.constants), frozen, with an + * ordinary prototype. The fs group follows node_constants.cc's + * DefineFsConstants order and guards (libuv's UV_* values are fixed on + * POSIX); crypto_table (borrowed) is a compiler-emitted function that + * answers the baked crypto.constants table, called only on first use. One + * object per thread. */ +#define SCR_FS_CONSTANT(o, name) scr_os_constant((o), #name, (double)(name)) + +static void scr_fs_constants(ScrDyn *o) { + scr_os_constant(o, "UV_FS_SYMLINK_DIR", 1); + scr_os_constant(o, "UV_FS_SYMLINK_JUNCTION", 2); +#ifdef O_RDONLY + SCR_FS_CONSTANT(o, O_RDONLY); +#endif +#ifdef O_WRONLY + SCR_FS_CONSTANT(o, O_WRONLY); +#endif +#ifdef O_RDWR + SCR_FS_CONSTANT(o, O_RDWR); +#endif + scr_os_constant(o, "UV_DIRENT_UNKNOWN", 0); + scr_os_constant(o, "UV_DIRENT_FILE", 1); + scr_os_constant(o, "UV_DIRENT_DIR", 2); + scr_os_constant(o, "UV_DIRENT_LINK", 3); + scr_os_constant(o, "UV_DIRENT_FIFO", 4); + scr_os_constant(o, "UV_DIRENT_SOCKET", 5); + scr_os_constant(o, "UV_DIRENT_CHAR", 6); + scr_os_constant(o, "UV_DIRENT_BLOCK", 7); +#ifdef S_IFMT + SCR_FS_CONSTANT(o, S_IFMT); +#endif +#ifdef S_IFREG + SCR_FS_CONSTANT(o, S_IFREG); +#endif +#ifdef S_IFDIR + SCR_FS_CONSTANT(o, S_IFDIR); +#endif +#ifdef S_IFCHR + SCR_FS_CONSTANT(o, S_IFCHR); +#endif +#ifdef S_IFBLK + SCR_FS_CONSTANT(o, S_IFBLK); +#endif +#ifdef S_IFIFO + SCR_FS_CONSTANT(o, S_IFIFO); +#endif +#ifdef S_IFLNK + SCR_FS_CONSTANT(o, S_IFLNK); +#endif +#ifdef S_IFSOCK + SCR_FS_CONSTANT(o, S_IFSOCK); +#endif +#ifdef O_CREAT + SCR_FS_CONSTANT(o, O_CREAT); +#endif +#ifdef O_EXCL + SCR_FS_CONSTANT(o, O_EXCL); +#endif + /* libuv's Windows-only open flags are 0 everywhere else. */ + scr_os_constant(o, "UV_FS_O_FILEMAP", 0); + scr_os_constant(o, "UV_FS_O_TEMPORARY", 0); + scr_os_constant(o, "UV_FS_O_SHORT_LIVED", 0); + scr_os_constant(o, "UV_FS_O_SEQUENTIAL", 0); + scr_os_constant(o, "UV_FS_O_RANDOM", 0); +#ifdef O_NOCTTY + SCR_FS_CONSTANT(o, O_NOCTTY); +#endif +#ifdef O_TRUNC + SCR_FS_CONSTANT(o, O_TRUNC); +#endif +#ifdef O_APPEND + SCR_FS_CONSTANT(o, O_APPEND); +#endif +#ifdef O_DIRECTORY + SCR_FS_CONSTANT(o, O_DIRECTORY); +#endif +#ifdef O_NOATIME + SCR_FS_CONSTANT(o, O_NOATIME); +#endif +#ifdef O_NOFOLLOW + SCR_FS_CONSTANT(o, O_NOFOLLOW); +#endif +#ifdef O_SYNC + SCR_FS_CONSTANT(o, O_SYNC); +#endif +#ifdef O_DSYNC + SCR_FS_CONSTANT(o, O_DSYNC); +#endif +#ifdef O_SYMLINK + SCR_FS_CONSTANT(o, O_SYMLINK); +#endif +#ifdef O_DIRECT + SCR_FS_CONSTANT(o, O_DIRECT); +#endif +#ifdef O_NONBLOCK + SCR_FS_CONSTANT(o, O_NONBLOCK); +#endif +#ifdef S_IRWXU + SCR_FS_CONSTANT(o, S_IRWXU); +#endif +#ifdef S_IRUSR + SCR_FS_CONSTANT(o, S_IRUSR); +#endif +#ifdef S_IWUSR + SCR_FS_CONSTANT(o, S_IWUSR); +#endif +#ifdef S_IXUSR + SCR_FS_CONSTANT(o, S_IXUSR); +#endif +#ifdef S_IRWXG + SCR_FS_CONSTANT(o, S_IRWXG); +#endif +#ifdef S_IRGRP + SCR_FS_CONSTANT(o, S_IRGRP); +#endif +#ifdef S_IWGRP + SCR_FS_CONSTANT(o, S_IWGRP); +#endif +#ifdef S_IXGRP + SCR_FS_CONSTANT(o, S_IXGRP); +#endif +#ifdef S_IRWXO + SCR_FS_CONSTANT(o, S_IRWXO); +#endif +#ifdef S_IROTH + SCR_FS_CONSTANT(o, S_IROTH); +#endif +#ifdef S_IWOTH + SCR_FS_CONSTANT(o, S_IWOTH); +#endif +#ifdef S_IXOTH + SCR_FS_CONSTANT(o, S_IXOTH); +#endif +#ifdef F_OK + SCR_FS_CONSTANT(o, F_OK); +#endif +#ifdef R_OK + SCR_FS_CONSTANT(o, R_OK); +#endif +#ifdef W_OK + SCR_FS_CONSTANT(o, W_OK); +#endif +#ifdef X_OK + SCR_FS_CONSTANT(o, X_OK); +#endif + scr_os_constant(o, "UV_FS_COPYFILE_EXCL", 1); + scr_os_constant(o, "COPYFILE_EXCL", 1); + scr_os_constant(o, "UV_FS_COPYFILE_FICLONE", 2); + scr_os_constant(o, "COPYFILE_FICLONE", 2); + scr_os_constant(o, "UV_FS_COPYFILE_FICLONE_FORCE", 4); + scr_os_constant(o, "COPYFILE_FICLONE_FORCE", 4); +} +#undef SCR_FS_CONSTANT + +static SCR_TL ScrDyn *scr_node_constants_value; +static void scr_node_constants_clear(void) { + ScrDyn *value = scr_node_constants_value; + scr_node_constants_value = NULL; + scr_dyn_release(value); +} + +ScrDyn *scr_node_constants(const ScrDyn *crypto_table) { + if (scr_node_constants_value) return scr_dyn_retain(scr_node_constants_value); + ScrDyn *crypto = scr_dyn_call(crypto_table, NULL, 0, "constants"); + if (!crypto) return NULL; + ScrDyn *o = scr_dyn_new_obj(); + scr_os_dlopen_constants(o); + scr_os_errno_constants(o); + scr_os_constant(o, "PRIORITY_LOW", SCR_OS_PRIORITY_LOW); + scr_os_constant(o, "PRIORITY_BELOW_NORMAL", SCR_OS_PRIORITY_BELOW_NORMAL); + scr_os_constant(o, "PRIORITY_NORMAL", SCR_OS_PRIORITY_NORMAL); + scr_os_constant(o, "PRIORITY_ABOVE_NORMAL", SCR_OS_PRIORITY_ABOVE_NORMAL); + scr_os_constant(o, "PRIORITY_HIGH", SCR_OS_PRIORITY_HIGH); + scr_os_constant(o, "PRIORITY_HIGHEST", SCR_OS_PRIORITY_HIGHEST); + scr_os_signal_constants(o); + scr_fs_constants(o); + if (crypto && crypto->kind == SCR_DYN_OBJ) { + for (size_t i = 0; i < crypto->v.obj.len; i++) { + const ScrDynEntry *entry = &crypto->v.obj.entries[i]; + if (entry->accessor || !entry->value) continue; + scr_dyn_obj_set(o, entry->key, entry->key_len, scr_dyn_retain(entry->value)); + } + } + scr_dyn_release(crypto); + scr_dyn_release(scr_dyn_freeze(o)); + scr_node_constants_value = o; + scr_atexit(scr_node_constants_clear); + return scr_dyn_retain(o); +} diff --git a/packages/runtime/src/scr_os.h b/packages/runtime/src/scr_os.h index b40a8027a..6f8d9b616 100644 --- a/packages/runtime/src/scr_os.h +++ b/packages/runtime/src/scr_os.h @@ -34,6 +34,10 @@ ScrStr *scr_os_cpus_model(double i); double scr_os_cpus_field(double i, double field); /* os.constants: the per-thread null-prototype constants object (+1). */ ScrDyn *scr_os_constants(void); +/* The deprecated node:constants module object (scr_os.c): the os groups, + * fs.constants and the crypto table the borrowed function answers, merged + * and frozen (+1). */ +ScrDyn *scr_node_constants(const ScrDyn *crypto_table); double scr_os_available_parallelism(void); /* uv_available_parallelism: CPUs this process may use, >= 1 */ /* os.userInfo()'s field trio (pw_name / pw_shell / pw_dir — the passwd * home, not the $HOME cascade). +1 fresh; abort on lookup failure. */ diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index b6fdb9cd6..42a7ef607 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -1988,3 +1988,342 @@ ScrBytes *scr_url_checked_to_path_buffer(const ScrDyn *value, const ScrDyn *opti scr_url_release(url); return result; } + +/* ── node:punycode (deprecated, DEP0040) ─────────────────────────────── + * A port of the punycode.js 2.1.0 that Node 24 bundles as lib/punycode.js. + * It works on UTF-16 code units: ucs2.decode pairs surrogates and keeps + * lone ones, which is exactly a code-point walk over WTF-8 storage. + * Errors throw the module's RangeErrors ("Overflow: ...", "Illegal input + * >= 0x80 ...", "Invalid input"); decoded code points past U+10FFFF throw + * String.fromCodePoint's "Invalid code point N". All inputs are borrowed; + * results are +1, or NULL with the error pending. The DEP0040 report is + * scr_punycode_warn (scr_async_dyn.c). */ + +enum { + PC_MAX_INT = 2147483647, + PC_BASE = 36, + PC_TMIN = 1, + PC_TMAX = 26, + PC_SKEW = 38, + PC_DAMP = 700, + PC_INITIAL_BIAS = 72, + PC_INITIAL_N = 128 +}; + +static void pc_error(const char *message) { + scr_throw_error_msg(SCR_ERR_RANGE, message, strlen(message)); +} +#define PC_OVERFLOW "Overflow: input needs wider integers to process" +#define PC_NOT_BASIC "Illegal input >= 0x80 (not a basic code point)" +#define PC_INVALID "Invalid input" + +typedef struct { + uint32_t *data; + size_t len, cap; +} PcPoints; + +static void pc_push(PcPoints *p, uint32_t cp) { + if (p->len == p->cap) { + size_t cap = p->cap ? p->cap * 2 : 16; + uint32_t *grown = realloc(p->data, cap * sizeof *grown); + if (!grown) scr_trap("scriptc: out of memory\n"); + p->data = grown; + p->cap = cap; + } + p->data[p->len++] = cp; +} + +/* ucs2decode over WTF-8 storage: a pair is one 4-byte sequence, a lone + * surrogate one 3-byte sequence carrying its own value. */ +static void pc_code_points(const char *s, size_t n, PcPoints *out) { + const unsigned char *b = (const unsigned char *)s; + for (size_t i = 0; i < n;) { + uint32_t c = b[i]; + if (c < 0x80) { + i += 1; + } else if (c < 0xE0 && i + 1 < n) { + c = ((c & 0x1F) << 6) | (b[i + 1] & 0x3F); + i += 2; + } else if (c < 0xF0 && i + 2 < n) { + c = ((c & 0x0F) << 12) | ((uint32_t)(b[i + 1] & 0x3F) << 6) | (b[i + 2] & 0x3F); + i += 3; + } else if (i + 3 < n) { + c = ((c & 0x07) << 18) | ((uint32_t)(b[i + 1] & 0x3F) << 12) | + ((uint32_t)(b[i + 2] & 0x3F) << 6) | (b[i + 3] & 0x3F); + i += 4; + } else { + i += 1; + } + pc_push(out, c); + } +} + +static int64_t pc_adapt(int64_t delta, int64_t points, bool first) { + int64_t k = 0; + delta = first ? delta / PC_DAMP : delta >> 1; + delta += delta / points; + for (; delta > ((PC_BASE - PC_TMIN) * PC_TMAX) >> 1; k += PC_BASE) delta /= PC_BASE - PC_TMIN; + return k + (PC_BASE - PC_TMIN + 1) * delta / (delta + PC_SKEW); +} + +static int64_t pc_basic_to_digit(unsigned char c) { + if (c >= 0x30 && c < 0x3A) return 26 + (c - 0x30); + if (c >= 0x41 && c < 0x5B) return c - 0x41; + if (c >= 0x61 && c < 0x7B) return c - 0x61; + return PC_BASE; +} + +static char pc_digit_to_basic(int64_t digit) { + return (char)(digit + 22 + 75 * (digit < 26)); +} + +/* String.fromCodePoint(...points). */ +static ScrStr *pc_from_points(const PcPoints *p) { + for (size_t i = 0; i < p->len; i++) { + if (p->data[i] > 0x10FFFF) { + char message[48]; + int size = snprintf(message, sizeof message, "Invalid code point %u", (unsigned)p->data[i]); + scr_throw_error_msg(SCR_ERR_RANGE, message, (size_t)size); + return NULL; + } + } + char *out = malloc(p->len * 4 + 1); + if (!out) scr_trap("scriptc: out of memory\n"); + size_t offset = 0; + for (size_t i = 0; i < p->len; i++) { + uint32_t point = p->data[i]; + if (point >= 0xD800 && point <= 0xDBFF && i + 1 < p->len && p->data[i + 1] >= 0xDC00 && + p->data[i + 1] <= 0xDFFF) { + point = 0x10000 + ((point - 0xD800) << 10) + (p->data[i + 1] - 0xDC00); + i++; + } + offset += scr_wtf8_put(out + offset, point); + } + ScrStr *result = scr_str_new(out, offset); + free(out); + return result; +} + +/* punycode.decode over the bytes of one label. Any non-ASCII byte fails + * where its first code unit would: before the last delimiter it is not + * basic, after it it is no digit — so byte positions stand in for code + * unit positions until the first error. */ +static ScrStr *pc_decode(const char *s, size_t n) { + PcPoints output = {0}; + size_t basic = 0; + for (size_t k = n; k > 0; k--) { + if (s[k - 1] == '-') { + basic = k - 1; + break; + } + } + ScrStr *result = NULL; + for (size_t j = 0; j < basic; j++) { + if ((unsigned char)s[j] >= 0x80) { + pc_error(PC_NOT_BASIC); + goto done; + } + pc_push(&output, (unsigned char)s[j]); + } + int64_t i = 0, cp = PC_INITIAL_N, bias = PC_INITIAL_BIAS; + for (size_t index = basic > 0 ? basic + 1 : 0; index < n;) { + int64_t old = i; + for (int64_t w = 1, k = PC_BASE;; k += PC_BASE) { + if (index >= n) { + pc_error(PC_INVALID); + goto done; + } + int64_t digit = pc_basic_to_digit((unsigned char)s[index++]); + if (digit >= PC_BASE) { + pc_error(PC_INVALID); + goto done; + } + if (digit > (PC_MAX_INT - i) / w) { + pc_error(PC_OVERFLOW); + goto done; + } + i += digit * w; + int64_t t = k <= bias ? PC_TMIN : (k >= bias + PC_TMAX ? PC_TMAX : k - bias); + if (digit < t) break; + if (w > PC_MAX_INT / (PC_BASE - t)) { + pc_error(PC_OVERFLOW); + goto done; + } + w *= PC_BASE - t; + } + int64_t out = (int64_t)output.len + 1; + bias = pc_adapt(i - old, out, old == 0); + if (i / out > PC_MAX_INT - cp) { + pc_error(PC_OVERFLOW); + goto done; + } + cp += i / out; + i %= out; + pc_push(&output, 0); + memmove(output.data + i + 1, output.data + i, (output.len - 1 - (size_t)i) * sizeof *output.data); + output.data[i++] = (uint32_t)cp; + } + result = pc_from_points(&output); +done: + free(output.data); + return result; +} + +/* punycode.encode of one label's code points, appended to buf. */ +static bool pc_encode(const PcPoints *input, ScrJsonBuf *buf) { + int64_t cp = PC_INITIAL_N, delta = 0, bias = PC_INITIAL_BIAS; + size_t basic_length = 0; + for (size_t k = 0; k < input->len; k++) { + if (input->data[k] < 0x80) { + scr_jb_putc(buf, (char)input->data[k]); + basic_length++; + } + } + size_t handled = basic_length; + if (basic_length) scr_jb_putc(buf, '-'); + while (handled < input->len) { + int64_t m = PC_MAX_INT; + for (size_t k = 0; k < input->len; k++) + if (input->data[k] >= cp && input->data[k] < m) m = input->data[k]; + int64_t handled_plus_one = (int64_t)handled + 1; + if (m - cp > (PC_MAX_INT - delta) / handled_plus_one) { + pc_error(PC_OVERFLOW); + return false; + } + delta += (m - cp) * handled_plus_one; + cp = m; + for (size_t k = 0; k < input->len; k++) { + int64_t current = input->data[k]; + if (current < cp && ++delta > PC_MAX_INT) { + pc_error(PC_OVERFLOW); + return false; + } + if (current == cp) { + int64_t q = delta; + for (int64_t j = PC_BASE;; j += PC_BASE) { + int64_t t = j <= bias ? PC_TMIN : (j >= bias + PC_TMAX ? PC_TMAX : j - bias); + if (q < t) break; + scr_jb_putc(buf, pc_digit_to_basic(t + (q - t) % (PC_BASE - t))); + q = (q - t) / (PC_BASE - t); + } + scr_jb_putc(buf, pc_digit_to_basic(q)); + bias = pc_adapt(delta, handled_plus_one, handled == basic_length); + delta = 0; + handled++; + } + } + delta++; + cp++; + } + return true; +} + +ScrStr *scr_punycode_decode(const ScrStr *input) { return pc_decode(input->data, input->len); } + +ScrStr *scr_punycode_encode(const ScrStr *input) { + PcPoints points = {0}; + pc_code_points(input->data, input->len, &points); + ScrJsonBuf buf; + scr_jb_init(&buf); + bool ok = pc_encode(&points, &buf); + free(points.data); + ScrStr *result = scr_jb_finish(&buf); + if (ok) return result; + scr_str_release(result); + return NULL; +} + +ScrArr *scr_punycode_ucs2_decode(const ScrStr *input) { + PcPoints points = {0}; + pc_code_points(input->data, input->len, &points); + ScrArr *out = scr_arr_new(SCR_ELEM_F64, points.len); + for (size_t i = 0; i < points.len; i++) scr_arr_push_f64(out, (double)points.data[i]); + free(points.data); + return out; +} + +/* mapDomain: only the part after the first '@' maps (and only up to a + * second one — punycode.js keeps parts[0] and parts[1]); the RFC 3490 + * separators U+002E, U+3002, U+FF0E and U+FF61 all split labels, and the + * labels rejoin with '.'. */ +static ScrStr *pc_map_domain(const ScrStr *input, bool to_ascii) { + const char *s = input->data; + size_t n = input->len; + ScrJsonBuf buf; + scr_jb_init(&buf); + const char *at = memchr(s, '@', n); + if (at) { + scr_jb_write(&buf, s, (size_t)(at - s) + 1); + const char *rest = at + 1; + const char *second = memchr(rest, '@', n - (size_t)(rest - s)); + n = (second ? (size_t)(second - rest) : n - (size_t)(rest - s)); + s = rest; + } + size_t start = 0; + bool ok = true; + for (size_t i = 0; ok && i <= n;) { + size_t sep = 0; + if (i == n) sep = 1; + else if (s[i] == '.') sep = 1; + else if (i + 2 < n && (unsigned char)s[i] == 0xE3 && (unsigned char)s[i + 1] == 0x80 && + (unsigned char)s[i + 2] == 0x82) + sep = 3; + else if (i + 2 < n && (unsigned char)s[i] == 0xEF && + (((unsigned char)s[i + 1] == 0xBC && (unsigned char)s[i + 2] == 0x8E) || + ((unsigned char)s[i + 1] == 0xBD && (unsigned char)s[i + 2] == 0xA1))) + sep = 3; + if (!sep) { + i++; + continue; + } + const char *label = s + start; + size_t len = i - start; + bool ascii = true; + for (size_t k = 0; k < len; k++) ascii = ascii && (unsigned char)label[k] < 0x80; + if (to_ascii && !ascii) { + PcPoints points = {0}; + pc_code_points(label, len, &points); + scr_jb_puts(&buf, "xn--"); + ok = pc_encode(&points, &buf); + free(points.data); + } else if (!to_ascii && len >= 4 && memcmp(label, "xn--", 4) == 0) { + /* string.slice(4).toLowerCase(): ASCII letters fold; the one other + * code point whose lowercase is ASCII is KELVIN SIGN (U+212A → k). + * Any other non-ASCII stays non-ASCII and fails in decode either + * way, at the same position. */ + ScrJsonBuf lower; + scr_jb_init(&lower); + for (size_t k = 4; k < len; k++) { + unsigned char c = (unsigned char)label[k]; + if (c == 0xE2 && k + 2 < len && (unsigned char)label[k + 1] == 0x84 && + (unsigned char)label[k + 2] == 0xAA) { + scr_jb_putc(&lower, 'k'); + k += 2; + } else { + scr_jb_putc(&lower, (char)(c >= 'A' && c <= 'Z' ? c + 32 : c)); + } + } + ScrStr *folded = scr_jb_finish(&lower); + ScrStr *decoded = pc_decode(folded->data, folded->len); + scr_str_release(folded); + if (decoded) { + scr_jb_put_str(&buf, decoded); + scr_str_release(decoded); + } else { + ok = false; + } + } else { + scr_jb_write(&buf, label, len); + } + if (ok && i < n) scr_jb_putc(&buf, '.'); + i += sep; + start = i; + } + ScrStr *result = scr_jb_finish(&buf); + if (ok) return result; + scr_str_release(result); + return NULL; +} + +ScrStr *scr_punycode_to_ascii(const ScrStr *input) { return pc_map_domain(input, true); } +ScrStr *scr_punycode_to_unicode(const ScrStr *input) { return pc_map_domain(input, false); } diff --git a/packages/runtime/src/scr_url.h b/packages/runtime/src/scr_url.h index 134893054..05e5702bb 100644 --- a/packages/runtime/src/scr_url.h +++ b/packages/runtime/src/scr_url.h @@ -53,6 +53,15 @@ ScrDyn *scr_url_legacy_parse(const ScrDyn *url, const ScrDyn *parse_query, const ScrStr *scr_url_legacy_format(const ScrDyn *object, const ScrDyn *options, bool warn); ScrStr *scr_url_legacy_resolve(const ScrDyn *from, const ScrDyn *to, bool warn); ScrStr *scr_url_domain_to_ascii(const ScrDyn *domain, bool missing); +/* node:punycode (scr_url.c): punycode.js 2.1.0's encode/decode, toASCII/ + * toUnicode (mapDomain over RFC 3490 separators) and ucs2.decode (UTF-16 + * code points as an f64 array). Borrowed input; +1, or NULL with the + * module's RangeError pending. */ +ScrStr *scr_punycode_decode(const ScrStr *input); +ScrStr *scr_punycode_encode(const ScrStr *input); +ScrStr *scr_punycode_to_ascii(const ScrStr *input); +ScrStr *scr_punycode_to_unicode(const ScrStr *input); +ScrArr *scr_punycode_ucs2_decode(const ScrStr *input); ScrStr *scr_url_domain_to_unicode(const ScrDyn *domain, bool missing); ScrStr *scr_url_to_path(ScrUrl *u); /* +1, or throws */ ScrStr *scr_url_str_to_path(ScrStr *s); /* +1, or throws */ diff --git a/tests/corpus/6122-punycode-conversions.ts b/tests/corpus/6122-punycode-conversions.ts new file mode 100644 index 000000000..061987a78 --- /dev/null +++ b/tests/corpus/6122-punycode-conversions.ts @@ -0,0 +1,45 @@ +// @exit: 1 +// @no-node-shims +// node:punycode's conversions (punycode.js 2.1.0): encode/decode of bare +// labels, toASCII/toUnicode over domains and email addresses (only the part +// after '@' maps; every RFC 3490 separator splits labels), ucs2 code point +// arrays with astral pairs and lone surrogates, and the RangeErrors of +// malformed input. A static import links the module before anything runs, +// so its DEP0040 report precedes the program and no 'warning' listener the +// program registers sees it (the Node side runs without the preload shim, +// which would delay that report past the program's first turn). The report +// carries the process id, so the @exit directive keeps stderr out of the +// comparison. +import * as punycode from "node:punycode"; + +process.on("warning", (w) => console.log("listener", w.name)); + +const show = (label: string, f: () => unknown): void => { + try { + console.log(label, JSON.stringify(f())); + } catch (e) { + const err = e as Error; + console.log(label, "threw", err instanceof RangeError, err.name, err.message); + } +}; + +console.log(punycode.version); +for (const s of ["mañana", "bücher", "ü", "abc", "", "-> $1.00 <-", "ليهمابتكلموشعربي؟", "他们为什么不说中文", "😀x", "MaJiでKoiする5秒前"]) { + show(`encode ${s}`, () => punycode.encode(s)); + show(`roundtrip ${s}`, () => punycode.decode(punycode.encode(s))); +} +for (const s of ["maana-pta", "bcher-kva", "tda", "abc-", "-abc", "ABC-kva", "a-b-c-kva", "ü-kva", "kva-ü", "99999999999", "zzzzzzzzzzzzz", "a-", "-"]) { + show(`decode ${s}`, () => punycode.decode(s)); +} +for (const d of ["mañana.com", "bücher.de", "example.com", "", "a..b", "ex@mple@bücher.de", "user@bücher.de", "日本。jp", "a.b。c", "ÄÖÜ.test", "a.b."]) { + show(`toASCII ${d}`, () => punycode.toASCII(d)); +} +for (const d of ["xn--maana-pta.com", "xn--bcher-kva.de", "XN--bcher-kva.de", "xn--BCHER-KVA.de", "example.com", "user@xn--bcher-kva.de", "xn--.x", "xn--ü.x", "xn--zzzzzzzzzzzzz.x", "xn--a-Kva.x"]) { + show(`toUnicode ${d}`, () => punycode.toUnicode(d)); +} +show("ucs2.decode", () => punycode.ucs2.decode("aé😀\ud800b\udc00")); +show("ucs2.encode", () => punycode.ucs2.encode([97, 0xe9, 0x1f600, 0xd800, 0xdc00, 98])); +show("ucs2.encode join", () => punycode.ucs2.encode([0xd83d, 0xde00]).length); +show("ucs2.encode range", () => punycode.ucs2.encode([0x110000])); +show("ucs2 roundtrip", () => punycode.ucs2.encode(punycode.ucs2.decode("x😀\udfffy")) === "x😀\udfffy"); +process.exitCode = 1; diff --git a/tests/corpus/6124-punycode-require-warnings.cjs b/tests/corpus/6124-punycode-require-warnings.cjs new file mode 100644 index 000000000..c3255e6b7 --- /dev/null +++ b/tests/corpus/6124-punycode-require-warnings.cjs @@ -0,0 +1,32 @@ +// DEP0040 for require("punycode"): reported once, on the next tick after +// the requiring turn, to the stderr report and the 'warning' listeners; +// require("node:punycode") is the same module and does not report again. +// process.noDeprecation drops the report and the listener call. The program +// re-runs itself per case and prints each child's output with process ids +// normalized. +"use strict"; +const { spawnSync } = require("child_process"); + +const mode = process.argv[2]; +if (mode === "direct") { + process.on("warning", (w) => console.log("listener", w.name, w.code, w.message)); + process.nextTick(() => console.log("earlier tick")); + console.log("before require"); + const direct = require("punycode"); + console.log("after require", direct.toASCII("bücher.de")); + const prefixed = require("node:punycode"); + console.log("prefixed", prefixed.toUnicode("xn--bcher-kva.de")); + setTimeout(() => console.log("timer"), 0); +} else if (mode === "silenced") { + process.on("warning", (w) => console.log("listener", w.code)); + process.noDeprecation = true; + const silencedModule = require("punycode"); + console.log("silenced", silencedModule.encode("ü")); +} else { + for (const child of ["direct", "silenced"]) { + const run = spawnSync(process.execPath, [process.argv[1], child], { encoding: "utf8" }); + console.log(`== ${child}: status ${run.status}`); + console.log(run.stdout); + console.log(run.stderr.replace(/\(node:\d+\)/g, "(node:)")); + } +} diff --git a/tests/corpus/6125-constants-module.cjs b/tests/corpus/6125-constants-module.cjs new file mode 100644 index 000000000..d7a547ac5 --- /dev/null +++ b/tests/corpus/6125-constants-module.cjs @@ -0,0 +1,26 @@ +// node:constants (deprecated in documentation only, so no warning): one +// frozen object merging os.constants' dlopen, errno, priority and signals +// groups with fs.constants and crypto.constants, in that order. Members +// read like the owning modules' constants, and a constant the platform +// lacks is absent (graceful-fs probes O_SYMLINK with hasOwnProperty). +const constants = require("constants"); +const nodeConstants = require("node:constants"); +const os = require("os"); + +console.log(typeof constants, Object.isFrozen(constants), constants === nodeConstants); +console.log(constants.O_RDONLY, constants.O_WRONLY, constants.O_RDWR); +console.log(constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC); +console.log(constants.S_IFMT, constants.S_IFREG, constants.S_IFDIR, constants.S_IRWXU); +console.log(constants.F_OK, constants.R_OK, constants.W_OK, constants.X_OK); +console.log(constants.COPYFILE_EXCL, constants.UV_FS_COPYFILE_FICLONE, constants.UV_DIRENT_DIR); +console.log(constants.SIGTERM === os.constants.signals.SIGTERM, constants.SIGINT); +console.log(constants.ENOENT === os.constants.errno.ENOENT, constants.EACCES); +console.log(constants.PRIORITY_HIGH, constants.PRIORITY_LOW, constants.RTLD_LAZY); +console.log(constants.TLS1_2_VERSION, constants.RSA_PKCS1_PADDING, typeof constants.defaultCoreCipherList); +console.log(constants.hasOwnProperty("O_SYMLINK") === ("O_SYMLINK" in constants)); +console.log(constants.hasOwnProperty("O_RDWR"), "SIGKILL" in constants, "UV_UDP_REUSEADDR" in constants); +console.log(constants.hasOwnProperty("NOT_A_CONSTANT"), "dlopen" in constants, "errno" in constants); +const keys = Object.keys(constants); +console.log(keys.indexOf("RTLD_LAZY") < keys.indexOf("E2BIG"), keys.indexOf("E2BIG") < keys.indexOf("PRIORITY_LOW")); +console.log(keys.indexOf("PRIORITY_LOW") < keys.indexOf("SIGHUP"), keys.indexOf("SIGHUP") < keys.indexOf("O_RDONLY")); +console.log(keys.indexOf("O_RDONLY") < keys.indexOf("OPENSSL_VERSION_NUMBER"), keys.includes("POINT_CONVERSION_HYBRID")); diff --git a/tests/corpus/6126-punycode-dependency/main.cjs b/tests/corpus/6126-punycode-dependency/main.cjs new file mode 100644 index 000000000..530503a71 --- /dev/null +++ b/tests/corpus/6126-punycode-dependency/main.cjs @@ -0,0 +1,12 @@ +// A dependency under node_modules that requires node:punycode: Node's +// isInsideNodeModules() rule skips DEP0040 for it, so nothing reaches stderr +// or the 'warning' listeners, and the program's own later require of the +// cached module does not report either. +"use strict"; +process.on("warning", (w) => console.log("listener", w.code)); +const toAscii = require("puny-dep"); +const punycode = require("punycode"); + +console.log(toAscii("mañana.com"), toAscii("example.org")); +console.log(punycode.toUnicode("xn--maana-pta.com")); +setTimeout(() => console.log("timer"), 0); diff --git a/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.d.ts b/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.d.ts new file mode 100644 index 000000000..89b712210 --- /dev/null +++ b/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.d.ts @@ -0,0 +1,2 @@ +declare function toAscii(domain: string): string; +export = toAscii; diff --git a/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.js b/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.js new file mode 100644 index 000000000..417c44889 --- /dev/null +++ b/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/index.js @@ -0,0 +1,8 @@ +// A dependency requiring node:punycode: Node's isInsideNodeModules() rule +// skips DEP0040 for it. +"use strict"; +const punycode = require("punycode"); + +module.exports = function toAscii(domain) { + return punycode.toASCII(domain); +}; diff --git a/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/package.json b/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/package.json new file mode 100644 index 000000000..71edeb25c --- /dev/null +++ b/tests/corpus/6126-punycode-dependency/node_modules/puny-dep/package.json @@ -0,0 +1,5 @@ +{ + "name": "puny-dep", + "version": "1.0.0", + "main": "index.js" +} From fd6145f6270bb4852eff7ce34a01b46860f3729a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:17:24 -0700 Subject: [PATCH 07/34] Test the dispose hook only in programs that can install it Every emitted free loaded the runtime's dispose hook and branched on it, and every runtime array, map and byte-store free made an out-of-line call to scr_weak_dispose to do the same. The hook has two clients, weak collections and the kept sources of checked records, and a program that has neither never installs it. The emitted frees now test two module constants the emitter defines after every helper is emitted: whether the program constructs a WeakMap or WeakSet, and whether some helper can keep a record source (a checked record builder that notes one, or a JSON schema of a closed record). Class instances test the first, closed record layouts both; LLVM folds the test away, so a program without either frees with no hook load at all. The runtime's hot frees test the hook inline instead of calling. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/alloc.test.ts | 31 +++++++++- packages/compiler/src/backend/llvm/alloc.ts | 58 ++++++++++++++++--- packages/compiler/src/backend/llvm/dyn.ts | 6 ++ packages/compiler/src/backend/llvm/emitter.ts | 27 ++++++++- packages/compiler/src/backend/llvm/shapes.ts | 7 ++- packages/runtime/src/scr_array.c | 2 +- packages/runtime/src/scr_blob.c | 2 +- packages/runtime/src/scr_bytes.c | 2 +- packages/runtime/src/scr_events.c | 2 +- packages/runtime/src/scr_fetch.c | 4 +- packages/runtime/src/scr_map.c | 4 +- packages/runtime/src/scr_rc.h | 9 ++- .../corpus/6162-dispose-observer-weak-keys.ts | 41 +++++++++++++ 13 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 tests/corpus/6162-dispose-observer-weak-keys.ts diff --git a/packages/compiler/src/backend/llvm/alloc.test.ts b/packages/compiler/src/backend/llvm/alloc.test.ts index 5ba7ff3d1..980c7ab7a 100644 --- a/packages/compiler/src/backend/llvm/alloc.test.ts +++ b/packages/compiler/src/backend/llvm/alloc.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vitest"; -import { type AllocHost, emitObjectAlloc, emitObjectFree, inlineAllocSupported } from "./alloc.js"; +import { + type AllocHost, + emitObjectAlloc, + emitObjectFree, + inlineAllocSupported, +} from "./alloc.js"; function host(inlineAlloc: boolean): AllocHost & { decls: Set; oom: boolean } { const h = { @@ -63,7 +68,12 @@ describe("emitted object allocation", () => { ]); expect(emitObjectFree(h, false)).toEqual([ " call void @scr_obj_free_note()", + " %sf.obs = load i1, ptr @sc_weak_keys", + " br i1 %sf.obs, label %sf.observe, label %sf.block", + "sf.observe:", " call void @scr_weak_dispose(ptr %o)", + " br label %sf.block", + "sf.block:", " call void @scr_rt_free(ptr %o)", ]); expect([...h.decls].some((d) => d.includes("@scr_sa"))).toBe(false); @@ -167,4 +177,23 @@ describe("emitted object allocation", () => { expect(lines.at(-1)).toBe("sf.done:"); } }); + + test("frees test the dispose hook only where the program can observe them", () => { + const h = host(true); + const weak = emitObjectFree(h, false); + expectSingleDefinitions(weak); + expect(weak.slice(0, 2)).toEqual([ + " %sf.obs = load i1, ptr @sc_weak_keys", + " br i1 %sf.obs, label %sf.hooked, label %sf.block", + ]); + const records = emitObjectFree(h, true, "weak-or-source"); + expectSingleDefinitions(records); + expect(records.slice(0, 4)).toEqual([ + " %sf.obsw = load i1, ptr @sc_weak_keys", + " %sf.obss = load i1, ptr @sc_record_sources", + " %sf.obs = or i1 %sf.obsw, %sf.obss", + " br i1 %sf.obs, label %sf.hooked, label %sf.block", + ]); + expect(records).toContain(" call void %sf.hook(ptr %o)"); + }); }); diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index 7dfc385aa..ceec8a54d 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -325,20 +325,56 @@ function allocLines( return lines; } +/* ── dispose observers ──────────────────────────────────────────────── + * The runtime's dispose hook (scr_weak_dispose_hook) has two clients: weak + * collections, whose keys can be any object, and the kept sources of + * checked records (scr_record_source_note and the JSON schema parser), + * keyed by closed records. A program without either can never install the + * hook, so its frees need not test it. Whether the program has them is + * known only once every helper is emitted (dynCheck builders and JSON + * schemas are requested lazily), so the emitted frees and record writers + * test these two module constants, which the emitter defines last + * (LlvmEmitter.emitParts) and LLVM folds away. */ + +/** True when the program constructs a WeakMap or WeakSet. */ +export const WEAK_KEYS_GATE = "@sc_weak_keys"; +/** True when a checked conversion can keep a record's undeclared source. */ +export const RECORD_SOURCES_GATE = "@sc_record_sources"; + +/** Which dispose observers an object type can have: weak-collection keys + * only, or (closed record layouts) kept record sources too. */ +export type FreeObservers = "weak" | "weak-or-source"; + /** * Lines that free the object `%o` after its members were released: the - * RC-audit free note, the weak-collection dispose hook, and the block — - * `scr_cyc_free` for collector-headered objects, `scr_rt_free` otherwise. - * Labels use the `sf.` prefix; the lines end in an open block. + * RC-audit free note, the dispose hook (when the program can observe this + * type's disposals), and the block — `scr_cyc_free` for collector-headered + * objects, `scr_rt_free` otherwise. Labels use the `sf.` prefix; the lines + * end in an open block. */ -export function emitObjectFree(host: AllocHost, cyc: boolean): string[] { - const lines = threadLocalState(host, "sf", freeLines(host, cyc)); +export function emitObjectFree( + host: AllocHost, + cyc: boolean, + observers: FreeObservers = "weak", +): string[] { + const lines = threadLocalState(host, "sf", freeLines(host, cyc, observers)); if (host.objectAudit === false) return lines.filter((line) => line !== FREE_NOTE); host.declare(`declare void @scr_obj_free_note()`); return lines; } -function freeLines(host: AllocHost, cyc: boolean): string[] { +/** Lines that leave in `%.obs` whether the program can observe a + * disposal of this type (folds to a constant). */ +function observedLines(prefix: string, observers: FreeObservers): string[] { + if (observers === "weak") return [` %${prefix}.obs = load i1, ptr ${WEAK_KEYS_GATE}`]; + return [ + ` %${prefix}.obsw = load i1, ptr ${WEAK_KEYS_GATE}`, + ` %${prefix}.obss = load i1, ptr ${RECORD_SOURCES_GATE}`, + ` %${prefix}.obs = or i1 %${prefix}.obsw, %${prefix}.obss`, + ]; +} + +function freeLines(host: AllocHost, cyc: boolean, observers: FreeObservers): string[] { if (host.inlineAlloc !== true) { if (cyc) { host.declare(`declare void @scr_cyc_free(ptr)`); @@ -348,7 +384,12 @@ function freeLines(host: AllocHost, cyc: boolean): string[] { host.declare(`declare void @scr_weak_dispose(ptr)`); return [ ` call void @scr_obj_free_note()`, + ...observedLines("sf", observers), + ` br i1 %sf.obs, label %sf.observe, label %sf.block`, + `sf.observe:`, ` call void @scr_weak_dispose(ptr %o)`, + ` br label %sf.block`, + `sf.block:`, ` call void @scr_rt_free(ptr %o)`, ]; } @@ -356,11 +397,14 @@ function freeLines(host: AllocHost, cyc: boolean): string[] { host.declare(`declare void @scr_rt_free(ptr)`); declareGlobal(host, `@scr_weak_dispose_hook = external global ptr`); const lines = [ + ...observedLines("sf", observers), + ` br i1 %sf.obs, label %sf.hooked, label %sf.block`, + `sf.hooked:`, ` %sf.hook = load ptr, ptr @scr_weak_dispose_hook`, ` %sf.has = icmp ne ptr %sf.hook, null`, ` %sf.hasx = call i1 @llvm.expect.i1(i1 %sf.has, i1 false)`, ` br i1 %sf.hasx, label %sf.observe, label %sf.block`, - `sf.observe: ; weak collections observe the disposal`, + `sf.observe: ; a weak collection or a kept record source observes the disposal`, ` call void %sf.hook(ptr %o)`, ` br label %sf.block`, `sf.block:`, diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index bb35e70c2..2e8c0968f 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1106,6 +1106,10 @@ export class LlDyn { private readonly jsonSchemas = new Map(); private readonly jsonParsers = new Map(); private readonly recordNameTables = new Map(); + /** Some emitted helper can keep a record's undeclared source: a checked + * record builder that notes it, or a JSON schema of a closed record + * (alloc.ts RECORD_SOURCES_GATE). */ + keepsRecordSources = false; /** Targets the one-pass parser (scr_json_parse_schema) can build * directly: number, boolean and string leaves inside arrays and records @@ -1220,6 +1224,7 @@ export class LlDyn { const index = new Map(shape.fields.map((f, i) => [f.name, i + 1])); // The runtime names kept-source keys by this index (record-sources.ts). const order = recordSourceFieldOrder(shape); + if (recordMayKeepSource(shape)) this.keepsRecordSources = true; const fieldTy = `{ ptr, ${S}, ${S}, ptr }`; const entries = order.map((fieldName) => { const field = shape.fields[index.get(fieldName)! - 1]!; @@ -1978,6 +1983,7 @@ export class LlDyn { // that has them stays beside the record so its reflective // surfaces still observe them (scr_record_source_note). if (recordMayKeepSource(shape)) { + this.keepsRecordSources = true; const names = this.recordNameTable(t.shapeId, shape); host.declare( `declare void @scr_record_source_note(ptr, ptr, ptr, ptr, ${host.sizeType})`, diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2cccda19c..c6ae04917 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2,7 +2,14 @@ import { type ArraySearchKind, emitArraySearchHelpers } from "./array-search.js" import { emitStringInputs } from "./string-lifetimes.js"; import { emitLlvmLayouts } from "./layouts.js"; import { compileRegexMatcher, type RegexMatcherCode } from "./regex-matchers.js"; -import { CYC_BUF, CYC_HDR, FIRST_EMITTED_CYC_KIND, inlineAllocSupported } from "./alloc.js"; +import { + CYC_BUF, + CYC_HDR, + FIRST_EMITTED_CYC_KIND, + inlineAllocSupported, + RECORD_SOURCES_GATE, + WEAK_KEYS_GATE, +} from "./alloc.js"; import { hasLoneSurrogate, llvmBytes as llBytes, wtf8Bytes } from "../literals.js"; import { InternalCompilerError } from "../../errors.js"; import { createHash } from "node:crypto"; @@ -1899,7 +1906,10 @@ export class LlEmitter { } emitParts(): string[] { - const parts = limitInlineDynReleases(this.emitModuleParts()); + const parts = [ + ...limitInlineDynReleases(this.emitModuleParts()), + ...this.disposeObserverGates(), + ]; if (!this.executableTls) return parts; // A worker executable's thread-locals all live in the executable's own // TLS block: the program's internal ones, and the runtime's external @@ -1922,6 +1932,19 @@ export class LlEmitter { ); } + /** The dispose-observer gates the emitted frees and record writers test + * (alloc.ts): defined after every helper is emitted, because dynCheck + * builders, JSON schemas and weak collection constructors are requested + * lazily. */ + private disposeObserverGates(): string[] { + const weak = [...this.decls].some((d) => /@scr_weak_(?:map|set)_new\(/.test(d)); + return [ + `${WEAK_KEYS_GATE} = internal constant i1 ${weak}`, + `${RECORD_SOURCES_GATE} = internal constant i1 ${this.dyn.keepsRecordSources}`, + ``, + ]; + } + private emitModuleParts(): string[] { // Function bodies first (the literal/unit/fn-value tables fill as they // emit), then the file assembles around them — the runtime ABI’s order. diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 23565eff4..5f916dc6a 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -38,6 +38,7 @@ import { stackUnionBox, type InlineUnionFields, } from "./inline-unions.js"; +import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; /** What the tables need from the emitter: the extern-declaration ledger @@ -1404,7 +1405,9 @@ export function emitRecordShapes( } t++; } - freeBody.push(...emitObjectFree(host, traced)); + // Closed layouts can carry a kept record source (record-sources.ts). + const observers = recordMayKeepSource(shape) ? "weak-or-source" : "weak"; + freeBody.push(...emitObjectFree(host, traced, observers)); defs.push( ...releaseBody( host, @@ -1514,7 +1517,7 @@ export function emitRecordShapes( ` call void ${releaseSym(host, m.type)}(ptr %v${i}) ; ${llvmCommentText(m.name)} (acyclic)`, ); }); - gf.push(...emitObjectFree(host, true), ` ret void`, `}`, ``); + gf.push(...emitObjectFree(host, true, observers), ` ret void`, `}`, ``); defs.push(...gf); } } diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index ef64be56d..4ef24e906 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -1179,7 +1179,7 @@ static void scr_arr_destroy(void *object) { #ifdef SCR_RC_AUDIT scr_live_arrays--; #endif - scr_weak_dispose(a); + scr_weak_dispose_inline(a); scr_mem_free(a); } } diff --git a/packages/runtime/src/scr_blob.c b/packages/runtime/src/scr_blob.c index 5c4f9ba68..817a85e2d 100644 --- a/packages/runtime/src/scr_blob.c +++ b/packages/runtime/src/scr_blob.c @@ -44,7 +44,7 @@ static void *scr_blob_retain_v(void *h) { static void scr_blob_release_v(void *h) { ScrBlob *b = h; if (--b->rc) return; - scr_weak_dispose(b); + scr_weak_dispose_inline(b); scr_blob_store_release(b->store); free(b->parts); scr_str_release(b->type); diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 28258a29c..d33f32a97 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -296,7 +296,7 @@ ScrBytes *scr_bytes_as_buffer(ScrBytes *bytes) { void scr_bytes_release(ScrBytes *b) { if (!b || b->rc == SIZE_MAX) return; /* NULL: an uninitialized `let` local */ if (--b->rc == 0) { - scr_weak_dispose(b); + scr_weak_dispose_inline(b); if (b->backing) { scr_bytes_release(b->backing); /* a view: data points into the owner */ } else if (b->shared) { diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 670ea10d6..54eb87a0d 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1061,7 +1061,7 @@ static void scr_stdio_cleanup(void) { scr_stdio_write_hook = NULL; for (int i = 0; i < 3; i++) { ScrStdio *stream = &scr_stdio_streams[i]; - scr_weak_dispose(stream); + scr_weak_dispose_inline(stream); scr_dyn_release(stream->write); stream->write = NULL; scr_dyn_release(stream->write_error); diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index f586b96f8..eff2ae6bc 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -2522,7 +2522,7 @@ static SfResponse *sf_response_retain(SfResponse *r) { static void sf_response_release(SfResponse *r) { if (!r || --r->rc > 0) return; - scr_weak_dispose(r); + scr_weak_dispose_inline(r); sf_stream_release(r->body); sf_headers_release(r->headers); scr_str_release(r->url); @@ -4760,7 +4760,7 @@ static void *sf_request_retain_v(void *ptr) { static void sf_request_release_v(void *ptr) { SfRequest *r = ptr; if (--r->rc) return; - scr_weak_dispose(r); + scr_weak_dispose_inline(r); sf_response_release(r->payload); scr_str_release(r->url); scr_str_release(r->method); scr_str_release(r->redirect); sf_signal_release(r->signal); free(r); diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 8e34533ad..a0af45b39 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -696,7 +696,7 @@ static void scr_map_trace(void *o, ScrTraceVisit visit, void *ctx) { static void scr_map_gcfree(void *o) { ScrMap *m = (ScrMap *)o; bool keys = m->traced & SCR_MAP_TRACED_KEY, vals = m->traced & SCR_MAP_TRACED_VAL; - scr_weak_dispose(m); + scr_weak_dispose_inline(m); for (size_t e = 0; e < m->nentries; e++) { if (!m->entries[e].hash) continue; if (!keys) scr_map_release_key(m, m->entries[e].key); @@ -751,7 +751,7 @@ ScrMap *scr_map_retain(ScrMap *m) { static void scr_map_destroy(void *object) { ScrMap *m = object; - scr_weak_dispose(m); + scr_weak_dispose_inline(m); for (size_t e = 0; e < m->nentries; e++) { if (!m->entries[e].hash) continue; scr_map_release_key(m, m->entries[e].key); diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index cdc03c966..4d6cc68b5 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -420,8 +420,15 @@ static inline void *scr_cyc_alloc_inline(size_t size, ScrCycType *type) { scr_cyc_live++; return h + 1; } -static inline void scr_cyc_free_inline(void *obj) { +/* scr_weak_dispose's NULL test inline, for the runtime's hot frees: the + * hook stays NULL unless the program creates a weak collection or keeps a + * checked record's source, so the free costs one predictable branch, not a + * call. */ +static inline void scr_weak_dispose_inline(void *obj) { if (scr_weak_dispose_hook) scr_weak_dispose_hook(obj); +} +static inline void scr_cyc_free_inline(void *obj) { + scr_weak_dispose_inline(obj); scr_cyc_live--; if (scr_cyc_gen(scr_cyc_hdr(obj)) == SCR_CYC_OLD) scr_cyc_old_freed++; scr_mem_free(scr_cyc_hdr(obj)); diff --git a/tests/corpus/6162-dispose-observer-weak-keys.ts b/tests/corpus/6162-dispose-observer-weak-keys.ts new file mode 100644 index 000000000..f9c5738fa --- /dev/null +++ b/tests/corpus/6162-dispose-observer-weak-keys.ts @@ -0,0 +1,41 @@ +// With a weak collection in the program, every object type's free tells +// the collection to forget the key: objects built later at a freed key's +// address (the allocator reuses blocks of the same size) are not members. +class Node { + id: number; + constructor(id: number) { + this.id = id; + } +} +interface Tag { + label: string; +} + +const seenNodes = new WeakSet(); +const seenTags = new WeakSet(); +const seenArrays = new WeakMap(); +for (let i = 0; i < 64; i++) { + seenNodes.add(new Node(i)); + seenTags.add({ label: "t" + i }); + seenArrays.set([i], "a" + i); +} +let hits = 0; +for (let i = 0; i < 64; i++) { + if (seenNodes.has(new Node(i))) hits++; + if (seenTags.has({ label: "t" + i })) hits++; + if (seenArrays.has([i])) hits++; +} +console.log("stale hits", hits); + +const kept = new Node(1); +const tag: Tag = { label: "kept" }; +seenNodes.add(kept); +seenTags.add(tag); +console.log(seenNodes.has(kept), seenTags.has(tag)); +seenNodes.delete(kept); +console.log(seenNodes.has(kept), seenTags.has(tag)); + +// Checked records with undeclared keys in the same program. +const parsed = JSON.parse('{"label":"x","more":1}') as Tag; +seenTags.add(parsed); +console.log(JSON.stringify(parsed), seenTags.has(parsed)); From 06e987b8484e7bb947a052e721779b64a3329a92 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:17:30 -0700 Subject: [PATCH 08/34] Skip the record-source lookup in typed JSON.stringify unless one was kept Every closed record written by a typed JSON.stringify called scr_jb_try_record_source first, an out-of-line call and table probe for records that almost never keep a source (2% of json-records samples). The record writer now tests the record-sources constant first, so a program without a checked conversion that can keep a source emits no lookup. Where one can, the writer tests the dispose hook inline before calling: keeping a source installs the hook, so a NULL hook means the table is empty, and only a program that has kept a source pays the call. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/alloc.test.ts | 8 ++++ packages/compiler/src/backend/llvm/alloc.ts | 11 +++++ packages/compiler/src/backend/llvm/walkers.ts | 19 ++++++++ tests/corpus/6161-dispose-observer-gates.ts | 47 +++++++++++++++++++ tests/corpus/6163-dispose-observer-workers.ts | 43 +++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 tests/corpus/6161-dispose-observer-gates.ts create mode 100644 tests/corpus/6163-dispose-observer-workers.ts diff --git a/packages/compiler/src/backend/llvm/alloc.test.ts b/packages/compiler/src/backend/llvm/alloc.test.ts index 980c7ab7a..49971c3ce 100644 --- a/packages/compiler/src/backend/llvm/alloc.test.ts +++ b/packages/compiler/src/backend/llvm/alloc.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { type AllocHost, + disposeHookLoadLines, emitObjectAlloc, emitObjectFree, inlineAllocSupported, @@ -196,4 +197,11 @@ describe("emitted object allocation", () => { ]); expect(records).toContain(" call void %sf.hook(ptr %o)"); }); + + test("loads the dispose hook from emitted code only with the inline allocator", () => { + expect(disposeHookLoadLines(host(false), "%t1")).toBeNull(); + expect(disposeHookLoadLines(host(true), "%t1")).toEqual([ + " %t1 = load ptr, ptr @scr_weak_dispose_hook", + ]); + }); }); diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index ceec8a54d..7fdc4b683 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -79,6 +79,17 @@ function threadLocalState(host: AllocHost, prefix: string, lines: string[]): str return [...preamble, ...text.split("\n")]; } +/** Lines that load the runtime's dispose hook into `dest`, or null on + * targets without the inline allocator, where the hook's storage is not + * addressed from emitted code. The hook is NULL until a weak collection + * or a kept record source installs it, and then never NULL again. */ +export function disposeHookLoadLines(host: AllocHost, dest: string): string[] | null { + if (host.inlineAlloc !== true) return null; + declareGlobal(host, `@scr_weak_dispose_hook = external global ptr`); + const prefix = dest.replace(/^%/, "").replace(/[^-$._A-Za-z0-9]/g, "_"); + return threadLocalState(host, prefix, [` ${dest} = load ptr, ptr @scr_weak_dispose_hook`]); +} + /** Worker executables: the calling script thread's ScrThreadHot block into * `dest`. Its address also identifies the thread (the owner word of a * lifetime-region chunk; llvm/regions.ts). */ diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index 9b8d80b87..6ab7205e9 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -28,6 +28,7 @@ import { traceAdapter, type ShapeHost, } from "./shapes.js"; +import { disposeHookLoadLines, RECORD_SOURCES_GATE } from "./alloc.js"; import { recordMayKeepSource, recordSourceFieldOrder } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import { emitUnionPeek, emitUnionTag } from "./union-repr.js"; @@ -396,6 +397,24 @@ export class LlWalkers { const writer = this.recordFieldWriter(shapeId); const lSource = B.newLabel("jw.source"); const lNative = B.newLabel("jw.native"); + // Only a program that can keep record sources looks one up (the gate + // folds to a constant), and only once one was kept: keeping a source + // installs the dispose hook, so a NULL hook means the table is empty. + const lHooked = B.newLabel("jw.hooked"); + const kept = B.tmp(); + B.line(`${kept} = load i1, ptr ${RECORD_SOURCES_GATE}`); + B.condBr(kept, lHooked, lNative); + B.startBlock(lHooked); + const hook = B.tmp(); + const hookLines = disposeHookLoadLines(host, hook); + if (hookLines !== null) { + const lProbe = B.newLabel("jw.probe"); + for (const line of hookLines) B.line(line.trimStart()); + const installed = B.tmp(); + B.line(`${installed} = icmp ne ptr ${hook}, null`); + B.condBr(installed, lProbe, lNative); + B.startBlock(lProbe); + } if (traceAdapter(host, { kind: "record", shapeId }) === null) { // One lookup: the runtime writes the record when it kept a source. host.declare(`declare zeroext i1 @scr_jb_try_record_source(ptr, ptr, ptr)`); diff --git a/tests/corpus/6161-dispose-observer-gates.ts b/tests/corpus/6161-dispose-observer-gates.ts new file mode 100644 index 000000000..aa53c9c85 --- /dev/null +++ b/tests/corpus/6161-dispose-observer-gates.ts @@ -0,0 +1,47 @@ +// A program without weak collections still keeps the undeclared keys of +// checked records, and forgets them when the record is freed: a record +// built later at the same address (the allocator reuses freed blocks of +// its size) serializes only its own fields. Class instances and records +// of shapes that never keep a source free without the dispose hook. +interface Row { + id: number; + name: string; +} +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +function parsedRows(round: number): Row[] { + const text = + `[{"id":${round},"extra":"kept-${round}","name":"a"},` + + `{"name":"b","id":${round + 1}},` + + `{"id":${round + 2},"name":"c","tags":[${round}]}]`; + return JSON.parse(text) as Row[]; +} + +let checksum = 0; +for (let round = 0; round < 3; round++) { + const rows = parsedRows(round); + console.log(JSON.stringify(rows)); + // Drop the parsed rows, then build fresh rows of the same shape. + rows.length = 0; + const fresh: Row[] = []; + for (let i = 0; i < 64; i++) fresh.push({ id: i, name: "n" + i }); + const text = JSON.stringify(fresh); + checksum += text.length; + console.log(text.includes("extra"), text.includes("tags"), Object.keys(fresh[0]!).join(",")); +} +console.log(checksum); + +let sum = 0; +for (let i = 0; i < 10000; i++) { + const p = new Point(i, -i); + const r = { a: p.x, b: [p.y] }; + sum += r.a + r.b[0]!; +} +console.log(sum); diff --git a/tests/corpus/6163-dispose-observer-workers.ts b/tests/corpus/6163-dispose-observer-workers.ts new file mode 100644 index 000000000..aa5781752 --- /dev/null +++ b/tests/corpus/6163-dispose-observer-workers.ts @@ -0,0 +1,43 @@ +import { Worker, isMainThread, parentPort } from "node:worker_threads"; + +// Each script thread has its own dispose hook: a worker that keeps checked +// record sources and weak keys serializes and forgets them on its own, +// while the main thread runs the same code without sharing its state. +interface Row { + id: number; + name: string; +} +class Key { + id: number; + constructor(id: number) { + this.id = id; + } +} + +function run(who: string, base: number): string[] { + const out: string[] = []; + const text = `[{"id":${base},"extra":"${who}","name":"a"},{"name":"b","id":${base + 1}}]`; + const rows = JSON.parse(text) as Row[]; + out.push(JSON.stringify(rows)); + rows.length = 0; + const fresh: Row[] = []; + for (let i = 0; i < 32; i++) fresh.push({ id: base + i, name: who }); + out.push(String(JSON.stringify(fresh).includes("extra"))); + const seen = new WeakSet(); + for (let i = 0; i < 32; i++) seen.add(new Key(i)); + let stale = 0; + for (let i = 0; i < 32; i++) if (seen.has(new Key(i))) stale++; + out.push(`${who} stale ${stale}`); + return out; +} + +if (isMainThread) { + for (const line of run("main", 0)) console.log(line); + const worker = new Worker(new URL(import.meta.url)); + worker.on("message", (lines: string[]) => { + for (const line of lines) console.log(line); + }); + worker.on("exit", (code) => console.log("worker exit", code)); +} else { + parentPort!.postMessage(run("worker", 100)); +} From 4b23d34efe1007a4ee7132e33d45e5a02f9932ba Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:17:31 -0700 Subject: [PATCH 09/34] Format toString(radix) with a number radix without a dyn box n.toString(radix) passed the radix as a dyn value, so every call boxed a number and converted it back, and kept scr_json.c's dyn machinery linked into programs that use nothing else of it. A number-typed radix now calls scr_num_to_string_radix_f64, which truncates it as ToIntegerOrInfinity does and holds the formatting that moved from scr_json.c to scr_lib.c. Other radix types keep the generic entry point, which converts and then calls the same function. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-calls.ts | 24 +++++++- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 3 + packages/runtime/src/scr_json.c | 56 +----------------- packages/runtime/src/scr_lib.c | 58 +++++++++++++++++++ packages/runtime/src/scr_number.h | 1 + tests/corpus/6160-number-radix-typed.ts | 36 ++++++++++++ 8 files changed, 126 insertions(+), 54 deletions(-) create mode 100644 tests/corpus/6160-number-radix-typed.ts diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index f69c96e6e..a72a5f4b6 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -144,6 +144,7 @@ export const LIB_FN_SYMS: Record = { "num.toFixed0": "scr_num_to_fixed0", "num.toFixed": "scr_num_to_fixed", "num.toStringRadix": "scr_num_to_string_radix", + "num.toStringRadixF64": "scr_num_to_string_radix_f64", "num.sameValue": "scr_num_same_value", "intl.numFormatEnUs": "scr_intl_num_format_en_us", "number.isFinite": "scr_num_is_finite", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index b3094d5c7..f3b344a44 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4105,10 +4105,32 @@ function lowerNumberToStringCall( } if (call.arguments.length === 1) { if (operand.type.kind !== "f64") return null; + const arg = call.arguments[0]!; + // A number radix needs no box: ToIntegerOrInfinity of a number is a + // truncation, which the typed entry point applies itself. + if (!ts.isSpreadElement(arg) && lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "f64") { + const radix = lowerer.lowerExpr(arg); + if (radix.type.kind === "f64") { + return { + kind: "libCall", + fn: "num.toStringRadixF64", + args: [operand, radix], + type: STRING, + loc: locOf(call), + }; + } + return { + kind: "libCall", + fn: "num.toStringRadix", + args: [operand, lowerer.coerceToExpected(radix, DYN)], + type: STRING, + loc: locOf(call), + }; + } return { kind: "libCall", fn: "num.toStringRadix", - args: [operand, lowerer.lowerExprExpecting(call.arguments[0]!, DYN)], + args: [operand, lowerer.lowerExprExpecting(arg, DYN)], type: STRING, loc: locOf(call), }; diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 1bb9cbd14..2e844fc26 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -137,6 +137,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fetch.streamFrom", "num.toFixed", "num.toStringRadix", + "num.toStringRadixF64", "insp.jsonDyn", // diagnostics_channel: publish runs subscribers synchronously (a throw // propagates — the documented divergence from triggerUncaughtException); diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ca2749591..9cdad7366 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -751,6 +751,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "num.toFixed0": { argTypes: [F64], result: STRING }, "num.toFixed": { argTypes: [F64, F64], result: STRING }, "num.toStringRadix": { argTypes: [F64, DYN], result: STRING }, + /** toString(radix) with a number radix (scr_lib.c): truncates it as + * ToIntegerOrInfinity does, THROWS RangeError outside 2..36. */ + "num.toStringRadixF64": { argTypes: [F64, F64], result: STRING }, /** Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is `===`. Plain bool * result; never throws. (Union-armed operands take unionEq's sameValue diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b23e78241..9a3f315d2 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -13045,63 +13045,13 @@ double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) { return 0; } -/* Radix digit generation follows V8's DoubleToRadixStringView algorithm. - * Copyright 2012 the V8 project authors. BSD license in - * ../vendor/v8-number-radix/LICENSE. The decimal path retains Ryū. */ +/* toString(radix) with any radix value: undefined means 10, anything else + * converts with ToNumber (which can run program code) first. */ ScrStr *scr_num_to_string_radix(double value, const ScrDyn *argument) { double requested = 10; if (argument && argument->kind != SCR_DYN_UNDEF && !scr_dyn_number_coerce_js(argument, &requested)) return NULL; - requested = isnan(requested) ? 0 : trunc(requested); - if (requested < 2 || requested > 36 || !isfinite(requested)) { - static const char message[] = "toString() radix argument must be between 2 and 36"; - scr_throw_error_msg(SCR_ERR_RANGE, message, sizeof message - 1); - return NULL; - } - int radix = (int)requested; - if (radix == 10 || !isfinite(value) || value == 0) return scr_f64_to_scrstr(value); - static const char digits[] = "0123456789abcdefghijklmnopqrstuvwxyz"; - char buffer[2200]; - const size_t middle = sizeof buffer / 2; - size_t begin = middle, end = middle; - bool negative = value < 0; - value = fabs(value); - double integer = floor(value), fraction = value - integer; - double tolerance = 0.5 * (nextafter(value, INFINITY) - value); - if (tolerance <= 0) tolerance = nextafter(0, INFINITY); - if (fraction >= tolerance) { - buffer[end++] = '.'; - do { - fraction *= radix; - tolerance *= radix; - int digit = (int)fraction; - buffer[end++] = digits[digit]; - fraction -= digit; - if ((fraction > 0.5 || (fraction == 0.5 && (digit & 1))) && fraction + tolerance > 1) { - for (;;) { - end--; - if (end == middle) { integer += 1; break; } - char last = buffer[end]; - digit = last > '9' ? last - 'a' + 10 : last - '0'; - if (digit + 1 < radix) { buffer[end++] = digits[digit + 1]; break; } - } - break; - } - } while (fraction >= tolerance); - } - /* V8's Double::Exponent is the unbiased exponent minus 52. Beyond - * that precision boundary, trailing digits are unrepresented zeros. */ - while (integer / radix >= 0x1p53) { - integer /= radix; - buffer[--begin] = '0'; - } - do { - double remainder = fmod(integer, radix); - buffer[--begin] = digits[(int)remainder]; - integer = (integer - remainder) / radix; - } while (integer > 0); - if (negative) buffer[--begin] = '-'; - return scr_str_new(buffer + begin, end - begin); + return scr_num_to_string_radix_f64(value, requested); } ScrDyn *scr_caught_to_dyn(const ScrCaught *c) { diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 9307d9b01..c7ef8632b 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10660,6 +10660,64 @@ ScrStr *scr_num_to_fixed0(double x) { * the receiver's non-finite arm, as ECMA-262 requires. Invalid precision * raises V8's catchable RangeError text; otherwise the result is +1. The * exact-binary-value digits come from scr_f64_to_fixed (scr_number.c). */ +/* Number.prototype.toString(radix) with a number radix, which + * ToIntegerOrInfinity truncates (NaN to 0). Radix digit generation follows + * V8's DoubleToRadixStringView algorithm. Copyright 2012 the V8 project + * authors. BSD license in ../vendor/v8-number-radix/LICENSE. The decimal + * path retains Ryū. */ +ScrStr *scr_num_to_string_radix_f64(double value, double requested) { + requested = isnan(requested) ? 0 : trunc(requested); + if (requested < 2 || requested > 36 || !isfinite(requested)) { + static const char message[] = "toString() radix argument must be between 2 and 36"; + scr_throw_error_msg(SCR_ERR_RANGE, message, sizeof message - 1); + return NULL; + } + int radix = (int)requested; + if (radix == 10 || !isfinite(value) || value == 0) return scr_f64_to_scrstr(value); + static const char digits[] = "0123456789abcdefghijklmnopqrstuvwxyz"; + char buffer[2200]; + const size_t middle = sizeof buffer / 2; + size_t begin = middle, end = middle; + bool negative = value < 0; + value = fabs(value); + double integer = floor(value), fraction = value - integer; + double tolerance = 0.5 * (nextafter(value, INFINITY) - value); + if (tolerance <= 0) tolerance = nextafter(0, INFINITY); + if (fraction >= tolerance) { + buffer[end++] = '.'; + do { + fraction *= radix; + tolerance *= radix; + int digit = (int)fraction; + buffer[end++] = digits[digit]; + fraction -= digit; + if ((fraction > 0.5 || (fraction == 0.5 && (digit & 1))) && fraction + tolerance > 1) { + for (;;) { + end--; + if (end == middle) { integer += 1; break; } + char last = buffer[end]; + digit = last > '9' ? last - 'a' + 10 : last - '0'; + if (digit + 1 < radix) { buffer[end++] = digits[digit + 1]; break; } + } + break; + } + } while (fraction >= tolerance); + } + /* V8's Double::Exponent is the unbiased exponent minus 52. Beyond + * that precision boundary, trailing digits are unrepresented zeros. */ + while (integer / radix >= 0x1p53) { + integer /= radix; + buffer[--begin] = '0'; + } + do { + double remainder = fmod(integer, radix); + buffer[--begin] = digits[(int)remainder]; + integer = (integer - remainder) / radix; + } while (integer > 0); + if (negative) buffer[--begin] = '-'; + return scr_str_new(buffer + begin, end - begin); +} + ScrStr *scr_num_to_fixed(double x, double fraction_digits) { double fd = isnan(fraction_digits) ? 0 : trunc(fraction_digits); if (!(fd >= 0 && fd <= 100)) { diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index 84db27075..f980daeb5 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -73,6 +73,7 @@ bool scr_num_is_safe_integer(double x); ScrStr *scr_num_to_exponential(double x); ScrStr *scr_num_to_fixed0(double x); ScrStr *scr_num_to_fixed(double x, double fraction_digits); +ScrStr *scr_num_to_string_radix_f64(double value, double radix); /* Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is ==. Never throws. */ diff --git a/tests/corpus/6160-number-radix-typed.ts b/tests/corpus/6160-number-radix-typed.ts new file mode 100644 index 000000000..4e11e06b2 --- /dev/null +++ b/tests/corpus/6160-number-radix-typed.ts @@ -0,0 +1,36 @@ +// Number.prototype.toString(radix) with a number-typed radix takes the +// typed entry point: ToIntegerOrInfinity truncates the radix (NaN to 0), +// out-of-range radixes throw the same RangeError, and the argument is +// evaluated once, before formatting. An optional radix keeps the generic +// conversion, where undefined means 10. +const radixes = [2, 2.9, 7, 10, 16, 16.5, 35.99, 36]; +const values = [0, -0, 1, -1, 255, -255.5, 0.1, 1 / 3, 2 ** 53, -(2 ** 60), 1e21, 123.456, NaN, Infinity, -Infinity]; +for (const radix of radixes) { + const row: string[] = []; + for (const value of values) row.push(value.toString(radix)); + console.log(radix, row.join(" ")); +} + +let calls = 0; +function nextRadix(): number { + calls++; + return 8 + calls; +} +console.log((1234).toString(nextRadix()), (1234).toString(nextRadix()), calls); + +for (const bad of [-0, 0.5, 1, 1.99, 36.5, 37, -2, NaN, Infinity, -Infinity]) { + try { + console.log((42).toString(bad)); + } catch (error) { + console.log(String(bad), error instanceof RangeError, (error as Error).message); + } +} + +function maybeRadix(i: number): number | undefined { + return i % 2 === 0 ? undefined : 2 + i; +} +for (let i = 0; i < 4; i++) console.log((255).toString(maybeRadix(i))); + +let total = 0; +for (let i = 0; i < 2000; i++) total += (i * 7919).toString(2 + (i % 35)).length; +console.log(total); From 577b0135a971413c885396f99945acb4639585d5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 09:12:27 -0700 Subject: [PATCH 10/34] Add execution tiers to the IR and a report-only tier analysis Stage S2 of the two-tier design. IrFunction gains a tier (absent means static); lowering assigns it from function bodies, placing functions that use the dyn vocabulary in the dynamic tier, and the validator enforces invariant I1: a static function performs no dynamic operation, only boundary operations (dynFrom, dynCheck, kind tests) and moves. Serialized IR keeps the field out in this stage and loaders derive it again, so every IR, LLVM and executable artifact is unchanged. Every committed refusal and runtime fence becomes a recorded demotion request attributed to the binding, field, function instance, class or module the dynamic tier would take over, while the compiler still refuses and fences as before. `scriptc coverage` reports dynamic-tier statements (statementsDynamic), `--explain-dynamic[=]` prints the demotion chains, and `--tier=auto|static-only` is wired through build, run and coverage for stage S6; both modes compile identically today. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/cli.mdx | 6 + docs/content/docs/coverage.mdx | 13 +- packages/compiler/src/cli/command.ts | 43 +- packages/compiler/src/cli/usage.ts | 12 + packages/compiler/src/compile-types.ts | 10 + packages/compiler/src/coverage/report.ts | 148 ++++- .../compiler/src/coverage/verdict-cache.ts | 5 + packages/compiler/src/diagnostics/envelope.ts | 36 +- packages/compiler/src/executable/prepare.ts | 1 + packages/compiler/src/frontend/analysis.ts | 3 + .../compiler/src/frontend/lowering/lowerer.ts | 67 ++ .../compiler/src/frontend/tier-analysis.ts | 629 ++++++++++++++++++ packages/compiler/src/index.ts | 18 + packages/compiler/src/ir/ir.ts | 8 + packages/compiler/src/ir/serialize.ts | 24 +- packages/compiler/src/ir/tiers.test.ts | 142 ++++ packages/compiler/src/ir/tiers.ts | 157 +++++ packages/compiler/src/ir/validate.test.ts | 6 +- packages/compiler/src/ir/validate.ts | 12 + packages/compiler/src/library/prepare.ts | 2 + tests/coverage-fixtures/tier-demotions.ts | 25 + .../__snapshots__/coverage-divergences.txt | 1 + .../__snapshots__/coverage-dynamic-mix.txt | 1 + .../coverage-explain-dynamic.txt | 16 + .../harness/__snapshots__/coverage-js-gap.txt | 1 + .../harness/__snapshots__/coverage-mixed.txt | 1 + .../__snapshots__/coverage-npm-packages.txt | 1 + .../__snapshots__/coverage-tier-demotions.txt | 13 + .../__snapshots__/coverage-width-copies.txt | 1 + tests/harness/coverage.test.ts | 107 ++- 30 files changed, 1494 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/src/frontend/tier-analysis.ts create mode 100644 packages/compiler/src/ir/tiers.test.ts create mode 100644 packages/compiler/src/ir/tiers.ts create mode 100644 tests/coverage-fixtures/tier-demotions.ts create mode 100644 tests/harness/__snapshots__/coverage-explain-dynamic.txt create mode 100644 tests/harness/__snapshots__/coverage-tier-demotions.txt diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index a6f75c6d3..e8596fc3d 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -127,6 +127,12 @@ Verifies and loads the installed precompiled runtime objects and vendored librar
--external-types <specifier=file.d.ts>
Coverage only. Map an exact bare module specifier to a local declaration file supplied by an embedder. Repeat the option for multiple modules. The declaration supplies checker types so application coverage can continue; runtime imports and values remain explicit SC1010 blockers. Relative paths resolve from the current working directory. Accepted files end in .d.ts, .d.mts, or .d.cts.
+
--tier <auto|static-only>
+
The tier policy for code that has no static lowering. auto is the default. static-only guarantees that the program contains no dynamic-tier demotions: every such construct stays a compile error in TypeScript and a deferred runtime error in JavaScript, which is how both modes behave today. Use it in CI to keep a program fully static. Library mode is always static-only. See Dynamic tier.
+ +
--explain-dynamic[=<name>]
+
Coverage only. After the report, list the functions that already operate on values typed only at runtime and the bindings, fields, functions, classes and modules that unsupported constructs would move to the dynamic tier, each with the diagnostic behind it. With a name such as x, Point, or Point.x, show only the matching units. See Dynamic tier.
+
--sanitize
Build an executable with AddressSanitizer and a reference-count audit at exit, the same checks scriptc's own test suite uses. Requires a C compiler and is available only for host builds. Not supported with --emit=asm or --emit=obj.
diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx index 2d26e684b..805d7f612 100644 --- a/docs/content/docs/coverage.mdx +++ b/docs/content/docs/coverage.mdx @@ -36,6 +36,7 @@ When a program has unsupported operations, the report adds a group for each root statements analyzedExecutable statements in application code, excluding dependency implementation code. compile staticallyStatements that compile to native instructions. + in dynamic tierOf those, the statements in functions that operate on values typed only at runtime, such as any values and untyped JavaScript. Shown when there are any. See Dynamic tier. blockersUnsupported operations with counts, diagnostic codes, reasons, locations, and hints. in unreached codeBlockers in code the entry never reaches. A build never compiles that code, so these cannot fail it. differs from NodeSites that compile but can behave differently from Node.js. See Node divergences. @@ -51,6 +52,16 @@ If a function's signature can't compile, the statements in its body count as not + + +## Dynamic tier + +Code that operates on values whose types are known only at runtime, such as `any` values, the result of an untyped `JSON.parse`, or untyped JavaScript, compiles into dynamic-tier functions. They are native code too, but they check types and look up properties at runtime, so they are slower than code with static types. The report's `in dynamic tier` line counts their statements. + +`--explain-dynamic` lists those functions and the operation that makes each one dynamic. It also lists the units an unsupported construct would move to the dynamic tier: the binding, field, class, function, or module that has no static representation for the construct, with the diagnostic behind each one. These are the constructs the dynamic tier is planned to compile; today they remain blockers in TypeScript and deferred runtime errors in JavaScript. Add a name, such as `--explain-dynamic=Point`, to show only the matching units. With `--print=diagnostics`, the document gains a `tiers` object with the same information. + +`--tier=static-only` keeps every unsupported construct a blocker instead of moving it to the dynamic tier; code that already operates on runtime-typed values compiles as before. Today `auto`, the default, behaves the same way. + ## Diagnostic codes Coverage and `scriptc build` report the same diagnostic codes. Common ones include: @@ -160,7 +171,7 @@ For editors, CI annotations, and other tools, `scriptc coverage --print=diagnost stats - Coverage only, absent after a preflight failure: the report's statement counts. statementsDynamic is always 0 and remains for schema compatibility. + Coverage only, absent after a preflight failure: the report's statement counts. statementsStatic counts every statement that compiles to native code; statementsDynamic is the part of those in dynamic-tier functions. category diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index d729de866..6810cc8fb 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -7,8 +7,10 @@ import { coverageEnvelope, coveragePasses, renderCoverage, + renderExplainDynamic, type CoverageFailOn, } from "../coverage/report.js"; +import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js"; import { buildEnvelope } from "../diagnostics/envelope.js"; import { renderWarnings } from "../diagnostics/render.js"; import { isNativeOptimization } from "../backend/optimization.js"; @@ -45,8 +47,15 @@ function parseCli( ): ReturnType< typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }> > { + // `--explain-dynamic` takes an optional value: alone it lists every unit. + const normalized = args.map((arg) => (arg === "--explain-dynamic" ? "--explain-dynamic=" : arg)); try { - return parseArgs({ args, options: CLI_OPTIONS, allowPositionals: true, allowNegative: true }); + return parseArgs({ + args: normalized, + options: CLI_OPTIONS, + allowPositionals: true, + allowNegative: true, + }); } catch (err) { // parseArgs appends a paragraph about `--` and positionals to the // unknown-option message; the first sentence is the part that names @@ -91,6 +100,8 @@ async function main(args: string[], host: CliHost): Promise { externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || + values.tier !== undefined || + values["explain-dynamic"] !== undefined || !values["keep-llvm"] ) { fail( @@ -152,10 +163,12 @@ async function main(args: string[], host: CliHost): Promise { values["windows-subsystem"] !== undefined || values["detached-exit"] || (values["npm-static"] ?? []).length > 0 || - externalTypeArgs.length > 0 + externalTypeArgs.length > 0 || + values.tier !== undefined || + values["explain-dynamic"] !== undefined ) { fail( - "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--npm-static/--ffi/--external-types: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, and external type mappings belong to coverage", + "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", ); } const profilePath = resolve(profileArg); @@ -223,6 +236,15 @@ async function main(args: string[], host: CliHost): Promise { fail(`--fail-on is a coverage option (a build already fails on blockers)\n\n${USAGE}`); } const failOn = failOnRaw as CoverageFailOn | undefined; + const tierRaw = values.tier; + if (tierRaw !== undefined && !(TIER_MODES as readonly string[]).includes(tierRaw)) { + fail(`unknown --tier value "${tierRaw}" (supported: ${TIER_MODES.join(", ")})\n\n${USAGE}`); + } + const tier = tierRaw as TierMode | undefined; + const explainDynamic = values["explain-dynamic"]; + if (explainDynamic !== undefined && command !== "coverage") { + fail(`--explain-dynamic is a coverage option\n\n${USAGE}`); + } if (printNativeLinkInfo && command !== "build") { fail(`--print=native-link-info is a build option\n\n${USAGE}`); } @@ -340,6 +362,8 @@ async function main(args: string[], host: CliHost): Promise { ...(npmStatic !== undefined ? { npmStatic } : {}), ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), ...(Object.keys(externalTypes).length > 0 ? { externalTypes } : {}), + ...(tier !== undefined ? { tier } : {}), + ...(explainDynamic !== undefined ? { explainDynamic: true } : {}), }); if (printDiagnostics) { // Keep stdout pure JSON for tooling. @@ -354,6 +378,18 @@ async function main(args: string[], host: CliHost): Promise { process.stdout.write( renderCoverage(coverage, { color, sourceTexts, root: process.cwd() }) + "\n", ); + if (explainDynamic !== undefined && coverage.tiers !== undefined) { + process.stdout.write( + "\n" + + renderExplainDynamic(coverage.tiers, { + color, + sourceTexts, + root: process.cwd(), + query: explainDynamic, + }) + + "\n", + ); + } } return coveragePasses(coverage, failOn) ? 0 : 1; } @@ -386,6 +422,7 @@ async function main(args: string[], host: CliHost): Promise { ...(npmStatic !== undefined ? { npmStatic } : {}), ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), ...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}), + ...(tier !== undefined ? { tier } : {}), }); if (!result.ok) { if (printDiagnostics) { diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index a0388374b..97c56c274 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -69,6 +69,16 @@ Options: local declaration file. The declaration supplies types for analysis; the host module remains an explicit external-boundary blocker (repeatable) + --tier + tier policy (default: auto). static-only keeps every + construct without a static lowering a compile error, so + the program has no dynamic-tier demotions. Both modes + currently compile identically + --explain-dynamic[=] + coverage only: list the functions that operate on dynamic + values and the bindings, fields, functions, classes and + modules the dynamic tier would take over, with the + refusal behind each; a name shows only that unit's chain -h, --help show this help -v, --version print the version `; @@ -90,6 +100,8 @@ export const CLI_OPTIONS = { "npm-static": { type: "string", multiple: true }, "provenance-sources": { type: "boolean", default: false }, "external-types": { type: "string", multiple: true }, + tier: { type: "string" }, + "explain-dynamic": { type: "string" }, lib: { type: "boolean", default: false }, profile: { type: "string" }, help: { type: "boolean", short: "h", default: false }, diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index de2c1a335..f069df247 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -3,6 +3,7 @@ import type { NativeLinkInfo } from "./backend/native-link-info.js"; import type { NativeOptimization } from "./backend/optimization.js"; import type { ScrDiagnostic } from "./diagnostics/diagnostic.js"; import type { CoverageInput } from "./coverage/report.js"; +import type { TierMode } from "./frontend/tier-analysis.js"; export type CompileOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe"; @@ -45,6 +46,10 @@ export interface CompileBaseOptions { /** Attach the machine-readable external link recipe to an object result. * Valid only with outputKind "obj"; it never invokes a linker. */ nativeLinkInfo?: boolean; + /** `--tier` (frontend/tier-analysis.ts). "static-only" keeps every demotion + * request a refusal (design invariant I2). Until demotion lands both modes + * compile exactly as before. */ + tier?: TierMode; } /** Executable compile options. This remains the compatibility type for the @@ -161,6 +166,11 @@ export interface AnalyzeOptions { * to analyze project code, but imported runtime values remain explicit * SC1010 blockers rather than being counted as executable. */ externalTypes?: Readonly>; + /** `--tier` (see CompileBaseOptions.tier). */ + tier?: TierMode; + /** `--explain-dynamic`: attach the tier-assignment report + * (CoverageInput.tiers). Such analyses bypass the verdict cache. */ + explainDynamic?: boolean; } export interface AnalyzeResult { diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 9d0776d9a..730704f6f 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -17,10 +17,13 @@ import { type DiagnosticsEnvelope, type LocatedDiagnostic, type SourcePosition, + type TierUnitPosition, + type TiersEnvelope, } from "../diagnostics/envelope.js"; import { renderDiagnostics } from "../diagnostics/render.js"; import type { LowerStats } from "../frontend/lowering/lowerer.js"; import type { ProvenanceSources } from "../frontend/provenance-registry.js"; +import type { DemotionUnit, TierReport } from "../frontend/tier-analysis.js"; export interface CoverageInput { file: string; @@ -51,6 +54,9 @@ export interface CoverageInput { /** --provenance-sources: diagnostics of elided pure-annotated dead * consts (off the build; the provenance section names them). */ provenanceElided?: ScrDiagnostic[]; + /** --explain-dynamic: the report-only tier assignment (which bindings, + * fields, functions, classes and modules the dynamic tier would take). */ + tiers?: TierReport; /** True when tsc preflight failed — no coverage can be computed. */ preflightFailed: boolean; } @@ -180,6 +186,12 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions out.push(` statements analyzed ${total}`); out.push(` compile statically ${ok} ${c(pctColor, c(BOLD, `(${pct}%)`))}`); + const dynamic = dynamicStatements(input); + if (dynamic > 0) { + out.push( + ` in dynamic tier ${dynamic} ${c(DIM, "(operate on values typed only at runtime; --explain-dynamic lists them)")}`, + ); + } if (skipped > 0) { out.push( @@ -432,13 +444,147 @@ export function coverageEnvelope( stats: { statementsTotal: total, statementsStatic: ok, - statementsDynamic: 0, + statementsDynamic: dynamicStatements(input), statementsFailed: failed, functionsSkipped: skipped, percentStatic: total === 0 ? 100 : Math.floor((ok / total) * 100), }, }), + ...(input.tiers !== undefined ? { tiers: tiersEnvelope(input.tiers, locator) } : {}), groups, diagnostics: located, }; } + +/** Reached statements in dynamic-tier functions, never more than the + * compiled statements they are part of. */ +function dynamicStatements(input: CoverageInput): number { + const compiled = input.stats.statementsTotal - input.stats.statementsFailed; + return Math.max(0, Math.min(input.stats.statementsDynamic ?? 0, compiled)); +} + +function tiersEnvelope(tiers: TierReport, locator: SourceLocator): TiersEnvelope { + const at = (loc: { file: string; start: number }): SourcePosition => ({ + file: loc.file, + ...locator.position(loc.file, loc.start), + }); + const unit = (u: DemotionUnit): TierUnitPosition => ({ + kind: u.kind, + name: u.name, + ...(u.owner !== undefined ? { owner: u.owner } : {}), + ...at(u.loc), + }); + return { + mode: tiers.mode, + statementsDynamic: tiers.statementsDynamic, + dynamicFunctions: tiers.dynamicFunctions.map((fn) => ({ + name: fn.name, + ...at(fn.loc), + operation: fn.operation, + operationAt: at(fn.operationLoc), + statements: fn.statements, + })), + requests: tiers.requests.map((r) => ({ + unit: unit(r.unit), + enclosing: unit(r.enclosing), + seed: r.seed, + scope: r.scope, + code: r.code, + message: r.message, + ...at(r.loc), + })), + units: tiers.units.map((u) => ({ ...unit(u), requests: u.requests, sites: u.sites })), + }; +} + +/** IR function names as a user reads them: `%init.3` is a module's top + * level, `%Point.norm` the method `Point.norm`; generated helpers keep + * their `%`-prefixed names. */ +function functionDisplayName(name: string): string { + if (name.startsWith("%init.")) return ""; + if (/^%[A-Za-z_$][\w$]*\.[A-Za-z_$#][\w$]*$/.test(name)) return name.slice(1); + return name; +} + +function unitLabel(u: DemotionUnit, root: string | undefined): string { + if (u.kind === "module") return `module ${displayPath(u.name, root)}`; + if (u.kind === "binding" && u.owner !== undefined) return `binding ${u.name} in ${u.owner}`; + return `${u.kind} ${u.name}`; +} + +function unitMatches(u: DemotionUnit, query: string): boolean { + return ( + u.name === query || + u.name.endsWith(`.${query}`) || + u.name.split(" ")[0] === query || + `${u.owner ?? ""}:${u.name}` === query + ); +} + +/** `scriptc coverage --explain-dynamic[=]`: the dynamic tier today + * and the demotion chains the report-only analysis computed. With a query, + * only units whose name matches it (`x`, `Point.x`, `parse:x`). */ +export function renderExplainDynamic( + tiers: TierReport, + opts: CoverageRenderOptions & { query?: string } = {}, +): string { + const c = (code: string, s: string) => (opts.color ? code + s + RESET : s); + const locator = new SourceLocator(opts.sourceTexts); + const where = (loc: { file: string; start: number }) => { + const p = locator.position(loc.file, loc.start); + return `${displayPath(loc.file, opts.root)}:${p.line}:${p.column}`; + }; + const query = opts.query !== undefined && opts.query !== "" ? opts.query : undefined; + const out: string[] = []; + out.push( + ` ${c(BOLD, "dynamic tier")} ${c(DIM, `(tier assignment, report-only; --tier=${tiers.mode})`)}`, + ); + if (query === undefined) { + const fns = tiers.dynamicFunctions; + out.push( + ` ${fns.length} function${fns.length === 1 ? "" : "s"} already operate${fns.length === 1 ? "s" : ""} on dynamic values ${c(DIM, `(${tiers.statementsDynamic} statement${tiers.statementsDynamic === 1 ? "" : "s"})`)}`, + ); + for (const fn of fns) { + out.push( + ` ${functionDisplayName(fn.name)} ${c(DIM, `${where(fn.loc)}: ${fn.operation} at ${where(fn.operationLoc)}`)}`, + ); + } + } + const live = tiers.requests.filter((r) => r.scope !== "unreached" && r.seed !== "excluded"); + const excluded = tiers.requests.filter((r) => r.seed === "excluded").length; + const unreached = tiers.requests.filter((r) => r.scope === "unreached").length; + if (query === undefined) { + out.push(""); + out.push( + ` ${live.length} demotion request${live.length === 1 ? "" : "s"} ${c(DIM, "(refusals and fences the dynamic tier would replace)")}` + + (unreached > 0 ? c(DIM, `, ${unreached} more in unreached code`) : "") + + (excluded > 0 ? c(DIM, `, ${excluded} excluded by policy`) : ""), + ); + } + const units = tiers.units.filter((u) => query === undefined || unitMatches(u, query)); + if (query !== undefined && units.length === 0) { + out.push(` no demoted unit matches '${query}'`); + return out.join("\n"); + } + for (const u of units) { + const counts = [ + u.requests > 0 ? `${u.requests} request${u.requests === 1 ? "" : "s"}` : "", + u.sites > 0 ? `${u.sites} site${u.sites === 1 ? "" : "s"} inside` : "", + ] + .filter((s) => s !== "") + .join(", "); + out.push(` ${unitLabel(u, opts.root)} ${c(DIM, `${where(u.loc)} (${counts})`)}`); + if (query === undefined && u.requests === 0) continue; + for (const r of live) { + if (r.unit.kind !== u.kind || r.unit.name !== u.name || r.unit.loc.start !== u.loc.start) + continue; + if (r.unit.loc.file !== u.loc.file) continue; + out.push( + ` ${c(DIM, "<-")} ${r.code} ${r.seed} at ${where(r.loc)}: ${capMessage(r.message)}`, + ); + if (r.enclosing.name !== r.unit.name || r.enclosing.kind !== r.unit.kind) + out.push(` ${c(DIM, "->")} ${unitLabel(r.enclosing, opts.root)} turns dynamic`); + } + } + return out.join("\n"); +} diff --git a/packages/compiler/src/coverage/verdict-cache.ts b/packages/compiler/src/coverage/verdict-cache.ts index 841e2b57b..250259685 100644 --- a/packages/compiler/src/coverage/verdict-cache.ts +++ b/packages/compiler/src/coverage/verdict-cache.ts @@ -89,6 +89,9 @@ export function verdictKey( opts: AnalyzeOptions, cache: VerdictCacheOptions, ): string | null { + // The tier report carries source locations the semantic replay does not + // move; explaining analyses always lower. + if (opts.explainDynamic === true) return null; let ffi = ""; if (opts.ffiProfilePath !== undefined) { try { @@ -120,6 +123,8 @@ export function verdictKey( opts.ffiProfilePath ?? "", ffi, external.join("\n"), + // Demotion (stage S6) makes the tier mode part of the verdict. + ...(opts.tier === "static-only" ? ["tier=static-only"] : []), ].join("\0"), ); } diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index 39b193dd0..4c7c158d9 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -81,6 +81,34 @@ export interface DiagnosticGroup { sites: SourcePosition[]; } +export interface TierUnitPosition extends SourcePosition { + kind: "binding" | "field" | "function" | "class" | "module"; + name: string; + owner?: string; +} + +/** `scriptc coverage --explain-dynamic --print=diagnostics`: the tier + * assignment of frontend/tier-analysis.ts with source positions. */ +export interface TiersEnvelope { + mode: "auto" | "static-only"; + statementsDynamic: number; + dynamicFunctions: (SourcePosition & { + name: string; + operation: string; + operationAt: SourcePosition; + statements: number; + })[]; + requests: (SourcePosition & { + unit: TierUnitPosition; + enclosing: TierUnitPosition; + seed: string; + scope: string; + code: string; + message: string; + })[]; + units: (TierUnitPosition & { requests: number; sites: number })[]; +} + export interface DiagnosticsEnvelope { schema: typeof DIAGNOSTICS_SCHEMA; schemaVersion: typeof DIAGNOSTICS_SCHEMA_VERSION; @@ -97,8 +125,10 @@ export interface DiagnosticsEnvelope { stats?: { statementsTotal: number; statementsStatic: number; - /** Always 0: every compiled statement is native. Kept so schema - * version 1 consumers keep reading the same fields. */ + /** The compiled statements (counted in statementsStatic, which covers + * every statement that compiles to native code) that belong to + * dynamic-tier functions: code operating on values whose types are only + * known at runtime. */ statementsDynamic: number; statementsFailed: number; functionsSkipped: number; @@ -106,6 +136,8 @@ export interface DiagnosticsEnvelope { }; /** build only: the produced artifact path. */ artifact?: string; + /** coverage --explain-dynamic only: the report-only tier assignment. */ + tiers?: TiersEnvelope; groups: DiagnosticGroup[]; diagnostics: LocatedDiagnostic[]; } diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index f6012dd2a..7becd2358 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -69,6 +69,7 @@ export function prepareExecutableModule( lowered = fe.lower({ targetPlatform: buildPlatform, ...(ffi !== null ? { ffiImports: ffi.functions } : {}), + ...(opts.tier !== undefined ? { tierMode: opts.tier } : {}), }); timing("lower"); } catch (e) { diff --git a/packages/compiler/src/frontend/analysis.ts b/packages/compiler/src/frontend/analysis.ts index b38a267fa..87c7d22ca 100644 --- a/packages/compiler/src/frontend/analysis.ts +++ b/packages/compiler/src/frontend/analysis.ts @@ -90,6 +90,8 @@ export function analyzeWithFrontend( coverage: true, targetPlatform: buildPlatform, ...(ffi !== null ? { ffiImports: ffi.functions } : {}), + ...(opts.tier !== undefined ? { tierMode: opts.tier } : {}), + ...(opts.explainDynamic === true ? { tierReport: { preflight } } : {}), }); const provenance = provenanceSources(); return { @@ -109,6 +111,7 @@ export function analyzeWithFrontend( ...(provenance !== null ? { provenance } : {}), ...(lowered.statsByFile ? { statsByFile: lowered.statsByFile } : {}), ...(lowered.provenanceElided ? { provenanceElided: lowered.provenanceElided } : {}), + ...(lowered.tiers !== undefined ? { tiers: lowered.tiers } : {}), preflightFailed: false, }, sourceTexts: fe.sourceTexts(), diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b4520d3be..c535d603f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -110,6 +110,13 @@ import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-d import { ClassProtocols } from "./class-protocols.js"; import { earlyReadVarSymbols } from "./var-hoisting.js"; import { recordTextCodecClass } from "../../ir/ir.js"; +import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js"; +import { + analyzeTiers, + dynamicStatementCount, + type TierMode, + type TierReport, +} from "../tier-analysis.js"; import { DeferredModuleInitializers } from "./deferred-module-initializers.js"; import { finalizeClassMethodValues } from "./class-method-values.js"; import type { ClassSymbolKey } from "./symbol-fields.js"; @@ -627,6 +634,9 @@ export interface LowerStats { /** Functions whose signature couldn't be analyzed (bodies not counted). */ functionsSkipped: number; + /** Coverage only: reached statements that compile into dynamic-tier + * functions (tier-analysis.ts), a subset of the compiled statements. */ + statementsDynamic?: number; } /** Every identifier a binding name binds: the identifier itself, or all @@ -701,6 +711,11 @@ export interface LowerResult { /** Compile-time-detectable behavior differences from Node (SC6xxx * divergence warnings) in the program's own modules. Never failures. */ divergences?: ScrDiagnostic[]; + /** Functions lowering placed in the dynamic tier (ir/tiers.ts), present + * when there are any. */ + dynamicFunctions?: DynamicFunction[]; + /** LowerOptions.tierReport: the report-only tier assignment. */ + tiers?: TierReport; } export interface LowerOptions { @@ -750,6 +765,12 @@ export interface LowerOptions { /** The mapped entries plus relative declaration dependencies, attributed * to their owning external specifier. */ externalTypeSpecifiersByFile?: ReadonlyMap; + /** `--tier` (tier-analysis.ts); "auto" when absent. */ + tierMode?: TierMode; + /** Coverage only: compute the tier-assignment report (`--explain-dynamic`). + * `preflight` holds the import and package refusals coverage reports + * beside the lowering's own blockers; they request module demotions. */ + tierReport?: { preflight: readonly ScrDiagnostic[] }; } interface RuntimeFenceFallback { @@ -825,6 +846,8 @@ export interface LowererMode { externalTypes?: ReadonlyMap; /** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */ externalTypeSpecifiersByFile?: ReadonlyMap; + /** LowerOptions.tierMode, shared by every pass. */ + tierMode?: TierMode; } function directExternalTypeSpecifiersByFile( @@ -916,6 +939,7 @@ export function lowerToIr( const externalTypes = options.externalTypes ?? new Map(); const externalTypeSpecifiersByFile = options.externalTypeSpecifiersByFile ?? directExternalTypeSpecifiersByFile(externalTypes); + const tierMode = options.tierMode ?? "auto"; const validation = new Lowerer(program, entry, moduleOrder, { frontendServices: options.frontendServices, projectCheckJs: options.projectCheckJs ?? false, @@ -927,6 +951,7 @@ export function lowerToIr( workerTargets, externalTypes, externalTypeSpecifiersByFile, + tierMode, }); const ffiValidation = validateFfiImports(validation); timing("ffi-validate"); @@ -949,6 +974,7 @@ export function lowerToIr( workerTargets, externalTypes, externalTypeSpecifiersByFile, + tierMode, ffiBindingSymbols: ffiValidation.symbolsByName, }); for (const d of extraModuleCycleDiags) reachableEmit.pushDiag(d); @@ -979,6 +1005,7 @@ export function lowerToIr( ffiBindingSymbols: ffiValidation.symbolsByName, externalTypes, externalTypeSpecifiersByFile, + tierMode, }); for (const d of extraModuleCycleDiags) emit.pushDiag(d); for (const d of ffiValidation.diagnostics) emit.pushDiag(d); @@ -1037,18 +1064,42 @@ export function lowerToIr( ffiBindingSymbols: ffiValidation.symbolsByName, externalTypes, externalTypeSpecifiersByFile, + tierMode, }); const rem = remainder.run(); + timing("coverage-remainder"); + // Tiers (tier-analysis.ts): reached statements in dynamic-tier functions, + // and with tierReport the report-only demotion analysis. + const dynamicFunctions = resultLowerer.dynamicBodies; + const tiers = + options.tierReport === undefined + ? undefined + : analyzeTiers(program, { + mode: tierMode, + dynamicFunctions, + reached: [...options.tierReport.preflight, ...result.diagnostics], + deferred: result.runtimeFences, + unreached: [...rem.diagnostics, ...rem.runtimeFences], + }); + // A dynamic function's own statements can include ones that failed. + const statementsDynamic = Math.min( + tiers?.statementsDynamic ?? dynamicStatementCount(program, dynamicFunctions), + result.stats.statementsTotal - result.stats.statementsFailed, + ); + if (tiers !== undefined) tiers.statementsDynamic = statementsDynamic; + timing("tier-analysis", { dynamic: dynamicFunctions.length }); // Unreached JS statements whose fences deferred to runtime count as // failed statements in the remainder's stats, so their diagnostics must // travel with them: a failed statement is never left unexplained. return { ...result, + stats: { ...result.stats, statementsDynamic }, unreached: { diagnostics: rem.diagnostics, stats: rem.stats, ...(rem.runtimeFences.length > 0 ? { runtimeFences: rem.runtimeFences } : {}), }, + ...(tiers !== undefined ? { tiers } : {}), }; } @@ -2664,6 +2715,14 @@ export class Lowerer { /** Coverage remainder mode: the reachability gate inverts (see wantBody) * and no module is built. */ readonly remainder: boolean; + /** `--tier` (tier-analysis.ts). Stage S2 is report-only: both modes + * refuse and fence exactly as before. Stage S6 demotes the units of + * committed refusals in "auto" and keeps them refusals in "static-only". */ + readonly tierMode: TierMode; + /** Functions finishModule placed in the dynamic tier (ir/tiers.ts). */ + dynamicFunctions: DynamicFunction[] = []; + /** Their IR, for coverage's dynamic statement count. */ + dynamicBodies: IrFunction[] = []; /** Deferred collection diagnostics (failed signatures/class shapes) by * declaration symbol: an unreached declaration must not fail the build, * so its diagnostics wait until a reference makes them relevant. */ @@ -2750,6 +2809,7 @@ export class Lowerer { this.frontendServices = mode.frontendServices; this.reachable = mode.reachable ?? null; this.remainder = mode.remainder ?? false; + this.tierMode = mode.tierMode ?? "auto"; this.alreadyFlushed = mode.alreadyFlushed ?? new Set(); this.targetPlatform = mode.targetPlatform ?? process.platform; this.startupCrash = mode.startupCrash ?? null; @@ -6336,11 +6396,18 @@ export class Lowerer { }; if (module) sanitizeUnregisteredClassTypes(module, (name) => this.classes.has(name)); if (module) checkCycleBindingAccesses(this, module); + // Execution tiers from the final bodies (ir/tiers.ts). Assigned with or + // without a module: coverage reports the dynamic tier of programs that + // still have blockers. + this.dynamicFunctions = assignLegacyTiers(functions); + if (this.dynamicFunctions.length > 0) + this.dynamicBodies = functions.filter((fn) => fn.tier === "dynamic"); return { module, diagnostics: this.diags, runtimeFences: this.runtimeFences, stats: this.stats, + ...(this.dynamicFunctions.length > 0 ? { dynamicFunctions: this.dynamicFunctions } : {}), ...(this.statsByFile.size > 0 ? { statsByFile: this.statsByFile } : {}), ...(this.provenanceElided.length > 0 ? { provenanceElided: this.provenanceElided } : {}), }; diff --git a/packages/compiler/src/frontend/tier-analysis.ts b/packages/compiler/src/frontend/tier-analysis.ts new file mode 100644 index 000000000..ded7c5225 --- /dev/null +++ b/packages/compiler/src/frontend/tier-analysis.ts @@ -0,0 +1,629 @@ +/* Tier assignment, report-only (two-tier design, stage S2). + * + * The design assigns every binding, field, function instance, class and + * module a tier. Static is today's compiler; dynamic code operates on + * dynamic values. Demotion from static to dynamic is monotone and is seeded + * by `any`, untyped JavaScript, types and operations without a static + * lowering, reflection a static layout cannot answer, and dynamic modules + * (DESIGN.md §6). + * + * Stage S2 computes what WOULD be demoted without demoting anything: + * + * 1. Existing dynamic code. Functions whose bodies already use the `dyn` + * vocabulary are the dynamic tier today (ir/tiers.ts assigns them). The + * source statements whose lowering performs a dynamic operation are the + * coverage report's statementsDynamic. + * 2. Demotion requests. Every refusal or runtime fence the lowerer commits + * (today's poison sites: the SC1xxx/SC2xxx families) and every refused + * import or package becomes a request, attributed to the unit whose + * static representation could not express the construct. The lowerer + * still refuses or fences exactly as before; the requests are recorded + * from the committed diagnostics, so speculative probes that roll back + * record nothing. Under `--tier=static-only` every request stays today's + * refusal; stage S6 turns requests into demotions in the default mode. + * 3. Demoted units. A request demotes its unit, and the function instance + * containing the site turns dynamic (it would hold dynamic operations, + * invariant I1). Units are deduplicated; each keeps its requests, which + * is the chain `--explain-dynamic` prints. + * + * Attribution is syntactic and approximate: the site's innermost node, the + * operand it acts on, and that operand's declaration. Stage S6 replaces it + * with requests the failing lowering names exactly. */ +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { IrFunction, SrcLoc } from "../ir/ir.js"; +import { dynamicOperationSites, type DynamicFunction } from "../ir/tiers.js"; +import * as ts from "./ts7/adapter.js"; + +/** `--tier`: "auto" (the default) will demote; "static-only" keeps every + * demotion request a refusal (design invariant I2). Identical in S2. */ +export type TierMode = "auto" | "static-only"; + +export const TIER_MODES: readonly TierMode[] = ["auto", "static-only"]; + +export type DemotionUnitKind = "binding" | "field" | "function" | "class" | "module"; + +export interface DemotionUnit { + kind: DemotionUnitKind; + /** Display name: `x`, `Point.x`, `parse`, `Parser.next`, `Point`, or the + * module's file path. */ + name: string; + /** Bindings: the function declaring a local, absent for module scope. */ + owner?: string; + /** The unit's declaration (the site itself when none is known). */ + loc: SrcLoc; +} + +/** Why a unit is demoted (DESIGN.md §6.2): a type with no static + * representation, an operation with no static lowering, reflection on a + * class, a use of a binding whose declaration was refused, a module or + * package that cannot be static, or a construct excluded by policy (no + * demotion: it stays a refusal). */ +export type DemotionSeed = "type" | "operation" | "reflection" | "cascade" | "module" | "excluded"; + +export type DemotionScope = "reached" | "deferred" | "unreached"; + +export interface DemotionRequest { + unit: DemotionUnit; + /** The function instance (or module top level) containing the site. */ + enclosing: DemotionUnit; + seed: DemotionSeed; + scope: DemotionScope; + code: string; + message: string; + loc: SrcLoc; +} + +export interface DemotedUnit extends DemotionUnit { + /** Requests naming this unit directly. */ + requests: number; + /** Requests whose sites this function instance or module contains. */ + sites: number; +} + +export interface TierDynamicFunction extends DynamicFunction { + /** Source statements with a dynamic operation in this function. */ + statements: number; +} + +export interface TierReport { + mode: TierMode; + /** Reached source statements that perform a dynamic operation. */ + statementsDynamic: number; + dynamicFunctions: TierDynamicFunction[]; + requests: DemotionRequest[]; + /** Units the requests would demote (reached and deferred scopes), + * deduplicated, by kind then source order. */ + units: DemotedUnit[]; +} + +/* ── statements of dynamic functions ─────────────────────────────────── */ + +function isClassLike(node: ts.Node): boolean { + return ts.isClassDeclaration(node) || ts.isClassExpression(node); +} + +/** The innermost node spanning exactly or containing [start, end). */ +function nodeAt(sf: ts.SourceFile, start: number, end: number): ts.Node { + let node: ts.Node = sf; + for (;;) { + let next: ts.Node | undefined; + node.forEachChild((child) => { + if (child.getStart(sf) <= start && end <= child.getEnd()) { + next = child; + return true; + } + return undefined; + }); + if (next === undefined) return node; + node = next; + } +} + +/** The source statement an IR location belongs to: the innermost + * statement containing it, or an arrow function's expression body. Null + * for synthetic locations. */ +function statementAt(program: ts.Program, loc: SrcLoc): ts.Node | null { + if (loc.start === 0 && loc.end === 0) return null; + const sf = program.getSourceFile(loc.file); + if (sf === undefined) return null; + let node: ts.Node | undefined = nodeAt(sf, loc.start, loc.end); + while (node !== undefined && !ts.isSourceFile(node)) { + const parent: ts.Node | undefined = node.parent; + if (parent !== undefined && ts.isArrowFunction(parent) && parent.body === node) return node; + if (ts.isStatement(node) && !ts.isBlock(node)) return node; + node = parent; + } + return null; +} + +/** Dynamic statements are the source statements whose lowering performs a + * dynamic operation (in the function itself or in a generated helper it + * calls). They count once however many functions and operations they + * involve. A function's tier is the unit of I1, but static operations + * inside a dynamic function stay static, so the statement is the honest + * measure of how much code is dynamic. */ +function dynamicFunctionReports( + program: ts.Program, + bodies: readonly IrFunction[], +): { functions: TierDynamicFunction[]; statements: number } { + const byLoc = new Map(); + const all = new Set(); + const functions: TierDynamicFunction[] = []; + for (const fn of bodies) { + const sites = dynamicOperationSites(fn); + if (sites.length === 0) continue; + const own = new Set(); + for (const site of sites) { + const key = `${site.loc.file}\0${site.loc.start}\0${site.loc.end}`; + let statement = byLoc.get(key); + if (statement === undefined) { + statement = statementAt(program, site.loc); + byLoc.set(key, statement); + } + if (statement === null) continue; + own.add(statement); + all.add(statement); + } + functions.push({ + name: fn.name, + loc: fn.loc, + operation: sites[0]!.operation, + operationLoc: sites[0]!.loc, + statements: own.size, + }); + } + return { functions, statements: all.size }; +} + +/* ── demotion requests ───────────────────────────────────────────────── */ + +const TYPE_CODES = new Set([ + "SC2001", + "SC2002", + "SC2003", + "SC2005", + "SC2006", + "SC2007", + "SC2008", + "SC2009", + "SC2011", + "SC1063", + "SC1100", + "SC1101", +]); +const MODULE_CODES = new Set([ + "SC1010", + "SC1012", + "SC1013", + "SC1014", + "SC1015", + "SC1016", + "SC2013", +]); +/** Runtime code generation (`eval`, `new Function`): excluded by policy, + * since the dynamic tier compiles ahead of time (DESIGN.md §12 class X). */ +const EXCLUDED_CODES = new Set(["SC2010"]); +const EXCLUDED_SURFACE = /^'(?:new Function|Function|eval)'/; + +/** Refusals and fences that name a static-tier gap. Type errors, + * environment and target refusals, divergences and internal errors are + * not demotion requests. */ +export function isDemotionCode(code: string): boolean { + return /^SC[12]\d{3}$/.test(code); +} + +function isFunctionLikeOrFile(node: ts.Node): boolean { + return ( + ts.isSourceFile(node) || + ts.isFunctionLikeDeclaration(node) || + ts.isClassStaticBlockDeclaration(node) + ); +} + +function nameText(name: ts.Node | undefined): string | null { + if (name === undefined) return null; + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text; + if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; + return null; +} + +function className(node: ts.Node | undefined): string { + if (node === undefined) return ""; + if ((ts.isClassDeclaration(node) || ts.isClassExpression(node)) && node.name !== undefined) + return node.name.text; + const parent = node.parent; + if (parent !== undefined && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) + return parent.name.text; + return ""; +} + +/** The display name of a function-like node, as a user would look it up. */ +function functionName(node: ts.Node): string { + if (ts.isSourceFile(node)) return ""; + if (ts.isClassStaticBlockDeclaration(node)) return `${className(node.parent)}.`; + if (ts.isConstructorDeclaration(node)) return `${className(node.parent)}.constructor`; + const own = ts.isFunctionLikeDeclaration(node) ? nameText(node.name) : null; + const parent = node.parent; + if (parent !== undefined && isClassLike(parent) && own !== null) + return `${className(parent)}.${own}`; + if (own !== null) return own; + if (parent !== undefined) { + if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent)) { + const bound = nameText(parent.name); + if (bound !== null) return bound; + } + if ( + ts.isPropertyDeclaration(parent) && + parent.parent !== undefined && + isClassLike(parent.parent) + ) { + const field = nameText(parent.name); + if (field !== null) return `${className(parent.parent)}.${field}`; + } + if (ts.isBinaryExpression(parent) && parent.right === node) { + const left = parent.left; + if (ts.isIdentifier(left)) return left.text; + if (ts.isPropertyAccessExpression(left)) return left.getText(); + } + } + const sf = node.getSourceFile(); + const pos = ts.getLineAndCharacterOfPosition(sf, node.getStart(sf)); + return ``; +} + +function locOfNode(node: ts.Node): SrcLoc { + const sf = node.getSourceFile(); + return { file: sf.fileName, start: node.getStart(sf), end: node.getEnd() }; +} + +function enclosingFunction(node: ts.Node): ts.Node { + let current: ts.Node | undefined = node.parent; + while (current !== undefined && !isFunctionLikeOrFile(current)) current = current.parent; + return current ?? node.getSourceFile(); +} + +function functionUnit(node: ts.Node, instance: string | null): DemotionUnit { + // Top-level code runs in the module's initializer function. + if (ts.isSourceFile(node)) + return { + kind: "function", + name: "", + loc: { file: node.fileName, start: 0, end: 0 }, + }; + const name = functionName(node); + return { + kind: "function", + name: instance === null ? name : `${name} ${instance}`, + loc: locOfNode(node), + }; +} + +/** Generic instances append their instantiation context to diagnostics + * (Lowerer.pushDiag: ` (instantiating 'f' with )`); the instance, not + * the source function, is the unit. Returns the type-argument text. */ +function instanceOf(message: string): string | null { + const at = message.lastIndexOf(" (instantiating "); + if (at < 0 || !message.endsWith(")")) return null; + const context = message.slice(at + 2, -1); + const withAt = context.indexOf(" with "); + return withAt < 0 ? context : context.slice(withAt + 6); +} + +/** Strip wrappers and walk receivers down to the value an operation acts + * on: `a.b.c(…)` → `a`, `(x as T)[k]` → `x`. Records whether the chain + * passes through `.prototype`. */ +function rootOf(expr: ts.Node): { root: ts.Node; viaPrototype: boolean } { + let node = expr; + let viaPrototype = false; + for (;;) { + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isNonNullExpression(node) || + ts.isAssertionExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isSpreadElement(node) || + ts.isAwaitExpression(node) + ) { + node = node.expression!; + } else if (ts.isPropertyAccessExpression(node)) { + if (node.name.text === "prototype") viaPrototype = true; + node = node.expression; + } else if (ts.isElementAccessExpression(node) || ts.isCallExpression(node)) { + node = node.expression; + } else if ( + ts.isDeleteExpression(node) || + ts.isTypeOfExpression(node) || + ts.isVoidExpression(node) + ) { + node = node.expression; + } else { + return { root: node, viaPrototype }; + } + } +} + +const REFLECTION_NAMESPACES = new Set(["Object", "Reflect"]); + +/** The expression an operation at `node` acts on. Reflection calls + * (`Object.defineProperty(x, …)`, `Reflect.set(x, …)`) act on their first + * argument; `new C()` and `x instanceof C` on the class value. */ +function subjectOf(node: ts.Node): ts.Node { + let current = node; + // A refused callee or member name: the whole call or access is the site. + const parent = current.parent; + if (parent !== undefined && ts.isPropertyAccessExpression(parent) && parent.name === current) + current = parent; + if (ts.isCallExpression(current)) { + const callee = current.expression; + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + REFLECTION_NAMESPACES.has(callee.expression.text) && + current.arguments.length > 0 + ) { + return current.arguments[0]!; + } + return callee; + } + if (ts.isNewExpression(current)) return current.expression; + if (ts.isBinaryExpression(current)) { + if (current.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword) return current.right; + if (current.operatorToken.kind === ts.SyntaxKind.EqualsToken) return current.left; + } + return current; +} + +/** The declaration owning a type-refused node: the binding, parameter, + * field or function whose declared type has no static representation. */ +function typeOwnerOf(node: ts.Node): ts.Node | null { + let current: ts.Node | undefined = node; + while (current !== undefined && !ts.isSourceFile(current)) { + if ( + ts.isVariableDeclaration(current) || + ts.isParameter(current) || + ts.isBindingElement(current) || + ts.isPropertyDeclaration(current) || + ts.isPropertySignature(current) || + ts.isCatchClause(current) + ) { + return current; + } + if (ts.isFunctionLikeDeclaration(current) || isClassLike(current)) return current; + if (ts.isStatement(current)) return null; + current = current.parent; + } + return null; +} + +function isModuleObjectName(node: ts.Node): boolean { + return ts.isIdentifier(node) && (node.text === "exports" || node.text === "module"); +} + +class UnitResolver { + constructor(private readonly checker: ts.TypeChecker) {} + + /** The unit a declaration node denotes. */ + declarationUnit(decl: ts.Node): DemotionUnit | null { + if (isClassLike(decl)) return { kind: "class", name: className(decl), loc: locOfNode(decl) }; + if (ts.isFunctionLikeDeclaration(decl)) return functionUnit(decl, null); + if (ts.isPropertyDeclaration(decl) || ts.isPropertySignature(decl)) { + const field = nameText(decl.name) ?? ""; + const owner = decl.parent; + const ownerName = owner !== undefined && isClassLike(owner) ? className(owner) : null; + return { + kind: ownerName === null ? "binding" : "field", + name: ownerName === null ? field : `${ownerName}.${field}`, + loc: locOfNode(decl), + }; + } + if (ts.isCatchClause(decl)) { + const binding = decl.variableDeclaration; + if (binding === undefined) return null; + return this.declarationUnit(binding); + } + if (ts.isVariableDeclaration(decl) || ts.isParameter(decl) || ts.isBindingElement(decl)) { + const name = nameText(decl.name) ?? decl.name?.getText() ?? ""; + const scope = enclosingFunction(decl); + const unit: DemotionUnit = { kind: "binding", name, loc: locOfNode(decl) }; + if (ts.isParameter(decl) && ts.isFunctionLikeDeclaration(decl.parent)) + unit.owner = functionName(decl.parent); + else if (!ts.isSourceFile(scope)) unit.owner = functionName(scope); + return unit; + } + return null; + } + + /** The unit an operand expression denotes. */ + valueUnit(expr: ts.Node): { unit: DemotionUnit; reflection: boolean } | null { + const { root, viaPrototype } = rootOf(expr); + const sf = root.getSourceFile(); + if (root.kind === ts.SyntaxKind.ThisKeyword) { + const owner = enclosingFunction(root); + if (ts.isSourceFile(owner)) + return { + unit: { kind: "module", name: sf.fileName, loc: locOfNode(sf) }, + reflection: false, + }; + const cls = owner.parent; + if (cls !== undefined && isClassLike(cls)) { + // `this.f` names a field; `this` itself is the instance. + const access = root.parent; + if ( + access !== undefined && + ts.isPropertyAccessExpression(access) && + access.expression === root + ) { + return { + unit: { + kind: "field", + name: `${className(cls)}.${access.name.text}`, + loc: locOfNode(access), + }, + reflection: false, + }; + } + return { + unit: { kind: "class", name: className(cls), loc: locOfNode(cls) }, + reflection: viaPrototype, + }; + } + return null; + } + if (!ts.isIdentifier(root)) return null; + let symbol = this.checker.getSymbolAtLocation(root); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = this.checker.getAliasedSymbol(symbol); + const decls = symbol === undefined ? [] : this.checker.declarationsOf(symbol); + const decl = decls.find((d) => !d.getSourceFile().isDeclarationFile); + if (decl === undefined) { + // CommonJS `exports`/`module` as values: the module object. + if (isModuleObjectName(root)) + return { + unit: { kind: "module", name: sf.fileName, loc: locOfNode(sf) }, + reflection: false, + }; + return null; + } + if (ts.isSourceFile(decl)) + return { + unit: { kind: "module", name: decl.fileName, loc: locOfNode(decl) }, + reflection: false, + }; + const unit = this.declarationUnit(decl); + if (unit === null) return null; + // `C.prototype.m = …`, `Object.defineProperty(C.prototype, …)`: the + // class (or the constructor function's prototype object). + if (viaPrototype && (unit.kind === "class" || unit.kind === "function")) + return { unit: { ...unit, kind: "class" }, reflection: true }; + return { unit, reflection: unit.kind === "class" }; + } +} + +function seedOf(code: string, message: string): DemotionSeed { + if (EXCLUDED_CODES.has(code) || EXCLUDED_SURFACE.test(message)) return "excluded"; + if (code === "SC2004") return "cascade"; + if (MODULE_CODES.has(code)) return "module"; + if (TYPE_CODES.has(code)) return "type"; + return "operation"; +} + +/** One request for a committed refusal or fence diagnostic. */ +function requestFor( + program: ts.Program, + resolver: UnitResolver, + diag: ScrDiagnostic, + scope: DemotionScope, +): DemotionRequest | null { + if (!isDemotionCode(diag.code)) return null; + let seed = seedOf(diag.code, diag.message); + const base = { code: diag.code, message: diag.message, loc: diag.loc, scope }; + const sf = program.getSourceFile(diag.loc.file); + if (sf === undefined) { + const unit: DemotionUnit = { kind: "module", name: diag.loc.file, loc: diag.loc }; + return { ...base, unit, enclosing: unit, seed }; + } + const moduleUnit: DemotionUnit = { kind: "module", name: sf.fileName, loc: locOfNode(sf) }; + const node = nodeAt(sf, diag.loc.start, diag.loc.end); + const instance = instanceOf(diag.message); + const owner = ts.isSourceFile(node) ? sf : enclosingFunction(node); + const enclosing = functionUnit(owner, instance); + if (seed === "module" || ts.isSourceFile(node)) + return { ...base, unit: moduleUnit, enclosing, seed }; + let unit: DemotionUnit | null = null; + if (seed === "type") { + const decl = typeOwnerOf(node); + if (decl !== null) unit = resolver.declarationUnit(decl); + } + if (unit === null) { + const value = resolver.valueUnit(subjectOf(node)); + if (value !== null) { + unit = value.unit; + if (value.reflection && seed === "operation") seed = "reflection"; + } + } + return { ...base, unit: unit ?? enclosing, enclosing, seed }; +} + +function unitKey(unit: DemotionUnit): string { + return `${unit.kind}\0${unit.name}\0${unit.owner ?? ""}\0${unit.loc.file}\0${unit.loc.start}`; +} + +const KIND_ORDER: readonly DemotionUnitKind[] = ["module", "class", "field", "function", "binding"]; + +function demotedUnits(requests: readonly DemotionRequest[]): DemotedUnit[] { + const units = new Map(); + const entry = (unit: DemotionUnit): DemotedUnit => { + const key = unitKey(unit); + let found = units.get(key); + if (found === undefined) units.set(key, (found = { ...unit, requests: 0, sites: 0 })); + return found; + }; + for (const request of requests) { + if (request.scope === "unreached" || request.seed === "excluded") continue; + entry(request.unit).requests++; + entry(request.enclosing).sites++; + } + return [...units.values()].sort( + (a, b) => + KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || + a.loc.file.localeCompare(b.loc.file) || + a.loc.start - b.loc.start || + a.name.localeCompare(b.name), + ); +} + +export interface TierAnalysisInput { + mode: TierMode; + /** The dynamic-tier IR functions of the reached program. */ + dynamicFunctions: readonly IrFunction[]; + reached: readonly ScrDiagnostic[]; + deferred: readonly ScrDiagnostic[]; + unreached: readonly ScrDiagnostic[]; +} + +/** Coverage's statementsDynamic (see dynamicFunctionReports). */ +export function dynamicStatementCount( + program: ts.Program, + dynamicFunctions: readonly IrFunction[], +): number { + return dynamicFunctionReports(program, dynamicFunctions).statements; +} + +export function analyzeTiers(program: ts.Program, input: TierAnalysisInput): TierReport { + const resolver = new UnitResolver(program.getTypeChecker()); + const requests: DemotionRequest[] = []; + const add = (diags: readonly ScrDiagnostic[], scope: DemotionScope): void => { + for (const diag of diags) { + let request: DemotionRequest | null; + try { + request = requestFor(program, resolver, diag, scope); + } catch { + // Attribution is best effort; a report never fails an analysis. + if (!isDemotionCode(diag.code)) continue; + const unit: DemotionUnit = { kind: "module", name: diag.loc.file, loc: diag.loc }; + request = { + unit, + enclosing: unit, + seed: seedOf(diag.code, diag.message), + scope, + code: diag.code, + message: diag.message, + loc: diag.loc, + }; + } + if (request !== null) requests.push(request); + } + }; + add(input.reached, "reached"); + add(input.deferred, "deferred"); + add(input.unreached, "unreached"); + const dynamic = dynamicFunctionReports(program, input.dynamicFunctions); + return { + mode: input.mode, + statementsDynamic: dynamic.statements, + dynamicFunctions: dynamic.functions, + requests, + units: demotedUnits(requests), + }; +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c3009665a..56b787e18 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -172,11 +172,22 @@ export { } from "./diagnostics/render.js"; export { renderCoverage, + renderExplainDynamic, coverageEnvelope, coveragePasses, type CoverageInput, type CoverageFailOn, } from "./coverage/report.js"; +export { + TIER_MODES, + type DemotedUnit, + type DemotionRequest, + type DemotionSeed, + type DemotionUnit, + type DemotionUnitKind, + type TierMode, + type TierReport, +} from "./frontend/tier-analysis.js"; export { buildEnvelope, DIAGNOSTICS_SCHEMA_VERSION, @@ -208,6 +219,13 @@ export { } from "./compat/fetch-profile.js"; export { LIB_FN_SIGS } from "./ir/builtin-signatures.js"; export { validateModule } from "./ir/validate.js"; +export { + assignLegacyTiers, + dynamicOperationOf, + functionTier, + type DynamicFunction, + type IrTier, +} from "./ir/tiers.js"; export { deserializeModule, deserializeModuleBytes, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 9591c684a..9a4a12189 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1656,6 +1656,14 @@ export interface IrFunction { sourceName?: string; /** Ordinary authored function declarations/expressions own a prototype. */ ownsPrototype?: true; + /** The function's execution tier (ir/tiers.ts). Absent means static: the + * function may use dynamic values only through boundary operations + * (dynFrom, dynCheck, kind tests) and moves, which the validator enforces + * (invariant I1). "dynamic" admits the dynamic operation vocabulary + * (dynKeyGet, dynCall, dyn.* builtins, ...). Lowering assigns the field + * from the body (assignLegacyTiers); stage S2 of the two-tier design + * keeps it out of serialized IR, so loaders derive it again. */ + tier?: "dynamic"; body: IrStmt[]; loc: SrcLoc; } diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index 54e210ea3..1d0e5364e 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -7,6 +7,7 @@ * text parts and decoded from bytes one function at a time; neither direction * materializes the complete document as a single string. */ import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; +import { deriveModuleTiers } from "./tiers.js"; // Version 16 removes embedded-engine types, operations, and module metadata. // Older modules can require runtime support that no longer exists. @@ -42,6 +43,19 @@ function encodeNumber(_key: string, value: unknown): unknown { return value; } +/** Stage S2 of the two-tier design keeps function tiers out of IR + * artifacts, so a report-only release changes no serialized byte; loaders + * derive them again (deriveModuleTiers). No IR node other than a function + * carries a `tier` key. */ +function encodeWithoutTier(key: string, value: unknown): unknown { + return key === "tier" ? undefined : encodeNumber(key, value); +} + +function encodeFunction(fn: IrFunction): string | undefined { + if (fn.tier !== undefined) return JSON.stringify(fn, encodeWithoutTier); + return ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, encodeNumber); +} + /** The compact encoding as ordered parts: the module header up to the * function array, one part per function (with separate comma parts), and the * header's remainder. Concatenating the parts yields exactly @@ -58,9 +72,7 @@ export function serializeModuleParts(mod: IrModule): string[] { for (let index = 0; index < mod.functions.length; index++) { const fn = mod.functions[index]!; if (index > 0) parts.push(","); - parts.push( - (ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, encodeNumber)) ?? "null", - ); + parts.push(encodeFunction(fn) ?? "null"); } parts.push("]" + header.slice(offset + slot.length)); return parts; @@ -70,7 +82,7 @@ export function serializeModuleParts(mod: IrModule): string[] { * readable default. Both return one string, so modules whose encoding * exceeds the host's string limit must use `serializeModuleParts`. */ export function serializeModule(mod: IrModule, compact = false): string { - if (!compact) return JSON.stringify(mod, encodeNumber, 2); + if (!compact) return JSON.stringify(mod, encodeWithoutTier, 2); return serializeModuleParts(mod).join(""); } @@ -96,7 +108,7 @@ function checkedVersion(mod: IrModule): IrModule { } export function deserializeModule(json: string): IrModule { - return checkedVersion(JSON.parse(json, decodeNumber) as IrModule); + return deriveModuleTiers(checkedVersion(JSON.parse(json, decodeNumber) as IrModule)); } /** A node as it reads back from the compact encoding: a fresh object, as @@ -249,5 +261,5 @@ export function deserializeModuleBytes(bytes: Buffer): IrModule { ); } mod.functions = functions; - return mod; + return deriveModuleTiers(mod); } diff --git a/packages/compiler/src/ir/tiers.test.ts b/packages/compiler/src/ir/tiers.test.ts new file mode 100644 index 000000000..c5f1ad4b8 --- /dev/null +++ b/packages/compiler/src/ir/tiers.test.ts @@ -0,0 +1,142 @@ +import { expect, test } from "vitest"; +import { BOOL, DYN, F64, STRING, VOID, type IrExpr, type IrFunction, type IrModule } from "./ir.js"; +import { + deserializeModule, + deserializeModuleBytes, + serializeModule, + serializeModuleParts, +} from "./serialize.js"; +import { + assignLegacyTiers, + dynamicOperationOf, + firstDynamicOperation, + functionTier, +} from "./tiers.js"; +import { validateModule } from "./validate.js"; + +const loc = { file: "tiers.ts", start: 0, end: 0 }; +const dynParam = { kind: "varRef", localId: "v", type: DYN, loc } as const satisfies IrExpr; + +function fn(name: string, value: IrExpr): IrFunction { + return { + name, + params: [{ localId: "v", name: "v", type: DYN }], + returnType: value.type, + locals: [{ id: "v", name: "v", type: DYN, mutable: false }], + body: [{ kind: "return", value, loc }], + loc, + }; +} + +function moduleOf(...functions: IrFunction[]): IrModule { + const main: IrFunction = { + name: "%main", + params: [], + returnType: VOID, + locals: [], + body: [], + loc, + }; + return { irVersion: 16, sourceFile: "tiers.ts", functions: [main, ...functions], entry: "%main" }; +} + +const keyGet: IrExpr = { + kind: "dynKeyGet", + key: { kind: "strLit", value: "name", type: STRING, loc }, + value: dynParam, + type: DYN, + loc, +}; + +test("dynamic operations and boundary operations are told apart", () => { + expect(dynamicOperationOf(keyGet)).toBe("dynKeyGet"); + expect( + dynamicOperationOf({ + kind: "libCall", + fn: "dyn.add", + args: [dynParam, dynParam], + type: DYN, + loc, + }), + ).toBe("dyn.add"); + expect( + dynamicOperationOf({ + kind: "call", + callee: "%dyn.class.property.12", + args: [dynParam], + type: DYN, + loc, + }), + ).toBe("%dyn.class.property"); + // Boundary operations: conversions and kind tests. + expect(dynamicOperationOf({ kind: "dynCheck", value: dynParam, type: F64, loc })).toBeNull(); + expect( + dynamicOperationOf({ + kind: "dynFrom", + value: { kind: "numLit", value: 1, type: F64, loc }, + type: DYN, + loc, + }), + ).toBeNull(); + expect( + dynamicOperationOf({ kind: "dynTest", test: "string", value: dynParam, type: BOOL, loc }), + ).toBeNull(); + expect( + dynamicOperationOf({ kind: "libCall", fn: "dyn.typedRefIs", args: [dynParam], type: DYN, loc }), + ).toBeNull(); + // Moves and calls of ordinary functions. + expect(dynamicOperationOf(dynParam)).toBeNull(); + expect( + dynamicOperationOf({ kind: "call", callee: "helper", args: [dynParam], type: DYN, loc }), + ).toBeNull(); +}); + +test("lowering's classification puts exactly the functions with dynamic operations in the dynamic tier", () => { + const reader = fn("reader", keyGet); + const mover = fn("mover", dynParam); + const dynamic = assignLegacyTiers([reader, mover]); + expect(dynamic.map((d) => [d.name, d.operation])).toEqual([["reader", "dynKeyGet"]]); + expect(functionTier(reader)).toBe("dynamic"); + expect(functionTier(mover)).toBe("static"); + expect(firstDynamicOperation(mover)).toBeNull(); +}); + +test("the validator enforces I1: static functions perform no dynamic operation", () => { + const reader = fn("reader", keyGet); + const errors = validateModule(moduleOf(reader)); + expect(errors.map((e) => e.message)).toEqual([ + "in reader: static-tier function performs the dynamic operation dynKeyGet", + ]); + reader.tier = "dynamic"; + expect(validateModule(moduleOf(reader))).toEqual([]); + // A logical operator over dynamic values applies ToBoolean to them. + const either = fn("either", { + kind: "logical", + op: "||", + left: dynParam, + right: dynParam, + type: DYN, + loc, + }); + expect(validateModule(moduleOf(either)).map((e) => e.message)).toEqual([ + "in either: static-tier function performs the dynamic operation logical", + ]); +}); + +test("serialized IR carries no tiers in stage S2, and loading derives them again", () => { + const reader = fn("reader", keyGet); + const mod = moduleOf(reader); + const untiered = serializeModule(mod, true); + assignLegacyTiers(mod.functions); + expect(reader.tier).toBe("dynamic"); + expect(serializeModule(mod, true)).toBe(untiered); + expect(serializeModuleParts(mod).join("")).toBe(untiered); + expect(serializeModule(mod)).not.toContain('"tier"'); + for (const loaded of [ + deserializeModule(untiered), + deserializeModuleBytes(Buffer.from(untiered)), + ]) { + expect(loaded.functions.map(functionTier)).toEqual(["static", "dynamic"]); + expect(validateModule(loaded)).toEqual([]); + } +}); diff --git a/packages/compiler/src/ir/tiers.ts b/packages/compiler/src/ir/tiers.ts new file mode 100644 index 000000000..201bc2a58 --- /dev/null +++ b/packages/compiler/src/ir/tiers.ts @@ -0,0 +1,157 @@ +/* Execution tiers (two-tier design, stage S2: report-only). + * + * Every IR function belongs to one of two tiers: + * + * - static: today's compiler. Exact layouts, direct calls, unboxed values. + * A static function may hold dynamic (`dyn`) values, pass them to calls, + * return them and store them, and it may cross the boundary with the + * boundary operations below, but it never operates on a dynamic value. + * - dynamic: code that operates on dynamic values with JavaScript semantics + * (property reads and writes, generic operators, calls of dynamic values, + * dynamic object and array construction). + * + * Invariant I1 (validate.ts): a static function contains no dynamic + * operation. Only boundary operations and moves may mix the tiers. Calls + * of generated `%dyn.*` helpers count as dynamic operations: they are the + * dynamic dispatch the lowering placed out of line. Calls of other dynamic + * functions are boundaries (the design's "call to a dynamic function"). + * + * Boundary operations: + * - `dynFrom` (static to dynamic, the design's `toJs`) and `caughtToDyn`; + * - `dynCheck` (dynamic to static, the design's `fromJs`); + * - kind tests that only inspect a dynamic value's tag (`dynTest`, the + * `dyn.*Is` and `dyn.typeof` builtins): the tag test of a conversion; + * - calls of non-`dyn.*` builtins whose signatures take or return `dyn` + * (typed APIs implemented through dyn, leak L6 of the design). They stay + * boundaries until stage S1 gives them typed signatures. + * + * Classification of existing code. The `dyn` vocabulary is today's + * dynamic tier (`ScrDyn`): `any`, untyped JavaScript, unschematized + * `JSON.parse`, and the generated dynamic dispatch helpers already lower to + * it. Lowering therefore assigns tiers from function bodies: a function + * containing a dynamic operation is dynamic, every other function is static + * (`assignLegacyTiers`). Demotion (stage S6) will add tier decisions that + * do not follow from a body; until then the classification is exact, and + * the validator guards it against later passes and hand-written IR. + * + * Serialization. Stage S2 must not change any artifact, so serialized IR + * omits `tier` (serialize.ts) and loaders derive it again with the same + * classification. The next IR version serializes the field. */ +import type { IrExpr, IrFunction, IrModule, SrcLoc } from "./ir.js"; +import { everyStmtList, type IrVisitor } from "./traverse.js"; + +export type IrTier = "static" | "dynamic"; + +export function functionTier(fn: IrFunction): IrTier { + return fn.tier === "dynamic" ? "dynamic" : "static"; +} + +/** `dyn.*` builtins that only inspect a dynamic value's kind: the tag test + * half of a dynamic-to-static conversion. */ +const DYN_KIND_TESTS: ReadonlySet = new Set([ + "dyn.typedRefIs", + "dyn.typedRefKeyIs", + "dyn.classIs", + "dyn.nativeSetIs", + "dyn.nativeMapIs", + "dyn.nativeUrlIs", + "dyn.nativeDateIs", + "dyn.nativeRegexIs", + "dyn.dataViewIs", + "dyn.typeof", +]); + +const DYN_HELPER_PREFIX = "%dyn."; + +/** `%dyn.class.property.12` → `%dyn.class.property`. */ +function dynHelperFamily(name: string): string { + return name.replace(/(\.\d+)+$/, ""); +} + +/** The dynamic operation an expression performs, named for reports + * (`dynKeyGet`, `dyn.keySet`, ...), or null for static operations, boundary + * operations and moves. Only the node itself is inspected. */ +export function dynamicOperationOf(e: IrExpr): string | null { + switch (e.kind) { + case "dynCall": + case "dynInvoke": + case "dynObjLit": + case "dynArrLit": + case "dynKeyGet": + case "dynHasKey": + case "dynScalarEq": + case "dynDestrCheck": + case "dynIterN": + return e.kind; + case "logical": + // `a || b` over dyn operands applies ToBoolean to a dynamic value. + return e.type.kind === "dyn" ? "logical" : null; + case "libCall": + return e.fn.startsWith("dyn.") && !DYN_KIND_TESTS.has(e.fn) ? e.fn : null; + case "call": + // Generated `%dyn.*` helpers are dynamic operations lowered out of + // line (class dynamic dispatch, dynamic HOF adapters). + return e.callee.startsWith(DYN_HELPER_PREFIX) ? dynHelperFamily(e.callee) : null; + default: + return null; + } +} + +export interface DynamicOperationSite { + operation: string; + loc: SrcLoc; +} + +function collectDynamicOperations(fn: IrFunction, first: boolean): DynamicOperationSite[] { + const found: DynamicOperationSite[] = []; + const visitor: IrVisitor = { + expr: (e) => { + const operation = dynamicOperationOf(e); + if (operation === null) return true; + found.push({ operation, loc: e.loc }); + return !first; + }, + stmt: () => true, + }; + everyStmtList(fn.body, visitor); + return found; +} + +/** The first dynamic operation in a function body, in traversal order. */ +export function firstDynamicOperation(fn: IrFunction): DynamicOperationSite | null { + return collectDynamicOperations(fn, true)[0] ?? null; +} + +/** Every dynamic operation in a function body (coverage reports). */ +export function dynamicOperationSites(fn: IrFunction): DynamicOperationSite[] { + return collectDynamicOperations(fn, false); +} + +/** A function the classification placed in the dynamic tier, with the + * operation that put it there. */ +export interface DynamicFunction { + name: string; + loc: SrcLoc; + operation: string; + operationLoc: SrcLoc; +} + +/** Assign every function its tier from its body (see the header). Returns + * the dynamic functions in module order. */ +export function assignLegacyTiers(functions: readonly IrFunction[]): DynamicFunction[] { + const dynamic: DynamicFunction[] = []; + for (const fn of functions) { + const site = firstDynamicOperation(fn); + if (site === null) continue; + fn.tier = "dynamic"; + dynamic.push({ name: fn.name, loc: fn.loc, operation: site.operation, operationLoc: site.loc }); + } + return dynamic; +} + +/** Loaders call this on deserialized modules (stage S2 serializes no + * tiers). */ +export function deriveModuleTiers(mod: IrModule): IrModule { + assignLegacyTiers(mod.functions); + return mod; +} diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index b7caf8c41..8086cc176 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -20,6 +20,7 @@ import { type IrUnionDef, } from "./ir.js"; import { deserializeModule, serializeModule } from "./serialize.js"; +import { assignLegacyTiers } from "./tiers.js"; import { validateModule } from "./validate.js"; const loc = { file: "numeric-read.ts", start: 0, end: 0 }; @@ -399,7 +400,7 @@ test.each(["sortPrimitive", "toSortedPrimitive"] as const)( ); function expressionModule(expr: IrExpr, unions: IrUnionDef[]): IrModule { - return { + const mod: IrModule = { irVersion: 16, sourceFile: loc.file, entry: "main", @@ -415,6 +416,9 @@ function expressionModule(expr: IrExpr, unions: IrUnionDef[]): IrModule { }, ], }; + // Lowering assigns tiers from function bodies (ir/tiers.ts). + assignLegacyTiers(mod.functions); + return mod; } test("numeric byte tokens and DataView stores validate their storage before emission", () => { diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f7d30a1bb..1bac14de6 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -59,6 +59,7 @@ import { import { DYN_CLASS_PROPERTIES } from "./ir.js"; import { unionWideningTags } from "./analysis.js"; import { alwaysReturns } from "./control-flow.js"; +import { dynamicOperationOf, functionTier } from "./tiers.js"; import { LIB_FN_SIGS } from "./builtin-signatures.js"; import { REGEX_INTRINSIC_SIGS, STR_INTRINSIC_SIGS } from "./intrinsic-signatures.js"; @@ -1007,6 +1008,14 @@ function validateFunction( const locals = new Map(fn.locals.map((l) => [l.id, l])); const err = (message: string, loc: SrcLoc) => errors.push({ message: `in ${fn.name}: ${message}`, loc }); + // Invariant I1 (ir/tiers.ts): a static function reaches dynamic values + // only through boundary operations and moves. + const staticTier = functionTier(fn) === "static"; + const checkTier = (e: IrExpr): void => { + const operation = dynamicOperationOf(e); + if (operation !== null) + err(`static-tier function performs the dynamic operation ${operation}`, e.loc); + }; const asyncCaches = [ ["asyncCacheGlobal", fn.asyncCacheGlobal], @@ -1251,6 +1260,8 @@ function validateFunction( continue; } if (e.kind === "logical") { + // checkExpr already checked the root's tier. + if (staticTier && e !== root) checkTier(e); if ( e.type.kind !== "f64" && e.type.kind !== "string" && @@ -1294,6 +1305,7 @@ function validateFunction( } function checkExpr(e: IrExpr): void { + if (staticTier) checkTier(e); switch (e.kind) { case "numLit": case "strLit": diff --git a/packages/compiler/src/library/prepare.ts b/packages/compiler/src/library/prepare.ts index 94f20198a..298bb43bb 100644 --- a/packages/compiler/src/library/prepare.ts +++ b/packages/compiler/src/library/prepare.ts @@ -553,6 +553,8 @@ export function prepareLibrary( try { lowered = fe.lower({ targetPlatform: buildPlatform, + // Library artifacts are static-tier only (two-tier design I2). + tierMode: "static-only", ...(cbImports.length > 0 ? { ffiImports: cbImports } : {}), ...(cbImports.length > 0 ? { libraryCallbacks: true } : {}), // The profile-mapped exports are called from OUTSIDE the graph: diff --git a/tests/coverage-fixtures/tier-demotions.ts b/tests/coverage-fixtures/tier-demotions.ts new file mode 100644 index 000000000..52f2058f1 --- /dev/null +++ b/tests/coverage-fixtures/tier-demotions.ts @@ -0,0 +1,25 @@ +// The report-only tier assignment of `scriptc coverage --explain-dynamic`. +class Point { + constructor( + public x: number, + public y: number, + ) {} +} + +// Reflection on a class: the dynamic tier would give Point a prototype object. +(Point.prototype as any).norm = function () { + return 0; +}; + +// Already dynamic: a property read on an `any` value. +function readName(text: string): string { + const data: any = JSON.parse(text); + return String(data.name); +} + +// An operation without a static lowering, on a parameter. +function precise(n: number): string { + return n.toPrecision(3); +} + +console.log(readName('{"name":"a"}'), precise(1.5), new Point(1, 2).x); diff --git a/tests/harness/__snapshots__/coverage-divergences.txt b/tests/harness/__snapshots__/coverage-divergences.txt index 67c9b57e3..2f811f892 100644 --- a/tests/harness/__snapshots__/coverage-divergences.txt +++ b/tests/harness/__snapshots__/coverage-divergences.txt @@ -2,6 +2,7 @@ scriptc coverage tests/coverage-fixtures/divergences.ts statements analyzed 18 compile statically 18 (100%) + in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them) fully static — all analyzed statements compile to native code. diff --git a/tests/harness/__snapshots__/coverage-dynamic-mix.txt b/tests/harness/__snapshots__/coverage-dynamic-mix.txt index a3e714bde..2ad84f0af 100644 --- a/tests/harness/__snapshots__/coverage-dynamic-mix.txt +++ b/tests/harness/__snapshots__/coverage-dynamic-mix.txt @@ -2,6 +2,7 @@ scriptc coverage tests/coverage-fixtures/dynamic-mix.ts statements analyzed 8 compile statically 7 (87%) + in dynamic tier 2 (operate on values typed only at runtime; --explain-dynamic lists them) blockers: ×1 SC2012 '.toPrecision()' on numbers cannot compile statically diff --git a/tests/harness/__snapshots__/coverage-explain-dynamic.txt b/tests/harness/__snapshots__/coverage-explain-dynamic.txt new file mode 100644 index 000000000..1c86a808c --- /dev/null +++ b/tests/harness/__snapshots__/coverage-explain-dynamic.txt @@ -0,0 +1,16 @@ + dynamic tier (tier assignment, report-only; --tier=auto) + 4 functions already operate on dynamic values (1 statement) + readName tests/coverage-fixtures/tier-demotions.ts:15:1: %dyn.error.stringConstructor at tests/coverage-fixtures/tier-demotions.ts:17:10 + %dyn.error.stringConstructor.0 tests/coverage-fixtures/tier-demotions.ts:17:10: dynCall at tests/coverage-fixtures/tier-demotions.ts:17:10 + %dyn.class.property.0 tests/coverage-fixtures/tier-demotions.ts:17:17: %dyn.class.readPropertiesOrEmpty at tests/coverage-fixtures/tier-demotions.ts:17:17 + %method.value:%%Error.toString tests/coverage-fixtures/tier-demotions.ts:17:10: dyn.this at tests/coverage-fixtures/tier-demotions.ts:17:10 + + 2 demotion requests (refusals and fences the dynamic tier would replace) + class Point tests/coverage-fixtures/tier-demotions.ts:2:1 (1 request) + <- SC1090 reflection at tests/coverage-fixtures/tier-demotions.ts:10:1: assignment to non-variables are not supported yet + -> function turns dynamic + function tests/coverage-fixtures/tier-demotions.ts:1:1 (1 site inside) + function precise tests/coverage-fixtures/tier-demotions.ts:21:1 (1 site inside) + binding n in precise tests/coverage-fixtures/tier-demotions.ts:21:18 (1 request) + <- SC2012 operation at tests/coverage-fixtures/tier-demotions.ts:22:10: '.toPrecision()' on numbers cannot compile statically + -> function precise turns dynamic \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-js-gap.txt b/tests/harness/__snapshots__/coverage-js-gap.txt index 3150216b6..3b6fa678c 100644 --- a/tests/harness/__snapshots__/coverage-js-gap.txt +++ b/tests/harness/__snapshots__/coverage-js-gap.txt @@ -2,5 +2,6 @@ scriptc coverage tests/coverage-fixtures/js-gap/gap.js statements analyzed 5 compile statically 5 (100%) + in dynamic tier 3 (operate on values typed only at runtime; --explain-dynamic lists them) fully static — all analyzed statements compile to native code. \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-mixed.txt b/tests/harness/__snapshots__/coverage-mixed.txt index 88b61c39b..c3b348f9c 100644 --- a/tests/harness/__snapshots__/coverage-mixed.txt +++ b/tests/harness/__snapshots__/coverage-mixed.txt @@ -2,6 +2,7 @@ scriptc coverage tests/coverage-fixtures/mixed.ts statements analyzed 13 compile statically 12 (92%) + in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them) blockers: ×1 SC1031 object destructuring of non-record values (the source is array-typed) diff --git a/tests/harness/__snapshots__/coverage-npm-packages.txt b/tests/harness/__snapshots__/coverage-npm-packages.txt index 0ba05286a..475b10e45 100644 --- a/tests/harness/__snapshots__/coverage-npm-packages.txt +++ b/tests/harness/__snapshots__/coverage-npm-packages.txt @@ -2,6 +2,7 @@ scriptc coverage tests/corpus/native-npm-imports/main.ts statements analyzed 14 compile statically 14 (100%) + in dynamic tier 3 (operate on values typed only at runtime; --explain-dynamic lists them) npm package compilation: mathkit static diff --git a/tests/harness/__snapshots__/coverage-tier-demotions.txt b/tests/harness/__snapshots__/coverage-tier-demotions.txt new file mode 100644 index 000000000..7b52f9d63 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-tier-demotions.txt @@ -0,0 +1,13 @@ +scriptc coverage tests/coverage-fixtures/tier-demotions.ts + + statements analyzed 5 + compile statically 3 (60%) + in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them) + + blockers: + ×1 SC1090 assignment to non-variables + at tests/coverage-fixtures/tier-demotions.ts:10:1 + hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary) + ×1 SC2012 '.toPrecision()' on numbers cannot compile statically + at tests/coverage-fixtures/tier-demotions.ts:22:10 + hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-width-copies.txt b/tests/harness/__snapshots__/coverage-width-copies.txt index dd99d4bb5..46e8e949d 100644 --- a/tests/harness/__snapshots__/coverage-width-copies.txt +++ b/tests/harness/__snapshots__/coverage-width-copies.txt @@ -2,6 +2,7 @@ scriptc coverage tests/coverage-fixtures/width-copies.ts statements analyzed 14 compile statically 14 (100%) + in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them) fully static — all analyzed statements compile to native code. diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index d4582d151..6148b13ac 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -3,7 +3,13 @@ import { globSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; -import { analyze, coverageEnvelope, coveragePasses, renderCoverage } from "@scriptc/compiler"; +import { + analyze, + coverageEnvelope, + coveragePasses, + renderCoverage, + renderExplainDynamic, +} from "@scriptc/compiler"; import { shardSelect, shardSuffix } from "./shard.js"; const repoRoot = join(import.meta.dirname, "../.."); @@ -230,6 +236,105 @@ test("CLI: build --print=diagnostics reports failures and divergence warnings as } }); +test("coverage counts the statements of dynamic-tier functions", async () => { + expect(analyze(join(repoRoot, "tests/corpus/400-fib.ts")).coverage.stats.statementsDynamic).toBe( + 0, + ); + const { coverage, sourceTexts } = analyze(fixture("tier-demotions.ts")); + expect(coverage.stats.statementsDynamic).toBe(1); + expect(coverage.tiers).toBeUndefined(); + expect(coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }).stats).toMatchObject({ + statementsTotal: 5, + statementsStatic: 3, + statementsDynamic: 1, + statementsFailed: 2, + }); + await expect(report(fixture("tier-demotions.ts"))).toMatchFileSnapshot( + "__snapshots__/coverage-tier-demotions.txt", + ); +}); + +test("--explain-dynamic attributes each refusal to the unit the dynamic tier would take", async () => { + const { coverage, sourceTexts } = analyze(fixture("tier-demotions.ts"), { explainDynamic: true }); + const tiers = coverage.tiers!; + expect(tiers.mode).toBe("auto"); + expect(tiers.statementsDynamic).toBe(1); + expect(tiers.dynamicFunctions.map((fn) => fn.name)).toContain("readName"); + const requests = [...tiers.requests].sort((a, b) => a.loc.start - b.loc.start); + expect( + requests.map((r) => [r.code, r.seed, r.scope, r.unit.kind, r.unit.name, r.enclosing.name]), + ).toEqual([ + ["SC1090", "reflection", "reached", "class", "Point", ""], + ["SC2012", "operation", "reached", "binding", "n", "precise"], + ]); + expect(tiers.units.map((u) => [u.kind, u.name, u.requests, u.sites])).toEqual([ + ["class", "Point", 1, 0], + ["function", "", 0, 1], + ["function", "precise", 0, 1], + ["binding", "n", 1, 0], + ]); + const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }); + expect(envelope.tiers?.requests[0]).toMatchObject({ unit: { kind: expect.any(String) } }); + const relative = JSON.parse(JSON.stringify(tiers).replaceAll(repoRoot + "/", "")) as typeof tiers; + await expect(renderExplainDynamic(relative)).toMatchFileSnapshot( + "__snapshots__/coverage-explain-dynamic.txt", + ); + expect(renderExplainDynamic(relative, { query: "Point" })).toContain("class Point"); + expect(renderExplainDynamic(relative, { query: "Point" })).not.toContain("binding n"); + expect(renderExplainDynamic(relative, { query: "nothing" })).toContain( + "no demoted unit matches 'nothing'", + ); +}); + +test("CLI: --tier and --explain-dynamic", () => { + const scriptcCli = join(repoRoot, "packages/cli/src/main.ts"); + const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-")); + const run = (...args: string[]) => + spawnSync(process.execPath, ["--import", "tsx", scriptcCli, ...args], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, SCRIPTC_NO_CACHE: "1" }, + }); + try { + const explained = run( + "coverage", + fixture("tier-demotions.ts"), + "--explain-dynamic", + "--tier=static-only", + ); + expect(explained.status).toBe(0); + expect(explained.stdout).toContain("in dynamic tier 1"); + expect(explained.stdout).toContain("--tier=static-only"); + expect(explained.stdout).toContain("binding n in precise"); + const one = run("coverage", fixture("tier-demotions.ts"), "--explain-dynamic=Point"); + expect(one.stdout).toContain("class Point"); + expect(one.stdout).not.toContain("binding n in precise"); + const bad = run("coverage", fixture("tier-demotions.ts"), "--tier=dynamic-only"); + expect(bad.status).toBe(1); + expect(bad.stderr).toContain('unknown --tier value "dynamic-only"'); + const misplaced = run("build", fixture("tier-demotions.ts"), "--explain-dynamic"); + expect(misplaced.status).toBe(1); + expect(misplaced.stderr).toContain("--explain-dynamic is a coverage option"); + // Until demotion lands, static-only compiles exactly like the default. + const ir = (name: string, ...args: string[]) => { + const out = join(outDir, name); + const built = run( + "build", + join(repoRoot, "tests/corpus/400-fib.ts"), + "--emit=ir", + "-o", + out, + ...args, + ); + expect(built.status).toBe(0); + return readFileSync(out, "utf8"); + }; + expect(ir("static-only.ir.json", "--tier=static-only")).toBe(ir("auto.ir.json")); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } +}); + test("fully static program reports 100%", () => { const out = report(join(repoRoot, "tests/corpus/400-fib.ts")); expect(out).toContain("(100%)"); From 1b8b44dd333ebc12baf020377b55512730f4fdc8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:48:56 -0700 Subject: [PATCH 11/34] Release the replacement buffer when a replace callback throws scr_regex_replace_callback freed its output buffer with free() when the replacer threw after an earlier match had been written, but the buffer is the body of a runtime string, so the process aborted with "free(): invalid pointer" instead of propagating the exception. It now abandons the buffer through scr_jb_abandon, which releases the string. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_json.c | 2 ++ packages/runtime/src/scr_json.h | 2 ++ packages/runtime/src/scr_regex.c | 2 +- .../6165-regex-replace-callback-throws.ts | 26 +++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/6165-regex-replace-callback-throws.ts diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 9a3f315d2..b96a19757 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -212,6 +212,8 @@ static void scr_jb_dispose(ScrJsonBuf *b) { scr_jb_init(b); } +void scr_jb_abandon(ScrJsonBuf *b) { scr_jb_dispose(b); } + void scr_jb_putc(ScrJsonBuf *b, char c) { scr_jb_grow(b, 1); b->data[b->len++] = c; diff --git a/packages/runtime/src/scr_json.h b/packages/runtime/src/scr_json.h index 8a353f40a..e3077703e 100644 --- a/packages/runtime/src/scr_json.h +++ b/packages/runtime/src/scr_json.h @@ -119,6 +119,8 @@ _Static_assert(offsetof(ScrJsonBuf, data) == 0 && offsetof(ScrJsonBuf, len) == s "LLVM backend expects ScrJsonBuf to start with { data, len, cap }"); void scr_jb_init(ScrJsonBuf *b); +/* Release a buffer's text without finishing it (error paths). */ +void scr_jb_abandon(ScrJsonBuf *b); /* Append a borrowed byte span verbatim, including embedded NUL bytes. */ void scr_jb_write(ScrJsonBuf *b, const char *s, size_t n); /* scr_jb_write for text that holds no lone surrogate (fixed text, JSON diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index b24261a4d..4a6b88e1e 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -1358,7 +1358,7 @@ ScrStr *scr_regex_replace_callback(ScrStr *s, ScrRegex *re, ScrDyn *callback, bo for (size_t i = 0; i < count; i++) scr_dyn_release(matches[i].arguments); free(matches); if (scr_exc_pending()) { - free(output.data); + scr_jb_abandon(&output); scr_regex_input_dispose(&input); return NULL; } diff --git a/tests/corpus/6165-regex-replace-callback-throws.ts b/tests/corpus/6165-regex-replace-callback-throws.ts new file mode 100644 index 000000000..57cb8af18 --- /dev/null +++ b/tests/corpus/6165-regex-replace-callback-throws.ts @@ -0,0 +1,26 @@ +// A replace callback that throws after earlier matches were replaced: the +// partial result is discarded and the exception propagates, with the +// callbacks that ran counted exactly as in Node. +let calls = 0; +function replacer(m: string, digit?: string): string { + calls++; + if (digit === "3") throw new RangeError("stop at " + m); + return "<" + m + ">"; +} +for (const subject of ["a1b2c3d4", "x3", "no digits"]) { + calls = 0; + try { + console.log(subject.replace(/(\d)|z/g, replacer), calls); + } catch (error) { + console.log(error instanceof RangeError, (error as Error).message, calls); + } +} +const parts: string[] = []; +for (let i = 0; i < 50; i++) { + try { + parts.push(("k" + i + "-3").replace(/(\d)/g, replacer)); + } catch { + parts.push("thrown"); + } +} +console.log(parts.filter((p) => p === "thrown").length, calls); From dfacb791a1b1cf81fb05508c9feb4817bc91088b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:48:56 -0700 Subject: [PATCH 12/34] Call typed replace callbacks without a dyn box s.replace(re, fn) and replaceAll boxed a typed callback into a dyn function and called it through the dyn ABI: per match, a dyn argument array, a dyn node per capture and for the offset, the box's argument checks back to typed values, and a dyn result converted to a string. When every parameter the callback declares receives a value of its declared kind in each match, the box is unobservable (the runtime only calls it, with `this` undefined), so the backend passes the closure to scr_regex_replace_fn, which collects the matches as before and calls the closure through its own ABI. That holds for callbacks returning a string that take the match string alone, or, with a regex literal whose captures the compiled-matcher parser reads, captures every match sets (strings), then the offset (a number) and the subject (a string). Callbacks with optional, union or rest parameters, `arguments`, or captures some match leaves unset keep the dyn path. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-dispatch.ts | 60 +++++++++ .../src/backend/llvm/regex-matchers.test.ts | 25 ++++ .../src/backend/llvm/regex-matchers.ts | 34 +++++ packages/runtime/src/scr_regex.c | 122 ++++++++++++++++++ packages/runtime/src/scr_regex.h | 10 ++ .../6164-regex-replace-typed-callback.ts | 67 ++++++++++ 6 files changed, 318 insertions(+) create mode 100644 tests/corpus/6164-regex-replace-typed-callback.ts diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 4a9f4017a..ab195f5c0 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -9,6 +9,7 @@ import { emitTestLibCall } from "./lib-test.js"; import { InternalCompilerError } from "../../errors.js"; import { MAY_THROW_LIB_FNS } from "../../ir/builtin-effects.js"; +import type { IrExpr, IrType } from "../../ir/ir.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, LibCallExpr, LibCallPrefix, LlValue } from "./expr-context.js"; import { @@ -21,6 +22,7 @@ import { isDynUndefined } from "../../ir/runtime-features.js"; import { DYN_KIND } from "./dyn.js"; import { rawBytes } from "./lib-abi.js"; import { emitInlineAtomicsOp } from "./expr-bytes.js"; +import { regexLiteralCaptures } from "./regex-matchers.js"; export function emitAssertInspectLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; @@ -554,8 +556,66 @@ function emitAssertFailing( * `a.name.localeCompare(b.name)` reads both fields without retaining). */ const READ_ONLY_STRING_LIB_FNS: ReadonlySet = new Set(["str.localeCompare"]); +/** The parameter count of a replace callback the runtime can call through + * the closure ABI (scr_regex_replace_fn), or null when it needs the dyn + * box. Each declared parameter must receive a value of its declared kind + * in every match: the match string at 0; with more than one parameter the + * pattern must be a literal whose captures are known, so position i is a + * capture that every match sets (a string), the offset (a number) right + * after the captures, or the subject (a string) after that. */ +function typedReplacerArity(callback: IrType, pattern: IrExpr): number | null { + if (callback.kind !== "func" || callback.rest || callback.argumentsAll) return null; + if (callback.ret.kind !== "string") return null; + const params = callback.params; + if (params.length > 4) return null; + if (params.length === 0) return 0; + if (params[0]!.kind !== "string") return null; + if (params.length === 1) return 1; + if (pattern.kind !== "regexLit") return null; + const captures = regexLiteralCaptures(pattern.pattern, pattern.flags); + if (!captures) return null; + for (let i = 1; i < params.length; i++) { + const kind = params[i]!.kind; + const ok = + i < captures.count + ? kind === "string" && captures.always[i] === true + : i === captures.count + ? kind === "f64" + : i === captures.count + 1 && kind === "string"; + if (!ok) return null; + } + return params.length; +} + +/** `s.replace(re, fn)` / `replaceAll` with a typed callback the runtime can + * call directly: the dyn box the IR wraps it in is never observable (the + * runtime only calls it, with `this` undefined), so the closure goes to + * scr_regex_replace_fn as it is. Null keeps the generic path. */ +function emitTypedRegexReplace(host: LlvmEmitterContext, e: LibCallExpr): LlValue | null { + const [subject, pattern, callback, all] = e.args; + if (!subject || !pattern || callback?.kind !== "dynFrom" || all?.kind !== "boolLit") return null; + const arity = typedReplacerArity(callback.value.type, pattern); + if (arity === null) return null; + const B = host.B; + const s = host.emitExpr(subject); + const re = host.emitExpr(pattern); + const fn = host.emitExpr(callback.value); + host.declare(`declare ptr @scr_regex_replace_fn(ptr, ptr, ptr, i32, i1 zeroext)`); + const result = B.tmp(); + B.line( + `${result} = call ptr @scr_regex_replace_fn(ptr ${s.name}, ptr ${re.name}, ptr ${fn.name}, i32 ${arity}, i1 zeroext ${all.value})`, + ); + const output = host.own({ name: result, type: e.type }); + host.emitPendingCheck(); + return output; +} + export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { if (USES_TIMERS_LIB_FNS.has(e.fn)) host.usesTimers = true; + if (e.fn === "regex.replaceCallback") { + const typed = emitTypedRegexReplace(host, e); + if (typed) return typed; + } if (e.fn === "process.nextTick") return host.emitAsyncContextLibCall(e); if ( e.fn === "sp.pipeline" || diff --git a/packages/compiler/src/backend/llvm/regex-matchers.test.ts b/packages/compiler/src/backend/llvm/regex-matchers.test.ts index a3627cb7f..1acd9c465 100644 --- a/packages/compiler/src/backend/llvm/regex-matchers.test.ts +++ b/packages/compiler/src/backend/llvm/regex-matchers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { compileRegexMatcher, regexLiteralBailReason, + regexLiteralCaptures, regexLiteralCompiles, } from "./regex-matchers.js"; @@ -11,6 +12,30 @@ function body(pattern: string, flags = "", vector = true): string { return code.lines.join("\n"); } +describe("regex literal captures", () => { + test("counts captures with the whole match and marks the ones every match sets", () => { + expect(regexLiteralCaptures("[&<>]", "g")).toEqual({ count: 1, always: [true] }); + expect(regexLiteralCaptures("(\\w+)=(\\d)+", "")).toEqual({ + count: 3, + always: [true, true, true], + }); + expect(regexLiteralCaptures("(?a)(b)?|(c)", "")).toEqual({ + count: 4, + always: [true, false, false, false], + }); + expect(regexLiteralCaptures("x(?:(a)|(b))y(c)*(d){0,2}(e){1,3}", "")).toEqual({ + count: 6, + always: [true, false, false, false, false, true], + }); + }); + + test("answers null outside the parsed subset", () => { + expect(regexLiteralCaptures("(?=a)(b)", "")).toBeNull(); + expect(regexLiteralCaptures("(a)\\1", "")).toBeNull(); + expect(regexLiteralCaptures("a", "v")).toBeNull(); + }); +}); + describe("regex literal matcher subset", () => { test.each([ ['^(\\d+\\.\\d+\\.\\d+\\.\\d+) - - \\[([^\\]]+)\\] "(\\w+) ([^ ?"]+)(?:\\?([^ "]*))?$', ""], diff --git a/packages/compiler/src/backend/llvm/regex-matchers.ts b/packages/compiler/src/backend/llvm/regex-matchers.ts index ad5c47c86..001f081e5 100644 --- a/packages/compiler/src/backend/llvm/regex-matchers.ts +++ b/packages/compiler/src/backend/llvm/regex-matchers.ts @@ -1639,6 +1639,40 @@ function parseSubset(pattern: string, flags: string): { root: RxNode; ncap: numb } } +/** The capture count of a regex literal (the whole match included) and, + * per capture, whether every match sets it: no alternation of several + * branches and no repetition that can run zero times encloses it. Null + * when the pattern is outside the parsed subset. */ +export function regexLiteralCaptures( + pattern: string, + flags: string, +): { count: number; always: boolean[] } | null { + const parsed = parseSubset(pattern, flags); + if (!parsed) return null; + const always = Array.from({ length: parsed.ncap }, (_, i) => i === 0); + const walk = (node: RxNode, sure: boolean): void => { + switch (node.kind) { + case "cap": + always[node.index] = sure; + walk(node.body, sure); + return; + case "seq": + for (const item of node.items) walk(item, sure); + return; + case "alt": + for (const alt of node.alts) walk(alt, sure && node.alts.length === 1); + return; + case "rep": + walk(node.body, sure && node.min > 0); + return; + default: + return; + } + }; + walk(parsed.root, true); + return { count: parsed.ncap, always }; +} + /** Whether a literal compiles to a native matcher (tests and size caps). */ export function regexLiteralCompiles(pattern: string, flags: string): boolean { return parseSubset(pattern, flags) !== null; diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index 4a6b88e1e..a2e591b0e 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -1367,6 +1367,128 @@ ScrStr *scr_regex_replace_callback(ScrStr *s, ScrRegex *re, ScrDyn *callback, bo return scr_jb_finish(&output); } +/* One call of a typed replacer (scr_regex_replace_fn's contract): string + * arguments are passed +1 (the closure ABI's owned parameters), the offset + * as a number, and the result is the closure's +1 string. */ +static ScrStr *scr_regex_call_replacer(ScrClosure *f, uint32_t arity, int ncap, + ScrStr *const *caps, double offset, ScrStr *subject) { + void *p[4] = { NULL, NULL, NULL, NULL }; + for (uint32_t i = 0; i < arity && i < 4; i++) { + if ((int)i < ncap) { + if (!caps[i]) scr_trap("scriptc: internal error: typed replacer capture is unset\n"); + p[i] = scr_str_retain(caps[i]); + } else if ((int)i == ncap + 1) { + p[i] = scr_str_retain(subject); + } + } + void *fn = f->fn; + typedef ScrStr *R; + switch (arity) { + case 0: return ((R (*)(ScrClosure *))fn)(f); + case 1: return ((R (*)(ScrClosure *, void *))fn)(f, p[0]); + case 2: + if (ncap == 1) return ((R (*)(ScrClosure *, void *, double))fn)(f, p[0], offset); + return ((R (*)(ScrClosure *, void *, void *))fn)(f, p[0], p[1]); + case 3: + if (ncap == 1) return ((R (*)(ScrClosure *, void *, double, void *))fn)(f, p[0], offset, p[2]); + if (ncap == 2) return ((R (*)(ScrClosure *, void *, void *, double))fn)(f, p[0], p[1], offset); + return ((R (*)(ScrClosure *, void *, void *, void *))fn)(f, p[0], p[1], p[2]); + case 4: + if (ncap == 2) + return ((R (*)(ScrClosure *, void *, void *, double, void *))fn)(f, p[0], p[1], offset, p[3]); + if (ncap == 3) + return ((R (*)(ScrClosure *, void *, void *, void *, double))fn)(f, p[0], p[1], p[2], offset); + if (ncap >= 4) + return ((R (*)(ScrClosure *, void *, void *, void *, void *))fn)(f, p[0], p[1], p[2], p[3]); + break; + default: break; + } + scr_trap("scriptc: internal error: typed replacer signature\n"); +} + +/* scr_regex_replace_callback's algorithm for a typed replacer: the matches + * are collected first, keeping only the captures the replacer reads, then + * the replacer runs once per match in order. */ +ScrStr *scr_regex_replace_fn(ScrStr *s, ScrRegex *re, ScrClosure *f, uint32_t arity, bool all) { + uint8_t *bc = scr_regex_bc(re); + int flags = lre_get_flags(bc); + bool global = (flags & LRE_FLAG_GLOBAL) != 0; + if (all && !global) { + static const char message[] = "String.prototype.replaceAll called with a non-global RegExp argument"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + bool sticky = (flags & LRE_FLAG_STICKY) != 0; + bool unicode = (flags & (LRE_FLAG_UNICODE | LRE_FLAG_UNICODE_SETS)) != 0; + int capture_count = lre_get_capture_count(bc); + size_t keep = arity < (uint32_t)capture_count ? arity : (size_t)capture_count; + ScrRegexInput input; + scr_regex_input_init(&input, re, bc, s); + int len = input.len; + struct { int start, end; } *matches = NULL; + ScrStr **caps = NULL; + size_t count = 0, capacity = 0; + if (global) re->last_index = 0; + int position = sticky ? scr_regex_start(re, len) : 0; + if (position < 0) position = len + 1; + if (sticky && !global) re->last_index = 0; + while (position <= len && scr_exec(&input, bc, position) == 1) { + uint8_t **capture = input.capture; + int start = scr_capture_index(&input, capture[0]); + int end = scr_capture_index(&input, capture[1]); + if (sticky && !global) re->last_index = end; + if (count == capacity) { + size_t next = capacity ? capacity * 2 : 8; + if (next < capacity || next > SIZE_MAX / sizeof(*matches) / (keep + 1)) scr_regex_oom(); + void *grown = realloc(matches, next * sizeof(*matches)); + if (!grown) scr_regex_oom(); + matches = grown; + if (keep) { + grown = realloc(caps, next * keep * sizeof(*caps)); + if (!grown) scr_regex_oom(); + caps = grown; + } + capacity = next; + } + for (size_t group = 0; group < keep; group++) { + const uint8_t *cs = capture[group * 2], *ce = capture[group * 2 + 1]; + caps[count * keep + group] = + cs && ce ? scr_regex_span(&input, scr_capture_index(&input, cs), scr_capture_index(&input, ce)) + : NULL; + } + matches[count].start = start; + matches[count].end = end; + count++; + if (!global) break; + position = start == end ? scr_advance(&input, end, unicode) : end; + } + ScrJsonBuf output; + scr_jb_init(&output); + int next = 0; + scr_dyn_this_push_dyn(NULL); + for (size_t i = 0; i < count; i++) { + ScrStr *replacement = scr_regex_call_replacer(f, arity, capture_count, caps + i * keep, + (double)matches[i].start, s); + if (scr_exc_pending()) break; + scr_regex_put_span(&output, &input, next, matches[i].start); + scr_jb_put_str(&output, replacement); + scr_str_release(replacement); + next = matches[i].end; + } + scr_dyn_this_pop(); + for (size_t i = 0; i < count * keep; i++) scr_str_release(caps[i]); + free(caps); + free(matches); + if (scr_exc_pending()) { + scr_jb_abandon(&output); + scr_regex_input_dispose(&input); + return NULL; + } + scr_regex_put_span(&output, &input, next, len); + scr_regex_input_dispose(&input); + return scr_jb_finish(&output); +} + /* The pinned Node ANSI matcher derives from ansi-regex, copyright (c) * Sindre Sorhus , used under the MIT license: * Permission is hereby granted, free of charge, to any person obtaining a diff --git a/packages/runtime/src/scr_regex.h b/packages/runtime/src/scr_regex.h index e34e877de..014e5aa83 100644 --- a/packages/runtime/src/scr_regex.h +++ b/packages/runtime/src/scr_regex.h @@ -80,6 +80,16 @@ ScrStr *scr_regex_flags(ScrRegex *re); /* +1 */ ScrStr *scr_regex_to_string(ScrRegex *re); /* +1 */ /* replace: first match without /g, every match with it. */ ScrStr *scr_regex_replace(ScrStr *s, ScrRegex *re, ScrStr *rep); +/* replace/replaceAll with a typed replacer closure of `arity` (at most 4) + * parameters returning a string. Parameter i receives what Node passes at + * position i: the match and captures (strings) below the capture count n, + * the match offset (a number) at n, the subject (a string) at n + 1. The + * compiler admits a closure only when every position it declares is one of + * those with the declared kind, and every capture it reads is set in every + * match (scr_regex_replace_callback handles the rest). Borrows all. */ +struct ScrClosure; +ScrStr *scr_regex_replace_fn(ScrStr *s, ScrRegex *re, struct ScrClosure *f, uint32_t arity, + bool all); /* replaceAll: throws Node's TypeError when /g is missing (may-throw). */ ScrStr *scr_regex_replace_all(ScrStr *s, ScrRegex *re, ScrStr *rep); /* split: captured substrings are interleaved with the split pieces; diff --git a/tests/corpus/6164-regex-replace-typed-callback.ts b/tests/corpus/6164-regex-replace-typed-callback.ts new file mode 100644 index 000000000..8945235cd --- /dev/null +++ b/tests/corpus/6164-regex-replace-typed-callback.ts @@ -0,0 +1,67 @@ +// String replace/replaceAll with typed function replacers. Callbacks whose +// every parameter receives its declared kind in each match (the match, the +// captures every match sets, the offset, the subject) are called through +// the closure directly; the rest keep the generic path. Both must match +// Node: all matches are collected before the first call, calls run in +// order with the offset in UTF-16 code units, and a throw stops the +// replacement. +const entities: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; +const escapeHtml = (text: string): string => text.replace(/[&<>"]/g, (c) => entities[c] ?? c); +console.log(escapeHtml('a & "c"'), escapeHtml("plain")); +console.log("hello world".replace(/o/, (m) => m.toUpperCase())); +console.log("hello world".replaceAll(/o/g, (m) => `[${m}]`)); +console.log("abc".replace(/x/g, () => "never")); + +console.log("a-b-c".replace(/-/g, (m: string, offset: number) => `${m}${offset}`)); +console.log("ab".replace(/b/, (m: string, offset: number, input: string) => `${m}@${offset}/${input}`)); +console.log("id-12 id-345".replace(/(\d+)/g, (_m: string, digits: string) => String(Number(digits) * 2))); +console.log("k1=v1;k2=v2".replace(/(\w+)=(\w+)/g, (_m: string, k: string, v: string) => `${v}=${k}`)); +console.log("x1y22".replace(/(\d)+/g, (m: string, last: string, at: number) => `<${m}:${last}:${at}>`)); +console.log("a=1".replace(/(\w)=(\d)/, (m: string, k: string, v: string, at: number) => `${m}|${k}|${v}|${at}`)); +console.log("ab ab".replace(/(?a)(b)/g, (m: string, a: string, b: string, at: number, all: string) => `${b}${a}${at}${all.length}`)); + +// Offsets count UTF-16 code units; empty matches advance by code points under /u. +console.log("héllo wörld 😀!".replace(/o/g, (m: string, at: number) => `${m}${at}`)); +console.log("😀a".replace(/(?:)/gu, (m: string, at: number) => `[${at}]`)); +console.log("😀a".replace(/(?:)/g, (m: string, at: number) => `[${at}]`)); + +// Captures that some match leaves unset keep the generic path. +console.log("ab".replace(/(a)|(b)/g, (m: string, a?: string, b?: string) => `${m}:${a}:${b};`)); +console.log("ab".replace(/(x)?b/, (m: string, x?: string) => `${m}:${x}`)); +const dynamicPattern = /(\d)/g; +console.log("1-2".replace(dynamicPattern, (m: string, d: string) => d + d)); + +// The callback runs after every match is collected: it can reuse the regex. +const word = /\w+/g; +console.log("one two three".replace(word, (m: string) => `${m.length}${word.lastIndex}`), word.lastIndex); +const sticky = /a/y; +sticky.lastIndex = 1; +console.log("baaa".replace(sticky, (m: string) => m.toUpperCase()), sticky.lastIndex); + +let calls = 0; +try { + "a1b2c3".replace(/\d/g, (m: string) => { + calls++; + if (m === "2") throw new Error("stop at " + m); + return "#"; + }); +} catch (error) { + console.log((error as Error).message, calls); +} +try { + "a1b2".replace(/(\d)|z/g, (m: string, d?: string) => { + if (d === "2") throw new Error("generic stop at " + d); + return "#" + m; + }); +} catch (error) { + console.log((error as Error).message); +} +try { + "aaa".replaceAll(/a/, (m: string) => m); +} catch (error) { + console.log(error instanceof TypeError, (error as Error).message); +} + +let total = 0; +for (let i = 0; i < 3000; i++) total += escapeHtml(`<${i}> & "${i}"`).length; +console.log(total); From 023d6201bc16dab915fdbdb25520107f9fe3b69e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 09:15:16 -0700 Subject: [PATCH 13/34] Record TypeScript order baselines for the new corpus programs Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/6160-number-radix-typed.ts.json | 6 ++++++ .../tests/corpus/6161-dispose-observer-gates.ts.json | 6 ++++++ .../tests/corpus/6162-dispose-observer-weak-keys.ts.json | 6 ++++++ .../tests/corpus/6163-dispose-observer-workers.ts.json | 6 ++++++ .../tests/corpus/6164-regex-replace-typed-callback.ts.json | 6 ++++++ .../tests/corpus/6165-regex-replace-callback-throws.ts.json | 6 ++++++ 6 files changed, 36 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6160-number-radix-typed.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6161-dispose-observer-gates.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6162-dispose-observer-weak-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6163-dispose-observer-workers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6164-regex-replace-typed-callback.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6165-regex-replace-callback-throws.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6160-number-radix-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6160-number-radix-typed.ts.json new file mode 100644 index 000000000..6df464b2b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6160-number-radix-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6160-number-radix-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6161-dispose-observer-gates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6161-dispose-observer-gates.ts.json new file mode 100644 index 000000000..1387cc325 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6161-dispose-observer-gates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6161-dispose-observer-gates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6162-dispose-observer-weak-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6162-dispose-observer-weak-keys.ts.json new file mode 100644 index 000000000..ab4838284 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6162-dispose-observer-weak-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6162-dispose-observer-weak-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6163-dispose-observer-workers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6163-dispose-observer-workers.ts.json new file mode 100644 index 000000000..327aa105f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6163-dispose-observer-workers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6163-dispose-observer-workers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6164-regex-replace-typed-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6164-regex-replace-typed-callback.ts.json new file mode 100644 index 000000000..8ca65275f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6164-regex-replace-typed-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6164-regex-replace-typed-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6165-regex-replace-callback-throws.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6165-regex-replace-callback-throws.ts.json new file mode 100644 index 000000000..0f4b99484 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6165-regex-replace-callback-throws.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6165-regex-replace-callback-throws.ts" + ], + "diags": [] +} From f80eb5fc39ce2232abd2fd793241786a20951b84 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 13:39:12 -0500 Subject: [PATCH 14/34] Give every arm of record unions that share arms a collector header A shape union's arms all carry a collector header when one of them does. The emitter applied that rule in one pass over the unions in module order, so a header that a later union gave a shared arm did not reach the other arms of an earlier union. With First = A | B and Second = B | C, where only C is cyclic, B got a header through Second, First's trace helper then called A's trace function, and A had none: the module failed LLVM verification with "use of undefined value '@sc_rtrace_r0'". The rule now repeats until no union adds a header. Corpus 5919-record-unions-shared-arms chains Third = D | A, First = A | B and Second = B | C, so the requirement passes from Second through First to Third. --- packages/compiler/src/backend/llvm/emitter.ts | 26 +++++--- .../5919-record-unions-shared-arms.ts.json | 6 ++ .../corpus/5919-record-unions-shared-arms.ts | 60 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5919-record-unions-shared-arms.ts.json create mode 100644 tests/corpus/5919-record-unions-shared-arms.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 775e771ca..26c3b840e 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1395,13 +1395,25 @@ export class LlEmitter { this.tracedUnions = traced.unions; // A shape union's value is whichever record it holds, and the collector // visits it as that record: when one arm carries a collector header, - // every arm must (nullable-unions.ts). - for (const union of this.nullableUnions.allShapeUnions()) - if ( - this.tracedUnions.has(union.unionId) || - union.arms.some((arm) => this.tracedShapes.has(`record:${arm.shapeId}`)) - ) - for (const arm of union.arms) this.tracedShapes.add(`record:${arm.shapeId}`); + // every arm must (nullable-unions.ts). Unions that share an arm pass a + // header on to each other's arms, so repeat until no union adds one. + const shapeUnions = this.nullableUnions.allShapeUnions(); + for (let changed = true; changed;) { + changed = false; + for (const union of shapeUnions) { + if ( + !this.tracedUnions.has(union.unionId) && + !union.arms.some((arm) => this.tracedShapes.has(`record:${arm.shapeId}`)) + ) + continue; + for (const arm of union.arms) { + const key = `record:${arm.shapeId}`; + if (this.tracedShapes.has(key)) continue; + this.tracedShapes.add(key); + changed = true; + } + } + } this.shapeHost = { sizeType: this.sizeType, cycleBufOffset: this.cycleBufOffset, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5919-record-unions-shared-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5919-record-unions-shared-arms.ts.json new file mode 100644 index 000000000..bfb23e115 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5919-record-unions-shared-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5919-record-unions-shared-arms.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5919-record-unions-shared-arms.ts b/tests/corpus/5919-record-unions-shared-arms.ts new file mode 100644 index 000000000..89f34dd83 --- /dev/null +++ b/tests/corpus/5919-record-unions-shared-arms.ts @@ -0,0 +1,60 @@ +// Record unions that share arms: Third = D | A, First = A | B and +// Second = B | C. Only C is cyclic. Every arm of a union whose values the +// cycle collector can visit carries a collector header, and the shared +// arms pass that requirement from Second back through First to Third. +type A = { kind: "a"; v: number }; +type B = { kind: "b"; w: number }; +type C = { kind: "c"; kids: C[] }; +type D = { kind: "d"; label: string }; +type Third = D | A; +type First = A | B; +type Second = B | C; + +function third(u: Third): string { + return u.kind === "d" ? u.label : `a${u.v}`; +} + +function first(u: First): number { + return u.kind === "a" ? u.v : u.w; +} + +function second(u: Second): number { + return u.kind === "b" ? u.w : u.kids.length + u.kids.reduce((n, k) => n + second(k), 0); +} + +const zs: Third[] = [ + { kind: "d", label: "dee" }, + { kind: "a", v: 7 }, +]; +console.log(zs.map(third).join(",")); + +const xs: First[] = [ + { kind: "a", v: 1 }, + { kind: "b", w: 2 }, +]; +console.log(xs.map(first).join(",")); + +const leaf: C = { kind: "c", kids: [] }; +const ys: Second[] = [ + { kind: "b", w: 3 }, + { kind: "c", kids: [leaf, { kind: "c", kids: [leaf] }] }, +]; +console.log(ys.map(second).join(",")); + +// One record held through two unions. +const sharedA: A = { kind: "a", v: 5 }; +const sharedB: B = { kind: "b", w: 6 }; +const viaThird: Third = sharedA; +const viaFirst: First[] = [sharedA, sharedB]; +const viaSecond: Second = sharedB; +console.log(third(viaThird), viaFirst.map(first).join(","), second(viaSecond)); + +// A cyclic C reachable only through Second, dropped and rebuilt in a loop. +let total = 0; +for (let i = 0; i < 200; i++) { + const node: C = { kind: "c", kids: [] }; + node.kids.push(node); + const held: Second[] = [node, { kind: "b", w: i }]; + total += held.length + node.kids.length; +} +console.log("total", total); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index c393620c3..8ef52f39a 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2136,6 +2136,7 @@ "5913-prototype-type-error-messages.ts": 3102, "5914-dyn-in-class-prototype-members.ts": 2530, "5915-dyn-error-string-conversions.ts": 3023, + "5919-record-unions-shared-arms.ts": 3182, "5920-promise-try-args.ts": 3028, "5921-promise-try-args-js.js": 3179, "5922-matchall-groups-callbacks.ts": 4418, @@ -5154,6 +5155,7 @@ "5913-prototype-type-error-messages.ts": 3454, "5914-dyn-in-class-prototype-members.ts": 3792, "5915-dyn-error-string-conversions.ts": 3851, + "5919-record-unions-shared-arms.ts": 4941, "5920-promise-try-args.ts": 3195, "5921-promise-try-args-js.js": 3082, "5922-matchall-groups-callbacks.ts": 5275, From fa64c19fdc8a536867144474fcae3e7d22d3fb42 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 09:51:38 -0700 Subject: [PATCH 15/34] Build the coverage result without a conditional spread over its tiers The self-hosted compiler refuses a conditional spread of `tiers` over a LowerResult that already declares the field, so assign it instead. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/lowerer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c535d603f..d104eae39 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1091,7 +1091,7 @@ export function lowerToIr( // Unreached JS statements whose fences deferred to runtime count as // failed statements in the remainder's stats, so their diagnostics must // travel with them: a failed statement is never left unexplained. - return { + const covered: LowerResult = { ...result, stats: { ...result.stats, statementsDynamic }, unreached: { @@ -1099,8 +1099,9 @@ export function lowerToIr( stats: rem.stats, ...(rem.runtimeFences.length > 0 ? { runtimeFences: rem.runtimeFences } : {}), }, - ...(tiers !== undefined ? { tiers } : {}), }; + if (tiers !== undefined) covered.tiers = tiers; + return covered; } /** The native namespace type produced by a literal static import() of a From b0ce48f168fdb9170e185ff4ae7f6a4f9aa79ef2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 11:47:04 -0700 Subject: [PATCH 16/34] Fence refused statements inside capture buffers instead of dropping them A refused statement in a JavaScript function body was dropped without a diagnostic or runtime fence when the body lowered inside a capture buffer whose owner kept the result. tryLowerExpression lowers the receiver of `(function () { ... }).call(this)` or `.apply(...)` and a computed class base that way: the refused statement (for example `console.log(root === module.exports)`) was missing from the kept function, its diagnostic was handed back to the enclosing statement, and that statement, having lowered cleanly, filed it as an informational runtime-fence entry. The program printed nothing for that statement. lowerStmts now emits the statement's runtime fence inside a buffer as well (Lowerer.capturedRuntimeFence, the same message deferToRuntimeFence would give), leaving the diagnostics with the buffer's owner. The three JavaScript class initializer sites that skipped their fence under a buffer (static field, instance field, static initializer block) do the same. An owner that discards the candidate discards the fence with it, so every program that compiled before keeps byte-identical IR unless it contained such a dropped statement: all 3006 corpus programs produce the same IR as before. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 28 +++++--- .../src/frontend/lowering/lower-stmts.ts | 6 ++ .../compiler/src/frontend/lowering/lowerer.ts | 65 ++++++++++++++----- .../6110-cjs-speculative-wrapper-fences.cjs | 26 ++++++++ .../speculative-function-refusal.ts | 14 ++++ tests/harness/errors.test.ts | 53 +++++++++++++++ 6 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 tests/corpus/6110-cjs-speculative-wrapper-fences.cjs create mode 100644 tests/diagnostics/speculative-function-refusal.ts diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 7b2d176c3..f666effd1 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -4419,6 +4419,7 @@ export function lowerStaticFieldInits(lowerer: Lowerer, info: ClassInfo): IrStmt // instantiation's bindings/context (a no-op for everything else — // generic FAMILIES own their statics and carry no genericInstance). const diagsBefore = lowerer.diags.length; + const sinkMark = lowerer.diagSinkMark(); try { return withInstanceBindings(lowerer, info, () => lowerStaticFieldInitsInner(lowerer, info)); } catch (error) { @@ -4426,8 +4427,11 @@ export function lowerStaticFieldInits(lowerer: Lowerer, info: ClassInfo): IrStmt // Keep their refusal attached to the class declaration instead of // letting a poisoned initializer escape the module lowering pass. if (!(error instanceof PoisonError)) throw error; - if (info.decl && isJsSourceFile(info.decl.getSourceFile()) && lowerer.diagSink === null) { - const fence = lowerer.deferToRuntimeFence(diagsBefore, info.decl, { kind: "statement" }); + if (info.decl && isJsSourceFile(info.decl.getSourceFile())) { + const fence = + lowerer.diagSink === null + ? lowerer.deferToRuntimeFence(diagsBefore, info.decl, { kind: "statement" }) + : lowerer.capturedRuntimeFence(sinkMark, info.decl); if (fence) return [fence]; } return []; @@ -4564,6 +4568,7 @@ function lowerStaticFieldInitsInner(lowerer: Lowerer, info: ClassInfo): IrStmt[] lowerer.stats.statementsTotal++; lowerer.bumpFileStat(locOf(f.initializer).file, "total"); const diagsBefore = lowerer.diags.length; + const sinkMark = lowerer.diagSinkMark(); try { const previousThis = lowerer.ctx.thisLocal; const receiver = @@ -4599,10 +4604,11 @@ function lowerStaticFieldInitsInner(lowerer: Lowerer, info: ClassInfo): IrStmt[] if (!(e instanceof PoisonError)) throw e; lowerer.stats.statementsFailed++; lowerer.bumpFileStat(locOf(f.initializer).file, "failed"); - if (isJsSourceFile(f.initializer.getSourceFile()) && lowerer.diagSink === null) { - const fence = lowerer.deferToRuntimeFence(diagsBefore, f.initializer, { - kind: "statement", - }); + if (isJsSourceFile(f.initializer.getSourceFile())) { + const fence = + lowerer.diagSink === null + ? lowerer.deferToRuntimeFence(diagsBefore, f.initializer, { kind: "statement" }) + : lowerer.capturedRuntimeFence(sinkMark, f.initializer); if (fence) out.push(fence); } } @@ -7703,6 +7709,7 @@ export function fieldInitStmts(lowerer: Lowerer, info: ClassInfo, thisLocal: IrL lowerer.stats.statementsTotal++; lowerer.bumpFileStat(locOf(f.initializer).file, "total"); const diagsBefore = lowerer.diags.length; + const sinkMark = lowerer.diagSinkMark(); try { const value = lowerer.lowerExprExpecting(f.initializer, f.type); out.push({ @@ -7717,10 +7724,11 @@ export function fieldInitStmts(lowerer: Lowerer, info: ClassInfo, thisLocal: IrL if (!(e instanceof PoisonError)) throw e; lowerer.stats.statementsFailed++; lowerer.bumpFileStat(locOf(f.initializer).file, "failed"); - if (isJsSourceFile(f.initializer.getSourceFile()) && lowerer.diagSink === null) { - const fence = lowerer.deferToRuntimeFence(diagsBefore, f.initializer, { - kind: "statement", - }); + if (isJsSourceFile(f.initializer.getSourceFile())) { + const fence = + lowerer.diagSink === null + ? lowerer.deferToRuntimeFence(diagsBefore, f.initializer, { kind: "statement" }) + : lowerer.capturedRuntimeFence(sinkMark, f.initializer); if (fence) out.push(fence); } } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 76a3971d0..148f837ec 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -671,6 +671,7 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir if (sf !== undefined) lowerer.bumpFileStat(sf.fileName, "total"); } const diagsBefore = lowerer.diags.length; + const sinkMark = lowerer.diagSinkMark(); try { let lowered: IrStmt | IrStmt[] | null; let appended = false; @@ -755,6 +756,11 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir }, }); if (fence) out.push(fence); + } else if (deferFences) { + // Inside a capture buffer the owner keeps the diagnostics, but + // the statement still must not vanish from a result it keeps. + const fence = lowerer.capturedRuntimeFence(sinkMark, stmt); + if (fence) out.push(fence); } } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index fe998af36..42cc16705 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -7011,23 +7011,8 @@ export class Lowerer { } this.runtimeFences.push(...captured); - const first = captured[0]; + const fence = this.runtimeFenceStmt(captured[0], node, target.fallback, target.bareMessage); const loc = locOf(node); - const code = first?.code ?? target.fallback!.code; - const baseMessage = first?.message ?? target.fallback!.message; - const messageLoc = first?.loc ?? loc; - const source = first - ? (this.program.getSourceFile(messageLoc.file) ?? node.getSourceFile()) - : node.getSourceFile(); - const pos = ts.getLineAndCharacterOfPosition(source, messageLoc.start); - const fence: IrStmt = { - kind: "runtimeFence", - code, - message: target.bareMessage - ? baseMessage - : `${baseMessage} [${code} at ${messageLoc.file}:${pos.line + 1}]`, - loc, - }; if (target.kind === "statement") return fence; const params = target.params ?? []; @@ -7055,6 +7040,54 @@ export class Lowerer { return { kind: "closure", fnName: fn.name, captures: [], type: target.type, loc }; } + /** The runtimeFence statement that throws `first`'s message (or the + * fallback's) at `node`. */ + private runtimeFenceStmt( + first: ScrDiagnostic | undefined, + node: ts.Node, + fallback: { code: string; message: string } | undefined, + bareMessage = false, + ): IrStmt & { kind: "runtimeFence" } { + const loc = locOf(node); + const code = first?.code ?? fallback?.code ?? "SC1090"; + const baseMessage = + first?.message ?? + fallback?.message ?? + "this statement uses a construct with no static lowering"; + const messageLoc = first?.loc ?? loc; + const source = first + ? (this.program.getSourceFile(messageLoc.file) ?? node.getSourceFile()) + : node.getSourceFile(); + const pos = ts.getLineAndCharacterOfPosition(source, messageLoc.start); + return { + kind: "runtimeFence", + code, + message: bareMessage + ? baseMessage + : `${baseMessage} [${code} at ${messageLoc.file}:${pos.line + 1}]`, + loc, + }; + } + + /** The current length of the capture buffer (diagSink), or 0 without one: + * the mark capturedRuntimeFence reads the refusal from. */ + diagSinkMark(): number { + return this.diagSink?.length ?? 0; + } + + /** The runtimeFence for a JavaScript statement refused while a capture + * buffer (diagSink) is active. The buffer's owner decides whether its + * diagnostics report, and some owners keep the lowered result (a + * successful tryLowerExpression candidate, a computed class base whose + * diagnostics defer to the class). Without a fence the refused statement + * would vanish from that result; with one it throws the refusal when it + * runs, exactly like the fence lowerStmts emits outside a buffer. The + * diagnostics stay in the buffer for its owner. Null without a buffer. */ + capturedRuntimeFence(sinkMark: number, node: ts.Node): IrStmt | null { + if (this.diagSink === null) return null; + return this.runtimeFenceStmt(this.diagSink[sinkMark], node, undefined); + } + /** All diagnostics land here; while a generic instance body is lowering, * the instantiation context is appended so the user knows which concrete * types made the (source-anchored) construct fail. */ diff --git a/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs new file mode 100644 index 000000000..a26760710 --- /dev/null +++ b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs @@ -0,0 +1,26 @@ +// Function expressions lowered speculatively — the `.call(this)` and +// `.apply` receivers of CommonJS wrappers, and a computed class base — keep +// every statement of their bodies. A statement with no static lowering +// (here `module.exports` read as a value) compiles to its runtime fence +// instead of vanishing; these never run, so the binary matches Node. +const verbose = process.argv.length > 99; +(function () { + var root = this; + console.log("call wrapper"); + if (verbose) console.log(root === module.exports); + console.log("call wrapper done"); +}).call(this); +(function (label) { + console.log("apply wrapper", label); + if (verbose) console.log(typeof module.exports); +}).apply(null, ["ok"]); +class Base { + hello() { + return "hello"; + } +} +class Derived extends (function () { + if (verbose) console.log(module.exports); + return Base; +})() {} +console.log(new Derived().hello()); diff --git a/tests/diagnostics/speculative-function-refusal.ts b/tests/diagnostics/speculative-function-refusal.ts new file mode 100644 index 000000000..fc48d9935 --- /dev/null +++ b/tests/diagnostics/speculative-function-refusal.ts @@ -0,0 +1,14 @@ +// Function expressions lowered speculatively — an `.apply` receiver and a +// computed class base — report a refused statement in their body like any +// other function: the statement never disappears from the build. +(function () { + console.log("wrapper"); + console.log(eval("1 + 1")); +}).apply(null, []); + +class Base {} +class Derived extends (function () { + console.log(new Function("return 1")()); + return Base; +})() {} +console.log(typeof Derived); diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 28daa88d3..51f04d310 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -960,3 +960,56 @@ for (const input of [ciphertext, Buffer.from("forged data"), Buffer.alloc(0)]) { expect(r.stderr).toBe(""); }); }); + +// Function expressions the frontend lowers speculatively (the receiver of +// `.call`/`.apply`, a computed class base) keep their refused statements as +// runtime fences. They used to vanish: the capture buffer held the refusal, +// the candidate was kept, and the statement was simply missing from it. +describe("refused statements in speculatively lowered JavaScript functions", () => { + test.each([ + [ + "speculative-call-this", + `(function () { + var root = this; + console.log("before"); + console.log(root === module.exports); + console.log("after"); +}).call(this); +`, + "before\n", + 4, + ], + [ + "speculative-apply", + `(function () { + console.log("before"); + console.log(typeof module.exports); +}).apply(null, []); +`, + "before\n", + 3, + ], + [ + "speculative-class-base", + `class Base {} +class Derived extends (function () { + console.log("before"); + console.log(typeof module.exports); + return Base; +})() {} +console.log(typeof Derived); +`, + "before\n", + 4, + ], + ] as const)("%s", async (name, source, stdout, line) => { + const r = await compileAndRun(name, source, "cjs"); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(stdout); + expect(r.stderr).toMatch( + new RegExp( + `^Uncaught Error: 'module\\.exports' is part of the standard library types but has no scriptc lowering yet \\[SC2020 at .*${name}\\.cjs:${line}\\]\\n$`, + ), + ); + }); +}); From a5548597e5d4481a5fb79dc0978308770cddf4f0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 14:02:03 -0500 Subject: [PATCH 17/34] Read zlib options held in typed objects A zlib options object that reaches the runtime as a typed reference (a cast object literal such as `{ level: 0 } as zlib.ZlibOptions`, which the checked-dynamic options slot holds by reference) was not a plain checked-dynamic object, so the options reader found no keys: every option was ignored and out-of-range values were not reported. The sync, callback and promisified one-shots and the stream constructors compressed with the defaults instead. scr_zlib_options_parse and scr_zlib_stream_new now read a typed reference through its materialized view, as the blob and fetch option readers do, so the options apply and invalid ones throw Node's ERR_OUT_OF_RANGE. --- .../corpus/5633-zlib-typed-options.ts.json | 6 ++ packages/runtime/src/scr_zlib.c | 15 +++++ packages/runtime/src/scr_zlib_stream.c | 7 +++ tests/corpus/5633-zlib-typed-options.ts | 58 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 5 files changed, 88 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5633-zlib-typed-options.ts.json create mode 100644 tests/corpus/5633-zlib-typed-options.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5633-zlib-typed-options.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5633-zlib-typed-options.ts.json new file mode 100644 index 000000000..f4d9673d5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5633-zlib-typed-options.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5633-zlib-typed-options.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_zlib.c b/packages/runtime/src/scr_zlib.c index b61cdc1cb..ff09e7ea1 100644 --- a/packages/runtime/src/scr_zlib.c +++ b/packages/runtime/src/scr_zlib.c @@ -420,7 +420,22 @@ void scr_zlib_options_drop(ScrZlibOptions *o) { o->dict = NULL; } +static bool scr_zlib_options_parse_object(int mode, const ScrDyn *opts, ScrZlibOptions *out); + +/* A typed options object (a record that crossed into a checked-dynamic + * slot by reference) is read through its materialized view, as a plain + * object is. */ bool scr_zlib_options_parse(int mode, const ScrDyn *opts, ScrZlibOptions *out) { + if (opts == NULL || opts->kind != SCR_DYN_TYPED_REF) return scr_zlib_options_parse_object(mode, opts, out); + ScrDyn *view = scr_dyn_typed_ref_materialize(opts); + bool ok = false; + if (scr_exc_pending()) memset(out, 0, sizeof *out); + else ok = scr_zlib_options_parse_object(mode, view, out); + scr_dyn_release(view); + return ok; +} + +static bool scr_zlib_options_parse_object(int mode, const ScrDyn *opts, ScrZlibOptions *out) { memset(out, 0, sizeof *out); out->window_bits = 15; out->level = Z_DEFAULT_COMPRESSION; diff --git a/packages/runtime/src/scr_zlib_stream.c b/packages/runtime/src/scr_zlib_stream.c index 14d8747e8..4ed717d53 100644 --- a/packages/runtime/src/scr_zlib_stream.c +++ b/packages/runtime/src/scr_zlib_stream.c @@ -431,6 +431,13 @@ static const char *scr_zs_class_name(int mode) { } ScrStream *scr_zlib_stream_new(double mode_d, ScrDyn *opts) { + /* A typed options object is read through its materialized view. */ + if (opts != NULL && opts->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(opts); + ScrStream *stream = scr_exc_pending() ? NULL : scr_zlib_stream_new(mode_d, view); + scr_dyn_release(view); + return stream; + } int mode = (int)mode_d; const ScrDyn *o = opts != NULL && scr_dyn_truthy(opts) ? opts : NULL; ScrZlibOptions zo; diff --git a/tests/corpus/5633-zlib-typed-options.ts b/tests/corpus/5633-zlib-typed-options.ts new file mode 100644 index 000000000..da657ca14 --- /dev/null +++ b/tests/corpus/5633-zlib-typed-options.ts @@ -0,0 +1,58 @@ +// zlib options held in a typed object (a cast object literal that crosses +// into the options slot by reference) are read like a plain options +// object: levels, windowBits and strategies apply, and out-of-range values +// throw Node's ERR_OUT_OF_RANGE from the sync, callback, promisified and +// stream forms. +import * as zlib from "node:zlib"; +import { promisify } from "node:util"; + +const data = Buffer.from("hello hello hello hello hello"); +const stored = { level: 0 } as zlib.ZlibOptions; +const fast = { level: 1 } as zlib.ZlibOptions; +const huffman = { strategy: zlib.constants.Z_HUFFMAN_ONLY } as zlib.ZlibOptions; +const smallWindow = { windowBits: 9, memLevel: 1 } as zlib.ZlibOptions; +const badLevel = { level: 99 } as zlib.ZlibOptions; +const badWindow = { windowBits: 3 } as zlib.ZlibOptions; + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "ok"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message); + } +} + +console.log("sync stored", zlib.deflateSync(data, stored).toString("hex")); +console.log("sync fast", zlib.deflateSync(data, fast).toString("hex")); +console.log("sync huffman", zlib.deflateSync(data, huffman).toString("hex")); +console.log("raw small window", zlib.deflateRawSync(data, smallWindow).toString("hex")); +console.log("gzip stored", zlib.gzipSync(data, stored).subarray(10).toString("hex")); +attempt("sync bad level", () => zlib.deflateSync(data, badLevel)); +attempt("sync bad window", () => zlib.inflateSync(zlib.deflateSync(data), badWindow)); +attempt("callback bad level", () => zlib.gzip(data, badLevel, () => console.log("not called"))); +attempt("stream bad level", () => zlib.createDeflate(badLevel)); + +const deflateAsync = promisify(zlib.deflate); + +async function main(): Promise { + const viaCallback = await new Promise((resolve, reject) => + zlib.deflate(data, stored, (err, out) => (err ? reject(err) : resolve(out))), + ); + console.log("callback stored", viaCallback.toString("hex")); + console.log("promise stored", (await deflateAsync(data, stored)).toString("hex")); + try { + await deflateAsync(data, badLevel); + console.log("promise bad level fulfilled"); + } catch (e) { + console.log("promise bad level", (e as NodeJS.ErrnoException).code); + } + const stream = zlib.createDeflate(fast); + const parts: Buffer[] = []; + stream.on("data", (chunk: Buffer) => parts.push(chunk)); + stream.on("end", () => console.log("stream fast", Buffer.concat(parts).toString("hex"))); + stream.end(data); +} + +main().catch((e) => console.log("unexpected", String(e))); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index acaa95479..33d8c4c1d 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1976,6 +1976,7 @@ "5630-promisify-zlib.ts": 2503, "5631-promisify-crypto.ts": 2200, "5632-promisify-dns.ts": 1700, + "5633-zlib-typed-options.ts": 1800, "5644-net-socket-addresses.ts": 2220, "5645-net-socket-callbacks.ts": 2442, "5646-net-server-connections.ts": 2389, @@ -5006,6 +5007,7 @@ "5630-promisify-zlib.ts": 3392, "5631-promisify-crypto.ts": 2600, "5632-promisify-dns.ts": 2000, + "5633-zlib-typed-options.ts": 2200, "5644-net-socket-addresses.ts": 2393, "5645-net-socket-callbacks.ts": 2060, "5646-net-server-connections.ts": 2755, From 8531a45509303e0db37786863fe67bb077b3bd68 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:04:28 -0700 Subject: [PATCH 18/34] Record baselines for the capture-buffer fence fixtures The diagnostics snapshot for speculative-function-refusal.ts and the ts7 order-parity baselines for it and 6110-cjs-speculative-wrapper-fences.cjs. Co-Authored-By: Claude Opus 5.5 --- ...6110-cjs-speculative-wrapper-fences.cjs.json | 6 ++++++ .../speculative-function-refusal.ts.json | 6 ++++++ .../speculative-function-refusal.ts.txt | 17 +++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/speculative-function-refusal.ts.json create mode 100644 tests/harness/__snapshots__/speculative-function-refusal.ts.txt diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs.json new file mode 100644 index 000000000..0a5ce66b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/speculative-function-refusal.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/speculative-function-refusal.ts.json new file mode 100644 index 000000000..e24349766 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/speculative-function-refusal.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/speculative-function-refusal.ts" + ], + "diags": [] +} diff --git a/tests/harness/__snapshots__/speculative-function-refusal.ts.txt b/tests/harness/__snapshots__/speculative-function-refusal.ts.txt new file mode 100644 index 000000000..c8b0cd28c --- /dev/null +++ b/tests/harness/__snapshots__/speculative-function-refusal.ts.txt @@ -0,0 +1,17 @@ +speculative-function-refusal.ts:6:15 - error SC2020: 'eval' is part of the standard library types but has no scriptc lowering yet + + 5 | console.log("wrapper"); + 6 | console.log(eval("1 + 1")); + | ^~~~ + 7 | }).apply(null, []); + + hint: runtime code evaluation cannot be compiled ahead of time + +speculative-function-refusal.ts:11:15 - error SC2020: 'new Function' is part of the standard library types but has no scriptc lowering yet + + 10 | class Derived extends (function () { + 11 | console.log(new Function("return 1")()); + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 12 | return Base; + + hint: runtime code generation cannot be compiled ahead of time (the eval stance) — write the function \ No newline at end of file From f6499596099e02d46a31520dee83fc518ad67150 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 09:31:23 -0700 Subject: [PATCH 19/34] Report refused local classes from speculative type queries again The previous change dropped the diagnostics of a function-local class refused during a speculative whole-program type query, expecting the class's own declaration to report them. In JavaScript that declaration only defers them to a runtime fence, so two npm programs that the compile refused (stream-shims and node24-file-url-conversion, both SC1090) began to compile and diverged from Node at run time. The speculative query now leaves the refusal reported, as it was wherever such an analysis already caught the poison, and only stops the poison from escaping the compiler. The JavaScript crash repro becomes a diagnostics fixture (local-class-type-query-js.js); the corpus program 6102 never crashed the base compiler and is removed. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 14 ++----- .../6102-local-class-type-query.js.json | 6 --- .../local-class-type-query-js.js.json | 6 +++ tests/corpus/6102-local-class-type-query.js | 42 ------------------- .../diagnostics/local-class-type-query-js.js | 26 ++++++++++++ .../local-class-type-query-js.js.txt | 6 +++ 6 files changed, 42 insertions(+), 58 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-js.js.json delete mode 100644 tests/corpus/6102-local-class-type-query.js create mode 100644 tests/diagnostics/local-class-type-query-js.js create mode 100644 tests/harness/__snapshots__/local-class-type-query-js.js.txt diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index bf4ed5464..94f99185a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -9804,27 +9804,21 @@ export class Lowerer { * instance type. Collection and body lowering own a refusal of that * class: the poison reaches the declaration or statement that asked, as * before. A speculative whole-program analysis owns none, so there the - * class is unmappable for the query and its diagnostics are dropped; - * lowering the class's own declaration collects it again and reports - * them if the program reaches it. */ + * refusal stays reported (the build fails with it, as it did where such + * an analysis already caught the poison) and the class is unmappable for + * the query instead of the poison escaping the compiler. */ collectLocalClassForType(decl: ts.ClassLikeDeclaration): boolean { if (this.speculativeTypeQueryDepth === 0 || this.fnStack.length > 0) { this.lowerClassExpressionInfo(decl); return true; } - const sink: ScrDiagnostic[] = []; - const previousSink = this.diagSink; - this.diagSink = sink; try { this.lowerClassExpressionInfo(decl); + return true; } catch (e) { if (e instanceof PoisonError) return false; throw e; - } finally { - this.diagSink = previousSink; } - for (const d of sink) this.pushDiag(d); - return true; } lowerClassExpression(expr: ts.ClassLikeDeclaration): IrExpr { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json deleted file mode 100644 index 86c6ba9e8..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/6102-local-class-type-query.js" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-js.js.json new file mode 100644 index 000000000..6bf774cd8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/local-class-type-query-js.js" + ], + "diags": [] +} diff --git a/tests/corpus/6102-local-class-type-query.js b/tests/corpus/6102-local-class-type-query.js deleted file mode 100644 index 92fe35725..000000000 --- a/tests/corpus/6102-local-class-type-query.js +++ /dev/null @@ -1,42 +0,0 @@ -// A whole-program analysis that queries types before reachability is known -// (the computed-key write census here) can name a function-local class whose -// collection is refused. That query has no owner for the refusal: the class -// stays unmapped for it, and the refusal belongs to the class's own -// declaration, which this program never executes (it used to crash the -// compiler with an uncaught PoisonError). -function main(run) { - if (!run) return "skipped"; - const Project = class { - constructor() { - this.name = "p"; - } - close() { - if (this.missingFilesMap) { - this.missingFilesMap.clear(); - this.missingFilesMap = void 0; - } - } - add(k) { - (this.missingFilesMap || (this.missingFilesMap = new Map())).set(k, 1); - } - }; - const holder = { p: new Project() }; - const key = String(run); - holder.p[key] = "q"; - return holder.p.name; -} - -class Counter { - constructor() { - this.counts = {}; - } - bump(key) { - this.counts[key] = (this.counts[key] || 0) + 1; - return this.counts[key]; - } -} - -const counter = new Counter(); -const word = process.argv.length > 99 ? "unused" : "word"; -counter.bump(word); -console.log(main(0), counter.bump(word)); diff --git a/tests/diagnostics/local-class-type-query-js.js b/tests/diagnostics/local-class-type-query-js.js new file mode 100644 index 000000000..2da619133 --- /dev/null +++ b/tests/diagnostics/local-class-type-query-js.js @@ -0,0 +1,26 @@ +// A whole-program analysis that queries types before reachability is known +// (the computed-key write census here, through `holder.p`) names a +// function-local class whose collection is refused. The refusal is reported, +// once, at the class; it used to crash the compiler with an uncaught +// PoisonError. +function main() { + const Project = class _Project { + constructor() { + this.name = "p"; + } + close() { + if (this.missingFilesMap) { + this.missingFilesMap.clear(); + this.missingFilesMap = void 0; + } + } + add(k) { + (this.missingFilesMap || (this.missingFilesMap = new Map())).set(k, 1); + } + }; + const holder = { p: new Project() }; + const key = process.argv.length > 5 ? String(process.argv.length) : "name"; + holder.p[key] = "q"; + console.log(holder.p.name); +} +main(); diff --git a/tests/harness/__snapshots__/local-class-type-query-js.js.txt b/tests/harness/__snapshots__/local-class-type-query-js.js.txt new file mode 100644 index 000000000..60422a5dc --- /dev/null +++ b/tests/harness/__snapshots__/local-class-type-query-js.js.txt @@ -0,0 +1,6 @@ +local-class-type-query-js.js:14:9 - error SC1090: fields assigned outside the constructor's top level ('this.missingFilesMap' would be undefined until the first assignment runs — assign it unconditionally at the top of the constructor) is not supported yet + + 13 | this.missingFilesMap.clear(); + 14 | this.missingFilesMap = void 0; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 15 | } \ No newline at end of file From 5746dc30c5fb4db460563988ef19beb2fe79018e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:05:40 -0700 Subject: [PATCH 20/34] Expect the compile to refuse stream-shims and node24-file-url-conversion again With the local-class refusal reported again, both npm fixtures fail to compile with SC1090 as they did before #1303; restore their build expectations. The deleted corpus program 6102 also leaves the measured cost table. Co-Authored-By: Claude Opus 5.5 --- tests/harness/corpus-costs.json | 2 -- tests/harness/npm.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 810e6a798..e413e9d3a 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2209,7 +2209,6 @@ "605-closures-forward-capture-tdz.ts": 3277, "6100-record-get-or-create.ts": 3771, "6101-record-get-or-create-unchecked/main.ts": 2107, - "6102-local-class-type-query.js": 2600, "6103-typeof-builtin-bindings.cjs": 2202, "6104-typeof-builtin-bindings.mjs": 2031, "6105-cjs-top-level-this/main.js": 1371, @@ -5245,7 +5244,6 @@ "605-closures-forward-capture-tdz.ts": 4638, "6100-record-get-or-create.ts": 3173, "6101-record-get-or-create-unchecked/main.ts": 3228, - "6102-local-class-type-query.js": 1967, "6103-typeof-builtin-bindings.cjs": 1796, "6104-typeof-builtin-bindings.mjs": 1715, "6105-cjs-top-level-this/main.js": 3213, diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index c4e70294e..a6cdee2b5 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -73,7 +73,7 @@ const KNOWN: Record = { "node24-env": { run: "SC1090" }, "node24-esm-metadata": { run: "SC1090" }, "node24-event-emitter": { run: "SC2020" }, - "node24-file-url-conversion": { run: "SC2020" }, + "node24-file-url-conversion": { build: "SC1090" }, "node24-fs-descriptors": { run: "SC1090" }, "node24-mime": { run: "SC2020" }, "node24-mime-errors": { run: "SC1090" }, @@ -104,7 +104,7 @@ const KNOWN: Record = { "package-imports": { build: "SC2013" }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { run: "SC1090" }, + "stream-shims": { build: "SC1090" }, "util-shims": { run: "SC2020" }, "web-streams": { build: "SC2013" }, "workspace-copied": { build: "SC2013" }, From 470d3177030e799b15f5b67016210892e8959c93 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 14:21:32 -0500 Subject: [PATCH 21/34] Keep class methods named kind and code direct in the compiler resolveInferredReturn turns a return statement into a runtime fence through a Record view with element writes (`mutable["kind"] = ...`). The class method census treats a literal element write as a possible write to every method of that name, so in the native compiler every method named `kind` or `code` became a dynamic callback: AstWireFile.kind, called for each AST node access, went through a property bag and a method value. Property writes go through the receiver check instead, and no class instance is assignable to Record, so those methods are called directly again. Output of the compiler is unchanged. --- .../src/frontend/lowering/function-returns.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index d3c5eb1ba..5ba63be26 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -429,11 +429,14 @@ export function resolveInferredReturn( if (ice.length > 0) lowerer.diags.push(...ice); lowerer.runtimeFences.push(...captured.filter((d) => d.code !== "SC9001")); const first = captured.find((d) => d.code !== "SC9001"); + // Property writes, not literal element writes: the class method + // census reads `x["kind"] = …` as a possible write to every method + // named `kind`, which would make those methods dynamic in native builds. const mutable = e.stmt as unknown as Record; - delete mutable["value"]; - mutable["kind"] = "runtimeFence"; - mutable["code"] = first?.code ?? "SC1090"; - mutable["message"] = first + delete mutable.value; + mutable.kind = "runtimeFence"; + mutable.code = first?.code ?? "SC1090"; + mutable.message = first ? `${first.message} [${first.code}]` : "this return value has no lowering onto the instance's settled return type [SC1090]"; } From c20c21b22cd6300e7a426d8628e0913117688c2c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:05:47 -0700 Subject: [PATCH 22/34] Declare the deferred fences of 6110 for the coverage contract The corpus program deliberately carries refused statements on untaken paths; the `// @deferred-fences: 9` header is the coverage sweep's exemption for such programs (9 failed statements across the speculative and final lowerings), so it still runs against Node in the differential oracle. Co-Authored-By: Claude Opus 5.5 --- tests/corpus/6110-cjs-speculative-wrapper-fences.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs index a26760710..6040f009c 100644 --- a/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs +++ b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs @@ -1,3 +1,4 @@ +// @deferred-fences: 9 // Function expressions lowered speculatively — the `.call(this)` and // `.apply` receivers of CommonJS wrappers, and a computed class base — keep // every statement of their bodies. A statement with no static lowering From a2707ac8d6d78dba7055f2dbe8ea5f1cae9a6d66 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:07:01 -0700 Subject: [PATCH 23/34] Name the inner location in package-anchored npm-static refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package-anchored preflight diagnostic that makes the build drop an npm package was reported only at the consumer's import, as `SC2013 cannot compile npm package 'ws' statically: SC1012: …`, with the package file and line lost. The refusal reason now carries them (`SC1012 at ws/lib/websocket.js:30: …`), in the SC2013 diagnostic and in the coverage report's package status. This is how the SC1012 construct behind ws, readable-stream, through2, socket.io and fastify was identified (nested require patterns, and a rest element in fastify), and how openai's SC1013 is located (`openai/internal/auth/x509-transport-state.mjs:2`). Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/pipeline.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 2ec9553f6..78971d7db 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -30,6 +30,21 @@ import { CONTRACT_TYPES_MODULE } from "./contract-types.js"; import { THREADS_MODULE } from "./threads-import.js"; import { EXPERIMENTAL_MODULE } from "./experimental-import.js"; +/** Where a package-anchored preflight diagnostic sits, as + * `:` — the SC2013 refusal that drops the package + * reports only at the consumer's import, so its reason carries the inner + * location. */ +function packageDiagWhere(load: LoadResult, loc: SrcLoc): string { + const file = loc.file.split("\\").join("/"); + const at = file.lastIndexOf("/node_modules/"); + const shown = at >= 0 ? file.slice(at + "/node_modules/".length) : file; + const text = load.program.getSourceFile(loc.file)?.text; + if (text === undefined) return shown; + let line = 1; + for (let i = 0; i < loc.start && i < text.length; i++) if (text.charCodeAt(i) === 10) line++; + return `${shown}:${line}`; +} + export interface ProgramLoadOptions { npmStatic?: readonly string[]; externalTypes?: Readonly> | undefined; @@ -344,7 +359,8 @@ function loadFrontend( const reasons = new Map(npmStaticOffenders()); for (const d of preflight) { const pkg = npmStaticPackageOfPath(d.loc.file); - if (pkg !== null && !reasons.has(pkg)) reasons.set(pkg, `${d.code}: ${d.message}`); + if (pkg !== null && !reasons.has(pkg)) + reasons.set(pkg, `${d.code} at ${packageDiagWhere(load, d.loc)}: ${d.message}`); } if (![...reasons.keys()].some((p) => effective.has(p))) { const named = new Map(); From 547e59529f50446bee47663a19f652c41294a0be Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 15:23:20 -0500 Subject: [PATCH 24/34] Resolve builtin classes through const aliases `const E = EventEmitter`, `const M = Map`, `const T = TypeError` and `const R = Readable` stopped at SC2020, and so did `typeof EventEmitter`, `Readable.name`, `Map.name`, `Error.name` and `===` between builtin classes. The zlib class alias resolver now covers the error classes, EventEmitter, the node:stream classes and Map/Set: a const binding of one (an alias of an alias, or an export used through a named or namespace import) constructs, extends and tests instanceof like the direct name, and typeof, .name and === fold for aliases and direct names. In TypeScript files the alias owns no runtime slot and other value uses report SC1090 at the use. In JavaScript files a copy of a name or of a globalThis property keeps its slot, which holds the same value as the name it copies; builtin module members (`zlib.Gzip`) own none, as before. --- .../lowering/builtins/class-values.ts | 159 ++++++++++++++++++ .../frontend/lowering/builtins/compression.ts | 72 +------- .../src/frontend/lowering/lower-classes.ts | 41 ++++- .../src/frontend/lowering/lower-exprs.ts | 45 ++--- .../src/frontend/lowering/lower-modules.ts | 4 +- .../src/frontend/lowering/lower-stmts.ts | 7 +- .../compiler/src/frontend/lowering/lowerer.ts | 15 +- .../src/frontend/lowering/surfaces.ts | 6 +- .../corpus/5976-builtin-class-aliases.ts.json | 6 + .../5977-builtin-class-aliases.cjs.json | 6 + .../main.ts.json | 7 + .../builtin-class-alias-escape.ts.json | 6 + tests/corpus/5976-builtin-class-aliases.ts | 109 ++++++++++++ tests/corpus/5977-builtin-class-aliases.cjs | 45 +++++ .../classes.ts | 8 + .../5978-builtin-class-alias-modules/main.ts | 33 ++++ .../diagnostics/builtin-class-alias-escape.ts | 15 ++ tests/diagnostics/class-values.ts | 4 +- .../builtin-class-alias-escape.ts.txt | 22 +++ .../harness/__snapshots__/class-values.ts.txt | 64 +++---- .../zlib-class-alias-escape.ts.txt | 2 +- tests/harness/corpus-costs.json | 6 + 22 files changed, 541 insertions(+), 141 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/builtins/class-values.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5976-builtin-class-aliases.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5977-builtin-class-aliases.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5978-builtin-class-alias-modules/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-class-alias-escape.ts.json create mode 100644 tests/corpus/5976-builtin-class-aliases.ts create mode 100644 tests/corpus/5977-builtin-class-aliases.cjs create mode 100644 tests/corpus/5978-builtin-class-alias-modules/classes.ts create mode 100644 tests/corpus/5978-builtin-class-alias-modules/main.ts create mode 100644 tests/diagnostics/builtin-class-alias-escape.ts create mode 100644 tests/harness/__snapshots__/builtin-class-alias-escape.ts.txt diff --git a/packages/compiler/src/frontend/lowering/builtins/class-values.ts b/packages/compiler/src/frontend/lowering/builtins/class-values.ts new file mode 100644 index 000000000..f24db09cf --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/class-values.ts @@ -0,0 +1,159 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { type ClassInfo } from "../lower-classes.js"; +import { isJsSourceFile } from "../../program.js"; +import { builtinMemberRequireDecl } from "./module-bindings.js"; +import { stripTypeCasts } from "./arguments.js"; +import { streamClassAliasDecl } from "../lower-stream.js"; +import { zlibClassName, zlibStreamModeOf } from "./compression.js"; +import { stdlibGlobalNameOf } from "../surfaces.js"; + +/** A builtin class an expression names at compile time: the error + * classes, node:events EventEmitter, the node:stream classes (each a + * runtime ClassInfo), the node:zlib stream classes (by node_zlib_mode), + * and the Map and Set constructors. `name` is the class's `name` + * property. */ +export type BuiltinClassRef = + | { kind: "error" | "emitter" | "stream"; info: ClassInfo; name: string } + | { kind: "zlib"; mode: number; name: string } + | { kind: "collection"; which: "Map" | "Set"; name: string }; + +/** The builtin class `expr` names: a direct spelling (`EventEmitter`, + * `stream.Readable`, `zlib.Gzip`, `Map`, `globalThis.TypeError`) or a + * `const` binding of one (`const E = EventEmitter`, an alias of an alias, + * an imported alias), through parentheses and type assertions. Null for + * anything else. */ +export function builtinClassRefOf(lowerer: Lowerer, expr: ts.Expression): BuiltinClassRef | null { + const e = stripTypeCasts(expr); + // Checker lookups only: answering must not run the fences and deferred + // diagnostics of a lowered reference (resolveValueSymbol). + let symbol: ts.Symbol | undefined; + if (ts.isIdentifier(e)) { + symbol = lowerer.checker.getSymbolAtLocation(e); + } else if (ts.isPropertyAccessExpression(e) && !e.questionDotToken) { + symbol = lowerer.checker.getSymbolAtLocation(e.name); + } else { + return null; + } + if (!symbol) return null; + const alias = builtinClassAliasOf(lowerer, symbol); + if (alias !== null) return alias; + if (symbol.flags & ts.SymbolFlags.Alias) symbol = lowerer.checker.getAliasedSymbol(symbol); + const which = stdlibGlobalNameOf(lowerer, e); + if (which === "Map" || which === "Set") return { kind: "collection", which, name: which }; + const error = lowerer.builtinErrorInfoOf(symbol); + if (error) return { kind: "error", info: error, name: runtimeClassName(error) }; + const emitter = lowerer.builtinEmitterInfoOf(symbol); + if (emitter) return { kind: "emitter", info: emitter, name: runtimeClassName(emitter) }; + const stream = lowerer.builtinStreamInfoOf(symbol); + if (stream) return { kind: "stream", info: stream, name: runtimeClassName(stream) }; + const mode = zlibStreamModeOf(lowerer, symbol); + if (mode !== null) return { kind: "zlib", mode, name: zlibClassName(mode) }; + return null; +} + +/** The builtin class a `const` binding names (`const E = EventEmitter`, + * `const M = Map`, `const C = zlib.Gzip`, or an alias of such a binding), + * or null. `let` and `var` bindings could be reassigned, and annotated + * bindings give the value another type, so they are not aliases. Resolved from the declaration, so uses lowered before the + * declaration statement (hoisted functions) see the same answer. */ +export function builtinClassAliasOf(lowerer: Lowerer, symbol: ts.Symbol): BuiltinClassRef | null { + const cached = lowerer.builtinClassAliases.get(symbol); + if (cached !== undefined) return cached; + // An import of an exported alias names the exporting binding. + if (symbol.flags & ts.SymbolFlags.Alias) { + lowerer.builtinClassAliases.set(symbol, null); + const target = lowerer.checker.getAliasedSymbol(symbol); + const ref = target === symbol ? null : builtinClassAliasOf(lowerer, target); + lowerer.builtinClassAliases.set(symbol, ref); + return ref; + } + const init = constInitializerOf(lowerer, symbol); + if (init === null) { + lowerer.builtinClassAliases.set(symbol, null); + return null; + } + // Marked first: a cycle through other bindings answers null. + lowerer.builtinClassAliases.set(symbol, null); + const ref = builtinClassRefOf(lowerer, init); + lowerer.builtinClassAliases.set(symbol, ref); + return ref; +} + +/** True when `symbol` is a builtin class alias whose declaration owns no + * runtime slot: new, instanceof, typeof, .name and === lower at each use, + * and other uses report SC1090. In JavaScript files, a binding whose + * initializer has a value (`const E = EventEmitter`, `const M = + * globalThis.Map`, both identity tokens) keeps its slot; only builtin + * module members (`zlib.Gzip`) and copies of slotless aliases own none. + * The `require("events").EventEmitter` and `stream.Writable` spellings + * keep their own declaration handling. */ +export function builtinClassAliasOwnsNoSlot(lowerer: Lowerer, symbol: ts.Symbol): boolean { + if (builtinClassAliasOf(lowerer, symbol) === null) return false; + const binding = + symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol; + const decl = lowerer.checker.valueDeclarationOf(binding); + if (decl === undefined || !ts.isVariableDeclaration(decl)) return false; + if (builtinMemberRequireDecl(decl.name, decl.initializer)) return false; + if (streamClassAliasDecl(lowerer, decl.name, decl.initializer)) return false; + if (!isJsSourceFile(decl.getSourceFile())) return true; + let init = decl.initializer; + while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; + if (init === undefined) return true; + let source: ts.Symbol | undefined; + if (ts.isPropertyAccessExpression(init)) { + if (lowerer.builtinNamespaceModuleOf(init.expression) !== null) return true; + source = lowerer.checker.getSymbolAtLocation(init.name); + } else { + source = lowerer.checker.getSymbolAtLocation(init); + } + return source !== undefined && builtinClassAliasOwnsNoSlot(lowerer, source); +} + +/** True for a `const` declaration that is a builtin class alias owning no + * runtime slot (see builtinClassAliasOwnsNoSlot): both declaration walks + * skip it. */ +export function registerBuiltinClassAlias( + lowerer: Lowerer, + nameNode: ts.Node, + init: ts.Expression | undefined, +): boolean { + if (!ts.isIdentifier(nameNode) || init === undefined) return false; + const symbol = lowerer.checker.getSymbolAtLocation(nameNode); + return symbol !== undefined && builtinClassAliasOwnsNoSlot(lowerer, symbol); +} + +/** True when two references name the same builtin class. */ +export function sameBuiltinClass(a: BuiltinClassRef, b: BuiltinClassRef): boolean { + if (a.kind === "zlib") return b.kind === "zlib" && a.mode === b.mode; + if (a.kind === "collection") return b.kind === "collection" && a.which === b.which; + return b.kind === a.kind && b.info === a.info; +} + +/** The initializer of an unannotated `const` binding whose initializer + * can name a class (an identifier or a property access), or null. */ +function constInitializerOf(lowerer: Lowerer, symbol: ts.Symbol): ts.Expression | null { + if ((symbol.flags & ts.SymbolFlags.BlockScopedVariable) === 0) return null; + const decl = lowerer.checker.valueDeclarationOf(symbol); + if ( + decl === undefined || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + decl.type !== undefined + ) { + return null; + } + const list = decl.parent; + if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null; + let init = decl.initializer; + while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; + if (init === undefined) return null; + if (ts.isIdentifier(init)) return init; + if (ts.isPropertyAccessExpression(init) && !init.questionDotToken) return init; + return null; +} + +/** Node's constructor name of a runtime class (`%TypeError` → TypeError). */ +function runtimeClassName(info: ClassInfo): string { + return info.def.name.startsWith("%") ? info.def.name.slice(1) : info.def.name; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/compression.ts b/packages/compiler/src/frontend/lowering/builtins/compression.ts index c9553c2f9..6de345dfe 100644 --- a/packages/compiler/src/frontend/lowering/builtins/compression.ts +++ b/packages/compiler/src/frontend/lowering/builtins/compression.ts @@ -132,8 +132,8 @@ export function zlibStreamModeOf( symbol: ts.Symbol | null | undefined, ): number | null { if (!symbol) return null; - const aliasMode = zlibClassAliasModeOf(lowerer, symbol, 0); - if (aliasMode !== undefined) return aliasMode; + const alias = lowerer.builtinClassAliasOf(symbol); + if (alias !== null) return alias.kind === "zlib" ? alias.mode : null; const target = symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol; const mode = ZLIB_STREAM_MODES.get(target.name); @@ -499,80 +499,12 @@ export function lowerZlibModuleCall( throw new InternalCompilerError(`unhandled lowered zlib member ${bi.member}`); } -/** The zlib stream class an expression names (`zlib.Gzip`, an imported - * `Gzip`, a `const C = zlib.Gzip` alias), as its node_zlib_mode, or null. */ -export function zlibClassModeOfExpr(lowerer: Lowerer, expr: ts.Expression): number | null { - let e = expr; - while (ts.isParenthesizedExpression(e)) e = e.expression; - if (ts.isIdentifier(e)) return zlibStreamModeOf(lowerer, lowerer.checker.getSymbolAtLocation(e)); - if (ts.isPropertyAccessExpression(e) && !e.questionDotToken) { - return zlibStreamModeOf(lowerer, lowerer.checker.getSymbolAtLocation(e.name)); - } - return null; -} - /** The class name of a zlib stream mode (Node's constructor name). */ export function zlibClassName(mode: number): string { for (const [name, m] of ZLIB_STREAM_MODES) if (m === mode) return name; return "Zlib"; } -/** The mode of a `const C = zlib.Gzip` binding (or an alias of such a - * binding), null when the symbol is a const binding of something else, - * undefined when it is not a const binding. Resolved from the declaration, - * so uses lowered before the declaration statement see the same answer. */ -function zlibClassAliasModeOf( - lowerer: Lowerer, - symbol: ts.Symbol, - depth: number, -): number | null | undefined { - const cached = lowerer.zlibClassAliases.get(symbol); - if (cached !== undefined) return cached; - if ((symbol.flags & ts.SymbolFlags.BlockScopedVariable) === 0) return undefined; - const decl = lowerer.checker.valueDeclarationOf(symbol); - if (decl === undefined || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) { - return undefined; - } - // const only: a var or let binding could be reassigned - const list = decl.parent; - if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) { - return undefined; - } - let init = decl.initializer; - while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; - if (init === undefined || depth > 16) return null; - let mode: number | null = null; - if (ts.isIdentifier(init)) { - const next = lowerer.checker.getSymbolAtLocation(init); - if (next !== undefined) { - const nested = zlibClassAliasModeOf(lowerer, next, depth + 1); - mode = nested === undefined ? zlibStreamModeOf(lowerer, next) : nested; - } - } else if (ts.isPropertyAccessExpression(init) && !init.questionDotToken) { - mode = zlibStreamModeOf(lowerer, lowerer.checker.getSymbolAtLocation(init.name)); - } - if (mode !== null) lowerer.zlibClassAliases.set(symbol, mode); - return mode; -} - -/** True for `const C = zlib.Gzip` (or an alias of an alias): a - * compile-time zlib class alias that owns no runtime slot, so the - * statement lowering skips it. */ -export function registerZlibClassAlias( - lowerer: Lowerer, - nameNode: ts.Node, - init: ts.Expression | undefined, -): boolean { - if (!ts.isIdentifier(nameNode) || init === undefined) return false; - const symbol = lowerer.checker.getSymbolAtLocation(nameNode); - return symbol !== undefined && zlibClassAliasOf(lowerer, symbol) !== null; -} - -/** The mode of a `const C = zlib.Gzip` alias symbol, or null. */ -export function zlibClassAliasOf(lowerer: Lowerer, symbol: ts.Symbol): number | null { - return zlibClassAliasModeOf(lowerer, symbol, 0) ?? null; -} - /** `value instanceof zlib.Gzip` (any spelling of the class, aliases * included). zlib streams are runtime Transforms: a Transform operand asks * the runtime for the stream's mode, and an operand typed as one of diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 39b3242d6..186936f48 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -12,6 +12,11 @@ import { InternalCompilerError } from "../../errors.js"; import { fsStreamClassOf, lowerFsStreamNew } from "./builtins/fs-streams.js"; import * as ts from "../ts7/adapter.js"; import { lowerZlibStreamNew, zlibClassName, zlibStreamModeOf } from "./builtins/compression.js"; +import { + type BuiltinClassRef, + builtinClassAliasOf, + builtinClassRefOf, +} from "./builtins/class-values.js"; import type { FnCtx, Lowerer } from "./lowerer.js"; import { BOOL, @@ -924,12 +929,15 @@ export function registerBuiltinStreamClasses(lowerer: Lowerer): void { * "stream" ambient module, EXCLUDING @types/node's (whose stream.Readable * also types child stdio — under @types/node the childStream mapping * keeps priority and the static stream classes stand down; the shipped - * fallback declarations are the supported surface). */ + * fallback declarations are the supported surface). A const binding of + * one (`const R = Readable`) names the same class (builtinClassAliasOf). */ export function builtinStreamInfoOf( lowerer: Lowerer, symbol: ts.Symbol | null | undefined, ): ClassInfo | null { if (!symbol) return null; + const alias = builtinClassAliasOf(lowerer, symbol); + if (alias !== null) return alias.kind === "stream" ? alias.info : null; if (!lowerer.isStdlibSymbol(symbol)) { // A const ALIAS of a namespace member (`const Writable = // stream.Writable` — the two-step spelling; the one-step @@ -1284,12 +1292,16 @@ function undefArmedFieldType(lowerer: Lowerer, p: ts.Symbol): IrType | null { * `extends EventEmitter`, `x instanceof EventEmitter`) — any import * spelling (named/default/namespace member, CJS require) resolves to * the ambient class. Provenance-checked like the error classes: only a - * stdlib-file declaration inside the "events" ambient module counts. */ + * stdlib-file declaration inside the "events" ambient module counts. A + * const binding of it (`const E = EventEmitter`) names the same class + * (builtinClassAliasOf). */ export function builtinEmitterInfoOf( lowerer: Lowerer, symbol: ts.Symbol | null | undefined, ): ClassInfo | null { if (!symbol) return null; + const alias = builtinClassAliasOf(lowerer, symbol); + if (alias !== null) return alias.kind === "emitter" ? alias.info : null; if (!lowerer.isStdlibSymbol(symbol)) { // A const ALIAS of the emitter class member (`const EventEmitter = // require('node:events').EventEmitter` — commander's spelling; the @@ -1330,12 +1342,17 @@ export function builtinEmitterInfoOf( /** The builtin error ClassInfo a VALUE symbol refers to (`new Error`, * `extends TypeError`, `x instanceof RangeError`), or null. Provenance- * checked: only the standard library's declarations count — a user's own - * `class Error` resolves through classBySymbol instead. */ + * `class Error` resolves through classBySymbol instead. A const binding + * of one (`const E = TypeError`) names the same class + * (builtinClassAliasOf). */ export function builtinErrorInfoOf( lowerer: Lowerer, symbol: ts.Symbol | null | undefined, ): ClassInfo | null { - if (!symbol || !lowerer.isStdlibSymbol(symbol)) return null; + if (!symbol) return null; + const alias = builtinClassAliasOf(lowerer, symbol); + if (alias !== null) return alias.kind === "error" ? alias.info : null; + if (!lowerer.isStdlibSymbol(symbol)) return null; for (const [irName, rec] of RUNTIME_ERROR_CLASSES) { if (rec.lib === symbol.name) return lowerer.classes.get(irName) ?? null; } @@ -8435,6 +8452,12 @@ function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType return lowerer.currentClass?.fields.get(parent.left.name.text) ?? null; } +/** Map, Set and the error classes construct in lowerNew's identifier + * branch, which a module member alias of one (`new mod.M()`) also takes. */ +function isCollectionOrErrorClass(ref: BuiltinClassRef | null): boolean { + return ref?.kind === "collection" || ref?.kind === "error"; +} + export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const loc = locOf(expr); const worker = lowerWorkerNew(lowerer, expr); @@ -8729,7 +8752,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { ts.isIdentifier(expr.expression) || (ts.isPropertyAccessExpression(expr.expression) && ts.isIdentifier(expr.expression.name) && - lowerer.isStdlibGlobal(expr.expression.expression, "globalThis")) + (lowerer.isStdlibGlobal(expr.expression.expression, "globalThis") || + // `new mod.M()` over an exported `const M = Map` or `const E = + // TypeError` + isCollectionOrErrorClass(builtinClassRefOf(lowerer, expr.expression)))) ) { // `import C = N.C; new C()` — the alias's own source-order guards // (a no-op for every non-import= binding). @@ -9094,7 +9120,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { if (symbol?.name === "Array" && lowerer.isStdlibSymbol(symbol)) { return lowerArrayConstructor(lowerer, expr, expr.arguments ?? []); } - if (symbol?.name === "Map" && lowerer.isStdlibSymbol(symbol)) { + // A const alias (`const M = Map`) constructs the same Map. + if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Map")) { const seedArg = (expr.arguments?.length ?? 0) === 1 ? expr.arguments![0]! : null; const isPairLit = (el: ts.Expression): el is ts.ArrayLiteralExpression => ts.isArrayLiteralExpression(el) && @@ -9226,7 +9253,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { } // `new Set()`: construct + bulk add from supported values, // including live collection cursors. Duplicates collapse in insertion order. - if (symbol?.name === "Set" && lowerer.isStdlibSymbol(symbol)) { + if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Set")) { const tsType = lowerer.typeOf(expr); let mapped = lowerer.mapTypeOf(tsType); const fieldType = assignedThisFieldType(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d4d03591d..09296bdfe 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -35,10 +35,12 @@ import { isZlibStreamMember, lowerZlibInstanceOf, lowerZlibStreamBytesWritten, - zlibClassAliasOf, - zlibClassModeOfExpr, - zlibClassName, } from "./builtins/compression.js"; +import { + builtinClassAliasOwnsNoSlot, + builtinClassRefOf, + sameBuiltinClass, +} from "./builtins/class-values.js"; import { constituentTypes } from "../ts7/checker.js"; import { dirname } from "node:path"; import * as posix from "node:path/posix"; @@ -616,8 +618,9 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (isNodeModuleValue(lowerer, expr.expression)) { return { kind: "strLit", value: "object", type: STRING, loc }; } - // `typeof zlib.Gzip`, `typeof C` over a zlib class alias. - if (zlibClassModeOfExpr(lowerer, expr.expression) !== null) { + // `typeof EventEmitter`, `typeof zlib.Gzip`, `typeof C` over a + // builtin class alias. + if (builtinClassRefOf(lowerer, expr.expression) !== null) { return { kind: "strLit", value: "function", type: STRING, loc }; } if (ts.isPropertyAccessExpression(expr.expression)) { @@ -902,13 +905,13 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { `a builtin function alias as an escaping value (call '${expr.text}' directly)`, ); } - // `const C = zlib.Gzip` owns no runtime slot: construction, + // `const E = EventEmitter` owns no runtime slot: construction, // instanceof, typeof and .name lower at their use sites. - if (sym && zlibClassAliasOf(lowerer, sym) !== null) { + if (sym && builtinClassAliasOwnsNoSlot(lowerer, sym)) { lowerer.unsupported( "SC1090", expr, - `a zlib class alias as an escaping value (use '${expr.text}' with new, instanceof, typeof or .name)`, + `a builtin class alias as an escaping value (use '${expr.text}' with new, instanceof, typeof or .name)`, ); } } @@ -1849,10 +1852,11 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const table = lowerInternTableProperty(lowerer, expr); if (table) return table; } - // `zlib.Gzip.name`, `C.name` over a zlib class alias. + // `EventEmitter.name`, `zlib.Gzip.name`, `C.name` over a builtin + // class alias. if (expr.name.text === "name" && !expr.questionDotToken) { - const mode = zlibClassModeOfExpr(lowerer, expr.expression); - if (mode !== null) return { kind: "strLit", value: zlibClassName(mode), type: STRING, loc }; + const ref = builtinClassRefOf(lowerer, expr.expression); + if (ref !== null) return { kind: "strLit", value: ref.name, type: STRING, loc }; } // Expando statics may still be absent even when JS inference sees // the eventual assignment's scalar type. Preserve the checked value. @@ -10266,17 +10270,18 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr lowerUnionTypeofTest(lowerer, expr, loc); if (test) return test; } - // `C === zlib.Gzip` between two zlib class references: the classes - // are compile-time values, so the answer folds. + // `C === EventEmitter` between two builtin class references: the + // classes are compile-time values, so the answer folds. if ( op === ts.SyntaxKind.EqualsEqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsEqualsToken ) { - const leftClass = zlibClassModeOfExpr(lowerer, expr.left); - const rightClass = leftClass === null ? null : zlibClassModeOfExpr(lowerer, expr.right); + const leftClass = builtinClassRefOf(lowerer, expr.left); + const rightClass = leftClass === null ? null : builtinClassRefOf(lowerer, expr.right); if (leftClass !== null && rightClass !== null) { const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; - return { kind: "boolLit", value: (leftClass === rightClass) !== negated, type: BOOL, loc }; + const same = sameBuiltinClass(leftClass, rightClass); + return { kind: "boolLit", value: same !== negated, type: BOOL, loc }; } } @@ -12352,11 +12357,11 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc // h2 compat 'connect' narrowing (lower-server.ts): a union tag test. const sockTest = lowerSocketInstanceOf(lowerer, expr, loc); if (sockTest !== null) return sockTest; - const zlibMode = zlibClassModeOfExpr(lowerer, expr.right); - if (zlibMode !== null) return lowerZlibInstanceOf(lowerer, expr, zlibMode, loc); + const rightClass = builtinClassRefOf(lowerer, expr.right); + if (rightClass?.kind === "zlib") return lowerZlibInstanceOf(lowerer, expr, rightClass.mode, loc); - if (lowerer.isStdlibGlobal(expr.right, "Set") || lowerer.isStdlibGlobal(expr.right, "Map")) { - const collection = lowerer.isStdlibGlobal(expr.right, "Map") ? "map" : "set"; + if (rightClass?.kind === "collection") { + const collection = rightClass.which === "Map" ? "map" : "set"; const value = lowerer.lowerExpr(expr.left); if (value.type.kind === "union") { const union = lowerer.unions.get(value.type.unionId); diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3baab9dd0..da70cd53a 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -80,7 +80,7 @@ import { createRequireProgramModuleDecl, } from "./builtins/module-bindings.js"; import { isPromisifyCall, registerBuiltinCallableAlias } from "./builtins/callable-aliases.js"; -import { registerZlibClassAlias } from "./builtins/compression.js"; +import { registerBuiltinClassAlias } from "./builtins/class-values.js"; import { stripTypeCasts } from "./builtins/arguments.js"; import { bindingContextualGenericFnNodeOf, @@ -1701,7 +1701,7 @@ export function collectGlobals( // resolution and value reads reuse the source's interned closure. if (isConst && registerOverloadedCallableAlias(lowerer, decl)) continue; if (isConst && registerBuiltinCallableAlias(lowerer, decl.name, decl.initializer)) continue; - if (registerZlibClassAlias(lowerer, decl.name, decl.initializer)) continue; + if (registerBuiltinClassAlias(lowerer, decl.name, decl.initializer)) continue; // `const f = (x) => ...` at file scope of an npm-static JS file // whose params are implicit-any: the implicit-monomorphization // twin of the generic binding above — no global exists; calls and diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 0f1b606ef..76ee0d48d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -148,7 +148,7 @@ import { } from "./builtins/module-bindings.js"; import { lowerNodeModuleCall } from "./builtins/module-resolution.js"; import { registerBuiltinCallableAlias } from "./builtins/callable-aliases.js"; -import { registerZlibClassAlias } from "./builtins/compression.js"; +import { builtinClassAliasOwnsNoSlot, registerBuiltinClassAlias } from "./builtins/class-values.js"; import { lowerEnumDeclaration } from "./lower-enums.js"; import { abstractPropertyDeclOf, @@ -847,6 +847,9 @@ export function predeclareForwardCapture(lowerer: Lowerer, symbol: ts.Symbol): b if (!decl.initializer) return false; const flags = ts.getCombinedNodeFlags(decl); if ((flags & ts.NodeFlags.BlockScoped) === 0 || (flags & ts.NodeFlags.Using) !== 0) return false; + // `const E = EventEmitter` has no storage to box: uses resolve at + // compile time (builtinClassAliasOf). + if (builtinClassAliasOwnsNoSlot(lowerer, symbol)) return false; // MODULE-scope bindings are pre-registered globals (collectGlobals): // references resolve through globalOf after the local search fails, so // a TDZ box here would SHADOW the global and never fill (the top-level @@ -4423,7 +4426,7 @@ export function lowerVarDecl( // reads reuse its interned closure identity. if (!isLet && registerOverloadedCallableAlias(lowerer, decl)) return null; if (!isLet && registerBuiltinCallableAlias(lowerer, decl.name, decl.initializer)) return null; - if (registerZlibClassAlias(lowerer, decl.name, decl.initializer)) return null; + if (registerBuiltinClassAlias(lowerer, decl.name, decl.initializer)) return null; // `const knownBy = (cmd) => ...` — an IMPLICIT-ANY function-value // binding in npm-static JS (function-scope bindings register here, in diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e19c4785d..5adeb7481 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -261,6 +261,7 @@ import { stdlibGlobalMember, nodeTypesOnlySymbol, } from "./surfaces.js"; +import { type BuiltinClassRef, builtinClassAliasOf } from "./builtins/class-values.js"; import { type FileParts, splitFiles, @@ -1858,11 +1859,11 @@ export class Lowerer { * typeParamBindings — the checker has no `T` to substitute, so the * binding rides the node-type accessor instead of mapType. */ implicitParamTypes: Map | null = null; - /** Immutable bindings that name a zlib stream class (`const C = - * zlib.Gzip`), keyed by the binding's symbol: the class's - * node_zlib_mode. No runtime slot exists: new, typeof, .name and === - * resolve at compile time, and instanceof checks the stream's mode. */ - readonly zlibClassAliases = new Map(); + /** The builtin class each binding names (`const E = EventEmitter`, + * `const C = zlib.Gzip`), keyed by the binding's symbol; null for + * bindings that name none (builtinClassAliasOf). new, instanceof, + * typeof, .name and === resolve at compile time. */ + readonly builtinClassAliases = new Map(); /** Whether some net.Socket in this program may get an encoding: a * setEncoding call whose receiver is a socket or an untyped value. * Computed once on first use (null until then). */ @@ -3073,6 +3074,10 @@ export class Lowerer { return builtinStreamInfoOf(this, symbol); } + builtinClassAliasOf(symbol: ts.Symbol): BuiltinClassRef | null { + return builtinClassAliasOf(this, symbol); + } + /** Program-wide qualified name for a top-level declaration. */ qualify(sf: ts.SourceFile, name: string): string { return `${this.fileTag.get(sf) ?? ""}${name}`; diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index d394c1960..73cc58c45 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -3207,7 +3207,9 @@ export function isStdlibSymbol(lowerer: Lowerer, symbol: ts.Symbol | undefined): * `global.process`) — `declare var` globals ARE properties of * `typeof globalThis`, same symbol either way; * - a local alias binding (`const process = globalThis.process`, the - * tamper-guard prologue) registered in stdlibGlobalAliases. */ + * tamper-guard prologue) registered in stdlibGlobalAliases; + * - a const binding of the Map or Set constructor (`const M = Map`, + * builtinClassAliasOf). */ export function stdlibGlobalNameOf(lowerer: Lowerer, expr: ts.Expression): string | null { if (ts.isParenthesizedExpression(expr)) return stdlibGlobalNameOf(lowerer, expr.expression); if (ts.isIdentifier(expr)) { @@ -3225,6 +3227,8 @@ export function stdlibGlobalNameOf(lowerer: Lowerer, expr: ts.Expression): strin if (!symbol) return null; const alias = lowerer.stdlibGlobalAliases.get(symbol); if (alias !== undefined) return alias; + const classAlias = lowerer.builtinClassAliasOf(symbol); + if (classAlias?.kind === "collection") return classAlias.which; const decl = lowerer.checker.declarationsOf(symbol)[0]; if (decl !== undefined && ts.isImportClause(decl) && decl.name !== undefined) { const importDecl = decl.parent; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5976-builtin-class-aliases.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5976-builtin-class-aliases.ts.json new file mode 100644 index 000000000..f83d23fc7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5976-builtin-class-aliases.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5976-builtin-class-aliases.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5977-builtin-class-aliases.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5977-builtin-class-aliases.cjs.json new file mode 100644 index 000000000..99d07e4cd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5977-builtin-class-aliases.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5977-builtin-class-aliases.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5978-builtin-class-alias-modules/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5978-builtin-class-alias-modules/main.ts.json new file mode 100644 index 000000000..fd6bb94f5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5978-builtin-class-alias-modules/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/5978-builtin-class-alias-modules/classes.ts", + "/tests/corpus/5978-builtin-class-alias-modules/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-class-alias-escape.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-class-alias-escape.ts.json new file mode 100644 index 000000000..f56d058f1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-class-alias-escape.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/builtin-class-alias-escape.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5976-builtin-class-aliases.ts b/tests/corpus/5976-builtin-class-aliases.ts new file mode 100644 index 000000000..692b3928d --- /dev/null +++ b/tests/corpus/5976-builtin-class-aliases.ts @@ -0,0 +1,109 @@ +// Builtin classes through const aliases: EventEmitter, the stream classes, +// the error classes, Map and Set construct, extend and test instanceof +// through an alias, and typeof, .name and === answer as in Node for the +// aliases and the direct spellings. +import { EventEmitter } from "node:events"; +import * as events from "node:events"; +import { PassThrough, Readable, Transform } from "node:stream"; +import * as stream from "node:stream"; +import { AssertionError } from "node:assert"; +import * as assert from "node:assert"; + +function lateEmitter(): boolean { + return new LateEmitter() instanceof EventEmitter && typeof LateMap === "function"; +} + +function localHoist(): string { + function inner(): string { + return `${typeof LocalMap} ${LocalMap.name} ${new LocalMap().size}`; + } + const LocalMap = Map; + return inner(); +} + +const Emitter = EventEmitter; +const NsEmitter = events.EventEmitter; +const Again = Emitter; +const LateEmitter = events.EventEmitter; +const LateMap = Map; + +const e = new Emitter(); +e.on("ping", (n: number) => console.log("ping", n)); +e.emit("ping", 1); +console.log("emitter", e instanceof Emitter, e instanceof NsEmitter, e instanceof Again); +console.log("hoisted use", lateEmitter(), localHoist()); + +class Bus extends Again { + send(text: string): void { + this.emit("text", text); + } +} +const bus = new Bus(); +bus.on("text", (text: string) => console.log("bus", text)); +bus.send("hello"); +console.log("subclass", bus instanceof Emitter, bus instanceof Bus); + +const Pass = PassThrough; +const R = Readable; +const T = stream.Transform; +const pass = new Pass(); +console.log("stream", pass instanceof Pass, pass instanceof R, pass instanceof T); +console.log("readable", new R({ read() {} }) instanceof Pass, e instanceof R); + +class Upper extends T { + override _transform( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error: Error | null, data?: string) => void, + ): void { + callback(null, chunk.toString().toUpperCase()); + } +} +const upper = new Upper(); +upper.on("data", (chunk: Buffer) => console.log("upper", chunk.toString())); +upper.end("quiet"); + +const TE = TypeError; +const RE = RangeError; +class LimitError extends RE { + constructor(limit: number) { + super(`over ${limit}`); + this.name = "LimitError"; + } +} +try { + throw new TE("bad input"); +} catch (err) { + console.log("error", err instanceof TE, err instanceof RE, err instanceof Error, (err as Error).message); +} +try { + throw new LimitError(3); +} catch (err) { + console.log("subclass error", err instanceof RE, err instanceof LimitError, (err as Error).message); +} +const AE = AssertionError; +try { + assert.strictEqual(1, 2); +} catch (err) { + console.log("assertion", err instanceof AE, err instanceof TE); +} +const viaNamespace = new assert.AssertionError({ message: "custom" }); +console.log("namespace error", viaNamespace.message, viaNamespace instanceof AE); + +const M = Map; +const S = Set; +const m = new M([ + ["a", 1], + ["b", 2], +]); +m.set("c", 3); +const s = new S([1, 2, 2, 3]); +console.log("collections", m.get("b"), m.size, s.size, m instanceof M, s instanceof S, s instanceof M); +const groups = M.groupBy([1, 2, 3, 4, 5], (n: number) => (n % 2 === 0 ? "even" : "odd")); +console.log("groupBy", groups.get("even"), groups.get("odd")); + +console.log("typeof", typeof Emitter, typeof R, typeof TE, typeof M, typeof EventEmitter, typeof Readable); +console.log("name", Emitter.name, NsEmitter.name, Pass.name, T.name, TE.name, AE.name, M.name, S.name); +console.log("direct name", EventEmitter.name, stream.Duplex.name, Error.name, RangeError.name, Map.name, Set.name); +console.log("identity", Emitter === EventEmitter, Again === NsEmitter, T === Transform, R === stream.Readable); +console.log("other class", (Pass as unknown) === (R as unknown), (M as unknown) !== (S as unknown), (TE as unknown) === (RE as unknown)); diff --git a/tests/corpus/5977-builtin-class-aliases.cjs b/tests/corpus/5977-builtin-class-aliases.cjs new file mode 100644 index 000000000..c149884ba --- /dev/null +++ b/tests/corpus/5977-builtin-class-aliases.cjs @@ -0,0 +1,45 @@ +// Builtin classes through const aliases in a CommonJS file. A copy of a +// name (or of a globalThis property) keeps its value, so it can be stored +// and compared; construction, instanceof, typeof and .name resolve to the +// class. Builtin module members construct through the alias. +const { EventEmitter } = require("node:events"); +const events = require("node:events"); +const zlib = require("node:zlib"); +const { PassThrough } = require("node:stream"); + +const Emitter = EventEmitter; +const NsEmitter = events.EventEmitter; +const Gz = zlib.Gzip; +const Gz2 = Gz; +const M = Map; +const GlobalSet = globalThis.Set; +const Pass = PassThrough; +const TE = TypeError; + +const e = new Emitter(); +e.on("ping", (n) => console.log("ping", n)); +e.emit("ping", 2); +console.log("emitter", e instanceof Emitter, e instanceof NsEmitter, e instanceof EventEmitter); + +const m = new M(); +m.set("k", 1); +const s = new GlobalSet(["a", "b", "a"]); +console.log("collections", m.get("k"), s.size, m instanceof M, s instanceof GlobalSet, m instanceof GlobalSet); + +const gz = new Gz2(); +console.log("zlib", gz instanceof Gz, gz instanceof zlib.Gunzip, Gz2 === zlib.Gzip); + +const pass = new Pass(); +console.log("stream", pass instanceof Pass, pass instanceof EventEmitter); + +try { + throw new TE("typed"); +} catch (err) { + console.log("error", err instanceof TE, err.message); +} + +const stored = [Emitter, M, GlobalSet, Pass]; +console.log("stored", stored.length, stored[0] === EventEmitter, stored[1] === Map, stored[2] === Set); +console.log("typeof", typeof Emitter, typeof NsEmitter, typeof Gz2, typeof M, typeof GlobalSet, typeof TE); +console.log("name", Emitter.name, NsEmitter.name, Gz2.name, M.name, GlobalSet.name, Pass.name, TE.name); +console.log("identity", Emitter === NsEmitter, M === Map, GlobalSet === Set, M === GlobalSet); diff --git a/tests/corpus/5978-builtin-class-alias-modules/classes.ts b/tests/corpus/5978-builtin-class-alias-modules/classes.ts new file mode 100644 index 000000000..73590d1e8 --- /dev/null +++ b/tests/corpus/5978-builtin-class-alias-modules/classes.ts @@ -0,0 +1,8 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +export const Emitter = EventEmitter; +export const Pass = PassThrough; +export const Dict = Map; +export const Bag = Set; +export const Failure = TypeError; diff --git a/tests/corpus/5978-builtin-class-alias-modules/main.ts b/tests/corpus/5978-builtin-class-alias-modules/main.ts new file mode 100644 index 000000000..cf39cd712 --- /dev/null +++ b/tests/corpus/5978-builtin-class-alias-modules/main.ts @@ -0,0 +1,33 @@ +// Builtin class aliases exported from one module and used through named +// and namespace imports: construction, instanceof, typeof, .name and === +// resolve to the builtin class in the importing module. +import { EventEmitter } from "node:events"; +import { Emitter, Pass, Dict, Bag, Failure } from "./classes.ts"; +import * as classes from "./classes.ts"; + +const e = new Emitter(); +e.on("tick", (n: number) => console.log("tick", n)); +e.emit("tick", 7); +console.log("emitter", e instanceof Emitter, e instanceof classes.Emitter, e instanceof EventEmitter); + +const viaNs = new classes.Emitter(); +console.log("namespace emitter", viaNs instanceof Emitter, Emitter === classes.Emitter); + +const pass = new classes.Pass(); +console.log("stream", pass instanceof Pass, pass instanceof classes.Emitter); + +const dict = new Dict([["k", 1]]); +const viaNsDict = new classes.Dict(); +viaNsDict.set("a", "b"); +const bag = new classes.Bag([1, 1, 2]); +console.log("collections", dict.get("k"), viaNsDict.get("a"), bag.size, dict instanceof classes.Dict, bag instanceof Bag); + +try { + throw new classes.Failure("wrong type"); +} catch (err) { + console.log("error", err instanceof Failure, err instanceof classes.Failure, (err as Error).message); +} + +console.log("typeof", typeof Emitter, typeof classes.Pass, typeof Dict, typeof classes.Bag, typeof Failure); +console.log("name", Emitter.name, classes.Pass.name, Dict.name, classes.Bag.name, Failure.name); +console.log("identity", Dict === Map, classes.Bag === Set, Failure === TypeError, (Dict as unknown) === (Bag as unknown)); diff --git a/tests/diagnostics/builtin-class-alias-escape.ts b/tests/diagnostics/builtin-class-alias-escape.ts new file mode 100644 index 000000000..b3f9be5d3 --- /dev/null +++ b/tests/diagnostics/builtin-class-alias-escape.ts @@ -0,0 +1,15 @@ +// A builtin class alias owns no runtime value in a TypeScript file: +// construction, instanceof, typeof and .name compile, other uses report +// at the use. A let binding of a class is not an alias. +import { EventEmitter } from "node:events"; + +const Emitter = EventEmitter; +const kinds = [Emitter]; +console.log(kinds.length); + +const M = Map; +const boxed: unknown = M; +console.log(boxed !== undefined); + +let Mutable = EventEmitter; +console.log(typeof Mutable); diff --git a/tests/diagnostics/class-values.ts b/tests/diagnostics/class-values.ts index 02c5360fa..3a53e5185 100644 --- a/tests/diagnostics/class-values.ts +++ b/tests/diagnostics/class-values.ts @@ -18,8 +18,10 @@ class Unrelated { static tag = "unrelated"; } -// Builtin classes as values: their construction is libCall-shaped. +// Builtin classes as values: a const alias resolves at compile time, and +// an array of builtin classes has no element type. const E = Error; +const errorClasses = [E]; // Classes extending builtin bases as values (the error-message completion // rule has no thunk ABI). diff --git a/tests/harness/__snapshots__/builtin-class-alias-escape.ts.txt b/tests/harness/__snapshots__/builtin-class-alias-escape.ts.txt new file mode 100644 index 000000000..9d1d981e2 --- /dev/null +++ b/tests/harness/__snapshots__/builtin-class-alias-escape.ts.txt @@ -0,0 +1,22 @@ +builtin-class-alias-escape.ts:7:16 - error SC1090: a builtin class alias as an escaping value (use 'Emitter' with new, instanceof, typeof or .name) is not supported yet + + 6 | const Emitter = EventEmitter; + 7 | const kinds = [Emitter]; + | ^~~~~~~ + 8 | console.log(kinds.length); + +builtin-class-alias-escape.ts:11:24 - error SC1090: a builtin class alias as an escaping value (use 'M' with new, instanceof, typeof or .name) is not supported yet + + 10 | const M = Map; + 11 | const boxed: unknown = M; + | ^ + 12 | console.log(boxed !== undefined); + +builtin-class-alias-escape.ts:14:15 - error SC2020: 'events.EventEmitter' is part of the standard library types but has no scriptc lowering yet + + 13 | + 14 | let Mutable = EventEmitter; + | ^~~~~~~~~~~~ + 15 | console.log(typeof Mutable); + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file diff --git a/tests/harness/__snapshots__/class-values.ts.txt b/tests/harness/__snapshots__/class-values.ts.txt index 9d5a43c1f..953d1c2d1 100644 --- a/tests/harness/__snapshots__/class-values.ts.txt +++ b/tests/harness/__snapshots__/class-values.ts.txt @@ -1,52 +1,52 @@ -class-values.ts:22:7 - error SC2020: 'ErrorConstructor' is part of the standard library types but has no scriptc lowering yet +class-values.ts:24:7 - error SC2009: values of type 'ErrorConstructor[]' cannot be compiled: the array shape is supported, but its element type 'ErrorConstructor' does not compile - 21 | // Builtin classes as values: their construction is libCall-shaped. - 22 | const E = Error; - | ^ - 23 | + 23 | const E = Error; + 24 | const errorClasses = [E]; + | ^~~~~~~~~~~~ + 25 | - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -class-values.ts:22:11 - error SC2020: 'Error' is part of the standard library types but has no scriptc lowering yet +class-values.ts:24:22 - error SC2009: values of type 'ErrorConstructor[]' cannot be compiled: the array shape is supported, but its element type 'ErrorConstructor' does not compile - 21 | // Builtin classes as values: their construction is libCall-shaped. - 22 | const E = Error; - | ^~~~~ - 23 | + 23 | const E = Error; + 24 | const errorClasses = [E]; + | ^~~ + 25 | - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -class-values.ts:44:1 - error SC1090: assigning the static 'tag' through a class value (JS creates an OWN property on the runtime class — assign through the declaring class's name, which subclasses here) is not supported yet +class-values.ts:46:1 - error SC1090: assigning the static 'tag' through a class value (JS creates an OWN property on the runtime class — assign through the declaring class's name, which subclasses here) is not supported yet - 43 | let someClass: typeof Base = Base; - 44 | someClass.tag = "write"; + 45 | let someClass: typeof Base = Base; + 46 | someClass.tag = "write"; | ^~~~~~~~~~~~~ - 45 | + 47 | -class-values.ts:48:13 - error SC1090: reading the static member 'tag' through a class value (a subclass of 'Base' redeclares it, so the runtime class decides which declaration answers) is not supported yet +class-values.ts:50:13 - error SC1090: reading the static member 'tag' through a class value (a subclass of 'Base' redeclares it, so the runtime class decides which declaration answers) is not supported yet - 47 | // runtime class decides which declaration answers. - 48 | console.log(someClass.tag); + 49 | // runtime class decides which declaration answers. + 50 | console.log(someClass.tag); | ^~~~~~~~~~~~~ - 49 | + 51 | -class-values.ts:52:10 - error SC1090: function-local classes with statics, decorators, or generic members are not supported yet +class-values.ts:54:10 - error SC1090: function-local classes with statics, decorators, or generic members are not supported yet - 51 | function make(): unknown { - 52 | return class { static value = 1; }; + 53 | function make(): unknown { + 54 | return class { static value = 1; }; | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ - 53 | } + 55 | } -class-values.ts:59:18 - error SC1090: class expressions with static initializers or static blocks outside a whole-initializer position (their declaration-time code must run exactly where the expression evaluates — bind the class in its own top-level `const C = class …` statement) is not supported yet +class-values.ts:61:18 - error SC1090: class expressions with static initializers or static blocks outside a whole-initializer position (their declaration-time code must run exactly where the expression evaluates — bind the class in its own top-level `const C = class …` statement) is not supported yet - 58 | // evaluates. - 59 | const pair = [1, class WithStatic { static x = 1; }] as const; + 60 | // evaluates. + 61 | const pair = [1, class WithStatic { static x = 1; }] as const; | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 60 | + 62 | -class-values.ts:79:56 - error SC1090: receiver-dependent static methods through a polymorphic class value is not supported yet +class-values.ts:81:56 - error SC1090: receiver-dependent static methods through a polymorphic class value is not supported yet - 78 | class OtherThis extends UsesThis {} - 79 | function self(value: typeof UsesThis): string { return value.self(); } + 80 | class OtherThis extends UsesThis {} + 81 | function self(value: typeof UsesThis): string { return value.self(); } | ^~~~~~~~~~ - 80 | console.log(self(OtherThis)); \ No newline at end of file + 82 | console.log(self(OtherThis)); \ No newline at end of file diff --git a/tests/harness/__snapshots__/zlib-class-alias-escape.ts.txt b/tests/harness/__snapshots__/zlib-class-alias-escape.ts.txt index ea9d76089..703038025 100644 --- a/tests/harness/__snapshots__/zlib-class-alias-escape.ts.txt +++ b/tests/harness/__snapshots__/zlib-class-alias-escape.ts.txt @@ -1,4 +1,4 @@ -zlib-class-alias-escape.ts:7:18 - error SC1090: a zlib class alias as an escaping value (use 'Gz' with new, instanceof, typeof or .name) is not supported yet +zlib-class-alias-escape.ts:7:18 - error SC1090: a builtin class alias as an escaping value (use 'Gz' with new, instanceof, typeof or .name) is not supported yet 6 | const Gz = zlib.Gzip; 7 | const classes = [Gz]; diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index b91073f87..5403a1468 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2190,6 +2190,9 @@ "5973-large-closure-environment.ts": 4499, "5974-typeof-module-exports.cjs": 2000, "5975-zlib-class-values.ts": 2800, + "5976-builtin-class-aliases.ts": 3100, + "5977-builtin-class-aliases.cjs": 2950, + "5978-builtin-class-alias-modules/main.ts": 2750, "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, @@ -5226,6 +5229,9 @@ "5973-large-closure-environment.ts": 5145, "5974-typeof-module-exports.cjs": 2500, "5975-zlib-class-values.ts": 4200, + "5976-builtin-class-aliases.ts": 5450, + "5977-builtin-class-aliases.cjs": 4300, + "5978-builtin-class-alias-modules/main.ts": 4450, "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, From fbcdb1d641bb2d54f34fb81db2dffa4b133acd32 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:29:28 -0700 Subject: [PATCH 25/34] Compile inline require(), computed and chained CommonJS exports, and a module's own exports object The npm compatibility probes ranked these CommonJS forms among the top blockers. They now compile with Node's semantics inside the static CommonJS model, where exports stay module globals or alias plumbing to declarations. Every remaining form keeps a refusal that names the construct. - Inline require() of program modules (cjs-inline-require.ts). `require("./x").f`, `.f(...)` and `new (require("./x").C)()` over a table module run the module's run-once %init at the call's position, then resolve the member like a binding does. `require("./x")` as a value over a single-value module is that value (`require("debug")("ns")`, table entries, construction). Inline requires are preflight module edges. An edge in a function body is lazy: it orders the dependency after the requiring module and does not close initialization cycles. Other require() calls name their construct instead of reporting an unlowerable 'Require' value. A package that nothing installed resolves throws MODULE_NOT_FOUND. - module.exports = require("./y") or a require binding (re-export), = TypeError (a builtin global), and = any computed value (`a || b`, ternary, `new X()`). Chained `exports.a = exports.b = v` evaluates v once. Export members assigned in value position (`const src = exports.src = []`) or inside functions write the export global. A fresh `exports = module.exports = {}` reset at the top of a module is a no-op. Any other rebinding of `exports` is refused by name. - A module's own exports object (cjs-exports-object.ts). Identity comparisons, typeof and truthiness of `exports` / `module.exports` use the object that top-level `this` already represents, while the module keeps its original exports. This covers the `root === module.exports` statement that used to vanish from `.call(this)` wrappers. `exports.f(x)` calls the member's export global when no assigned value can read `this`. Other value uses are refused by name, including in CommonJS files where the message used to say "ES module". - tsc interop (cjs-tsc-interop.ts). `const x_1 = __importDefault(require(...))` and `__importStar(...)` are require bindings, stamped and unstamped. The helper declarations lower to nothing once every call is such a binding. - Export tables: method entries compile, and `...require("./x")` spread entries now evaluate x at their place. Before, the module was never initialized. - TypeScript reads of members a JavaScript module attached to an exported function go through the function's own property table. - Nested require destructuring of program modules, `const { codes: { E } } = require("./errors")`, destructures the named export at the require (SC1012). `const { A } = ns` over a module binding snapshots the export. The capture-buffer fence tests merged from opt/cjs-exports-value used `root === module.exports`, which now compiles. They use eval() to keep testing the fence. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/cjs-exports-object.ts | 431 ++++++++++++++++++ .../frontend/lowering/cjs-inline-require.ts | 371 +++++++++++++++ .../src/frontend/lowering/cjs-tsc-interop.ts | 223 +++++++++ .../src/frontend/lowering/lower-calls.ts | 65 +++ .../src/frontend/lowering/lower-classes.ts | 9 + .../src/frontend/lowering/lower-exprs.ts | 140 +++++- .../src/frontend/lowering/lower-modules.ts | 85 +++- .../frontend/lowering/lower-node-module.ts | 18 + .../src/frontend/lowering/lower-stmts.ts | 195 +++++++- .../compiler/src/frontend/lowering/lowerer.ts | 47 +- packages/compiler/src/frontend/program.ts | 135 +++++- .../6150-cjs-inline-require/fallback.js | 6 + tests/corpus/6150-cjs-inline-require/inner.js | 4 + tests/corpus/6150-cjs-inline-require/late.js | 2 + tests/corpus/6150-cjs-inline-require/lib.js | 6 + tests/corpus/6150-cjs-inline-require/main.js | 55 +++ .../6150-cjs-inline-require/make-logger.js | 6 + .../6150-cjs-inline-require/package.json | 1 + tests/corpus/6150-cjs-inline-require/pick.js | 7 + tests/corpus/6150-cjs-inline-require/point.js | 6 + .../reexport-binding.js | 3 + .../reexport-inline.js | 2 + .../6150-cjs-inline-require/registry.js | 13 + .../corpus/6150-cjs-inline-require/shapes.js | 9 + tests/corpus/6150-cjs-inline-require/table.js | 4 + .../6150-cjs-inline-require/type-error.js | 1 + .../corpus/6150-cjs-inline-require/verify.js | 4 + tests/corpus/6151-cjs-export-chains/chain.js | 10 + .../6151-cjs-export-chains/constants.js | 1 + tests/corpus/6151-cjs-export-chains/main.js | 9 + .../6151-cjs-export-chains/package.json | 1 + tests/corpus/6151-cjs-export-chains/re.js | 15 + tests/corpus/6151-cjs-export-chains/sem.js | 16 + tests/corpus/6152-cjs-tsc-interop/fn.js | 3 + tests/corpus/6152-cjs-tsc-interop/main.js | 17 + .../corpus/6152-cjs-tsc-interop/package.json | 1 + tests/corpus/6152-cjs-tsc-interop/plain.js | 5 + tests/corpus/6152-cjs-tsc-interop/stamped.js | 12 + tests/corpus/6153-cjs-exports-object/lib.js | 13 + tests/corpus/6153-cjs-exports-object/main.js | 19 + .../6153-cjs-exports-object/package.json | 1 + .../constants.js | 8 + .../errors.js | 12 + .../main.js | 14 + .../package.json | 1 + .../6155-cjs-export-table-methods/lib.js | 20 + .../6155-cjs-export-table-methods/main.js | 5 + .../6155-cjs-export-table-methods/more.js | 4 + .../package.json | 1 + .../consumer.cjs | 4 + .../6156-cjs-function-members-from-ts/main.ts | 9 + .../package.json | 1 + .../status.cjs | 10 + tests/harness/corpus-costs.json | 14 + tests/harness/errors.test.ts | 88 +++- tests/harness/npm.test.ts | 8 +- 56 files changed, 2129 insertions(+), 41 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/cjs-exports-object.ts create mode 100644 packages/compiler/src/frontend/lowering/cjs-inline-require.ts create mode 100644 packages/compiler/src/frontend/lowering/cjs-tsc-interop.ts create mode 100644 tests/corpus/6150-cjs-inline-require/fallback.js create mode 100644 tests/corpus/6150-cjs-inline-require/inner.js create mode 100644 tests/corpus/6150-cjs-inline-require/late.js create mode 100644 tests/corpus/6150-cjs-inline-require/lib.js create mode 100644 tests/corpus/6150-cjs-inline-require/main.js create mode 100644 tests/corpus/6150-cjs-inline-require/make-logger.js create mode 100644 tests/corpus/6150-cjs-inline-require/package.json create mode 100644 tests/corpus/6150-cjs-inline-require/pick.js create mode 100644 tests/corpus/6150-cjs-inline-require/point.js create mode 100644 tests/corpus/6150-cjs-inline-require/reexport-binding.js create mode 100644 tests/corpus/6150-cjs-inline-require/reexport-inline.js create mode 100644 tests/corpus/6150-cjs-inline-require/registry.js create mode 100644 tests/corpus/6150-cjs-inline-require/shapes.js create mode 100644 tests/corpus/6150-cjs-inline-require/table.js create mode 100644 tests/corpus/6150-cjs-inline-require/type-error.js create mode 100644 tests/corpus/6150-cjs-inline-require/verify.js create mode 100644 tests/corpus/6151-cjs-export-chains/chain.js create mode 100644 tests/corpus/6151-cjs-export-chains/constants.js create mode 100644 tests/corpus/6151-cjs-export-chains/main.js create mode 100644 tests/corpus/6151-cjs-export-chains/package.json create mode 100644 tests/corpus/6151-cjs-export-chains/re.js create mode 100644 tests/corpus/6151-cjs-export-chains/sem.js create mode 100644 tests/corpus/6152-cjs-tsc-interop/fn.js create mode 100644 tests/corpus/6152-cjs-tsc-interop/main.js create mode 100644 tests/corpus/6152-cjs-tsc-interop/package.json create mode 100644 tests/corpus/6152-cjs-tsc-interop/plain.js create mode 100644 tests/corpus/6152-cjs-tsc-interop/stamped.js create mode 100644 tests/corpus/6153-cjs-exports-object/lib.js create mode 100644 tests/corpus/6153-cjs-exports-object/main.js create mode 100644 tests/corpus/6153-cjs-exports-object/package.json create mode 100644 tests/corpus/6154-cjs-require-nested-destructure/constants.js create mode 100644 tests/corpus/6154-cjs-require-nested-destructure/errors.js create mode 100644 tests/corpus/6154-cjs-require-nested-destructure/main.js create mode 100644 tests/corpus/6154-cjs-require-nested-destructure/package.json create mode 100644 tests/corpus/6155-cjs-export-table-methods/lib.js create mode 100644 tests/corpus/6155-cjs-export-table-methods/main.js create mode 100644 tests/corpus/6155-cjs-export-table-methods/more.js create mode 100644 tests/corpus/6155-cjs-export-table-methods/package.json create mode 100644 tests/corpus/6156-cjs-function-members-from-ts/consumer.cjs create mode 100644 tests/corpus/6156-cjs-function-members-from-ts/main.ts create mode 100644 tests/corpus/6156-cjs-function-members-from-ts/package.json create mode 100644 tests/corpus/6156-cjs-function-members-from-ts/status.cjs diff --git a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts new file mode 100644 index 000000000..c3edafddc --- /dev/null +++ b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts @@ -0,0 +1,431 @@ +/* A CommonJS module's own exports object, seen from inside the module. + * + * The compiled module system has no reflective exports object: each export + * is a module global (collectGlobals) or alias plumbing to a declaration. + * Inside the module that is enough for + * + * - member reads and member calls (`exports.f`, `exports.f(x)`, + * `module.exports.f(x)`): they are the member's export global, the + * storage every `exports.f =` statement assigns; + * - identity comparisons of the object itself (`root === module.exports`, + * `exports === module.exports`): while the module never replaces + * `module.exports` and never rebinds `exports`, both name the module's + * original exports object, which top-level `this` already represents + * (lowerCjsTopLevelThis). `typeof` and truthiness keep their existing + * lowerings. + * + * Every other use of the object as a value (passing it to a function, + * storing it, reading a member through an alias) would need a real object + * whose properties are the exports; those uses keep a named refusal. The + * dynamic CommonJS module object is the two-tier design's S9 stage. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { + cjsExportAssignmentOf, + cjsExportTargetLiteral, + isCjsJsFile, + isModuleExportsAccess, + isRequireStatement, + locOf, +} from "../program.js"; +import { isCjsModuleGlobal } from "./lower-node-module.js"; +import { DYN, type IrExpr, type IrGlobal } from "../../ir/ir.js"; + +/** True for the CommonJS wrapper's `exports` binding (not shadowed by any + * source declaration) in a CommonJS JS file. */ +export function isCjsExportsIdentifier(lowerer: Lowerer, node: ts.Expression): boolean { + if (!ts.isIdentifier(node) || node.text !== "exports") return false; + if (!isCjsJsFile(node.getSourceFile(), lowerer.program)) return false; + const symbol = lowerer.checker.getSymbolAtLocation(node); + const decls = symbol ? lowerer.checker.declarationsOf(symbol) : []; + return !decls.some( + (d) => + !d.getSourceFile().isDeclarationFile && + (ts.isVariableDeclaration(d) || + ts.isParameter(d) || + ts.isBindingElement(d) || + ts.isFunctionDeclaration(d) || + ts.isClassDeclaration(d) || + ts.isImportSpecifier(d) || + ts.isNamespaceImport(d) || + ts.isImportEqualsDeclaration(d)), + ); +} + +/** `module.exports` over the CommonJS `module` global in a CommonJS file. */ +export function isCjsModuleExports(lowerer: Lowerer, node: ts.Expression): boolean { + return ( + isModuleExportsAccess(node) && + isCjsModuleGlobal(lowerer, node.expression) && + isCjsJsFile(node.getSourceFile(), lowerer.program) + ); +} + +/** The export global behind a same-module member access `exports.` / + * `module.exports.`, or null (identifier-valued members are alias + * plumbing with no storage, and other receivers are not the exports + * object). */ +export function cjsInFileExportGlobalOf( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): IrGlobal | null { + if (access.questionDotToken || !ts.isIdentifier(access.name)) return null; + const sf = access.getSourceFile(); + const recv = + isModuleExportsAccess(access.expression) || + (ts.isIdentifier(access.expression) && + access.expression.text === "exports" && + !lowerer.resolveLocal(access.expression) && + !lowerer.globalOf(access.expression)); + if (!recv || !isCjsJsFile(sf, lowerer.program)) return null; + const sym = + lowerer.checker.getSymbolAtLocation(access.name) ?? + lowerer.cjsModuleExportSymbol(sf, access.name.text); + const resolved = + sym !== undefined && sym.flags & ts.SymbolFlags.Alias + ? lowerer.checker.getAliasedSymbol(sym) + : sym; + return ( + (sym ? lowerer.globalsBySymbol.get(sym) : undefined) ?? + (resolved ? lowerer.globalsBySymbol.get(resolved) : undefined) ?? + null + ); +} + +/** Whether `node` is an operand of an equality operator: only its identity + * is observed. */ +function identityUse(node: ts.Expression): boolean { + let child: ts.Node = node; + let parent = node.parent; + while (parent !== undefined && ts.isParenthesizedExpression(parent)) { + child = parent; + parent = parent.parent; + } + if (parent === undefined || !ts.isBinaryExpression(parent)) return false; + const op = parent.operatorToken.kind; + return ( + (op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.EqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken) && + (parent.left === child || parent.right === child) + ); +} + +/** Uses whose answer does not need the object: `typeof`, `void`, and + * truthiness tests (their existing lowerings answer them). */ +function answeredWithoutObject(node: ts.Expression): boolean { + let child: ts.Node = node; + let parent = node.parent; + while (parent !== undefined && ts.isParenthesizedExpression(parent)) { + child = parent; + parent = parent.parent; + } + if (parent === undefined) return false; + if (ts.isTypeOfExpression(parent) || ts.isVoidExpression(parent)) return true; + return truthinessOnly(child as ts.Expression); +} + +function truthinessOnly(node: ts.Expression): boolean { + let child: ts.Node = node; + for (let parent = node.parent; parent !== undefined; child = parent, parent = parent.parent) { + if (ts.isParenthesizedExpression(parent)) continue; + if (ts.isPrefixUnaryExpression(parent)) + return parent.operator === ts.SyntaxKind.ExclamationToken; + if ( + ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + parent.operatorToken.kind === ts.SyntaxKind.BarBarToken) + ) + continue; + if (ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) + return parent.expression === child; + if (ts.isForStatement(parent)) return parent.condition === child; + if (ts.isConditionalExpression(parent)) return parent.condition === child; + return false; + } + return false; +} + +/** Which of the module's exports bindings the file ever assigns (any + * nesting, compound operators included). */ +const assignmentsCache = new WeakMap(); + +function exportsAssignments( + lowerer: Lowerer, + sf: ts.SourceFile, +): { exports: boolean; moduleExports: boolean } { + const cached = assignmentsCache.get(sf); + if (cached !== undefined) return cached; + const out = { exports: false, moduleExports: false }; + assignmentsCache.set(sf, out); + ts.walkPreorder(sf, (node) => { + // A fresh `exports = module.exports = {}` reset leaves both bindings + // naming one (new, empty) exports object for the rest of the module. + if (ts.isExpressionStatement(node) && isFreshExportsReset(lowerer, node)) return "skip"; + if ( + !ts.isBinaryExpression(node) || + node.operatorToken.kind < ts.SyntaxKind.FirstAssignment || + node.operatorToken.kind > ts.SyntaxKind.LastAssignment + ) + return undefined; + let target = node.left; + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (isCjsModuleExports(lowerer, target)) out.moduleExports = true; + if (isCjsExportsIdentifier(lowerer, target)) out.exports = true; + return undefined; + }); + return out; +} + +/** Whether `node` still names the module's ORIGINAL exports object wherever + * it runs: `exports` unless the file rebinds it; `module.exports` unless the + * file rebinds `exports` or replaces `module.exports`. */ +function namesOriginalExports(lowerer: Lowerer, node: ts.Expression): boolean { + const assigned = exportsAssignments(lowerer, node.getSourceFile()); + if (assigned.exports) return false; + return ts.isIdentifier(node) || !assigned.moduleExports; +} + +/** The uses this module answers with the original exports object: identity + * comparisons and `typeof` of either spelling, and truthiness or `void` of + * `exports` (`module.exports` truthiness keeps its existing fold). */ +function objectAnsweredUse(node: ts.Expression): boolean { + if (identityUse(node)) return true; + let parent = node.parent; + while (parent !== undefined && ts.isParenthesizedExpression(parent)) parent = parent.parent; + if (parent !== undefined && ts.isTypeOfExpression(parent)) return true; + return ts.isIdentifier(node) && answeredWithoutObject(node); +} + +/** Whether `node` (`exports` or `module.exports`) is a use of the module's + * exports object as a VALUE (not a member access receiver, not an + * assignment target). */ +export function isCjsExportsObjectValue(lowerer: Lowerer, node: ts.Expression): boolean { + if (!isCjsExportsIdentifier(lowerer, node) && !isCjsModuleExports(lowerer, node)) return false; + let outer: ts.Node = node; + while (outer.parent !== undefined && ts.isParenthesizedExpression(outer.parent)) + outer = outer.parent; + const parent = outer.parent; + if (parent === undefined) return false; + if ( + (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && + parent.expression === outer + ) + return false; + if ( + ts.isBinaryExpression(parent) && + parent.left === outer && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + return false; + return true; +} + +/** Files whose exports object is observed by identity somewhere: their top- + * level `this` object must exist (lowerFileInit creates it). */ +export function usesCjsExportsIdentity(lowerer: Lowerer, sf: ts.SourceFile): boolean { + if (!isCjsJsFile(sf, lowerer.program)) return false; + let found = false; + ts.walkPreorder(sf, (node) => { + if ( + (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) && + isCjsExportsObjectValue(lowerer, node) && + objectAnsweredUse(node) && + namesOriginalExports(lowerer, node) + ) { + found = true; + return "stop"; + } + return undefined; + }); + return found; +} + +/** `exports` / `module.exports` used as a value inside its own module: the + * original exports object for identity, typeof and truthiness, or a named + * refusal. Null when `node` is not such a use. */ +export function lowerCjsExportsObjectValue(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + if (!isCjsExportsObjectValue(lowerer, node)) return null; + const sf = node.getSourceFile(); + // A module that replaces module.exports answers `module.exports` reads + // through the replacement's own machinery (the class-expression whole + // export, the replaced-exports reads). + if (!ts.isIdentifier(node) && exportsAssignments(lowerer, sf).moduleExports) return null; + const spelled = ts.isIdentifier(node) ? "exports" : "module.exports"; + const original = lowerer.cjsExportsThis.get(sf); + if (objectAnsweredUse(node) && original !== undefined && namesOriginalExports(lowerer, node)) { + return { kind: "varRef", localId: original.id, type: DYN, loc: locOf(node) }; + } + // `module.exports` truthiness keeps its existing fold (and anything the + // object is not needed for keeps whatever answered it before). + if (answeredWithoutObject(node)) return null; + lowerer.unsupported( + "SC1090", + node, + identityUse(node) + ? `comparing '${spelled}' in a module that replaces module.exports or rebinds exports (the compiled exports are a static table, not an object with an identity)` + : `'${spelled}' as a value other than a member access, comparison, typeof or truthiness test (the compiled exports are a static table, not an object: read ${spelled}.)`, + ); +} + +/** `exports = module.exports = {}` (either order) as a top-level statement + * that runs before anything could observe or extend the original exports + * object: only directives, require statements and function declarations + * precede it, and the module assigns neither binding anywhere else. Node + * swaps the original empty exports object for another empty one that both + * bindings name; the compiled module's static exports start out exactly + * the same, so the statement is a no-op (semver's internal/re.js). */ +export function isFreshExportsReset(lowerer: Lowerer, stmt: ts.ExpressionStatement): boolean { + const sf = stmt.parent; + if (!ts.isSourceFile(sf) || !isCjsJsFile(sf, lowerer.program)) return false; + let e: ts.Expression = stmt.expression; + const targets: ts.Expression[] = []; + for (;;) { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) break; + targets.push(e.left); + e = e.right; + } + if (!ts.isObjectLiteralExpression(e) || e.properties.length !== 0) return false; + if (targets.length !== 2) return false; + const hasExports = targets.some((t) => isCjsExportsIdentifier(lowerer, t)); + const hasModuleExports = targets.some((t) => isCjsModuleExports(lowerer, t)); + if (!hasExports || !hasModuleExports) return false; + for (const prior of sf.statements) { + if (prior === stmt) break; + if (ts.isFunctionDeclaration(prior) || isRequireStatement(prior)) continue; + if (ts.isExpressionStatement(prior) && ts.isStringLiteral(prior.expression)) continue; + return false; + } + let others = 0; + ts.walkPreorder(sf, (node) => { + if ( + !ts.isBinaryExpression(node) || + node.operatorToken.kind < ts.SyntaxKind.FirstAssignment || + node.operatorToken.kind > ts.SyntaxKind.LastAssignment + ) + return undefined; + let target = node.left; + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (isCjsModuleExports(lowerer, target) || isCjsExportsIdentifier(lowerer, target)) others++; + return undefined; + }); + return others === 2; +} + +/** The export global a WRITE `exports. = v` / `module.exports. = + * v` updates when it is not a top-level export statement (inside a + * function, or in value position): only while the module keeps its + * original exports object, which is then the object every requirer reads + * the member from. */ +export function cjsExportMemberWriteGlobalOf( + lowerer: Lowerer, + target: ts.PropertyAccessExpression, +): IrGlobal | null { + const sf = target.getSourceFile(); + if (!isCjsJsFile(sf, lowerer.program)) return null; + const assigned = exportsAssignments(lowerer, sf); + if (assigned.exports || assigned.moduleExports) return null; + return cjsInFileExportGlobalOf(lowerer, target); +} + +/** Whether a function body reads `this` (arrow functions inherit it; other + * nested functions bind their own). */ +function functionUsesThis(fn: ts.FunctionLikeDeclaration): boolean { + if (fn.body === undefined) return false; + let found = false; + ts.walkPreorder(fn.body, (node) => { + if (node !== fn.body && ts.isFunctionLike(node) && !ts.isArrowFunction(node)) return "skip"; + if (node.kind === ts.SyntaxKind.ThisKeyword) { + found = true; + return "stop"; + } + return undefined; + }); + return found; +} + +/** A function declaration, or a variable initialized with a function or + * arrow expression, that never reads `this`. */ +function symbolIsThisFreeFunction(lowerer: Lowerer, sym: ts.Symbol | undefined): boolean { + const decls = sym ? lowerer.checker.declarationsOf(sym) : []; + const fn = decls.find(ts.isFunctionDeclaration); + if (fn !== undefined) return !functionUsesThis(fn); + const v = decls.find(ts.isVariableDeclaration); + let init = v?.initializer; + while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; + return ( + init !== undefined && + (ts.isFunctionExpression(init) || ts.isArrowFunction(init)) && + !functionUsesThis(init) + ); +} + +/** True when every value the module assigns to its export `name` is a + * function that never reads `this`. A call through the exports object + * (`exports.f(x)`) binds `this` to that object, which the static export + * table cannot supply, so only these calls lower; the others keep a fence. */ +export function cjsExportCallIgnoresThis( + lowerer: Lowerer, + sf: ts.SourceFile, + name: string, + depth = 0, +): boolean { + if (depth > 16) return false; + const values: ts.Expression[] = []; + ts.walkPreorder(sf, (node) => { + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.name.text === name && + (isCjsExportsIdentifier(lowerer, node.left.expression) || + isCjsModuleExports(lowerer, node.left.expression)) + ) { + values.push(node.right); + } + return undefined; + }); + // Entries of a `module.exports = { ... }` table (direct, or a const + // literal the statement names). + let methodEntry: ts.MethodDeclaration | null = null; + let shorthandOnly = false; + for (const stmt of sf.statements) { + const cjs = cjsExportAssignmentOf(stmt); + if (cjs?.kind !== "table") continue; + const table = cjs.obj ?? cjsExportTargetLiteral(cjs.expr.right, sf); + for (const prop of table?.properties ?? []) { + if (prop.name === undefined || !ts.isIdentifier(prop.name) || prop.name.text !== name) + continue; + if (ts.isPropertyAssignment(prop)) values.push(prop.initializer); + else if (ts.isShorthandPropertyAssignment(prop)) { + const value = lowerer.checker.getShorthandAssignmentValueSymbol(prop); + if (!symbolIsThisFreeFunction(lowerer, value)) return false; + shorthandOnly = true; + } else if (ts.isMethodDeclaration(prop)) methodEntry = prop; + } + } + if (methodEntry !== null) return values.length === 0 && !functionUsesThis(methodEntry); + if (values.length === 0) return shorthandOnly; + return values.every((v) => { + let e = v; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isFunctionExpression(e) || ts.isArrowFunction(e)) return !functionUsesThis(e); + if (ts.isIdentifier(e)) { + return symbolIsThisFreeFunction(lowerer, lowerer.checker.getSymbolAtLocation(e)); + } + // Another module's member (`exports.f = require("./lib").f`, or through + // a module binding): that module's own export values decide. + if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.name)) { + const dep = ts.isIdentifier(e.expression) + ? lowerer.cjsLocalModuleBindingOf(e.expression) + ? lowerer.cjsModuleBindingDepOf(e.expression) + : null + : lowerer.cjsModuleRefDepOf(e.expression); + return dep !== null && cjsExportCallIgnoresThis(lowerer, dep, e.name.text, depth + 1); + } + return false; + }); +} diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts new file mode 100644 index 000000000..75474565b --- /dev/null +++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts @@ -0,0 +1,371 @@ +/* Inline CommonJS `require("./x")` of a program module in expression + * position. A top-level `const m = require("./x")` binding is alias + * plumbing: member reads resolve to the exporter's declarations, and the + * statement lowers to the dependency's run-once %init call. An inline + * require is the same thing without the binding: the %init call runs at + * exactly the call's position (Node evaluates the module there, or + * answers from its cache), and the value is then read statically: + * + * - `require("./x").f` and `require("./x").f(...)` over a module whose + * exports are a table (member exports or `module.exports = {...}`) + * read the member like a binding would; + * - `require("./x")` as a value over a module whose whole export is one + * value (`module.exports = fn` / `= class {}` / `= make()` / `= 42` / + * `= require("./y")`) answers that value. + * + * A table module's exports object as a value has no static + * representation; it keeps a named refusal (a first-class CommonJS module + * object is the two-tier design's S9 stage). */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { + canonicalBuiltinModule, + cjsExportAssignmentOf, + cjsExportDiscardReason, + cjsExportTargetLiteral, + isCjsJsFile, + isJsSourceFile, + isInlineRequireCycleCall, + isModuleExportsAccess, + locOf, + npmStaticDepSf7, + requireSpecOf, + resolveImport, +} from "../program.js"; +import { createRequireCalleeFileOf } from "./builtins/module-bindings.js"; +import { isCjsRequireGlobal } from "./lower-node-module.js"; +import { DYN, type IrExpr } from "../../ir/ir.js"; +import { nodeThrowExpr } from "../../ir/build.js"; +import { probeNodeRequireRefusal } from "../npm.js"; + +export interface InlineRequire { + call: ts.CallExpression; + spec: string; + dep: ts.SourceFile; +} + +/** A `require("")` call of the CommonJS global in a CommonJS JS + * file whose target is a compiled program module (relative, aliased, or an + * opted-in --npm-static package). Builtins, JSON documents and + * createRequire bindings answer null: their own lowerings own them. */ +export function inlineProgramRequireOf( + lowerer: Lowerer, + expr: ts.Expression, +): InlineRequire | null { + let e = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isCallExpression(e) || !ts.isIdentifier(e.expression)) return null; + const spec = requireSpecOf(e); + if (spec === null || canonicalBuiltinModule(spec) !== null) return null; + const sf = e.getSourceFile(); + if (!isCjsJsFile(sf, lowerer.program)) return null; + if (!isCjsRequireGlobal(lowerer, e.expression)) return null; + if (createRequireCalleeFileOf(lowerer, e.expression) !== null) return null; + const dep = + resolveImport(lowerer.program, sf, spec, "require") ?? + npmStaticDepSf7(lowerer.program, sf, spec, "require"); + if (!dep || dep.isDeclarationFile || dep.fileName.endsWith(".json")) return null; + return { call: e, spec, dep }; +} + +/** The kept (last, not discarded) top-level `module.exports = ` of a + * CommonJS module whose whole export is a single value, or null when its + * exports are a table: member exports only, or a literal / const-literal / + * Proxy-over-literal table. */ +export function cjsWholeExportValueOf( + sf: ts.SourceFile, +): { assign: ts.BinaryExpression; rhs: ts.Expression } | null { + let kept: ts.BinaryExpression | null = null; + for (const stmt of sf.statements) { + const cjs = cjsExportAssignmentOf(stmt); + if (cjs?.kind === "table" && cjsExportDiscardReason(stmt) === null) kept = cjs.expr; + } + if (kept === null) return null; + let rhs: ts.Expression = kept.right; + while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + if (ts.isObjectLiteralExpression(rhs) || cjsExportTargetLiteral(rhs, sf) !== null) return null; + return { assign: kept, rhs }; +} + +/** True when `dep`'s exports, through `module.exports = require("./y")` + * re-export chains, are a table that member reads resolve against. */ +export function cjsNamespaceModule(lowerer: Lowerer, dep: ts.SourceFile, depth = 0): boolean { + const whole = cjsWholeExportValueOf(dep); + if (whole === null) return true; + const inner = inlineProgramRequireOf(lowerer, whole.rhs); + return inner !== null && depth < 32 && cjsNamespaceModule(lowerer, inner.dep, depth + 1); +} + +/** An inline program-module require over a table module (member reads and + * member calls resolve through it), or null. */ +export function inlineNamespaceRequireOf( + lowerer: Lowerer, + expr: ts.Expression, +): InlineRequire | null { + const req = inlineProgramRequireOf(lowerer, expr); + return req !== null && cjsNamespaceModule(lowerer, req.dep) ? req : null; +} + +/** `value` evaluated after the required module's run-once %init (and its + * module-registry link) at the require's own position. */ +export function withRequireInit(lowerer: Lowerer, req: InlineRequire, value: IrExpr): IrExpr { + if (isInlineRequireCycleCall(req.call)) { + lowerer.unsupported( + "SC1090", + req.call, + `require() of '${req.spec}' while it is still evaluating (a require cycle: Node answers its partial exports, which the static export table does not model)`, + ); + } + // Every inline program require is a preflight module edge, so its module + // has an initializer; never read its exports without evaluating it. + if (!lowerer.initNameOf.has(req.dep)) { + lowerer.unsupported( + "SC1090", + req.call, + `require() of '${req.spec}' here (the module is not part of the compiled module order)`, + ); + } + const init = lowerer.requireInitStmt(req.spec, req.call); + if (init === null) return value; + return { kind: "seqExpr", stmts: [init], result: value, type: value.type, loc: value.loc }; +} + +/** The whole-export value of `dep` read from the requirer, following + * `module.exports = require("./y")` re-exports (the inner module already + * evaluated inside dep's own %init). Null for table modules and for + * whole-export shapes with no represented value. */ +export function wholeExportValue( + lowerer: Lowerer, + dep: ts.SourceFile, + blame: ts.Node, + depth = 0, +): IrExpr | null { + const whole = cjsWholeExportValueOf(dep); + if (whole === null || depth > 32) return null; + const inner = inlineProgramRequireOf(lowerer, whole.rhs); + if (inner !== null) return wholeExportValue(lowerer, inner.dep, blame, depth + 1); + // An identifier names a declaration of the exporting module (or a + // builtin global): reading it IS the export, exactly what requirer + // bindings alias to. + if (ts.isIdentifier(whole.rhs)) return lowerer.lowerExpr(whole.rhs); + if (ts.isClassExpression(whole.rhs)) { + return lowerer.classValueRef(lowerer.lowerClassExpressionInfo(whole.rhs), blame); + } + const g = lowerer.globalsByDeclNode.get(whole.assign); + if (g === undefined) return null; + return { kind: "varRef", localId: g.id, type: g.type, loc: locOf(blame) }; +} + +/** `require("./x")` as a value (`require("debug")("ns")`, `new + * (require("./C"))()`, `{ verify: require("./verify") }`): the module's + * %init at this position, then its whole-export value. Null when `call` + * is not an inline program-module require. */ +export function lowerInlineRequireValue(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + const req = inlineProgramRequireOf(lowerer, call); + if (req === null) return null; + const value = wholeExportValue(lowerer, req.dep, call, 0); + if (value === null) { + lowerer.unsupported( + "SC1090", + call, + cjsNamespaceModule(lowerer, req.dep) + ? `the exports object of '${req.spec}' as a value (its exports compile to a static table: read require(${JSON.stringify(req.spec)}).member directly, or bind const m = require(${JSON.stringify(req.spec)}) at the module's top level)` + : `the whole export of '${req.spec}' as a value (its 'module.exports =' value has no static representation)`, + ); + } + return withRequireInit(lowerer, req, value); +} + +/** Lowers `inner` (a member read or member call whose receiver is the + * inline require) with that require marked as handled, so the receiver + * resolves as a module binding, then sequences the module's %init first. */ +export function lowerWithInlineRequireInit( + lowerer: Lowerer, + req: InlineRequire, + inner: () => IrExpr, +): IrExpr { + lowerer.inlineRequireInitDone.add(req.call); + try { + return withRequireInit(lowerer, req, inner()); + } finally { + lowerer.inlineRequireInitDone.delete(req.call); + } +} + +/** A `require(...)` call of the CommonJS global in a CommonJS file that no + * other lowering claimed (not a builtin, JSON document, program module or + * createRequire binding): a package nothing installed resolves throws + * Node's catchable MODULE_NOT_FOUND at the call, and every other form is + * refused by name instead of as an unlowerable `require` value. */ +export function lowerUncompiledRequireCall( + lowerer: Lowerer, + call: ts.CallExpression, +): IrExpr | null { + if (!ts.isIdentifier(call.expression) || call.expression.text !== "require") return null; + const sf = call.getSourceFile(); + if (!isCjsJsFile(sf, lowerer.program) || !isCjsRequireGlobal(lowerer, call.expression)) + return null; + if (createRequireCalleeFileOf(lowerer, call.expression) !== null) return null; + const spec = requireSpecOf(call); + if (spec === null) { + lowerer.unsupported( + "SC1090", + call, + 'require() with a computed specifier (the compiled module graph is fixed at build time: require("") lowers)', + ); + } + if (canonicalBuiltinModule(spec) !== null) return null; + if (!spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#")) { + const refusal = probeNodeRequireRefusal(sf.fileName, spec); + if (refusal !== null) { + return nodeThrowExpr(0, "MODULE_NOT_FOUND", refusal.message, DYN, locOf(call)); + } + lowerer.unsupported( + "SC1090", + call, + `require() of the package '${spec}' in an expression (the package is not compiled into this program)`, + ); + } + lowerer.unsupported( + "SC1090", + call, + `require() of '${spec}' in an expression (it does not resolve to a compiled program module)`, + ); +} + +/** The elements of a top-level `const { key: { ... } } = require("./x")` + * declaration over a program module whose nested pattern destructures the + * named export's value (preflight admits exactly these). Plain elements + * stay alias plumbing. */ +export function requireNestedPatternsOf(decl: ts.VariableDeclaration): ts.BindingElement[] { + if (!ts.isObjectBindingPattern(decl.name) || decl.initializer === undefined) return []; + const spec = requireSpecOf(decl.initializer); + if (spec === null || canonicalBuiltinModule(spec) !== null) return []; + if (!isJsSourceFile(decl.getSourceFile())) return []; + return decl.name.elements.filter( + (e) => + e.name !== undefined && + ts.isObjectBindingPattern(e.name) && + e.propertyName !== undefined && + ts.isIdentifier(e.propertyName) && + e.initializer === undefined && + e.dotDotDotToken === undefined, + ); +} + +/** A plain method entry of a `module.exports = { ... }` table: identifier + * name, a body, not a generator. Its value is the method's function. */ +export function cjsTableMethodEntry( + prop: ts.ObjectLiteralElementLike, +): (ts.MethodDeclaration & { name: ts.Identifier; body: ts.Block }) | null { + if (!ts.isMethodDeclaration(prop) || !ts.isIdentifier(prop.name) || prop.body === undefined) + return null; + if (prop.asteriskToken !== undefined) return null; + return prop as ts.MethodDeclaration & { name: ts.Identifier; body: ts.Block }; +} + +/** `exports. = value` / `module.exports. = value` assignments in + * VALUE position inside a top-level statement (`const src = exports.src = + * []`), outside function bodies and outside the statement's own export + * chain (`statementExport`, which the statement-level machinery owns). */ +export function nestedCjsExportAssignmentsOf( + stmt: ts.Statement, + statementExport: ts.BinaryExpression | undefined, +): { name: ts.Identifier; value: ts.Expression }[] { + if (!ts.isVariableStatement(stmt) && !ts.isExpressionStatement(stmt)) return []; + const owned = new Set(); + for (let e: ts.Expression | undefined = statementExport; e !== undefined;) { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) break; + owned.add(e); + e = e.right; + } + const out: { name: ts.Identifier; value: ts.Expression }[] = []; + ts.walkPreorder(stmt, (node) => { + if ( + node !== stmt && + (ts.isFunctionLike(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node)) + ) + return "skip"; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + !owned.has(node) && + ts.isPropertyAccessExpression(node.left) && + node.left.questionDotToken === undefined && + ts.isIdentifier(node.left.name) && + ((ts.isIdentifier(node.left.expression) && node.left.expression.text === "exports") || + isModuleExportsAccess(node.left.expression)) + ) { + out.push({ name: node.left.name, value: node.right }); + } + return undefined; + }); + return out; +} + +/** `const { A, B: b } = ns` at a module's top level, where `ns` is a + * CommonJS module binding over a table module (picomatch's `const { + * MAX_LENGTH, ... } = constants`): each name takes a snapshot of the named + * export's value at this statement, exactly like destructuring the exports + * object would. Null for every other declaration; the plain elements and + * their key names otherwise. */ +export function cjsNamespaceDestructureOf( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): { dep: ts.SourceFile; elements: { name: ts.Identifier; key: string }[] } | null { + if (!ts.isObjectBindingPattern(decl.name) || decl.initializer === undefined) return null; + const stmt = decl.parent?.parent; + if (stmt === undefined || !ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) + return null; + if (!isJsSourceFile(stmt.parent)) return null; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (!ts.isIdentifier(init) || !lowerer.cjsLocalModuleBindingOf(init)) return null; + const dep = lowerer.cjsModuleBindingDepOf(init); + if (dep === null) return null; + const elements: { name: ts.Identifier; key: string }[] = []; + for (const e of decl.name.elements) { + if (e.initializer !== undefined || e.dotDotDotToken !== undefined) return null; + if (e.name === undefined || !ts.isIdentifier(e.name)) return null; + const key = + e.propertyName === undefined + ? e.name.text + : ts.isIdentifier(e.propertyName) || ts.isStringLiteral(e.propertyName) + ? e.propertyName.text + : null; + if (key === null) return null; + elements.push({ name: e.name, key }); + } + return { dep, elements }; +} + +/** The value of `dep`'s export `key` read from another module: its export + * global, or the function/class declaration it aliases. Null when it has no + * static value. */ +export function cjsExportValueByName( + lowerer: Lowerer, + dep: ts.SourceFile, + key: string, + blame: ts.Node, +): IrExpr | null { + let sym = lowerer.cjsModuleExportSymbol(dep, key); + if (sym === undefined) return null; + if (sym.flags & ts.SymbolFlags.Alias) sym = lowerer.checker.getAliasedSymbol(sym); + const direct = lowerer.globalsBySymbol.get(sym); + if (direct) return { kind: "varRef", localId: direct.id, type: direct.type, loc: locOf(blame) }; + for (const d of lowerer.checker.declarationsOf(sym)) { + const byDecl = lowerer.globalsByDeclNode.get(d); + if (byDecl) return { kind: "varRef", localId: byDecl.id, type: byDecl.type, loc: locOf(blame) }; + // Alias plumbing: a shorthand/identifier table entry or a member + // export naming a declaration. Reading that identifier IS the value. + if (ts.isShorthandPropertyAssignment(d)) return lowerer.lowerShorthandValue(d); + if (ts.isPropertyAssignment(d) && ts.isIdentifier(d.initializer)) { + return lowerer.lowerExpr(d.initializer); + } + if ((ts.isFunctionDeclaration(d) || ts.isClassDeclaration(d)) && d.name !== undefined) { + return lowerer.lowerExpr(d.name); + } + } + return null; +} diff --git a/packages/compiler/src/frontend/lowering/cjs-tsc-interop.ts b/packages/compiler/src/frontend/lowering/cjs-tsc-interop.ts new file mode 100644 index 000000000..f7955fc23 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/cjs-tsc-interop.ts @@ -0,0 +1,223 @@ +/* tsc's CommonJS import interop: `const x_1 = __importDefault(require("./m"))` + * and `const x_1 = __importStar(require("./m"))` at a module's top level, + * with the helpers tsc emits at the top of the file: + * + * var __importDefault = (this && this.__importDefault) || function (mod) { + * return (mod && mod.__esModule) ? mod : { "default": mod }; + * }; + * + * For a CommonJS dependency the helper's answer is fixed by the + * dependency's source: stamped with `__esModule` (tsc output), the binding + * IS the module's exports; otherwise `x_1.default` is the module's exports + * (and __importStar also copies every export onto x_1). Both are the + * static alias model a plain `const x_1 = require("./m")` already has, so + * the binding lowers as a require binding: the statement is the module's + * %init, `x_1.f` / `x_1.default.f` read the exports statically, and + * `x_1.default` over a single-value module is that value. The helper + * declarations themselves are dead once every call is such a binding (the + * self-probe `this.__importDefault` reads the pristine exports object, + * which never has the name), so they lower to nothing. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { + isCjsJsFile, + isEsModuleStamp, + npmStaticDepSf7, + requireSpecOf, + resolveImport, +} from "../program.js"; +import { cjsNamespaceModule } from "./cjs-inline-require.js"; + +const HELPERS = new Set([ + "__importDefault", + "__importStar", + "__createBinding", + "__setModuleDefault", +]); + +export interface TscInteropBinding { + helper: "__importDefault" | "__importStar"; + spec: string; + dep: ts.SourceFile; + /** The dependency stamps `__esModule`: the binding IS its exports. */ + stamped: boolean; +} + +/** `var = (this && this.) || ` at a module's top + * level: the tsc helper declaration shape. */ +function tscHelperDeclName(stmt: ts.Statement): string | null { + if (!ts.isVariableStatement(stmt)) return null; + const decls = stmt.declarationList.declarations; + if (decls.length !== 1) return null; + const d = decls[0]!; + if (!ts.isIdentifier(d.name) || !HELPERS.has(d.name.text) || d.initializer === undefined) + return null; + let init: ts.Expression = d.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (!ts.isBinaryExpression(init) || init.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + return null; + let probe: ts.Expression = init.left; + while (ts.isParenthesizedExpression(probe)) probe = probe.expression; + if ( + !ts.isBinaryExpression(probe) || + probe.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken || + probe.left.kind !== ts.SyntaxKind.ThisKeyword || + !ts.isPropertyAccessExpression(probe.right) || + probe.right.expression.kind !== ts.SyntaxKind.ThisKeyword || + probe.right.name.text !== d.name.text + ) + return null; + // The fallback is the helper body: a function, or (newer tsc output) a + // conditional or IIFE producing one. It never runs when the helper is dead. + return d.name.text; +} + +/** True when the dependency stamps itself as an ES module translation. */ +function stampsEsModule(dep: ts.SourceFile): boolean { + return dep.statements.some((s) => { + if (!ts.isExpressionStatement(s)) return false; + if (isEsModuleStamp(s.expression)) return true; + const e = s.expression; + return ( + ts.isBinaryExpression(e) && + e.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(e.left) && + ts.isIdentifier(e.left.expression) && + e.left.expression.text === "exports" && + e.left.name.text === "__esModule" && + e.right.kind === ts.SyntaxKind.TrueKeyword + ); + }); +} + +/** The interop binding a top-level `const x = __importDefault(require(s))` / + * `__importStar(require(s))` declaration makes, or null. The helper must be + * the module's own tsc helper declaration and the dependency a compiled + * CommonJS program module. */ +export function tscInteropBindingOfDecl( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): TscInteropBinding | null { + if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return null; + const stmt = decl.parent?.parent; + if (stmt === undefined || !ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) + return null; + const sf = stmt.parent; + if (!isCjsJsFile(sf, lowerer.program)) return null; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (!ts.isCallExpression(init) || init.arguments.length !== 1) return null; + const callee = init.expression; + if (!ts.isIdentifier(callee)) return null; + if (callee.text !== "__importDefault" && callee.text !== "__importStar") return null; + if (!sf.statements.some((s) => tscHelperDeclName(s) === callee.text)) return null; + const spec = requireSpecOf(init.arguments[0]!); + if (spec === null) return null; + const dep = + resolveImport(lowerer.program, sf, spec, "require") ?? + npmStaticDepSf7(lowerer.program, sf, spec, "require"); + if (!dep || dep.isDeclarationFile || !isCjsJsFile(dep, lowerer.program)) return null; + return { + helper: callee.text as TscInteropBinding["helper"], + spec, + dep, + stamped: stampsEsModule(dep), + }; +} + +/** The interop binding an identifier reads, or null. */ +export function tscInteropBindingOf( + lowerer: Lowerer, + expr: ts.Expression, +): TscInteropBinding | null { + if (!ts.isIdentifier(expr)) return null; + const sym = lowerer.checker.getSymbolAtLocation(expr); + const decl = sym ? lowerer.checker.declarationsOf(sym).find(ts.isVariableDeclaration) : undefined; + return decl ? tscInteropBindingOfDecl(lowerer, decl) : null; +} + +/** The module whose exports `expr` denotes through an interop binding: + * the binding itself when the dependency is stamped (or for __importStar, + * whose copy carries every export), and `binding.default` when it is not. + * Null for anything else. */ +export function tscInteropModuleOf( + lowerer: Lowerer, + expr: ts.Expression, +): TscInteropBinding | null { + let e = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + const direct = tscInteropBindingOf(lowerer, e); + if (direct !== null) { + return direct.stamped || direct.helper === "__importStar" ? direct : null; + } + if (ts.isPropertyAccessExpression(e) && !e.questionDotToken && e.name.text === "default") { + const via = tscInteropBindingOf(lowerer, e.expression); + if (via !== null && !via.stamped) return via; + } + return null; +} + +/** `binding.default` over an unstamped single-value dependency: that value + * (the caller lowers the dependency's whole export). */ +export function tscInteropDefaultValueOf( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): TscInteropBinding | null { + if (expr.questionDotToken || expr.name.text !== "default") return null; + const via = tscInteropBindingOf(lowerer, expr.expression); + if (via === null || via.stamped) return null; + return cjsNamespaceModule(lowerer, via.dep) ? null : via; +} + +/** True for a tsc helper declaration every reference to which is a + * recognized interop binding's callee, its own self-probe, or a reference + * from another such dead helper: the statement lowers to nothing. */ +export function isDeadTscHelperDecl(lowerer: Lowerer, stmt: ts.Statement): boolean { + const name = tscHelperDeclName(stmt); + if (name === null) return false; + const sf = stmt.getSourceFile(); + const helperSpans = new Map(); + for (const s of sf.statements) { + const n = tscHelperDeclName(s); + if (n !== null) helperSpans.set(n, s); + } + const dead = new Set(); + for (let changed = true; changed;) { + changed = false; + for (const [helper, decl] of helperSpans) { + if (dead.has(helper)) continue; + let live = false; + ts.walkPreorder(sf, (node) => { + if (!ts.isIdentifier(node) || node.text !== helper) return undefined; + const parent = node.parent; + if (parent !== undefined && ts.isVariableDeclaration(parent) && parent.name === node) + return undefined; + const at = node.getStart(sf); + if (at >= decl.getStart(sf) && at < decl.getEnd()) return undefined; + for (const [other, otherDecl] of helperSpans) { + if (other !== helper && dead.has(other)) { + if (at >= otherDecl.getStart(sf) && at < otherDecl.getEnd()) return undefined; + } + } + const call = parent; + const binding = call?.parent; + if ( + call !== undefined && + ts.isCallExpression(call) && + call.expression === node && + binding !== undefined && + ts.isVariableDeclaration(binding) && + tscInteropBindingOfDecl(lowerer, binding) !== null + ) + return undefined; + live = true; + return "stop"; + }); + if (!live) { + dead.add(helper); + changed = true; + } + } + } + return dead.has(name); +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..213e605bc 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -24,6 +24,13 @@ import { lowerZlibStreamMethodCall, } from "./builtins/compression.js"; import type { Lowerer } from "./lowerer.js"; +import { + inlineNamespaceRequireOf, + lowerInlineRequireValue, + lowerUncompiledRequireCall, + lowerWithInlineRequireInit, +} from "./cjs-inline-require.js"; +import { cjsExportCallIgnoresThis, cjsInFileExportGlobalOf } from "./cjs-exports-object.js"; import { lowerGenMethodCall } from "./lower-generators.js"; import { lowerClassSymbolMethodCall } from "./symbol-methods.js"; import { errorToStringCall } from "./error-methods.js"; @@ -234,6 +241,32 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const json = lowerJsonRequireCall(lowerer, expr); if (json) return json; + // Inline require() of a program module (cjs-inline-require.ts): a + // member call over its export table resolves like a binding's member + // call after the module's %init at this position; the require itself + // as a value answers the module's whole-export value. + if (ts.isPropertyAccessExpression(expr.expression) && !expr.expression.questionDotToken) { + const req = inlineNamespaceRequireOf(lowerer, expr.expression.expression); + if (req !== null && !lowerer.inlineRequireInitDone.has(req.call)) { + // Node calls the member with `this` bound to the module's exports. + if ( + ts.isIdentifier(expr.expression.name) && + !cjsExportCallIgnoresThis(lowerer, req.dep, expr.expression.name.text) + ) { + lowerer.unsupported( + "SC1090", + expr, + `calling '${expr.expression.name.text}' through require(${JSON.stringify(req.spec)}) when the function may read 'this' (Node binds it to the module's exports object, which compiles to a static table — bind the module and call a local function instead)`, + ); + } + return lowerWithInlineRequireInit(lowerer, req, () => lowerCall(lowerer, expr)); + } + } + { + const inlineValue = lowerInlineRequireValue(lowerer, expr); + if (inlineValue) return inlineValue; + } + // A call whose chain ROOTS at an ambient-undefined name (`declare // const value: Y | undefined; value?.foo("a")`, `declare function // chain...; chain(o).mapValues(f).value()`, a trap binding's read): @@ -310,6 +343,12 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerImportMetaResolveCall(lowerer, expr) ?? lowerRequireResolveCall(lowerer, expr); if (resolved) return resolved; } + // Any other require() call of the CommonJS global: MODULE_NOT_FOUND for + // packages nothing installed resolves, a named refusal otherwise. + { + const uncompiled = lowerUncompiledRequireCall(lowerer, expr); + if (uncompiled) return uncompiled; + } // `process.getuid?.()` — intercepted BEFORE the optional-chain // machinery (the member always exists on a POSIX target, so the @@ -1274,6 +1313,32 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const args = completeFuncValueArgs(lowerer, expr, callee.type, loc); return { kind: "callValue", callee, args, type: callee.type.ret, loc }; } + // `exports.f(x)` / `module.exports.f(x)` inside the module: a call of + // the member's export global (cjs-exports-object.ts), the value every + // `exports.f =` statement assigns. + if (ts.isPropertyAccessExpression(expr.expression) && !expr.questionDotToken) { + const g = cjsInFileExportGlobalOf(lowerer, expr.expression); + if ( + g && + (g.type.kind === "func" || g.type.kind === "dyn") && + !cjsExportCallIgnoresThis(lowerer, expr.getSourceFile(), expr.expression.name.text) + ) { + lowerer.unsupported( + "SC1090", + expr, + `calling '${expr.expression.getText()}' through the module's exports object when the function may read 'this' (Node binds it to the exports object, which compiles to a static table — call a local function instead)`, + ); + } + if (g && (g.type.kind === "func" || g.type.kind === "dyn")) { + const callee: IrExpr = { kind: "varRef", localId: g.id, type: g.type, loc }; + const checked = lowerCheckedCallableValue(lowerer, expr, callee, loc); + if (checked) return checked; + if (callee.type.kind === "func") { + const args = completeFuncValueArgs(lowerer, expr, callee.type, loc); + return { kind: "callValue", callee, args, type: callee.type.ret, loc }; + } + } + } if (ts.isPropertyAccessExpression(expr.expression)) { const intrinsic = fenceNodeModuleMutationCall(lowerer, expr, expr.expression) ?? diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index fcc54b32b..a18fb83a3 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -13,6 +13,7 @@ import { fsStreamClassOf, lowerFsStreamNew } from "./builtins/fs-streams.js"; import * as ts from "../ts7/adapter.js"; import { lowerZlibStreamNew, zlibClassName, zlibStreamModeOf } from "./builtins/compression.js"; import type { FnCtx, Lowerer } from "./lowerer.js"; +import { inlineNamespaceRequireOf, lowerWithInlineRequireInit } from "./cjs-inline-require.js"; import { BOOL, DATE_T, @@ -8445,6 +8446,14 @@ function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const loc = locOf(expr); + // `new (require("./x").C)()`: the module's %init at the require's + // position, then construction through the member as for a binding. + if (ts.isPropertyAccessExpression(expr.expression) && !expr.expression.questionDotToken) { + const req = inlineNamespaceRequireOf(lowerer, expr.expression.expression); + if (req !== null && !lowerer.inlineRequireInitDone.has(req.call)) { + return lowerWithInlineRequireInit(lowerer, req, () => lowerNew(lowerer, expr)); + } + } const worker = lowerWorkerNew(lowerer, expr); if (worker) return worker; const readlineUi = lowerReadlineUiNew(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d4d03591d..a50b2ee00 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -43,6 +43,18 @@ import { constituentTypes } from "../ts7/checker.js"; import { dirname } from "node:path"; import * as posix from "node:path/posix"; import type { Lowerer } from "./lowerer.js"; +import { + inlineNamespaceRequireOf, + lowerWithInlineRequireInit, + wholeExportValue, +} from "./cjs-inline-require.js"; +import { tscInteropDefaultValueOf } from "./cjs-tsc-interop.js"; +import { + cjsExportMemberWriteGlobalOf, + cjsInFileExportGlobalOf, + isCjsExportsIdentifier, + lowerCjsExportsObjectValue, +} from "./cjs-exports-object.js"; import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-assertions.js"; import { checkedArmExtraction } from "./coercions/unions.js"; import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js"; @@ -205,6 +217,7 @@ import { isNodeModuleValue, lowerCjsTopLevelThis, lowerNodeModuleIdentifier, + isCjsModuleGlobal, lowerNodeModuleProperty, lowerRequireCacheElement, lowerRequireCacheHas, @@ -858,6 +871,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const moduleValue = lowerNodeModuleIdentifier(lowerer, expr); if (moduleValue) return moduleValue; + if (expr.text === "exports") { + const exportsValue = lowerCjsExportsObjectValue(lowerer, expr); + if (exportsValue) return exportsValue; + } if (lowerer.isSelfReference(expr)) { return { kind: "selfRef", type: lowerer.ctx.selfType!, loc }; } @@ -1836,6 +1853,28 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isConditionalExpression(expr)) return lowerTernary(lowerer, expr); if (ts.isPropertyAccessExpression(expr)) { + // `require("./x").member` over a program module's export table: the + // module's %init at this position, then the member read exactly like + // a `const m = require("./x")` binding's (cjs-inline-require.ts). + if (!expr.questionDotToken) { + const req = inlineNamespaceRequireOf(lowerer, expr.expression); + if (req !== null && !lowerer.inlineRequireInitDone.has(req.call)) { + return lowerWithInlineRequireInit(lowerer, req, () => lowerExpr(lowerer, expr)); + } + // `module.exports` compared by identity inside its own module, or + // refused by name as any other value (cjs-exports-object.ts). + if (expr.name.text === "exports") { + const exportsValue = lowerCjsExportsObjectValue(lowerer, expr); + if (exportsValue) return exportsValue; + } + // tsc's `x_1.default` over a dependency whose whole export is one + // value: that value (cjs-tsc-interop.ts). + const interop = tscInteropDefaultValueOf(lowerer, expr); + if (interop !== null) { + const value = wholeExportValue(lowerer, interop.dep, expr); + if (value !== null) return value; + } + } { const member = lifetimeRegionMemberOf(lowerer.program, expr, lowerer.experimentalFiles); if (member !== null) @@ -2181,35 +2220,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // global (pure alias plumbing) and fall through unchanged; write // positions belong to the export-assignment machinery. { - const sf = expr.getSourceFile(); - const cjsMemberRecv = - isModuleExportsAccess(expr.expression) || - (ts.isIdentifier(expr.expression) && - expr.expression.text === "exports" && - !lowerer.resolveLocal(expr.expression) && - !lowerer.globalOf(expr.expression)); const writePos = ts.isBinaryExpression(expr.parent) && expr.parent.left === expr && expr.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; - if ( - !expr.questionDotToken && - cjsMemberRecv && - !writePos && - isCjsJsFile(sf, lowerer.program) - ) { - const sym = - lowerer.checker.getSymbolAtLocation(expr.name) ?? - lowerer.cjsModuleExportSymbol(sf, expr.name.text); - const resolved = - sym !== undefined && sym.flags & ts.SymbolFlags.Alias - ? lowerer.checker.getAliasedSymbol(sym) - : sym; - const g = - (sym ? lowerer.globalsBySymbol.get(sym) : undefined) ?? - (resolved ? lowerer.globalsBySymbol.get(resolved) : undefined); - if (g) return { kind: "varRef", localId: g.id, type: g.type, loc }; - } + const g = cjsInFileExportGlobalOf(lowerer, expr); + if (g && !writePos) return { kind: "varRef", localId: g.id, type: g.type, loc }; } // Namespace-qualified reads (`N.x`, `A.B.C.f`, import= alias // chains): the member is a compile-time-known declaration of a @@ -2805,6 +2821,23 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { ); } } + // A member a JavaScript module attached to one of its functions + // (`status.message = codes; module.exports = status`), read from + // TypeScript: JavaScript stores such members on the function's own + // property table (`fn.member = v` writes through the function's + // checked-dynamic face), so the read takes the same path and enters + // the static code at the type inference gave the member. + if (recvLowered.type.kind === "func" && jsFunctionExpandoMember(lowerer, expr)) { + const read: IrExpr = { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(recvLowered, DYN), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + type: DYN, + loc, + }; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + return mapped && mapped.kind !== "void" ? lowerer.coerceInto(expr, read, mapped) : read; + } lowerer.unsupported( "SC1090", expr, @@ -10151,6 +10184,35 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr // RHS value is the expression's value — its OWN type, so the // consumer sees exactly what the checker typed. if (ts.isPropertyAccessExpression(expr.left) && !expr.left.questionDotToken) { + // `const src = exports.src = []`: an export member assigned in + // value position writes its export global and yields the value. + { + let inFunction = false; + for (let p: ts.Node | undefined = expr.parent; p !== undefined; p = p.parent) { + if (ts.isFunctionLike(p) || ts.isClassDeclaration(p) || ts.isClassExpression(p)) { + inFunction = true; + break; + } + } + const g = inFunction + ? cjsExportMemberWriteGlobalOf(lowerer, expr.left) + : cjsInFileExportGlobalOf(lowerer, expr.left); + if (g) { + const value = lowerer.lowerExprExpecting(expr.right, g.type); + const tmp = lowerer.declareHiddenLocal("%exportValue", value.type); + const result = varRef(tmp.id, value.type, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: tmp.id, init: value, loc }, + { kind: "assign", localId: g.id, value: result, loc }, + ], + result, + type: value.type, + loc, + }; + } + } const recv = tryLowerExpression(lowerer, expr.left.expression); if (recv && recv.type.kind === "dyn") return lowerDynMemberAssignment(lowerer, expr, recv); const unionWrite = lowerUnionFieldWrite(lowerer, expr.left, expr.right); @@ -14977,6 +15039,9 @@ export function fieldTarget( access: ts.PropertyAccessExpression, ): FieldTarget | null { if (lowerer.cjsLocalModuleBindingOf(access.expression)) return null; + // `module.exports` / `module.id`: CommonJS module surface, never a field. + if (isCjsModuleGlobal(lowerer, access.expression)) return null; + if (isCjsExportsIdentifier(lowerer, access.expression)) return null; if (lowerer.chainBlocked(access)) return null; if ( access.name.text === "stackTraceLimit" && @@ -16263,3 +16328,28 @@ function isBuiltinModuleValueDecl(decl: ts.Node): boolean { ts.isVariableDeclaration(decl)) ); } + +/** True when `access` reads a member that JavaScript sources attach to a + * function by assignment (`fn.member = value`): the receiver's type is + * callable and every declaration of the member is such an assignment in a + * JS file. */ +function jsFunctionExpandoMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean { + if (access.questionDotToken || !ts.isIdentifier(access.name)) return false; + const recvType = lowerer.typeOf(access.expression); + if (lowerer.checker.getCallSignatures(recvType).length === 0) return false; + const member = lowerer.checker.getPropertyOfType(recvType, access.name.text); + const decls = member ? lowerer.checker.declarationsOf(member) : []; + return ( + decls.length > 0 && + decls.every((d) => { + if (!isJsSourceFile(d.getSourceFile())) return false; + const assign = ts.isBinaryExpression(d) ? d : d.parent; + return ( + assign !== undefined && + ts.isBinaryExpression(assign) && + assign.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(assign.left) + ); + }) + ); +} diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3baab9dd0..c114bea4f 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -18,7 +18,9 @@ import { isRelativeSpecifier } from "../workspace-registry.js"; import { canonicalBuiltinModule, cjsExportAssignmentOf, + cjsExportChainOf, cjsExportDiscardReason, + cjsExportTargetLiteral, entryPackageFilePredicate, isCjsJsFile, isJsSourceFile, @@ -26,6 +28,7 @@ import { locOf, makeCycleAdmission, orderedImportsOf, + requireSpecOf, resolveImport, resolveNpmImport, } from "../program.js"; @@ -36,6 +39,13 @@ import { withCycleInitFlags, } from "./cycle-initialization.js"; import { markRequireEarlyBindings } from "./require-initialization.js"; +import { + cjsTableMethodEntry, + inlineProgramRequireOf, + nestedCjsExportAssignmentsOf, + requireNestedPatternsOf, +} from "./cjs-inline-require.js"; +import { isDeadTscHelperDecl, tscInteropBindingOfDecl } from "./cjs-tsc-interop.js"; import { collectJsonRequires, isJsonRequireDecl } from "./json-require.js"; import { invalidJsonModuleDiag, @@ -1391,6 +1401,11 @@ export function collectGlobals( deferJsCollectionDiags(lowerer, sf, diagsBefore); } }; + // Export members assigned in VALUE position at the module's top + // level (`const src = exports.src = []`) are exports too. + for (const nested of nestedCjsExportAssignmentsOf(stmt, cjs?.expr)) { + registerExport(nested.name, nested.name.text, nested.value); + } if (cjs?.kind === "member" && ts.isIdentifier(cjs.name)) { // `exports.C = C` naming a CLASS declaration: alias plumbing — // no storage (a class VALUE global would fence for @@ -1398,6 +1413,13 @@ export function collectGlobals( // symbol (cjsMemberExportClassSymbol) and the class registry // applies unchanged. if (lowerer.cjsMemberExportClassSymbol(cjs.expr) !== null) continue; + // A chain `exports.a = exports.b = value`: every member is an + // export holding the chain's one value. + const chain = cjsExportChainOf(cjs.expr); + if (chain !== null) { + for (const nameNode of chain.names) registerExport(nameNode, nameNode.text, chain.value); + continue; + } registerExport(cjs.name, cjs.name.text, cjs.value); continue; } @@ -1429,7 +1451,22 @@ export function collectGlobals( // statement lowering evaluates the call exactly once at its // source position and assigns that slot. Unmappable calls retain // the existing statement-level fence. - const factoryValued = ts.isCallExpression(rhs); + // Any other computed value (`os.homedir || function () {…}`, + // `cond ? a : b`, `new Registry()`, `ns.member`) is the same single + // snapshot: Node stores whatever the expression produced. A + // require() of a program module is not: the exporter re-exports + // that module's exports, which requirers resolve through the + // checker's alias (the statement lowers to the dep's %init). + const factoryValued = + !ts.isIdentifier(rhs) && + !ts.isClassExpression(rhs) && + !ts.isObjectLiteralExpression(rhs) && + !( + requireSpecOf(rhs) !== null && + inlineProgramRequireOf(lowerer, rhs) !== null && + !sf.statements.some((s) => cjsExportAssignmentOf(s)?.kind === "member") + ) && + cjsExportTargetLiteral(rhs, sf) === null; if (cjsScalarLiteral(rhs) || fnValued || factoryValued) { const diagsBefore = lowerer.diags.length; try { @@ -1464,6 +1501,10 @@ export function collectGlobals( if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { registerExport(prop.name, prop.name.text, prop.initializer); } + // A METHOD entry (`module.exports = { f(x) { … } }`) is an + // expression-valued export whose value is the method's function. + const method = cjsTableMethodEntry(prop); + if (method !== null) registerExport(method.name, method.name.text, method.name); // other property forms fence in the statement lowering } continue; @@ -1527,6 +1568,16 @@ export function collectGlobals( collectNestedVarGlobals(lowerer, sf, stmt, tag); continue; } + // tsc's interop bindings and its then-dead helper declarations own no + // storage either (cjs-tsc-interop.ts). + if ( + isJsSourceFile(sf) && + (isDeadTscHelperDecl(lowerer, stmt) || + (stmt.declarationList.declarations.length === 1 && + tscInteropBindingOfDecl(lowerer, stmt.declarationList.declarations[0]!) !== null)) + ) { + continue; + } // CommonJS require declarations are alias plumbing (preflight owns // the module edges; resolveValueSymbol routes reads through to the // exporter's declarations): no storage, and the statement lowers to @@ -1549,6 +1600,38 @@ export function collectGlobals( stdlibGlobalAliasDecl(lowerer, decl.name, decl.initializer); } } + // `const { codes: { ERR_X } } = require("./errors")`: the outer + // keys stay alias plumbing, but a nested pattern destructures the + // export's value at the require, so its bindings own storage like + // any destructured declaration (lowerVarDeclarator assigns them). + if (ts.isVariableStatement(stmt)) { + const isLetList = + (stmt.declarationList.flags & ts.NodeFlags.Let) !== 0 || + (stmt.declarationList.flags & ts.NodeFlags.BlockScoped) === 0; + for (const decl of stmt.declarationList.declarations) { + for (const element of requireNestedPatternsOf(decl)) { + for (const nameNode of boundIdentifiersOf(element.name!)) { + const symbol = lowerer.checker.getSymbolAtLocation(nameNode); + if (!symbol || lowerer.globalsBySymbol.has(symbol)) continue; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(nameNode)); + const type = mapped && mapped.kind !== "void" ? mapped : DYN; + const g: IrGlobal = { + id: `%g.${tag}${nameNode.text}`, + name: nameNode.text, + type, + mutable: isLetList, + source: bindingSource(nameNode), + ...((type.kind === "func" || type.kind === "dyn") && !isVarDeclared(decl) + ? { tdz: true as const } + : {}), + }; + lowerer.globalsBySymbol.set(symbol, g); + lowerer.globalsList.push(g); + if (isVarDeclared(decl)) noteVarGlobalEntryInit(lowerer, sf, g); + } + } + } + } continue; } // `declare const __VERSION__: string` — AMBIENT: no storage exists diff --git a/packages/compiler/src/frontend/lowering/lower-node-module.ts b/packages/compiler/src/frontend/lowering/lower-node-module.ts index c15749601..292b38e87 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-module.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-module.ts @@ -8,6 +8,7 @@ import * as win32 from "node:path/win32"; import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import type { FileParts } from "./lower-modules.js"; +import { lowerCjsExportsObjectValue } from "./cjs-exports-object.js"; import { isCjsJsFile, isNodeEsmFile, locOf } from "../program.js"; import { runtimePathForTarget } from "../runtime-resolve.js"; import { @@ -177,6 +178,12 @@ export function cjsModuleRef(lowerer: Lowerer, node: ts.Node): IrExpr | null { return id === undefined ? null : numLit(id, locOf(node)); } +/** True when `node` is the CommonJS `require` global (not shadowed by a + * source binding, and not in an ES module). */ +export function isCjsRequireGlobal(lowerer: Lowerer, node: ts.Expression): boolean { + return cjsGlobal(lowerer, node, "require"); +} + export function isCjsModuleGlobal(lowerer: Lowerer, node: ts.Expression): boolean { return cjsGlobal(lowerer, node, "module"); } @@ -428,6 +435,13 @@ export function lowerNodeModuleIdentifier(lowerer: Lowerer, expr: ts.Identifier) if (!isCjsModuleGlobal(lowerer, expr)) return null; const ref = cjsModuleRef(lowerer, expr); if (ref) return ref; + if (isCjsJsFile(expr.getSourceFile(), lowerer.program)) { + lowerer.unsupported( + "SC1090", + expr, + "the CommonJS 'module' object in this position (module.exports members, the whole module.exports in comparisons, and the module metadata fields lower)", + ); + } lowerer.unsupported("SC1090", expr, "the CommonJS 'module' object in an ES module"); } @@ -441,6 +455,10 @@ export function lowerNodeModuleProperty( const member = expr.name.text; if (member === "exports" && direct && moduleExportsAlwaysTruthy(lowerer, expr)) return boolLit(true, locOf(expr)); + if (member === "exports" && direct) { + const value = lowerCjsExportsObjectValue(lowerer, expr); + if (value) return value; + } if (member === "exports" || member === "require") return null; let moduleRef = direct ? cjsModuleRef(lowerer, expr.expression) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index f35df085c..72f49a80b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -15,6 +15,19 @@ import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; import { bindingSource } from "../binding-source.js"; import type { Lowerer } from "./lowerer.js"; +import { + cjsExportValueByName, + cjsNamespaceDestructureOf, + cjsTableMethodEntry, + inlineProgramRequireOf, + requireNestedPatternsOf, +} from "./cjs-inline-require.js"; +import { + cjsExportMemberWriteGlobalOf, + isCjsExportsIdentifier, + isFreshExportsReset, +} from "./cjs-exports-object.js"; +import { isDeadTscHelperDecl, tscInteropBindingOfDecl } from "./cjs-tsc-interop.js"; import { arrayValueRead, arrayValueStore, @@ -60,6 +73,7 @@ import { import { enforceLibBoundary, lowerSurplusCalls } from "./lib-boundary.js"; import { cjsExportAssignmentOf, + cjsExportChainOf, cjsExportDiscardReason, cjsExportTargetLiteral, isCjsJsFile, @@ -1553,6 +1567,16 @@ function lowerVarDeclarator( const init = lowerer.requireInitStmt(target.spec, decl); return init ? [init] : []; } + // tsc's interop bindings (`const x_1 = __importDefault(require("./m"))`) + // are require bindings (cjs-tsc-interop.ts): the module's %init here. + if (ts.isSourceFile(stmt.parent)) { + const interop = tscInteropBindingOfDecl(lowerer, decl); + if (interop !== null) { + const init = lowerer.requireInitStmt(interop.spec, decl); + return init ? [init] : []; + } + if (isDeadTscHelperDecl(lowerer, stmt)) return []; + } // CommonJS require declarations (JS files): at the module's top // level the BINDINGS are alias plumbing (no storage; // resolveValueSymbol routes the reads), but the require itself is @@ -1573,7 +1597,21 @@ function lowerVarDeclarator( if (lowerer.externalTypes.has(spec)) lowerer.externalHostFence(spec, decl, false); if (ts.isSourceFile(stmt.parent)) { const init = lowerer.requireInitStmt(spec, decl); - return init ? [init] : []; + const out: IrStmt[] = init ? [init] : []; + // Nested patterns destructure the named export's value right after + // the module evaluated (`const { codes: { ERR_X } } = require(...)`). + for (const element of requireNestedPatternsOf(decl)) { + const key = element.propertyName as ts.Identifier; + const value = lowerer.lowerExpr(key); + lowerer.lowerBindingPattern( + element.name as ts.ObjectBindingPattern, + () => lowerer.lowerExpr(key), + value.type, + isLet, + out, + ); + } + return out; } // A nested BUILTIN require (`get inFreeBSDJail() { const { execSync } // = require('child_process'); ... }` — test/common's lazy-getter @@ -1600,6 +1638,35 @@ function lowerVarDeclarator( // `const { createSign } = crypto` over a builtin NAMESPACE binding: // alias plumbing (builtinImportOf routes the reads) — no statement. if (builtinNamespaceDestructureModuleOf(lowerer, decl) !== null) return []; + // `const { A, B } = ns` over a CommonJS module binding: each name + // snapshots the named export's value here (cjs-inline-require.ts). + { + const nsDestructure = cjsNamespaceDestructureOf(lowerer, decl); + if (nsDestructure !== null) { + const out: IrStmt[] = []; + for (const element of nsDestructure.elements) { + const target = lowerer.globalOf(element.name); + const value = cjsExportValueByName(lowerer, nsDestructure.dep, element.key, element.name); + if (target === null || value === null) { + lowerer.unsupported( + "SC1090", + element.name, + `destructuring the export '${element.key}' of a CommonJS module binding (it has no static value)`, + ); + } + out.push({ + kind: "assign", + localId: target.id, + value: typeEquals(value.type, target.type) + ? value + : lowerer.coerceInto(element.name, value, target.type), + loc: locOf(element.name), + ...("tdz" in target && target.tdz ? { initializes: true as const } : {}), + }); + } + return out; + } + } return lowerer.lowerDestructuringDecl(decl, isLet); } // STORED protocol values with statically closed representations: @@ -6049,6 +6116,49 @@ function lowerCjsExportStatement( if (whole?.kind !== "table" || cjsExportDiscardReason(stmt) !== null) continue; root = lowerer.globalsByDeclNode.get(whole.expr); } + // A CHAIN `exports.a = exports.b = value`: Node evaluates the value + // once, assigns the innermost member first, and every member holds + // the same value. Each member is its own export global + // (collectGlobals registers the inner ones too). + { + const chain = cjsExportChainOf(cjs.expr); + if (chain !== null && root?.type.kind !== "dyn") { + const globalOfName = (nameNode: ts.Identifier): IrGlobal => { + const symbol = + lowerer.checker.getSymbolAtLocation(nameNode) ?? + lowerer.cjsModuleExportSymbol(sf, nameNode.text); + const g = symbol ? lowerer.globalsBySymbol.get(symbol) : undefined; + if (!g) { + lowerer.lowerExpr(chain.value); + lowerer.badType(chain.value, lowerer.typeOf(chain.value)); + } + return g; + }; + const inner = chain.names[chain.names.length - 1]!; + const innerG = globalOfName(inner); + const body: IrStmt[] = [ + { + kind: "assign", + localId: innerG.id, + value: lowerer.lowerExprExpecting(chain.value, innerG.type), + loc: locOf(chain.value), + }, + ]; + for (let i = chain.names.length - 2; i >= 0; i--) { + const g = globalOfName(chain.names[i]!); + const read: IrExpr = { kind: "varRef", localId: innerG.id, type: innerG.type, loc }; + body.push({ + kind: "assign", + localId: g.id, + value: typeEquals(g.type, innerG.type) + ? read + : lowerer.coerceInto(chain.value, read, g.type), + loc, + }); + } + return { kind: "block", body, loc }; + } + } return assignExport(cjs.name, cjs.value, root); } let tableObj = cjs.obj; @@ -6114,6 +6224,30 @@ function lowerCjsExportStatement( body.push(assignExport(prop.name, prop.initializer)); continue; } + // METHOD entries: the export global holds the method's function, + // created at this statement like a function-expression entry. Node + // calls it with `this` bound to the table, which the static export + // table cannot represent, so a body that uses `this` keeps a fence. + { + const method = cjsTableMethodEntry(prop); + if (method !== null && !resolvedTarget) { + lowerer.rejectThisInObjectMethod(method.body); + const symbol = + lowerer.checker.getSymbolAtLocation(method.name) ?? + lowerer.cjsModuleExportSymbol(cjs.expr.getSourceFile(), method.name.text); + const g = symbol ? lowerer.globalsBySymbol.get(symbol) : undefined; + if (g) { + const fn = lowerer.lowerLambda(method); + body.push({ + kind: "assign", + localId: g.id, + value: typeEquals(fn.type, g.type) ? fn : lowerer.coerceInto(method, fn, g.type), + loc: locOf(method), + }); + continue; + } + } + } // ACCESSOR entries: no storage and no code here — reads lift the // getter as a module-level function and call it per read // (cjsExportAccessorRead); writes through the setter keep their @@ -6129,7 +6263,15 @@ function lowerCjsExportStatement( if (ts.isSpreadAssignment(prop)) { let inner: ts.Expression = prop.expression; while (ts.isParenthesizedExpression(inner)) inner = inner.expression; - if (requireSpecOf(inner) !== null) continue; + if (requireSpecOf(inner) !== null) { + // The spread still EVALUATES the module, at its place among the + // table's entries (a program module joins the graph through this + // inline require; cjs-inline-require.ts). + const req = inlineProgramRequireOf(lowerer, inner); + const init = req !== null ? lowerer.requireInitStmt(req.spec, req.call) : null; + if (init !== null && !resolvedTarget) body.push(init); + continue; + } } lowerer.unsupported( "SC1090", @@ -6162,6 +6304,16 @@ function lowerCjsSingleValueExport( ): IrStmt | null { let rhs: ts.Expression = expr.right; while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + // `module.exports = require("./x")`: the module re-exports x's exports. + // Requirers resolve through the checker's alias to x's own export + // machinery; this statement is x's evaluation at this position. + { + const req = inlineProgramRequireOf(lowerer, rhs); + // With later member exports (`module.exports.extra = …`) the module + // extends that object, so it keeps the factory root registration. + if (req !== null && !lowerer.globalsByDeclNode.has(expr)) + return lowerer.requireInitStmt(req.spec, req.call) ?? { kind: "block", body: [], loc }; + } if (ts.isIdentifier(rhs)) { const sym = lowerer.resolveValueSymbol(rhs); if (!sym) return null; @@ -6172,6 +6324,17 @@ function lowerCjsSingleValueExport( ) { return { kind: "block", body: [], loc }; } + // A require binding (`const x = require("./x"); module.exports = x`) + // re-exports that module exactly like the inline form (the binding's + // own statement already evaluated it), and a builtin global + // (`module.exports = TypeError`) is the global itself: requirer + // bindings alias straight through to it, so nothing is stored. + if ( + lowerer.checker.declarationsOf(sym).some((d) => ts.isSourceFile(d)) || + lowerer.isStdlibSymbol(sym) + ) { + return { kind: "block", body: [], loc }; + } const g = lowerer.globalsBySymbol.get(sym); if (g) { if (g.mutable) { @@ -6393,6 +6556,21 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm ); } if (topLevelJs && ts.isExpressionStatement(stmtNode)) { + if (isFreshExportsReset(lowerer, stmtNode)) + return { kind: "block", body: [], loc: locOf(expr) }; + // Any other `exports = …` rebinds the wrapper's exports binding: the + // members later attached through it belong to the new value. + if ( + ts.isBinaryExpression(expr) && + expr.operatorToken.kind === ts.SyntaxKind.EqualsToken && + isCjsExportsIdentifier(lowerer, expr.left) + ) { + lowerer.unsupported( + "SC1090", + expr.left, + "rebinding 'exports' (exports = module.exports = value): members attached through 'exports' afterwards are properties of that value, which the static export table does not model — write module.exports. = … and module.exports = value", + ); + } const cjs = cjsExportAssignmentOf(stmtNode); if (cjs) return lowerCjsExportStatement(lowerer, cjs); // The tsc CJS interop stamp — `Object.defineProperty(exports, @@ -6934,6 +7112,19 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm const parts = lowerDestructuringAssignParts(lowerer, expr.left, expr.right, locOf(expr)); return { kind: "block", body: parts.stmts, loc: locOf(expr) }; } + // `exports.x = v` inside a function updates the member's export + // global (cjs-exports-object.ts). + if (ts.isPropertyAccessExpression(expr.left)) { + const g = cjsExportMemberWriteGlobalOf(lowerer, expr.left); + if (g) { + return { + kind: "assign", + localId: g.id, + value: lowerer.lowerExprExpecting(expr.right, g.type), + loc: locOf(expr), + }; + } + } if (!ts.isIdentifier(expr.left)) { lowerer.unsupported("SC1090", expr.left, "assignment to non-variables"); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 9df0f01a9..46267b85d 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -272,6 +272,13 @@ import { appendWorkerModules, } from "./lower-modules.js"; import { prepareCjsModuleGraph, usesCjsTopLevelThis } from "./lower-node-module.js"; +import { + cjsNamespaceModule, + inlineNamespaceRequireOf, + inlineProgramRequireOf, +} from "./cjs-inline-require.js"; +import { tscInteropModuleOf } from "./cjs-tsc-interop.js"; +import { usesCjsExportsIdentity } from "./cjs-exports-object.js"; import { registerBuiltinEventTargetClasses } from "./lower-event-target.js"; import { type ClassInfo, @@ -1863,6 +1870,9 @@ export class Lowerer { /** Per CommonJS file using top-level `this`: the global holding the * object that stands for its original exports (lowerCjsTopLevelThis). */ readonly cjsExportsThis = new Map(); + /** Inline program-module require calls whose %init the enclosing member + * read or member call is already sequencing (cjs-inline-require.ts). */ + readonly inlineRequireInitDone = new Set(); /** Bindings whose unchecked string arithmetic can produce either a number * (NaN for an absent read) or a string. Preserve that result union at bare * reads instead of applying the checker-bare string narrowing adapter. */ @@ -3093,8 +3103,10 @@ export class Lowerer { ident.parent.name === ident ) { const recv = ident.parent.expression; - if (ts.isIdentifier(recv) && this.cjsLocalModuleBindingOf(recv)) { - const dep = this.localModuleBindingDepOf(recv); + if (this.cjsLocalModuleBindingOf(recv)) { + const dep = ts.isIdentifier(recv) + ? this.localModuleBindingDepOf(recv) + : this.cjsModuleRefDepOf(recv); const exported = dep ? this.cjsModuleExportSymbol(dep, ident.text) : undefined; // A createRequire call is typed as `any`, so tsgo can manufacture a // transient property symbol with no relation to the target module. @@ -3486,6 +3498,21 @@ export class Lowerer { return dep; } + /** The table module a non-identifier module reference names: an inline + * `require("./x")` (cjs-inline-require.ts) or tsc's unstamped + * `x_1.default` interop read (cjs-tsc-interop.ts). */ + cjsModuleRefDepOf(expr: ts.Expression): ts.SourceFile | null { + const inline = inlineNamespaceRequireOf(this, expr); + if (inline !== null) return inline.dep; + const interop = tscInteropModuleOf(this, expr); + return interop !== null && cjsNamespaceModule(this, interop.dep) ? interop.dep : null; + } + + /** The table module a CommonJS module binding identifier names. */ + cjsModuleBindingDepOf(expr: ts.Identifier): ts.SourceFile | null { + return this.localModuleBindingDepOf(expr); + } + /** The compiled module behind a require-like namespace binding: an * ordinary CommonJS require declaration, a canonical createRequire * declaration, or an ESM default import of CommonJS. One resolver keeps @@ -3498,6 +3525,10 @@ export class Lowerer { if (ts.isImportClause(decl)) return this.cjsDefaultImportDepOf(decl); if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) || !decl.initializer) return null; + { + const interop = tscInteropModuleOf(this, expr); + if (interop !== null) return cjsNamespaceModule(this, interop.dep) ? interop.dep : null; + } const directSpec = requireSpecOf(decl.initializer); if (directSpec !== null) { if (isJsonRequireDecl(this, decl)) return null; @@ -3544,6 +3575,10 @@ export class Lowerer { if (wholeAssignment === undefined) return null; let rhs: ts.Expression = wholeAssignment.right; while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + // A pure re-export (`module.exports = require("./core")`) is the inner + // module's export table, read like any namespace binding. + if (inlineProgramRequireOf(this, rhs) !== null && !this.globalsByDeclNode.has(wholeAssignment)) + return null; if (ts.isIdentifier(rhs)) { const value = this.checker.getSymbolAtLocation(rhs); if (value) { @@ -3596,7 +3631,9 @@ export class Lowerer { * symbols → resolveValueSymbol); the bare value keeps the * namespace-object fence, like ESM namespace imports of builtins. */ cjsLocalModuleBindingOf(expr: ts.Expression): boolean { - if (!ts.isIdentifier(expr)) return false; + // An inline `require("./x")` over a table module reads its members like + // a binding (cjs-inline-require.ts sequences the module's %init). + if (!ts.isIdentifier(expr)) return this.cjsModuleRefDepOf(expr) !== null; const sym = this.checker.getSymbolAtLocation(expr); if (this.localModuleBindingDepOf(expr) === null) return false; if (this.createRequireWholeExportSymbolOf(expr) !== null) return false; @@ -3609,6 +3646,8 @@ export class Lowerer { // the object literal) keep the namespace reading — member accesses // resolve through the table's property symbols. if (sym && sym.flags & ts.SymbolFlags.Alias) { + // `module.exports = TypeError`: the binding IS the builtin global. + if (this.isStdlibSymbol(this.checker.getAliasedSymbol(sym))) return false; const d = this.checker.declarationsOf(this.checker.getAliasedSymbol(sym))[0]; if (d && (ts.isClassDeclaration(d) || ts.isFunctionDeclaration(d))) return false; if (d && ts.isVariableDeclaration(d)) { @@ -3706,7 +3745,7 @@ export class Lowerer { const id = `%g.${tag}%loaded`; this.moduleGuardOf.set(fp.sf, id); this.globalsList.push({ id, name: "%loaded", type: BOOL, mutable: true }); - if (usesCjsTopLevelThis(this, fp.sf)) { + if (usesCjsTopLevelThis(this, fp.sf) || usesCjsExportsIdentity(this, fp.sf)) { const exportsThis: IrGlobal = { id: `%g.${tag}%exportsThis`, name: "%exportsThis", diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index df0cb673e..158bb2e59 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1837,6 +1837,36 @@ function nestedBareRequiresOf7(sf: ts.SourceFile): ts.CallExpression[] { return out; } +/** INLINE `require("...")` calls in expression position anywhere in a file + * (`require("./x").f`, `require("debug")("ns")`, `module.exports = + * require("./y")`, `{ v: require("./v") }`): each evaluates its module at + * that position, exactly like a bare require statement. Top-level require + * statements and bare require statements are collected by their own + * scans; a require that initializes a nested declaration keeps its + * lowering fence and is not an edge. */ +function inlineRequiresOf7(sf: ts.SourceFile): ts.CallExpression[] { + const out: ts.CallExpression[] = []; + ts.walkPreorder(sf, (n) => { + if (!ts.isCallExpression(n) || requireSpecOf7(n) === null) return; + let outer: ts.Node = n; + while ( + outer.parent !== undefined && + (ts.isParenthesizedExpression(outer.parent) || + ts.isAsExpression(outer.parent) || + ts.isTypeAssertion(outer.parent) || + ts.isNonNullExpression(outer.parent)) + ) { + outer = outer.parent; + } + const parent = outer.parent; + if (parent !== undefined && ts.isExpressionStatement(parent)) return; + if (parent !== undefined && ts.isVariableDeclaration(parent) && parent.initializer === outer) + return; + out.push(n); + }); + return out; +} + type RequireCallBindingKind7 = "ambient" | "createRequire" | "source"; function stripRequireCasts7(expr: ts.Expression): ts.Expression { @@ -2403,6 +2433,24 @@ function nsBindingUsesAreBareStatements7( export interface CycleEdge { dep: ts.SourceFile; stmt?: ts.ImportDeclaration | ts.ExportDeclaration; + /** An inline require() in a deferred position (a function body): Node + * evaluates the dependency when that code runs, after this module's own + * top level, so the edge orders the dependency after the module and + * never closes a cycle through the module's initialization. */ + lazy?: true; + /** The inline require() call behind the edge (inline edges only). */ + call?: ts.CallExpression; +} + +/** Inline require() calls whose edge closes a require cycle. Node answers + * them with the still-evaluating module's partial exports, which the static + * export globals do not model; their lowering refuses them by name at run + * time instead of refusing the whole program (the inline call kept its + * runtime fence before it became a module edge). */ +const inlineRequireCycleCalls = new WeakSet(); + +export function isInlineRequireCycleCall(call: ts.Node): boolean { + return inlineRequireCycleCalls.has(call); } /** True when the reference at `node` can only evaluate AFTER the module @@ -4860,6 +4908,21 @@ function preflight7(load: LoadResult): { pattern.elements.every((e) => { if (e.initializer !== undefined || e.dotDotDotToken !== undefined) return false; if (e.name !== undefined && ts.isIdentifier(e.name)) return true; + // A nested pattern under a named export of a program module + // destructures that export's VALUE at the require + // (`const { codes: { ERR_X } } = require("./errors")`): the + // inner bindings are ordinary destructured storage. + if ( + module === null && + builtin === null && + bindingKind !== "createRequire" && + pattern === req.decl!.name && + e.name !== undefined && + ts.isObjectBindingPattern(e.name) && + e.propertyName !== undefined && + ts.isIdentifier(e.propertyName) + ) + return true; if (module === null || e.name === undefined || !ts.isObjectBindingPattern(e.name)) return false; const key = e.propertyName; @@ -4973,6 +5036,32 @@ function preflight7(load: LoadResult): { continue; } } + // Inline requires of program modules are module edges like the bare + // statements above (cjs-inline-require.ts lowers each to the module's + // %init at its position). Only the CommonJS global in a CommonJS file + // counts, and targets outside the compiled graph add nothing here: + // their use sites keep their existing lowering fences. + if (jsRequireFile && !isNodeEsmFile7(sf, program)) { + for (const call of inlineRequiresOf7(sf)) { + if (requireCallBindingKind7(program, call) !== "ambient") continue; + const spec = requireSpecOf7(call)!; + if (load.externalTypes.has(spec) || canonicalBuiltinModule(spec) !== null) continue; + const lazy = inDeferredPosition7(call); + const edgeTo = (dep: ts.SourceFile): CycleEdge => + lazy ? { dep, lazy: true, call } : { dep, call }; + const projectDep = resolveImport7(program, sf, spec, "require"); + if (projectDep !== null) { + if (!projectDep.fileName.endsWith(".json")) deps.push(edgeTo(projectDep)); + continue; + } + if (isRelativeSpecifier(spec) || spec.startsWith("#")) continue; + const npmReq = resolveNpmImport7(sf.fileName, spec, "require"); + if (npmReq !== null && isNpmStaticPackage(npmReq.packageName)) { + const nDep = npmStaticProgramDep(program, npmReq.packageName, npmReq.typesFile); + if (nDep !== null) deps.push(edgeTo(nDep)); + } + } + } } } @@ -4999,7 +5088,10 @@ function preflight7(load: LoadResult): { // reads are all let/const bindings with module storage — the // lowering checks each one (cycleEarlyBindings) and throws Node's // ReferenceError on a read before the declaration ran. - const cycleAdmissionReason = makeCycleAdmission(program, (sf) => edges.get(sf) ?? []); + const cycleAdmissionReason = makeCycleAdmission(program, (sf) => { + const all = edges.get(sf) ?? []; + return all.some((e) => e.call !== undefined) ? all.filter((e) => e.call === undefined) : all; + }); const order: ts.SourceFile[] = []; const state = new Map(); const stack: string[] = []; @@ -5008,9 +5100,14 @@ function preflight7(load: LoadResult): { stack.push(sf.fileName); for (const e of edges.get(sf) ?? []) { if (e.dep === sf) continue; // self-import: a Node cache hit, not a cycle + if (e.lazy === true) continue; // visited after this module completes const s = state.get(e.dep); if (s === "done") continue; if (s === "visiting") { + if (e.call !== undefined) { + inlineRequireCycleCalls.add(e.call); + continue; + } // A back-edge: Node answers it from the cache (the module is // already evaluating) — admitted by the cheap per-edge rule, the // declaration-only init window rule, or the exact initialization @@ -5034,6 +5131,11 @@ function preflight7(load: LoadResult): { stack.pop(); state.set(sf, "done"); order.push(sf); + // Lazy inline requires run after this module's top level: their + // modules join the order afterwards (cache hits when already loaded). + for (const e of edges.get(sf) ?? []) { + if (e.lazy === true && state.get(e.dep) === undefined) visit(e.dep); + } }; visit(entry); @@ -5830,6 +5932,37 @@ export function cjsExportAssignmentOf( return null; } +/** The members and shared value of a CHAINED member export + * `exports.a = exports.b = module.exports.c = value` (outermost name + * first), or null when the assignment is not a chain of at least two + * identifier-named export members or its value is undefined. */ +export function cjsExportChainOf( + e: ts.BinaryExpression, +): { names: ts.Identifier[]; value: ts.Expression } | null { + const names: ts.Identifier[] = []; + let cur: ts.Expression = e; + for (;;) { + while (ts.isParenthesizedExpression(cur)) cur = cur.expression; + if (!ts.isBinaryExpression(cur) || cur.operatorToken.kind !== ts.SyntaxKind.EqualsToken) break; + const left = cur.left; + if ( + !ts.isPropertyAccessExpression(left) || + left.questionDotToken || + !ts.isIdentifier(left.name) + ) + break; + const recv = left.expression; + if (!((ts.isIdentifier(recv) && recv.text === "exports") || isModuleExportsAccess(recv))) break; + names.push(left.name); + cur = cur.right; + } + // tsc's `exports.a = exports.b = void 0;` preamble is not a value chain + // (the export-statement lowering owns it). + const isVoid = + cur.kind === ts.SyntaxKind.VoidExpression || (ts.isIdentifier(cur) && cur.text === "undefined"); + return names.length >= 2 && !isVoid ? { names, value: cur } : null; +} + /** Why Node would DISCARD this CommonJS export statement, or null when it * survives (Node's export-object identity rules — checker-free, pure * AST): `module.exports = {...}` REPLACES the export object, so members diff --git a/tests/corpus/6150-cjs-inline-require/fallback.js b/tests/corpus/6150-cjs-inline-require/fallback.js new file mode 100644 index 000000000..d89da1a00 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/fallback.js @@ -0,0 +1,6 @@ +const preferred = undefined; +module.exports = + preferred || + function fallback() { + return "fallback"; + }; diff --git a/tests/corpus/6150-cjs-inline-require/inner.js b/tests/corpus/6150-cjs-inline-require/inner.js new file mode 100644 index 000000000..7aa065c1e --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/inner.js @@ -0,0 +1,4 @@ +console.log("inner: evaluated"); +exports.inner = function () { + return "inner"; +}; diff --git a/tests/corpus/6150-cjs-inline-require/late.js b/tests/corpus/6150-cjs-inline-require/late.js new file mode 100644 index 000000000..a28525b26 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/late.js @@ -0,0 +1,2 @@ +console.log("late: evaluated"); +exports.value = "late value"; diff --git a/tests/corpus/6150-cjs-inline-require/lib.js b/tests/corpus/6150-cjs-inline-require/lib.js new file mode 100644 index 000000000..94a5872a4 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/lib.js @@ -0,0 +1,6 @@ +console.log("lib: evaluated"); +exports.f = function (x) { + return x * 10; +}; +exports.n = 7; +exports.counter = { hits: 0 }; diff --git a/tests/corpus/6150-cjs-inline-require/main.js b/tests/corpus/6150-cjs-inline-require/main.js new file mode 100644 index 000000000..0ebc20d36 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/main.js @@ -0,0 +1,55 @@ +// Inline require() of program modules: each call evaluates its module at +// exactly that position (or answers from the cache), and the value is the +// module's exports, read statically. +console.log("main: start"); +const f = require("./lib.js").f; +console.log("main: first inline require done", f(1)); +console.log("main: cached", require("./lib.js").n, require("./lib.js").f(2)); +exports.viaInline = require("./lib.js").f; +console.log("main: exported member", exports.viaInline(3), exports.viaInline === f); + +// Mutation through an inline read is visible to every requirer. +require("./lib.js").counter.hits++; +const lib = require("./lib.js"); +console.log("main: shared counter", lib.counter.hits, require("./lib.js").counter === lib.counter); + +// A whole-export function called directly off the require. +const debug = require("./make-logger.js")("app"); +console.log(debug("ready")); + +// Construction through a whole-export class and through a member class. +const p = new (require("./point.js"))(1, 2); +console.log("point", p.x + p.y); +const q = new (require("./shapes.js").Square)(3); +console.log("square", q.area()); + +// Lazy inline require: the module loads at the first call, once. +function late() { + return require("./late.js").value; +} +console.log("main: before late"); +console.log("late", late(), late()); + +// Member calls off an inline require of a table module. +console.log("table member", require("./table.js").twice(4), require("./table.js").label); + +// Table entries holding inline requires. +const table = { verify: require("./verify.js"), sign: require("./lib.js").f }; +console.log("table", table.verify("tok"), table.sign(4)); + +// Re-exports: module.exports = require(...) and through a binding. +const a = require("./reexport-inline.js"); +const b = require("./reexport-binding.js"); +console.log("reexports", a.inner(), b.inner(), a.inner === b.inner); + +// Single computed values as the whole export. +const T = require("./type-error.js"); +try { + throw new T("bad input"); +} catch (e) { + console.log(e instanceof TypeError, String(e)); +} +const pick = require("./pick.js"); +console.log("pick", pick(5), typeof require("./fallback.js"), require("./fallback.js")()); +const reg = require("./registry.js"); +console.log("registry", reg.size(), reg.add("x"), reg.size(), require("./registry.js") === reg); diff --git a/tests/corpus/6150-cjs-inline-require/make-logger.js b/tests/corpus/6150-cjs-inline-require/make-logger.js new file mode 100644 index 000000000..f698b54a7 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/make-logger.js @@ -0,0 +1,6 @@ +console.log("make-logger: evaluated"); +module.exports = function (ns) { + return function (msg) { + return ns + ": " + msg; + }; +}; diff --git a/tests/corpus/6150-cjs-inline-require/package.json b/tests/corpus/6150-cjs-inline-require/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6150-cjs-inline-require/pick.js b/tests/corpus/6150-cjs-inline-require/pick.js new file mode 100644 index 000000000..aadc1eb8e --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/pick.js @@ -0,0 +1,7 @@ +function double(x) { + return x * 2; +} +function triple(x) { + return x * 3; +} +module.exports = process.env.SCRIPTC_CORPUS_NEVER_SET ? triple : double; diff --git a/tests/corpus/6150-cjs-inline-require/point.js b/tests/corpus/6150-cjs-inline-require/point.js new file mode 100644 index 000000000..0f002800c --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/point.js @@ -0,0 +1,6 @@ +module.exports = class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } +}; diff --git a/tests/corpus/6150-cjs-inline-require/reexport-binding.js b/tests/corpus/6150-cjs-inline-require/reexport-binding.js new file mode 100644 index 000000000..e8307cc65 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/reexport-binding.js @@ -0,0 +1,3 @@ +const inner = require("./inner.js"); +console.log("reexport-binding: evaluated"); +module.exports = inner; diff --git a/tests/corpus/6150-cjs-inline-require/reexport-inline.js b/tests/corpus/6150-cjs-inline-require/reexport-inline.js new file mode 100644 index 000000000..26b6d44ea --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/reexport-inline.js @@ -0,0 +1,2 @@ +console.log("reexport-inline: evaluated"); +module.exports = require("./inner.js"); diff --git a/tests/corpus/6150-cjs-inline-require/registry.js b/tests/corpus/6150-cjs-inline-require/registry.js new file mode 100644 index 000000000..e34b1015f --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/registry.js @@ -0,0 +1,13 @@ +class Registry { + constructor() { + this.items = []; + } + add(item) { + this.items.push(item); + return this.items.length; + } + size() { + return this.items.length; + } +} +module.exports = new Registry(); diff --git a/tests/corpus/6150-cjs-inline-require/shapes.js b/tests/corpus/6150-cjs-inline-require/shapes.js new file mode 100644 index 000000000..4dd410384 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/shapes.js @@ -0,0 +1,9 @@ +class Square { + constructor(side) { + this.side = side; + } + area() { + return this.side * this.side; + } +} +exports.Square = Square; diff --git a/tests/corpus/6150-cjs-inline-require/table.js b/tests/corpus/6150-cjs-inline-require/table.js new file mode 100644 index 000000000..ce8f1e364 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/table.js @@ -0,0 +1,4 @@ +function twice(x) { + return x * 2; +} +module.exports = { twice, label: "table" }; diff --git a/tests/corpus/6150-cjs-inline-require/type-error.js b/tests/corpus/6150-cjs-inline-require/type-error.js new file mode 100644 index 000000000..483613b44 --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/type-error.js @@ -0,0 +1 @@ +module.exports = TypeError; diff --git a/tests/corpus/6150-cjs-inline-require/verify.js b/tests/corpus/6150-cjs-inline-require/verify.js new file mode 100644 index 000000000..7adb1f64a --- /dev/null +++ b/tests/corpus/6150-cjs-inline-require/verify.js @@ -0,0 +1,4 @@ +console.log("verify: evaluated"); +module.exports = function verify(token) { + return "verified " + token; +}; diff --git a/tests/corpus/6151-cjs-export-chains/chain.js b/tests/corpus/6151-cjs-export-chains/chain.js new file mode 100644 index 000000000..533e35ba8 --- /dev/null +++ b/tests/corpus/6151-cjs-export-chains/chain.js @@ -0,0 +1,10 @@ +let evaluations = 0; +function make() { + evaluations++; + return function () { + return "shared"; + }; +} +exports.a = exports.b = make(); +module.exports.c = exports.d = module.exports.e = 5; +exports.evaluations = evaluations; diff --git a/tests/corpus/6151-cjs-export-chains/constants.js b/tests/corpus/6151-cjs-export-chains/constants.js new file mode 100644 index 000000000..7a67f9f21 --- /dev/null +++ b/tests/corpus/6151-cjs-export-chains/constants.js @@ -0,0 +1 @@ +exports.VERSION = "1.2.3"; diff --git a/tests/corpus/6151-cjs-export-chains/main.js b/tests/corpus/6151-cjs-export-chains/main.js new file mode 100644 index 000000000..394c5e3d2 --- /dev/null +++ b/tests/corpus/6151-cjs-export-chains/main.js @@ -0,0 +1,9 @@ +// Chained member exports share one value, evaluated once; a fresh +// `exports = module.exports = {}` at the top of a module is a no-op reset. +const chain = require("./chain.js"); +console.log(chain.a(), chain.b(), chain.a === chain.b, chain.evaluations); +console.log(chain.c, chain.d, chain.e); +const re = require("./re.js"); +console.log(re.src.length, re.t.FULL, re.tokens.join(",")); +const sem = require("./sem.js"); +console.log(sem.src.join(" "), sem.re.join(","), sem.count()); diff --git a/tests/corpus/6151-cjs-export-chains/package.json b/tests/corpus/6151-cjs-export-chains/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6151-cjs-export-chains/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6151-cjs-export-chains/re.js b/tests/corpus/6151-cjs-export-chains/re.js new file mode 100644 index 000000000..8839b5cb4 --- /dev/null +++ b/tests/corpus/6151-cjs-export-chains/re.js @@ -0,0 +1,15 @@ +'use strict' +const constants = require("./constants.js"); +exports = module.exports = {} +const src = []; +const t = {}; +exports.src = src; +exports.t = t; +exports.tokens = []; +function createToken(name, value) { + t[name] = src.length; + src.push(value); + exports.tokens.push(name); +} +createToken("FULL", "^" + constants.VERSION + "$"); +createToken("LOOSE", "v?" + constants.VERSION); diff --git a/tests/corpus/6151-cjs-export-chains/sem.js b/tests/corpus/6151-cjs-export-chains/sem.js new file mode 100644 index 000000000..f12b5b25f --- /dev/null +++ b/tests/corpus/6151-cjs-export-chains/sem.js @@ -0,0 +1,16 @@ +'use strict' +// semver's internal/re.js shape: export members assigned in value position. +exports = module.exports = {} +const re = exports.re = [] +const src = exports.src = [] +const t = exports.t = {} +let R = 0 +const createToken = (name, value) => { + const index = R++ + t[name] = index + src[index] = value + re[index] = value.length +} +exports.count = () => R +createToken('MAIN', 'x.y.z') +createToken('LOOSE', 'v?x') diff --git a/tests/corpus/6152-cjs-tsc-interop/fn.js b/tests/corpus/6152-cjs-tsc-interop/fn.js new file mode 100644 index 000000000..09b227f5b --- /dev/null +++ b/tests/corpus/6152-cjs-tsc-interop/fn.js @@ -0,0 +1,3 @@ +module.exports = function square(x) { + return x * x; +}; diff --git a/tests/corpus/6152-cjs-tsc-interop/main.js b/tests/corpus/6152-cjs-tsc-interop/main.js new file mode 100644 index 000000000..0924dce35 --- /dev/null +++ b/tests/corpus/6152-cjs-tsc-interop/main.js @@ -0,0 +1,17 @@ +"use strict"; +// tsc's CommonJS output: interop helpers over require(), the void 0 +// preamble, and the (0, x.default)() call spelling. +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.run = void 0; +console.log("main: start"); +const plain_1 = __importDefault(require("./plain.js")); +const stamped_1 = __importDefault(require("./stamped.js")); +const fn_1 = __importDefault(require("./fn.js")); +function run() { + return [plain_1.default.greet("a"), (0, stamped_1.default)("b"), stamped_1.helper(), (0, fn_1.default)(2)].join(" "); +} +exports.run = run; +console.log(run(), plain_1.default.count, typeof fn_1.default); diff --git a/tests/corpus/6152-cjs-tsc-interop/package.json b/tests/corpus/6152-cjs-tsc-interop/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6152-cjs-tsc-interop/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6152-cjs-tsc-interop/plain.js b/tests/corpus/6152-cjs-tsc-interop/plain.js new file mode 100644 index 000000000..60e6732ab --- /dev/null +++ b/tests/corpus/6152-cjs-tsc-interop/plain.js @@ -0,0 +1,5 @@ +console.log("plain: evaluated"); +exports.greet = function (who) { + return "hello " + who; +}; +exports.count = 3; diff --git a/tests/corpus/6152-cjs-tsc-interop/stamped.js b/tests/corpus/6152-cjs-tsc-interop/stamped.js new file mode 100644 index 000000000..9e844af05 --- /dev/null +++ b/tests/corpus/6152-cjs-tsc-interop/stamped.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.helper = exports.default = void 0; +console.log("stamped: evaluated"); +function greet(who) { + return "stamped " + who; +} +exports.default = greet; +function helper() { + return "helper"; +} +exports.helper = helper; diff --git a/tests/corpus/6153-cjs-exports-object/lib.js b/tests/corpus/6153-cjs-exports-object/lib.js new file mode 100644 index 000000000..4f69bfca2 --- /dev/null +++ b/tests/corpus/6153-cjs-exports-object/lib.js @@ -0,0 +1,13 @@ +exports.lookup = function (ext) { + return "application/" + ext; +}; +exports.contentType = function (ext) { + return exports.lookup(ext) + "; charset=utf-8"; +}; +exports.describe = function () { + return module.exports.lookup("xml"); +}; +// A later reassignment is what member calls see from then on. +exports.lookup = function (ext) { + return "text/" + ext; +}; diff --git a/tests/corpus/6153-cjs-exports-object/main.js b/tests/corpus/6153-cjs-exports-object/main.js new file mode 100644 index 000000000..39cf61c6c --- /dev/null +++ b/tests/corpus/6153-cjs-exports-object/main.js @@ -0,0 +1,19 @@ +// A module's own exports object, used inside the module: identity +// comparisons (the UMD self-test), typeof and truthiness, and member calls. +const lib = require("./lib.js"); +(function () { + var root = this; + console.log("wrapper", root === module.exports, root === exports, typeof root); +}).call(this); +function check() { + return [exports === module.exports, typeof exports, typeof module.exports, !exports].join(" "); +} +console.log(check(), module.exports ? "truthy" : "falsy"); +console.log(lib.contentType("json"), lib.describe()); +exports.answer = function () { + return 42; +}; +function ask() { + return module.exports.answer() + exports.answer(); +} +console.log(exports.answer(), ask()); diff --git a/tests/corpus/6153-cjs-exports-object/package.json b/tests/corpus/6153-cjs-exports-object/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6153-cjs-exports-object/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6154-cjs-require-nested-destructure/constants.js b/tests/corpus/6154-cjs-require-nested-destructure/constants.js new file mode 100644 index 000000000..6a53c5d81 --- /dev/null +++ b/tests/corpus/6154-cjs-require-nested-destructure/constants.js @@ -0,0 +1,8 @@ +exports.MAX_LENGTH = 1024; +exports.SEP = "/"; +exports.helper = function (x) { + return x * 3; +}; +exports.bump = function () { + exports.MAX_LENGTH = 2048; +}; diff --git a/tests/corpus/6154-cjs-require-nested-destructure/errors.js b/tests/corpus/6154-cjs-require-nested-destructure/errors.js new file mode 100644 index 000000000..bdddfec3f --- /dev/null +++ b/tests/corpus/6154-cjs-require-nested-destructure/errors.js @@ -0,0 +1,12 @@ +console.log("errors: evaluated"); +const codes = { + ERR_ONE: { code: "ERR_ONE" }, + ERR_TWO: { code: "ERR_TWO" }, +}; +class AbortError extends Error { + constructor() { + super("aborted"); + this.name = "AbortError"; + } +} +module.exports = { codes, AbortError, version: "4.0" }; diff --git a/tests/corpus/6154-cjs-require-nested-destructure/main.js b/tests/corpus/6154-cjs-require-nested-destructure/main.js new file mode 100644 index 000000000..d928d5a27 --- /dev/null +++ b/tests/corpus/6154-cjs-require-nested-destructure/main.js @@ -0,0 +1,14 @@ +// Nested patterns under a named export of a program module destructure +// that export's value at the require (a snapshot), while plain keys stay +// live bindings to the export. +console.log("main: start"); +const { codes: { ERR_ONE, ERR_TWO }, AbortError, version } = require("./errors.js"); +console.log(ERR_ONE.code, ERR_TWO.code, new AbortError().name, version); +const errors = require("./errors.js"); +errors.codes.ERR_ONE = { code: "replaced" }; +console.log(ERR_ONE.code, errors.codes.ERR_ONE.code); +// Destructuring a module BINDING snapshots its exports the same way. +const constants = require("./constants.js"); +const { MAX_LENGTH, SEP: sep, helper } = constants; +constants.bump(); +console.log(MAX_LENGTH, constants.MAX_LENGTH, sep, helper(2)); diff --git a/tests/corpus/6154-cjs-require-nested-destructure/package.json b/tests/corpus/6154-cjs-require-nested-destructure/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6154-cjs-require-nested-destructure/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6155-cjs-export-table-methods/lib.js b/tests/corpus/6155-cjs-export-table-methods/lib.js new file mode 100644 index 000000000..d8aa56495 --- /dev/null +++ b/tests/corpus/6155-cjs-export-table-methods/lib.js @@ -0,0 +1,20 @@ +console.log("lib: start"); +const WIN = { SEP: "\\" }; +const POSIX = { SEP: "/" }; +function describe() { + return "table"; +} +module.exports = { + scale(x) { + return x * 10; + }, + chars(win32) { + return win32 === true ? WIN.SEP : POSIX.SEP; + }, + get label() { + return describe(); + }, + ...require("./more.js"), + k: (console.log("lib: k entry"), 1), +}; +console.log("lib: end"); diff --git a/tests/corpus/6155-cjs-export-table-methods/main.js b/tests/corpus/6155-cjs-export-table-methods/main.js new file mode 100644 index 000000000..b2ce150d0 --- /dev/null +++ b/tests/corpus/6155-cjs-export-table-methods/main.js @@ -0,0 +1,5 @@ +// Export tables with method entries, accessors and spreads of required +// modules: the spread evaluates its module at its place in the table. +console.log("main: start"); +const m = require("./lib.js"); +console.log(m.scale(2), m.chars(true), m.chars(false), m.label, m.extra(), m.k); diff --git a/tests/corpus/6155-cjs-export-table-methods/more.js b/tests/corpus/6155-cjs-export-table-methods/more.js new file mode 100644 index 000000000..4051ce311 --- /dev/null +++ b/tests/corpus/6155-cjs-export-table-methods/more.js @@ -0,0 +1,4 @@ +console.log("more: evaluated"); +exports.extra = function () { + return "more"; +}; diff --git a/tests/corpus/6155-cjs-export-table-methods/package.json b/tests/corpus/6155-cjs-export-table-methods/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6155-cjs-export-table-methods/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6156-cjs-function-members-from-ts/consumer.cjs b/tests/corpus/6156-cjs-function-members-from-ts/consumer.cjs new file mode 100644 index 000000000..12299e189 --- /dev/null +++ b/tests/corpus/6156-cjs-function-members-from-ts/consumer.cjs @@ -0,0 +1,4 @@ +const status = require("./status.cjs"); +exports.describe = function (code) { + return code + " " + status.message[code]; +}; diff --git a/tests/corpus/6156-cjs-function-members-from-ts/main.ts b/tests/corpus/6156-cjs-function-members-from-ts/main.ts new file mode 100644 index 000000000..070d576ff --- /dev/null +++ b/tests/corpus/6156-cjs-function-members-from-ts/main.ts @@ -0,0 +1,9 @@ +// A CommonJS module exporting a function that carries its own members +// (statuses' `status.message = codes; module.exports = status`), read from +// TypeScript through the default import: calls, member reads, member calls. +import status from "./status.cjs"; +import { describe } from "./consumer.cjs"; + +console.log(status(404), status.message[200], status.codes.length, status.isOk(200)); +console.log(status.redirect[301] === true, status.redirect[302]); +console.log(describe(500)); diff --git a/tests/corpus/6156-cjs-function-members-from-ts/package.json b/tests/corpus/6156-cjs-function-members-from-ts/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/6156-cjs-function-members-from-ts/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/6156-cjs-function-members-from-ts/status.cjs b/tests/corpus/6156-cjs-function-members-from-ts/status.cjs new file mode 100644 index 000000000..7a06d9ab1 --- /dev/null +++ b/tests/corpus/6156-cjs-function-members-from-ts/status.cjs @@ -0,0 +1,10 @@ +function status(code) { + return code + 1; +} +status.message = { 200: "OK", 404: "Not Found", 500: "Internal Server Error" }; +status.codes = [200, 404, 500]; +status.redirect = { 301: true, 302: true }; +status.isOk = function (code) { + return code === 200; +}; +module.exports = status; diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index acaa95479..b9fecac04 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2214,6 +2214,13 @@ "6143-cjs-require-json/main.js": 3245, "6144-cjs-require-builtin-nested/main.js": 2181, "6145-cjs-require-cycle/main.js": 3507, + "6150-cjs-inline-require/main.js": 4200, + "6151-cjs-export-chains/main.js": 3000, + "6152-cjs-tsc-interop/main.js": 2600, + "6153-cjs-exports-object/main.js": 2400, + "6154-cjs-require-nested-destructure/main.js": 2800, + "6155-cjs-export-table-methods/main.js": 2600, + "6156-cjs-function-members-from-ts/main.ts": 3000, "700-classes-basic.ts": 2530, "701-classes-composition.ts": 2357, "702-classes-this-capture.ts": 2140, @@ -5244,6 +5251,13 @@ "6143-cjs-require-json/main.js": 3050, "6144-cjs-require-builtin-nested/main.js": 3329, "6145-cjs-require-cycle/main.js": 4761, + "6150-cjs-inline-require/main.js": 9600, + "6151-cjs-export-chains/main.js": 7000, + "6152-cjs-tsc-interop/main.js": 6000, + "6153-cjs-exports-object/main.js": 5600, + "6154-cjs-require-nested-destructure/main.js": 6400, + "6155-cjs-export-table-methods/main.js": 6000, + "6156-cjs-function-members-from-ts/main.ts": 7000, "700-classes-basic.ts": 2674, "701-classes-composition.ts": 2606, "702-classes-this-capture.ts": 2403, diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 51f04d310..564d53bc3 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -972,7 +972,7 @@ describe("refused statements in speculatively lowered JavaScript functions", () `(function () { var root = this; console.log("before"); - console.log(root === module.exports); + console.log(root === eval("this")); console.log("after"); }).call(this); `, @@ -983,7 +983,7 @@ describe("refused statements in speculatively lowered JavaScript functions", () "speculative-apply", `(function () { console.log("before"); - console.log(typeof module.exports); + console.log(typeof eval("1")); }).apply(null, []); `, "before\n", @@ -994,7 +994,7 @@ describe("refused statements in speculatively lowered JavaScript functions", () `class Base {} class Derived extends (function () { console.log("before"); - console.log(typeof module.exports); + console.log(typeof eval("1")); return Base; })() {} console.log(typeof Derived); @@ -1008,8 +1008,88 @@ console.log(typeof Derived); expect(r.stdout).toBe(stdout); expect(r.stderr).toMatch( new RegExp( - `^Uncaught Error: 'module\\.exports' is part of the standard library types but has no scriptc lowering yet \\[SC2020 at .*${name}\\.cjs:${line}\\]\\n$`, + `^Uncaught Error: values of type '\\(x: string\\) => any' cannot be compiled: .* \\[SC2009 at .*${name}\\.cjs:${line}\\]\\n$`, ), ); }); }); + +// CommonJS forms around the module's exports that keep a refusal name the +// construct itself (they used to report an unlowerable `require` value, a +// "binding form with no lowering", or the ES-module `module` message). +describe("CommonJS export-object and inline require refusals", () => { + test.each([ + [ + "exports-as-argument", + `exports.x = 1; +function keys(o) { + return Object.keys(o); +} +console.log("before"); +console.log(keys(exports)); +`, + {}, + "before\n", + 6, + "'exports' as a value other than a member access, comparison, typeof or truthiness test \\(the compiled exports are a static table, not an object: read exports\\.\\)", + ], + [ + "exports-rebinding", + `console.log("before"); +exports = module.exports = function hash(x) { + return x; +}; +exports.extra = 1; +`, + {}, + "before\n", + 2, + "rebinding 'exports' \\(exports = module\\.exports = value\\)", + ], + [ + "require-computed", + `const name = "./lib.cjs"; +console.log("before"); +console.log(require(name).x); +`, + { "lib.cjs": "exports.x = 1;\n" }, + "before\n", + 3, + "require\\(\\) with a computed specifier", + ], + [ + "exports-call-reads-this", + `exports.timeouts = function (n) { + return this.scale * n; +}; +exports.scale = 2; +console.log("before"); +console.log(exports.timeouts(3)); +`, + {}, + "before\n", + 6, + "calling 'exports\\.timeouts' through the module's exports object when the function may read 'this'", + ], + [ + "require-table-as-value", + `function count(o) { + return Object.keys(o).length; +} +console.log("before"); +console.log(count(require("./lib.cjs"))); +`, + { "lib.cjs": "exports.x = 1;\n" }, + "before\n", + 5, + "the exports object of '\\./lib\\.cjs' as a value", + ], + ] as const)("%s", async (name, source, extra, stdout, line, message) => { + const r = await compileAndRun(name, source, "cjs", extra); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(stdout); + expect(r.stderr).toMatch( + new RegExp(`^Uncaught Error: ${message}.*\\[SC1090 at .*${name}\\.cjs:${line}\\]\\n$`), + ); + }); +}); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index c4e70294e..a025d0274 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -43,15 +43,15 @@ const KNOWN: Record = { "cjs-lexer-invisible": { build: "SC2013" }, "commander-calc": { run: "SC1090", argvs: [["--help"]] }, "commander-calc-async": { run: "SC1090", argvs: [["--help"]] }, - "create-require": { run: "SC2009" }, - "dotfolder": { run: "SC2009" }, + "create-require": { run: "SC1090" }, + "dotfolder": { run: "SC1090" }, "dual-package-kinds": { build: "SC2013" }, "dynamic-import": { build: "SC2012" }, "dynamic-import-local": { build: "SC2012" }, "esbuild-require": { build: "SC2013" }, "esm-missing-default": { build: "SC1090" }, "esm-missing-export": { build: "SC1090" }, - "fetch-destructure": { build: "SC1090" }, + "fetch-destructure": { run: "SC1031" }, "fs-shims": { run: "SC2020" }, "hoisted-workspace": { build: "SC2013" }, "import-refusal": { build: "SC2020" }, @@ -65,7 +65,7 @@ const KNOWN: Record = { "misc-shims": { build: "SC2013" }, "namespace": { build: "SC1013" }, "node24-async-events": { run: "SC2020" }, - "node24-commonjs-metadata": { run: "SC1090" }, + "node24-commonjs-metadata": { run: "SC2020" }, "node24-crypto-kdf": { run: "SC2020" }, "node24-deep-equality": { run: "SC2020" }, "node24-diagnostics-async": { run: "SC1090" }, From 78100a4124328b7cd11cebda153ff1bb95d18396 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:29:28 -0700 Subject: [PATCH 26/34] Record ts7 order baselines for the new CommonJS corpus programs A full SCRIPTC_UPDATE_BASELINES=1 re-record of order-parity.test.ts changed no existing baseline; these are the seven new fixtures (6150-6156). 6150's order places late.js after main.js, the lazy inline-require edge. Co-Authored-By: Claude Opus 5.5 --- .../6150-cjs-inline-require/main.js.json | 20 +++++++++++++++++++ .../6151-cjs-export-chains/main.js.json | 10 ++++++++++ .../corpus/6152-cjs-tsc-interop/main.js.json | 9 +++++++++ .../6153-cjs-exports-object/main.js.json | 7 +++++++ .../main.js.json | 8 ++++++++ .../main.js.json | 8 ++++++++ .../main.ts.json | 8 ++++++++ 7 files changed, 70 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6150-cjs-inline-require/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6151-cjs-export-chains/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6152-cjs-tsc-interop/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6153-cjs-exports-object/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6154-cjs-require-nested-destructure/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6155-cjs-export-table-methods/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6156-cjs-function-members-from-ts/main.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6150-cjs-inline-require/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6150-cjs-inline-require/main.js.json new file mode 100644 index 000000000..5f1e322f7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6150-cjs-inline-require/main.js.json @@ -0,0 +1,20 @@ +{ + "order": [ + "/tests/corpus/6150-cjs-inline-require/lib.js", + "/tests/corpus/6150-cjs-inline-require/inner.js", + "/tests/corpus/6150-cjs-inline-require/reexport-inline.js", + "/tests/corpus/6150-cjs-inline-require/reexport-binding.js", + "/tests/corpus/6150-cjs-inline-require/type-error.js", + "/tests/corpus/6150-cjs-inline-require/pick.js", + "/tests/corpus/6150-cjs-inline-require/registry.js", + "/tests/corpus/6150-cjs-inline-require/make-logger.js", + "/tests/corpus/6150-cjs-inline-require/point.js", + "/tests/corpus/6150-cjs-inline-require/shapes.js", + "/tests/corpus/6150-cjs-inline-require/table.js", + "/tests/corpus/6150-cjs-inline-require/verify.js", + "/tests/corpus/6150-cjs-inline-require/fallback.js", + "/tests/corpus/6150-cjs-inline-require/main.js", + "/tests/corpus/6150-cjs-inline-require/late.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6151-cjs-export-chains/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6151-cjs-export-chains/main.js.json new file mode 100644 index 000000000..883aa872a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6151-cjs-export-chains/main.js.json @@ -0,0 +1,10 @@ +{ + "order": [ + "/tests/corpus/6151-cjs-export-chains/chain.js", + "/tests/corpus/6151-cjs-export-chains/constants.js", + "/tests/corpus/6151-cjs-export-chains/re.js", + "/tests/corpus/6151-cjs-export-chains/sem.js", + "/tests/corpus/6151-cjs-export-chains/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6152-cjs-tsc-interop/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6152-cjs-tsc-interop/main.js.json new file mode 100644 index 000000000..19a1b20de --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6152-cjs-tsc-interop/main.js.json @@ -0,0 +1,9 @@ +{ + "order": [ + "/tests/corpus/6152-cjs-tsc-interop/plain.js", + "/tests/corpus/6152-cjs-tsc-interop/stamped.js", + "/tests/corpus/6152-cjs-tsc-interop/fn.js", + "/tests/corpus/6152-cjs-tsc-interop/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6153-cjs-exports-object/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6153-cjs-exports-object/main.js.json new file mode 100644 index 000000000..070b131e3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6153-cjs-exports-object/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6153-cjs-exports-object/lib.js", + "/tests/corpus/6153-cjs-exports-object/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6154-cjs-require-nested-destructure/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6154-cjs-require-nested-destructure/main.js.json new file mode 100644 index 000000000..ee9f71d22 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6154-cjs-require-nested-destructure/main.js.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/6154-cjs-require-nested-destructure/errors.js", + "/tests/corpus/6154-cjs-require-nested-destructure/constants.js", + "/tests/corpus/6154-cjs-require-nested-destructure/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6155-cjs-export-table-methods/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6155-cjs-export-table-methods/main.js.json new file mode 100644 index 000000000..9ac5f0e41 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6155-cjs-export-table-methods/main.js.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/6155-cjs-export-table-methods/more.js", + "/tests/corpus/6155-cjs-export-table-methods/lib.js", + "/tests/corpus/6155-cjs-export-table-methods/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6156-cjs-function-members-from-ts/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6156-cjs-function-members-from-ts/main.ts.json new file mode 100644 index 000000000..e73e603e3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6156-cjs-function-members-from-ts/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/6156-cjs-function-members-from-ts/status.cjs", + "/tests/corpus/6156-cjs-function-members-from-ts/consumer.cjs", + "/tests/corpus/6156-cjs-function-members-from-ts/main.ts" + ], + "diags": [] +} From 87b85d5b28011d4a6c95efe003e9dbd24425cf0a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:34:43 -0700 Subject: [PATCH 27/34] Name the weak collection constructors whole in the dispose gate The runtime ABI test reads every @scr_* spelling in the backend sources as a symbol the backend can emit; the gate's regex spelled a truncated @scr_weak_ prefix. Test the two declarations by their full names. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index a68f008f3..84614b6cb 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2002,7 +2002,9 @@ export class LlEmitter { * builders, JSON schemas and weak collection constructors are requested * lazily. */ private disposeObserverGates(): string[] { - const weak = [...this.decls].some((d) => /@scr_weak_(?:map|set)_new\(/.test(d)); + const weak = [...this.decls].some( + (d) => d.includes("@scr_weak_map_new(") || d.includes("@scr_weak_set_new("), + ); return [ `${WEAK_KEYS_GATE} = internal constant i1 ${weak}`, `${RECORD_SOURCES_GATE} = internal constant i1 ${this.dyn.keepsRecordSources}`, From e90c77d36a07279063bc9f7bd21e80824d221a4e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:52:04 -0700 Subject: [PATCH 28/34] Make selected npm packages join the type-checked program scriptc's resolver and tsgo must answer one runtime file for every opted-in npm edge; four disagreements left the file outside tsgo's program (SC2013 "did not join the type-checked program") for express, axios, body-parser, execa, fast-glob, globby, knex, uuid and tldts: - uuid: the node-condition hoist re-added the inner "types" key after the claims were deleted; tsgo then took the browser build. Delete the claims after the hoist. - tldts: tsgo's TypeScript-first lookup took the shipped root index.ts over "main". The fs shadow hides installed TypeScript sources of opted-in packages that have no same-stem runtime JavaScript. - express: tsgo redirected same-version duplicate installs to one copy, either dropping a copy or merging two Node module instances into one. The shadow serves opted-in package.json files without "version". - deep chains (get-intrinsic, dunder-proto, run-parallel, reusify, supports-color): maxNodeModuleJsDepth charges every node_modules hop. The program-root fixpoint also roots opted-in bare edges' runtime files that the program is missing. All changes are gated on --npm-static being active. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + .../compiler/src/frontend/npm-static.test.ts | 24 ++++- packages/compiler/src/frontend/npm-static.ts | 33 ++++++- packages/compiler/src/frontend/program.ts | 94 +++++++++++++++---- .../fixtures/npm-static/deep-packages-cli.ts | 3 + tests/fixtures/npm-static/dup-install-cli.ts | 4 + .../node_modules/dup-left/index.d.ts | 1 + .../npm-static/node_modules/dup-left/index.js | 5 + .../dup-left/node_modules/dup-leaf/index.js | 6 ++ .../node_modules/dup-leaf/package.json | 5 + .../node_modules/dup-left/package.json | 5 + .../node_modules/dup-right/index.d.ts | 1 + .../node_modules/dup-right/index.js | 5 + .../dup-right/node_modules/dup-leaf/index.js | 6 ++ .../node_modules/dup-leaf/package.json | 5 + .../node_modules/dup-right/package.json | 5 + .../npm-static/node_modules/hop-1/index.d.ts | 1 + .../npm-static/node_modules/hop-1/index.js | 5 + .../node_modules/hop-1/package.json | 5 + .../npm-static/node_modules/hop-10/index.js | 5 + .../node_modules/hop-10/package.json | 5 + .../npm-static/node_modules/hop-11/index.js | 4 + .../node_modules/hop-11/package.json | 5 + .../npm-static/node_modules/hop-2/index.js | 5 + .../node_modules/hop-2/package.json | 5 + .../npm-static/node_modules/hop-3/index.js | 5 + .../node_modules/hop-3/package.json | 5 + .../npm-static/node_modules/hop-4/index.js | 5 + .../node_modules/hop-4/package.json | 5 + .../npm-static/node_modules/hop-5/index.js | 5 + .../node_modules/hop-5/package.json | 5 + .../npm-static/node_modules/hop-6/index.js | 5 + .../node_modules/hop-6/package.json | 5 + .../npm-static/node_modules/hop-7/index.js | 5 + .../node_modules/hop-7/package.json | 5 + .../npm-static/node_modules/hop-8/index.js | 5 + .../node_modules/hop-8/package.json | 5 + .../npm-static/node_modules/hop-9/index.js | 5 + .../node_modules/hop-9/package.json | 5 + .../split-node-types/dist-node/build.js | 3 + .../split-node-types/dist-node/index.js | 2 + .../split-node-types/dist/index.d.ts | 2 + .../split-node-types/dist/index.js | 4 + .../split-node-types/package.json | 16 ++++ .../ts-root-source/dist/cjs/index.js | 6 ++ .../ts-root-source/dist/cjs/src/domain.js | 5 + .../ts-root-source/dist/types/index.d.ts | 1 + .../node_modules/ts-root-source/index.ts | 1 + .../node_modules/ts-root-source/package.json | 7 ++ .../node_modules/ts-root-source/src/domain.ts | 3 + .../npm-static/split-node-types-cli.ts | 3 + .../fixtures/npm-static/ts-root-source-cli.ts | 3 + tests/harness/npm-static.test.ts | 36 ++++++- 53 files changed, 384 insertions(+), 22 deletions(-) create mode 100644 tests/fixtures/npm-static/deep-packages-cli.ts create mode 100644 tests/fixtures/npm-static/dup-install-cli.ts create mode 100644 tests/fixtures/npm-static/node_modules/dup-left/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/dup-left/index.js create mode 100644 tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/index.js create mode 100644 tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/package.json create mode 100644 tests/fixtures/npm-static/node_modules/dup-left/package.json create mode 100644 tests/fixtures/npm-static/node_modules/dup-right/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/dup-right/index.js create mode 100644 tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/index.js create mode 100644 tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/package.json create mode 100644 tests/fixtures/npm-static/node_modules/dup-right/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-1/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/hop-1/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-1/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-10/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-10/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-11/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-11/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-2/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-2/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-3/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-3/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-4/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-4/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-5/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-5/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-6/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-6/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-7/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-7/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-8/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-8/package.json create mode 100644 tests/fixtures/npm-static/node_modules/hop-9/index.js create mode 100644 tests/fixtures/npm-static/node_modules/hop-9/package.json create mode 100644 tests/fixtures/npm-static/node_modules/split-node-types/dist-node/build.js create mode 100644 tests/fixtures/npm-static/node_modules/split-node-types/dist-node/index.js create mode 100644 tests/fixtures/npm-static/node_modules/split-node-types/dist/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/split-node-types/dist/index.js create mode 100644 tests/fixtures/npm-static/node_modules/split-node-types/package.json create mode 100644 tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/index.js create mode 100644 tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/src/domain.js create mode 100644 tests/fixtures/npm-static/node_modules/ts-root-source/dist/types/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/ts-root-source/index.ts create mode 100644 tests/fixtures/npm-static/node_modules/ts-root-source/package.json create mode 100644 tests/fixtures/npm-static/node_modules/ts-root-source/src/domain.ts create mode 100644 tests/fixtures/npm-static/split-node-types-cli.ts create mode 100644 tests/fixtures/npm-static/ts-root-source-cli.ts diff --git a/.gitignore b/.gitignore index e5b54a5fa..2ced4f3c3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/ !tests/corpus/*/node_modules/ !tests/fixtures/npm-static/node_modules/ !tests/fixtures/npm-static/nested/inner/node_modules/ +!tests/fixtures/npm-static/node_modules/*/node_modules/ !tests/fixtures/npm/store/*/node_modules/ !tests/fixtures/npm/cases/*/node_modules/ !tests/library-mode/*/node_modules/ @@ -32,6 +33,7 @@ output/ !tests/fixtures/npm/cases/workspace-copied/members/*/dist/ !tests/fixtures/npm/cases/workspace-copied/node_modules/*/dist/ !tests/fixtures/provenance/node_modules/*/dist/ +!tests/fixtures/npm-static/node_modules/*/dist/ .scriptc/ *.log .DS_Store diff --git a/packages/compiler/src/frontend/npm-static.test.ts b/packages/compiler/src/frontend/npm-static.test.ts index a97b2f41c..0b4614e34 100644 --- a/packages/compiler/src/frontend/npm-static.test.ts +++ b/packages/compiler/src/frontend/npm-static.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { expect, test } from "vitest"; -import { npmStaticIneligibleReason } from "./npm-static.js"; +import { npmStaticIneligibleReason, npmStaticTransformPkgJson } from "./npm-static.js"; test("an unreadable runtime entry is an eligibility refusal", async () => { const dir = await mkdtemp("/tmp/scriptc-npm-static-unreadable-"); @@ -14,3 +14,25 @@ test("an unreadable runtime entry is an eligibility refusal", async () => { await rm(dir, { recursive: true, force: true }); } }); + +test("a hoisted node condition leaves no types claim behind", () => { + // uuid 14: the node object carries its own "types" key. Left in place, + // tsgo maps the hidden declaration to its same-stem browser build. + const pkg: Record = { + types: "./dist/index.d.ts", + exports: { + ".": { + node: { types: "./dist/index.d.ts", default: "./dist-node/index.js" }, + default: "./dist/index.js", + }, + "./package.json": "./package.json", + }, + }; + npmStaticTransformPkgJson(pkg); + expect(pkg).toEqual({ + exports: { + ".": { default: "./dist-node/index.js" }, + "./package.json": "./package.json", + }, + }); +}); diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index d2f4ea3e8..3c8fa22e9 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -157,8 +157,6 @@ export function npmStaticTransformPkgJson(pkg: Record): void { return; } const obj = v as Record; - delete obj["types"]; - delete obj["typings"]; // 0. The "node" condition HOISTS: Node's runtime resolution matches it // (yaml's `{ types, node: "./dist/index.js", default: "./browser/ // index.js" }` runs the node dist; the browser dist is a DIFFERENT @@ -187,6 +185,14 @@ export function npmStaticTransformPkgJson(pkg: Record): void { } } } + // 1. The types claims go AFTER the hoist: a node object's own + // "types" key (uuid's `node: { types: "./dist/index.d.ts", default: + // "./dist-node/index.js" }`) splices in with its siblings, and left + // in place tsgo follows it, maps the hidden declaration to its + // same-stem JS (dist/index.js, the browser build) and disagrees with + // this resolver's runtime answer. + delete obj["types"]; + delete obj["typings"]; for (const value of Object.values(obj)) transform(value); }; if (pkg["exports"] !== undefined) transform(pkg["exports"]); @@ -208,6 +214,13 @@ function npmStaticTransformPkgJsonText(text: string): string { } if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return text; npmStaticTransformPkgJson(doc as Record); + // tsgo deduplicates installs by package id (name + version): a second + // copy of the same name@version (express/node_modules/fresh beside + // send/node_modules/fresh) becomes a redirect to the first copy loaded, + // so its own path never joins the program. Node loads every installed + // copy as its own module, and resolve.ts answers each importer's own + // copy; without a version the checker keeps the copies apart too. + delete (doc as Record)["version"]; return JSON.stringify(doc); } @@ -275,6 +288,20 @@ function packageIsUntyped(path: string): boolean { return untyped; } +/** An installed TypeScript source with no runtime JavaScript at its stem + * (tldts ships `index.ts` beside `main: "dist/cjs/index.js"`). Node never + * executes TypeScript under node_modules and resolve.ts answers only + * runtime JS for opted-in packages, yet tsgo's TypeScript-first lookup + * would take the package-root `index.ts` over `main`. A same-stem JS twin + * keeps the source visible: that pair is the shape npmStaticProgramDep + * accepts in the JavaScript's place. */ +function isUnloadableInstalledTsSource(path: string): boolean { + if (!/\.(ts|tsx|mts|cts)$/.test(path) || isDeclarationFileName(path)) return false; + if (!isNodeModulesPathNorm(path)) return false; + const stem = path.replace(/\.(ts|tsx|mts|cts)$/, ""); + return ![".js", ".jsx", ".mjs", ".cjs"].some((ext) => trackedExists(stem + ext)); +} + export interface NpmStaticFsShadow { /** Shadowed CONTENT for a real path (the types-stripped package.json), * or undefined (no shadow — fall through). */ @@ -383,7 +410,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow const target = shadowTargetOf(path); if (!target) return false; if (target.viaTypes) return true; // the @types twin hides whole - return isDeclarationFileName(path); + return isDeclarationFileName(path) || isUnloadableInstalledTsSource(path); }, }; } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index df0cb673e..d5a0bbd51 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -635,9 +635,14 @@ export function entryPackageFilePredicate(entryPath: string): (file: string) => } /** maxNodeModuleJsDepth can omit relative imports within an admitted - * package. Discover its runtime sources to a fixpoint so the checker and - * module resolver see the same graph regardless of import traversal order. */ -function entryPackageProgramRoots7(program: ts.Program, entryPath: string): string[] { + * package, and opted-in packages deep in the dependency graph. Discover + * their runtime sources to a fixpoint so the checker and module resolver + * see the same graph regardless of import traversal order or depth. */ +function entryPackageProgramRoots7( + program: ts.Program, + entryPath: string, + rootNpmStaticEdges: boolean, +): string[] { const belongsToEntry = entryPackageFilePredicate(entryPath); const roots = new Set(); for (const sf of program.getImplementationSourceFiles()) { @@ -656,15 +661,63 @@ function entryPackageProgramRoots7(program: ts.Program, entryPath: string): stri program.getSourceFile(target) === undefined ) roots.add(target); + // An opted-in package's bare edge counts against the same depth + // budget: tsgo charges every node_modules JS hop, so express's + // body-parser → qs → side-channel → get-intrinsic chain runs out of + // depth before its leaves load. Root the runtime file this edge + // lowers to (preflight's own resolution and miss rule) so the leaf + // joins like a shallow one; complete programs add nothing. Only + // the shadowed lowering world does this (the authored world checks + // user code against declarations), and only from package files: a + // program file's own edges are never deeper than one hop. + // (resolveBareModule directly: resolveNpmImport7's workspace + // registration stays with preflight's own walk.) + if ( + target === null && + rootNpmStaticEdges && + npmStaticPackageOfPath(sf.fileName) !== null && + !isRelativeSpecifier(specifier.text) + ) { + const spec = specifier.text; + if (spec.startsWith("#") || canonicalBuiltinModule(spec) !== null) continue; + const npm = resolveBareModule( + sf.fileName, + spec, + undefined, + moduleSpecifierKind7(specifier), + ); + if ( + npm !== null && + isNpmStaticPackage(npm.packageName) && + (isNodeModulesPath(npm.typesFile) || npm.workspaceDir !== undefined) && + isJsSourceFileName(npm.typesFile) && + npmStaticProgramFile(program, npm.typesFile) === null + ) + roots.add(tsgoPath(resolve(npm.typesFile))); + } } } return [...roots].sort(); } +/** "require" for a CommonJS edge (`require("x")`, `import x = require("x")`), + * "import" for every ESM form — the resolution kind preflight uses. */ +function moduleSpecifierKind7(specifier: ts.StringLiteralLike): "import" | "require" { + const parent = specifier.parent; + if (ts.isExternalModuleReference(parent)) return "require"; + return ts.isCallExpression(parent) && + ts.isIdentifier(parent.expression) && + parent.expression.text === "require" + ? "require" + : "import"; +} + function loadProgram7( host: ts.Ts7Host, entryPath: string, externalTypes: ReadonlyMap = new Map(), + /** The --npm-static lowering world: root opted-in packages' deep edges. */ + rootNpmStaticEdges = false, ): LoadResult & { disposeAll: () => void } { const config = adoptProjectConfig7(host, entryPath); const configuredPaths = config.options["paths"]; @@ -712,7 +765,7 @@ function loadProgram7( try { for (let pass = 0; pass < 32; pass++) { const candidates = [ - ...entryPackageProgramRoots7(program, entryPath), + ...entryPackageProgramRoots7(program, entryPath, rootNpmStaticEdges), ...createRequireProgramRoots7(program), ...forkTargetPaths(program, program.getImplementationSourceFiles()), ...workerTargetPaths(program, program.getImplementationSourceFiles()), @@ -926,7 +979,7 @@ export function loadProgram( }; const host = services.createProgramHost({ cwd: dirname(entryPath), fsShadow }); try { - const load = loadProgram7(host, entryPath, externalTypes); + const load = loadProgram7(host, entryPath, externalTypes, npmStaticPackages.length > 0); let authoredHost: ts.Ts7Host | null = null; let authoredLoad: ReturnType | null = null; return { @@ -4000,6 +4053,24 @@ function locOf7(node: ts.Node): { file: string; start: number; end: number } { return { file: sf.fileName, start: node.getStart(sf), end: node.getEnd() }; } +/** The program file holding an opted-in package's resolved runtime JS: + * the file itself, or its same-stem TypeScript twin. */ +function npmStaticProgramFile(program: ts.Program, resolvedFile: string): ts.SourceFile | null { + let dep = program.getSourceFile(resolvedFile) ?? null; + if (dep === null) { + // A workspace-linked package shipping its .ts SOURCE beside the + // compiled .js: the checker's resolution preferred the source (it + // outranks the sibling .js under bundler resolution), so the program + // holds the .ts twin at the same stem — the same module, in its + // richer spelling. + for (const ext of [".ts", ".tsx", ".mts", ".cts"]) { + dep = program.getSourceFile(resolvedFile.replace(/\.(js|mjs|cjs)$/, ext)) ?? null; + if (dep !== null) break; + } + } + return dep; +} + /** The PROGRAM source file an opted-in --npm-static package's resolved * entry maps to, or null with the package marked an offender (resolution * still answering declarations, or the file missing from the type-checked @@ -4016,18 +4087,7 @@ function npmStaticProgramDep( ); return null; } - let dep = program.getSourceFile(resolvedFile) ?? null; - if (dep === null) { - // A workspace-linked package shipping its .ts SOURCE beside the - // compiled .js: the checker's resolution preferred the source (it - // outranks the sibling .js under bundler resolution), so the program - // holds the .ts twin at the same stem — the same module, in its - // richer spelling. - for (const ext of [".ts", ".tsx", ".mts", ".cts"]) { - dep = program.getSourceFile(resolvedFile.replace(/\.(js|mjs|cjs)$/, ext)) ?? null; - if (dep !== null) break; - } - } + const dep = npmStaticProgramFile(program, resolvedFile); if (dep === null) { reportNpmStaticOffender( packageName, diff --git a/tests/fixtures/npm-static/deep-packages-cli.ts b/tests/fixtures/npm-static/deep-packages-cli.ts new file mode 100644 index 000000000..9b10fdf46 --- /dev/null +++ b/tests/fixtures/npm-static/deep-packages-cli.ts @@ -0,0 +1,3 @@ +import { depth } from "hop-1"; + +console.log(depth()); diff --git a/tests/fixtures/npm-static/dup-install-cli.ts b/tests/fixtures/npm-static/dup-install-cli.ts new file mode 100644 index 000000000..346be854d --- /dev/null +++ b/tests/fixtures/npm-static/dup-install-cli.ts @@ -0,0 +1,4 @@ +import { left } from "dup-left"; +import { right } from "dup-right"; + +console.log(left(), left(), right(), left()); diff --git a/tests/fixtures/npm-static/node_modules/dup-left/index.d.ts b/tests/fixtures/npm-static/node_modules/dup-left/index.d.ts new file mode 100644 index 000000000..f3363e894 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-left/index.d.ts @@ -0,0 +1 @@ +export declare function left(): number; diff --git a/tests/fixtures/npm-static/node_modules/dup-left/index.js b/tests/fixtures/npm-static/node_modules/dup-left/index.js new file mode 100644 index 000000000..ef32a8a0a --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-left/index.js @@ -0,0 +1,5 @@ +const leaf = require("dup-leaf"); +function left() { + return leaf.tick(); +} +exports.left = left; diff --git a/tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/index.js b/tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/index.js new file mode 100644 index 000000000..9246c7839 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/index.js @@ -0,0 +1,6 @@ +let calls = 0; +function tick() { + calls += 1; + return calls; +} +exports.tick = tick; diff --git a/tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/package.json b/tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/package.json new file mode 100644 index 000000000..f01a0c08f --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-left/node_modules/dup-leaf/package.json @@ -0,0 +1,5 @@ +{ + "name": "dup-leaf", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/dup-left/package.json b/tests/fixtures/npm-static/node_modules/dup-left/package.json new file mode 100644 index 000000000..feab6391f --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-left/package.json @@ -0,0 +1,5 @@ +{ + "name": "dup-left", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/dup-right/index.d.ts b/tests/fixtures/npm-static/node_modules/dup-right/index.d.ts new file mode 100644 index 000000000..51ed905a7 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-right/index.d.ts @@ -0,0 +1 @@ +export declare function right(): number; diff --git a/tests/fixtures/npm-static/node_modules/dup-right/index.js b/tests/fixtures/npm-static/node_modules/dup-right/index.js new file mode 100644 index 000000000..d0bba85ad --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-right/index.js @@ -0,0 +1,5 @@ +const leaf = require("dup-leaf"); +function right() { + return leaf.tick(); +} +exports.right = right; diff --git a/tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/index.js b/tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/index.js new file mode 100644 index 000000000..9246c7839 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/index.js @@ -0,0 +1,6 @@ +let calls = 0; +function tick() { + calls += 1; + return calls; +} +exports.tick = tick; diff --git a/tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/package.json b/tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/package.json new file mode 100644 index 000000000..f01a0c08f --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-right/node_modules/dup-leaf/package.json @@ -0,0 +1,5 @@ +{ + "name": "dup-leaf", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/dup-right/package.json b/tests/fixtures/npm-static/node_modules/dup-right/package.json new file mode 100644 index 000000000..9889f0261 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/dup-right/package.json @@ -0,0 +1,5 @@ +{ + "name": "dup-right", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-1/index.d.ts b/tests/fixtures/npm-static/node_modules/hop-1/index.d.ts new file mode 100644 index 000000000..4d0af9c97 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-1/index.d.ts @@ -0,0 +1 @@ +export declare function depth(): number; diff --git a/tests/fixtures/npm-static/node_modules/hop-1/index.js b/tests/fixtures/npm-static/node_modules/hop-1/index.js new file mode 100644 index 000000000..57fdb622e --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-1/index.js @@ -0,0 +1,5 @@ +const next = require("hop-2"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-1/package.json b/tests/fixtures/npm-static/node_modules/hop-1/package.json new file mode 100644 index 000000000..9dda7b293 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-1/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-1", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-10/index.js b/tests/fixtures/npm-static/node_modules/hop-10/index.js new file mode 100644 index 000000000..2fa002968 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-10/index.js @@ -0,0 +1,5 @@ +const next = require("hop-11"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-10/package.json b/tests/fixtures/npm-static/node_modules/hop-10/package.json new file mode 100644 index 000000000..624842847 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-10/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-10", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-11/index.js b/tests/fixtures/npm-static/node_modules/hop-11/index.js new file mode 100644 index 000000000..3af42d043 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-11/index.js @@ -0,0 +1,4 @@ +function depth() { + return 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-11/package.json b/tests/fixtures/npm-static/node_modules/hop-11/package.json new file mode 100644 index 000000000..258cf14d9 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-11/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-11", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-2/index.js b/tests/fixtures/npm-static/node_modules/hop-2/index.js new file mode 100644 index 000000000..d21c4aa70 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-2/index.js @@ -0,0 +1,5 @@ +const next = require("hop-3"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-2/package.json b/tests/fixtures/npm-static/node_modules/hop-2/package.json new file mode 100644 index 000000000..268f49b69 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-2/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-2", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-3/index.js b/tests/fixtures/npm-static/node_modules/hop-3/index.js new file mode 100644 index 000000000..83e9c2655 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-3/index.js @@ -0,0 +1,5 @@ +const next = require("hop-4"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-3/package.json b/tests/fixtures/npm-static/node_modules/hop-3/package.json new file mode 100644 index 000000000..68d73b9be --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-3/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-3", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-4/index.js b/tests/fixtures/npm-static/node_modules/hop-4/index.js new file mode 100644 index 000000000..e6af833de --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-4/index.js @@ -0,0 +1,5 @@ +const next = require("hop-5"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-4/package.json b/tests/fixtures/npm-static/node_modules/hop-4/package.json new file mode 100644 index 000000000..35987d11b --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-4/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-4", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-5/index.js b/tests/fixtures/npm-static/node_modules/hop-5/index.js new file mode 100644 index 000000000..e91733984 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-5/index.js @@ -0,0 +1,5 @@ +const next = require("hop-6"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-5/package.json b/tests/fixtures/npm-static/node_modules/hop-5/package.json new file mode 100644 index 000000000..45ee8a1bf --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-5/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-5", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-6/index.js b/tests/fixtures/npm-static/node_modules/hop-6/index.js new file mode 100644 index 000000000..c55749fc8 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-6/index.js @@ -0,0 +1,5 @@ +const next = require("hop-7"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-6/package.json b/tests/fixtures/npm-static/node_modules/hop-6/package.json new file mode 100644 index 000000000..e663924bd --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-6/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-6", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-7/index.js b/tests/fixtures/npm-static/node_modules/hop-7/index.js new file mode 100644 index 000000000..7438cec8d --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-7/index.js @@ -0,0 +1,5 @@ +const next = require("hop-8"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-7/package.json b/tests/fixtures/npm-static/node_modules/hop-7/package.json new file mode 100644 index 000000000..751f50e6c --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-7/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-7", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-8/index.js b/tests/fixtures/npm-static/node_modules/hop-8/index.js new file mode 100644 index 000000000..f60c54f01 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-8/index.js @@ -0,0 +1,5 @@ +const next = require("hop-9"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-8/package.json b/tests/fixtures/npm-static/node_modules/hop-8/package.json new file mode 100644 index 000000000..df486ce40 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-8/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-8", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/hop-9/index.js b/tests/fixtures/npm-static/node_modules/hop-9/index.js new file mode 100644 index 000000000..36861384c --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-9/index.js @@ -0,0 +1,5 @@ +const next = require("hop-10"); +function depth() { + return next.depth() + 1; +} +exports.depth = depth; diff --git a/tests/fixtures/npm-static/node_modules/hop-9/package.json b/tests/fixtures/npm-static/node_modules/hop-9/package.json new file mode 100644 index 000000000..5a6ffeb84 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/hop-9/package.json @@ -0,0 +1,5 @@ +{ + "name": "hop-9", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/fixtures/npm-static/node_modules/split-node-types/dist-node/build.js b/tests/fixtures/npm-static/node_modules/split-node-types/dist-node/build.js new file mode 100644 index 000000000..a155b52d4 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/split-node-types/dist-node/build.js @@ -0,0 +1,3 @@ +export default function build() { + return "node build"; +} diff --git a/tests/fixtures/npm-static/node_modules/split-node-types/dist-node/index.js b/tests/fixtures/npm-static/node_modules/split-node-types/dist-node/index.js new file mode 100644 index 000000000..1f79b2f93 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/split-node-types/dist-node/index.js @@ -0,0 +1,2 @@ +export { default as build } from "./build.js"; +export const NIL = "node"; diff --git a/tests/fixtures/npm-static/node_modules/split-node-types/dist/index.d.ts b/tests/fixtures/npm-static/node_modules/split-node-types/dist/index.d.ts new file mode 100644 index 000000000..47272b882 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/split-node-types/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare function build(): string; +export declare const NIL: string; diff --git a/tests/fixtures/npm-static/node_modules/split-node-types/dist/index.js b/tests/fixtures/npm-static/node_modules/split-node-types/dist/index.js new file mode 100644 index 000000000..308044e34 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/split-node-types/dist/index.js @@ -0,0 +1,4 @@ +export function build() { + return "browser build"; +} +export const NIL = "browser"; diff --git a/tests/fixtures/npm-static/node_modules/split-node-types/package.json b/tests/fixtures/npm-static/node_modules/split-node-types/package.json new file mode 100644 index 000000000..aaa29cebc --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/split-node-types/package.json @@ -0,0 +1,16 @@ +{ + "name": "split-node-types", + "version": "1.0.0", + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "node": { + "types": "./dist/index.d.ts", + "default": "./dist-node/index.js" + }, + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } +} diff --git a/tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/index.js b/tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/index.js new file mode 100644 index 000000000..aacc3bcce --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/index.js @@ -0,0 +1,6 @@ +"use strict"; +const domain = require("./src/domain.js"); +function getDomain(host) { + return domain.lastTwo(host); +} +exports.getDomain = getDomain; diff --git a/tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/src/domain.js b/tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/src/domain.js new file mode 100644 index 000000000..98b461afa --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/ts-root-source/dist/cjs/src/domain.js @@ -0,0 +1,5 @@ +"use strict"; +function lastTwo(host) { + return host.split(".").slice(-2).join("."); +} +exports.lastTwo = lastTwo; diff --git a/tests/fixtures/npm-static/node_modules/ts-root-source/dist/types/index.d.ts b/tests/fixtures/npm-static/node_modules/ts-root-source/dist/types/index.d.ts new file mode 100644 index 000000000..dac3eccb0 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/ts-root-source/dist/types/index.d.ts @@ -0,0 +1 @@ +export declare function getDomain(host: string): string; diff --git a/tests/fixtures/npm-static/node_modules/ts-root-source/index.ts b/tests/fixtures/npm-static/node_modules/ts-root-source/index.ts new file mode 100644 index 000000000..de03041f8 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/ts-root-source/index.ts @@ -0,0 +1 @@ +export { getDomain } from "./src/domain"; diff --git a/tests/fixtures/npm-static/node_modules/ts-root-source/package.json b/tests/fixtures/npm-static/node_modules/ts-root-source/package.json new file mode 100644 index 000000000..edfff933b --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/ts-root-source/package.json @@ -0,0 +1,7 @@ +{ + "name": "ts-root-source", + "version": "1.0.0", + "main": "dist/cjs/index.js", + "module": "dist/es6/index.js", + "types": "dist/types/index.d.ts" +} diff --git a/tests/fixtures/npm-static/node_modules/ts-root-source/src/domain.ts b/tests/fixtures/npm-static/node_modules/ts-root-source/src/domain.ts new file mode 100644 index 000000000..fe1e5eb18 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/ts-root-source/src/domain.ts @@ -0,0 +1,3 @@ +export function getDomain(host: string): string { + return "TypeScript source: " + host; +} diff --git a/tests/fixtures/npm-static/split-node-types-cli.ts b/tests/fixtures/npm-static/split-node-types-cli.ts new file mode 100644 index 000000000..ecfac96ba --- /dev/null +++ b/tests/fixtures/npm-static/split-node-types-cli.ts @@ -0,0 +1,3 @@ +import { build, NIL } from "split-node-types"; + +console.log(build(), NIL); diff --git a/tests/fixtures/npm-static/ts-root-source-cli.ts b/tests/fixtures/npm-static/ts-root-source-cli.ts new file mode 100644 index 000000000..3741b48e5 --- /dev/null +++ b/tests/fixtures/npm-static/ts-root-source-cli.ts @@ -0,0 +1,3 @@ +import { getDomain } from "ts-root-source"; + +console.log(getDomain("a.b.github.io")); diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index c5021fd06..99d890d4c 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -69,7 +69,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise const hash = createHash("sha256"); const inputs = [ entry, - ...globSync(join(pilotRoot, "**/node_modules/**/*.{js,mjs,cjs,json,d.ts}")).sort(), + ...globSync(join(pilotRoot, "**/node_modules/**/*.{js,mjs,cjs,json,ts}")).sort(), ...globSync( join(fixturesRoot, "commander-calc/node_modules/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), @@ -999,6 +999,40 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { expect(nativeRes.exitCode).toBe(nodeRes.exitCode); }, 180_000); + // The two resolvers must answer one file for every opted-in edge. Each + // shape below once left the runtime file outside tsgo's program (SC2013 + // "did not join the type-checked program"): uuid's node condition with + // declarations in another tree, tldts's package-root TypeScript source + // beside `main`, express's same-version copies of one package (tsgo's + // package-id redirect), and get-intrinsic's dependency chain deeper than + // maxNodeModuleJsDepth. + test.each([ + ["split-node-types-cli.ts", ["split-node-types"]], + ["ts-root-source-cli.ts", ["ts-root-source"]], + ["dup-install-cli.ts", ["dup-left", "dup-right", "dup-leaf"]], + ["deep-packages-cli.ts", Array.from({ length: 11 }, (_, i) => `hop-${i + 1}`)], + ])( + "%s joins the type-checked program and matches Node", + async (name, pkgs) => { + const entry = join(pilotRoot, name); + const { coverage } = analyze(entry, { npmStatic: pkgs }); + expect(coverage.npmStatic.map((p) => [p.package, p.status]).sort()).toEqual( + pkgs.map((p) => [p, "static"]).sort(), + ); + expect(coverage.preflightFailed).toBe(false); + expect(coverage.diagnostics).toHaveLength(0); + const binary = await buildStatic(entry, pkgs); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), + ]); + expect(nativeRes.stdout).toEqual(nodeRes.stdout); + expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, + 180_000, + ); + test("recursive inferred JavaScript results retain their promised ABI", async () => { const entry = join(pilotRoot, "recursive-result-cli.ts"); const binary = await buildStatic(entry, ["recursive-result"]); From 66027d850bf9d6245f0fa8184276cb3aace5da7e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 15:53:37 -0500 Subject: [PATCH 29/34] Explain 6110's deferred-fence count and record its corpus costs Corpus 6110 has three statements with no static lowering, and its @deferred-fences directive is 9 because coverage counts each lowering of a wrapper body: the .call and .apply wrapper bodies are each lowered four times. The program's header now says so. The commit also adds the program's corpus-costs entries, which shard balancing reads. --- tests/corpus/6110-cjs-speculative-wrapper-fences.cjs | 3 +++ tests/harness/corpus-costs.json | 2 ++ 2 files changed, 5 insertions(+) diff --git a/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs index 6040f009c..1b3546518 100644 --- a/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs +++ b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs @@ -4,6 +4,9 @@ // every statement of their bodies. A statement with no static lowering // (here `module.exports` read as a value) compiles to its runtime fence // instead of vanishing; these never run, so the binary matches Node. +// Coverage counts the three fenced statements nine times: each lowering of +// a wrapper body counts its statements, and the `.call` and `.apply` +// wrapper bodies are each lowered four times. const verbose = process.argv.length > 99; (function () { var root = this; diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 5403a1468..18147374d 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2217,6 +2217,7 @@ "6104-typeof-builtin-bindings.mjs": 2031, "6105-cjs-top-level-this/main.js": 1371, "6106-esm-top-level-this.mjs": 1915, + "6110-cjs-speculative-wrapper-fences.cjs": 1980, "6140-cjs-require-tdz-throws/main.js": 2731, "6141-cjs-require-tdz-checked/main.js": 2856, "6142-cjs-require-published-early/main.js": 3400, @@ -5256,6 +5257,7 @@ "6104-typeof-builtin-bindings.mjs": 1715, "6105-cjs-top-level-this/main.js": 3213, "6106-esm-top-level-this.mjs": 1672, + "6110-cjs-speculative-wrapper-fences.cjs": 2500, "6140-cjs-require-tdz-throws/main.js": 2989, "6141-cjs-require-tdz-checked/main.js": 4062, "6142-cjs-require-published-early/main.js": 5156, From 6eb11bf7f4affc14bd0d4c86268ba078aab6d871 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:29:24 -0700 Subject: [PATCH 30/34] Pin frame records that are arms of record unions in the corpus A record shape that is an arm of a union of records carries a hidden shape word at struct index 1, so its fields start at index 2. The new program keeps such records in frame storage while they own string and array fields, reads and writes those fields, passes the shapes through the union, compares identities and serializes them. With the frame storage's reference-field slots addressed at index + 1 instead of the shape's field base, the compiled program segfaults in the plain lane and ASan reports a heap-buffer-overflow in the sanitized one. Co-Authored-By: Claude Opus 5.5 --- ...997-frame-records-in-record-unions.ts.json | 6 ++ .../5997-frame-records-in-record-unions.ts | 88 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5997-frame-records-in-record-unions.ts.json create mode 100644 tests/corpus/5997-frame-records-in-record-unions.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5997-frame-records-in-record-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5997-frame-records-in-record-unions.ts.json new file mode 100644 index 000000000..7e3fb1c50 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5997-frame-records-in-record-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5997-frame-records-in-record-unions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5997-frame-records-in-record-unions.ts b/tests/corpus/5997-frame-records-in-record-unions.ts new file mode 100644 index 000000000..c066c3edb --- /dev/null +++ b/tests/corpus/5997-frame-records-in-record-unions.ts @@ -0,0 +1,88 @@ +// Records that are arms of a union of records and also live in frame +// storage. An arm of such a union carries a hidden shape word before its +// fields, so every field address has to skip it: the reference fields a +// frame record owns are read, overwritten and released through the same +// slots as heap records of the shape. The same shapes also flow through +// the union, are compared by identity and are serialized. +interface Ident { + kind: "ident"; + name: string; + tags: string[]; +} +interface Call { + kind: "call"; + callee: string; + args: Ident[]; +} +type Node = Ident | Call; + +const words = ["alpha", "beta", "gamma", "delta", "epsilon"]; +function word(i: number): string { + return words[i % words.length]! + i; +} + +function describe(node: Node): string { + switch (node.kind) { + case "ident": + return `${node.name}[${node.tags.join(",")}]`; + case "call": + return `${node.callee}(${node.args.map((a) => a.name).join(", ")})`; + } +} +function weight(node: Node): number { + return node.kind === "ident" ? node.name.length + node.tags.length : node.args.length * 10; +} + +// Heap values of the union: the arms are tagged shapes. +const nodes: Node[] = []; +for (let i = 0; i < 6; i++) { + const id: Ident = { kind: "ident", name: word(i), tags: [word(i + 1)] }; + nodes.push(i % 2 === 0 ? id : { kind: "call", callee: word(i + 2), args: [id, id] }); +} +console.log(nodes.map(describe).join(" | ")); + +// Frame records of the arms that only flow into field reads. +let total = 0; +let log = ""; +for (let i = 0; i < 10; i++) { + const w = word(i); + const id: Ident = { kind: "ident", name: w + "!", tags: [w, word(i + 3)] }; + total += id.name.length + id.tags.length + id.tags[1]!.length; + const call: Call = { kind: "call", callee: w.toUpperCase(), args: [] }; + total += call.callee.length + call.args.length; + log += id.kind[0]! + call.kind[0]! + id.name[0]!; +} +console.log(total, log); + +// A reassigned frame binding: each new value is built apart and copied over +// the slot, and the replaced reference fields are released. +let cur: Ident = { kind: "ident", name: word(0), tags: [] }; +for (let i = 1; i < 8; i++) { + cur = { kind: "ident", name: cur.name.slice(0, 3) + word(i), tags: [...cur.tags, cur.name] }; + if (i % 3 === 0) cur = { kind: "ident", name: cur.tags[0] ?? "none", tags: cur.tags.slice(1) }; +} +console.log(cur.name, cur.tags.length, cur.tags.join("+")); + +// Field writes into a frame record's reference slots. +for (let i = 0; i < 4; i++) { + const id: Ident = { kind: "ident", name: word(i), tags: [word(i)] }; + id.name = id.name + "/" + i; + id.tags = [id.name, ...id.tags]; + id.tags.push(word(i + 7)); + console.log(id.name, id.tags.join(" ")); +} + +// Through the union: describe and weigh by union, keep identity, and +// serialize the arms and the union values. +const a: Ident = { kind: "ident", name: "x", tags: ["t1", "t2"] }; +const b: Call = { kind: "call", callee: "f", args: [a] }; +const asNode: Node = a; +const picked: Node = weight(b) > weight(a) ? b : a; +console.log(describe(asNode), describe(b), weight(asNode), weight(picked)); +console.log(asNode === a, picked === b, b.args[0] === a, (picked as Call).args[0] === asNode); +a.tags.push("t3"); +console.log(describe(asNode), describe(picked)); +console.log(JSON.stringify(a), JSON.stringify(picked)); +console.log(JSON.stringify(nodes.slice(0, 2))); +const mixed: Node[] = [a, b, nodes[1]!]; +console.log(mixed.filter((n) => n.kind === "ident").length, mixed.indexOf(asNode), mixed.indexOf(b)); From f4daefa6beaaf8866c65522835181fbab7b17028 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:10:18 -0700 Subject: [PATCH 31/34] Compile overloaded methods on interfaces and object types An interface or type-literal member with several call signatures (the `on("event", handler)` registry shape, arity overloads, type overloads sharing one implementation) failed its whole record shape: the type mapper maps only single-signature function types, so the member mapped to nothing and every type mentioning the interface failed with it. JavaScript stores one function under such a member; overloads only constrain call sites. The member now keeps a record slot holding that function. Its type joins the overloads per parameter position and result: equal types stay, scalar mixes form their union, and other mixes travel as checked `unknown` values when every joined type crosses that boundary both ways (differing callbacks become checked function values that any implementation's callback type adapts from). Positions some overloads omit are optional, and an overload's own type parameters erase to `unknown`. Rest parameters and a void result beside a value result keep the member fenced, now with a diagnostic naming the overload and parameter that block it. Calls convert their arguments into the slot and the result back to the resolved overload's type, on record and union receivers, through optional chains, and on call results. Object-literal methods find their member slot through the literal's contextual type, so an implementation with optional parameters fills an arity overload set (and a single-signature member's slot). Value reads of the member keep SC2007. Library, declaration-file and class overload declarations are unchanged. The zero-cost gate passes against the parent commit with all 2379 programs identical. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 18 + .../src/frontend/lowering/lower-calls.ts | 53 +- .../compiler/src/frontend/lowering/lowerer.ts | 11 + packages/compiler/src/frontend/type-mapper.ts | 479 ++++++++++++------ .../6180-interface-overload-events.ts.json | 6 + .../6181-interface-overload-arity.ts.json | 6 + .../6182-interface-overload-types.ts.json | 6 + .../6183-interface-overload-flow.ts.json | 6 + .../diagnostics/overloaded-members.ts.json | 6 + .../corpus/6180-interface-overload-events.ts | 95 ++++ tests/corpus/6181-interface-overload-arity.ts | 56 ++ tests/corpus/6182-interface-overload-types.ts | 61 +++ tests/corpus/6183-interface-overload-flow.ts | 89 ++++ tests/diagnostics/overloaded-members.ts | 25 + .../__snapshots__/overloaded-members.ts.txt | 35 ++ 15 files changed, 794 insertions(+), 158 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-interface-overload-events.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-interface-overload-arity.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-interface-overload-types.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-interface-overload-flow.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/overloaded-members.ts.json create mode 100644 tests/corpus/6180-interface-overload-events.ts create mode 100644 tests/corpus/6181-interface-overload-arity.ts create mode 100644 tests/corpus/6182-interface-overload-types.ts create mode 100644 tests/corpus/6183-interface-overload-flow.ts create mode 100644 tests/diagnostics/overloaded-members.ts create mode 100644 tests/harness/__snapshots__/overloaded-members.ts.txt diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 8e30e8b82..c1cc72021 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -503,6 +503,24 @@ export function requireExactArityValue( ? lowerer.mapTypeOf(lowerer.typeOf(contextual)) : null; if (mapped && typeEquals(mapped, funcType)) return; + // An object-literal METHOD has no contextual type of its own: the + // literal's contextual type names the member slot it fills. An + // overloaded member's slot holds its erased function, which spells the + // completed parameters of an implementation like `m(a?: string)`. + if ( + contextual === null && + ts.isMethodDeclaration(blame) && + blame.parent !== undefined && + ts.isObjectLiteralExpression(blame.parent) && + (ts.isIdentifier(blame.name) || ts.isStringLiteral(blame.name)) + ) { + const literalType = lowerer.checker.getContextualType(blame.parent); + const member = literalType + ? lowerer.checker.getPropertyOfType(literalType, blame.name.text) + : undefined; + const slot = member ? lowerer.memberSlotTypeOf(lowerer.checker.getTypeOfSymbol(member)) : null; + if (slot && typeEquals(slot, funcType)) return; + } // A union-typed slot (`runner || defaultRunner` under a // `CommandRunner | undefined` context): the value can only inhabit // the union's one func arm — judge by it. diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..3589c960b 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -61,7 +61,11 @@ import { lowerProtocolMethodCall } from "./class-protocol-dispatch.js"; import { isJsSourceFile, locOf, requireSpecOf } from "../program.js"; import { lowerJsonRequireCall } from "./json-require.js"; import { lowerAssertInstanceCall } from "./assert-instances.js"; -import { isGenericCallableMemberType, typeKey } from "../type-mapper.js"; +import { + isGenericCallableMemberType, + isOverloadedCallableMemberType, + typeKey, +} from "../type-mapper.js"; import { dynFallbackType, jsFuncNameOf, newFnCtx, staticImportNamespaceType } from "./lowerer.js"; import { NARROW_FIRST, @@ -8185,6 +8189,30 @@ function lowerObjectAssignHybrid(lowerer: Lowerer, call: ts.CallExpression): IrE return { kind: "recordLit", fields, type: mapped, loc }; } +/** The accessed member is an overload set (isOverloadedCallableMemberType), + * also under an optional chain whose access still spells `| undefined`. */ +function isOverloadedMemberAccess(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean { + return isOverloadedCallableMemberType( + lowerer.checker.getNonNullableType(lowerer.typeOf(access)), + lowerer.checker, + ); +} + +/** The result type of the overload a call through an overloaded member + * resolved to: the erased slot's result converts to it. A non-generic + * overload answers through its declaration, because the resolved signature + * of an optional-chain call (`api?.m(x)`) also carries the chain's + * undefined; a generic one answers through its instantiation. */ +function resolvedOverloadResultType(lowerer: Lowerer, call: ts.CallExpression): IrType { + const { checker } = lowerer; + const signature = checker.getResolvedSignature(call); + const decl = signature ? checker.signatureDeclaration(signature) : undefined; + const declared = decl ? checker.getSignatureFromDeclaration(decl) : undefined; + const source = declared && declared.getTypeParameters().length === 0 ? declared : signature; + const result = source ? lowerer.mapTypeOf(checker.getReturnTypeOfSignature(source)) : null; + return result ?? lowerer.irTypeOf(call); +} + export function lowerRecordFieldCall( lowerer: Lowerer, call: ts.CallExpression, @@ -8216,7 +8244,18 @@ export function lowerRecordFieldCall( const callee = lowerer.lowerUnionProperty(access); if (callee?.type.kind !== "func") return null; const args = completeFuncValueArgs(lowerer, call, callee.type, locOf(call)); - return { kind: "callValue", callee, args, type: callee.type.ret, loc: locOf(call) }; + const value: IrExpr = { + kind: "callValue", + callee, + args, + type: callee.type.ret, + loc: locOf(call), + }; + // An overloaded member's erased result converts to the resolved + // overload's type, as on a single record receiver below. + return isOverloadedMemberAccess(lowerer, access) + ? lowerer.coerceInto(call, value, resolvedOverloadResultType(lowerer, call)) + : value; } const target = lowerer.fieldTarget(access); const loc = locOf(call); @@ -8291,10 +8330,14 @@ export function lowerRecordFieldCall( type: callee.type.ret, loc, }; - // A generic member's field holds its erased instance: the result - // converts to this call's instantiated return type at the call. - if (isGenericCallableMemberType(lowerer.typeOf(access), lowerer.checker)) + // A generic member's field holds its erased instance, and an overloaded + // member's field its erased function (the overloads joined): the result + // converts to this call's instantiated or resolved return type. + const memberType = lowerer.typeOf(access); + if (isGenericCallableMemberType(memberType, lowerer.checker)) result = lowerer.coerceInto(call, result, lowerer.irTypeOf(call)); + else if (isOverloadedMemberAccess(lowerer, access)) + result = lowerer.coerceInto(call, result, resolvedOverloadResultType(lowerer, call)); return init ? { kind: "seqExpr", stmts: [init], result, type: result.type, loc } : result; } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 94d526831..9b5b47a6b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -230,9 +230,11 @@ import { containsUnion, describeComponentBlocker, describeRecordMemberBlocker, + erasedOverloadMemberType, formatIrType, FETCH_AMBIENT_TYPES, isOsConstantsSymbol, + isOverloadedCallableMemberType, isUnitOnlyTsType, isWorkerHandleType, jsOpenObjectType, @@ -7679,6 +7681,15 @@ export class Lowerer { : mapped; } + /** The runtime type of a record member slot typed `t`: an overloaded + * member's erased function (erasedOverloadMemberType — the type a record + * shape stores for it), otherwise mapTypeOf. */ + memberSlotTypeOf(t: ts.Type): IrType | null { + if (isOverloadedCallableMemberType(t, this.checker)) + return erasedOverloadMemberType(t, this.typeCtx) ?? null; + return this.mapTypeOf(t); + } + /** The one position where a contextual UNION must not be adopted over the * expression's own: the LEFT operand of `&&`, `||`, or `??`. Adopting a * contextual union is normally safe because tsc proved the value diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index f37703fd8..c972b324c 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -9,6 +9,7 @@ import { BOOL, bytesOf, canConvertToDyn, + canDynCheckTo, CHILD_T, CRYPTOHASH_T, CRYPTOHMAC_T, @@ -3892,158 +3893,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (hybrid) return hybrid; } const callSigs = checker.getCallSignatures(widened); - if (callSigs.length === 1) { - const sig = callSigs[0]!; - if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null; - // A SYNTHESIZED rest param (tsc's JS inference for a function body - // reading `arguments` — `(...args: any[]) => any` whose args symbol - // has no declaration): the dotDotDot check below can't see it, and a - // fixed-arity func mapping would LIE about the value's calling - // convention. Detected by the param-count mismatch against the - // signature's own declaration, excluding an erased `this` parameter. - { - const sigDecl = checker.signatureDeclaration(sig); - const declParams = - sigDecl !== undefined && ts.isFunctionLike(sigDecl) ? sigDecl.parameters : undefined; - if ( - declParams !== undefined && - runtimeParameterCount(declParams) !== sig.getParameters().length - ) { - return null; - } - // tsgo never SYNTHESIZES that rest param into the inferred signature - // (5.9.3 did — the count mismatch above was the whole detector - // there), so the declaration's own body answers directly. - if (sigDecl !== undefined && ts.isFunctionLike(sigDecl) && bodyReadsArguments(sigDecl)) { - return null; - } - } - const params: IrType[] = []; - let typedRest = false; - for (const p of sig.getParameters()) { - const decl = checker.valueDeclarationOf(p); - const rest = decl !== undefined && ts.isParameter(decl) && decl.dotDotDotToken !== undefined; - - // arity ABI selected by lambdaSignature. Treating an inferred any[] - // declaration as a typed packed-array closure would bypass its boxed - // spread/error semantics and change coverage classification. - if (rest && decl !== undefined && isJsSourceFile(decl.getSourceFile())) return null; - const optional = - decl !== undefined && - ts.isParameter(decl) && - (decl.questionToken !== undefined || decl.initializer !== undefined); - let pt = mapType(checker.getTypeOfSymbol(p), ctx); - if ( - decl && - ts.isParameter(decl) && - ts.isSetAccessorDeclaration(decl.parent) && - isJsSourceFile(decl.getSourceFile()) - ) - pt = DYN; - // An inferred JavaScript binding pattern consumes an iterable; it - // does not require the caller's array to have exactly this length. - const executor = decl && ts.isParameter(decl) ? decl.parent : null; - const construction = executor?.parent; - const constructorSymbol = - construction && ts.isNewExpression(construction) - ? checker.getSymbolAtLocation(construction.expression) - : undefined; - const nativeExecutor = - executor && - (ts.isArrowFunction(executor) || ts.isFunctionExpression(executor)) && - construction && - ts.isNewExpression(construction) && - construction.arguments?.[0] === executor && - ts.isIdentifier(construction.expression) && - construction.expression.text === "Promise" && - constructorSymbol && - checker - .declarationsOf(constructorSymbol) - .some((site) => ctx.isStdlibFile(site.getSourceFile())); - if ( - !nativeExecutor && - decl && - ts.isParameter(decl) && - !decl.dotDotDotToken && - !ts.isSetAccessorDeclaration(decl.parent) && - isJsSourceFile(decl.getSourceFile()) && - !decl.type && - !/@(?:param|type)\b/.test( - decl.getSourceFile().text.slice(decl.parent?.pos ?? decl.pos, decl.getStart()), - ) - ) - pt = DYN; - if (pt) pt = jsOpenObjectType(decl, pt, ctx.shapes, ctx.unions); - // Belt and braces for non-strict type worlds: an optional param's - // ABI slot is always the undefined-armed union (strictNullChecks - // already spells it that way; arm it here if the world didn't). - if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn") { - const armed = withUndefinedArm(pt, ctx.unions); - if (!armed) return null; - pt = armed; - } - if (!pt) return null; - if (rest) { - if (pt.kind !== "array") return null; - typedRest = true; - } - // `(value: void) => void` (Promise's resolve) is callable with - // no arguments — a void param is dropped, not a mapping failure. - if (pt.kind === "void") continue; - params.push(pt); - } - const retT = checker.getReturnTypeOfSignature(sig); - // `() => never` (a throw-only lambda's inferred type) is assignable to - // `() => void` and its calls never produce a value — map the return - // like declaredReturnType does for declarations. JS null/undefined - // returns can read mutable fields, so callable values use the same - // checked-native return ABI as their implementations. - const sigDecl = checker.signatureDeclaration(sig); - const jsUnitReturn = - sigDecl !== undefined && - isJsSourceFile(sigDecl.getSourceFile()) && - (retT.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0; - let ret = - jsUnitReturn || (retT.flags & ts.TypeFlags.Any) !== 0 - ? DYN - : retT.flags & ts.TypeFlags.Never - ? VOID - : mapType(retT, ctx); - if (sigDecl && ts.isGetAccessorDeclaration(sigDecl) && isJsSourceFile(sigDecl.getSourceFile())) - ret = DYN; - if (sigDecl && isJsSourceFile(sigDecl.getSourceFile()) && ret?.kind === "array") ret = DYN; - if ( - sigDecl && - ts.isFunctionLike(sigDecl) && - isJsSourceFile(sigDecl.getSourceFile()) && - !sigDecl.type && - !/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) && - ret?.kind === "record" - ) - ret = DYN; - if ( - sigDecl && - ts.isFunctionLike(sigDecl) && - isJsSourceFile(sigDecl.getSourceFile()) && - !sigDecl.type && - !/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) && - (ret?.kind === "date" || - (ret?.kind === "union" && - ctx.unions.get(ret.unionId)?.arms.some((arm) => arm.kind === "func"))) - ) - ret = DYN; - if ( - ret && - sigDecl && - isJsSourceFile(sigDecl.getSourceFile()) && - functionCanReturnUndefined(sigDecl) - ) - ret = withUndefinedArm(ret, ctx.unions) ?? ret; - if (!ret) return null; - return typedRest - ? { kind: "func", params, ret, rest: true, restAbi: "typed" } - : funcOf(params, ret); - } + if (callSigs.length === 1) return mapCallSignature(callSigs[0]!, ctx); // Records: object types whose members are all data properties (shorthand // methods in type position count — they're func-typed fields) with // mappable types; no call/construct signatures, no index signatures, not @@ -4317,6 +4167,162 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return null; } +/** One non-overloaded call signature's func type (mapTypeInner's + * single-signature rule; erasedOverloadMemberType maps each overload of a + * member through it). */ +function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null { + const { checker } = ctx; + if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null; + // A SYNTHESIZED rest param (tsc's JS inference for a function body + // reading `arguments` — `(...args: any[]) => any` whose args symbol + // has no declaration): the dotDotDot check below can't see it, and a + // fixed-arity func mapping would LIE about the value's calling + // convention. Detected by the param-count mismatch against the + // signature's own declaration, excluding an erased `this` parameter. + { + const sigDecl = checker.signatureDeclaration(sig); + const declParams = + sigDecl !== undefined && ts.isFunctionLike(sigDecl) ? sigDecl.parameters : undefined; + if ( + declParams !== undefined && + runtimeParameterCount(declParams) !== sig.getParameters().length + ) { + return null; + } + // tsgo never SYNTHESIZES that rest param into the inferred signature + // (5.9.3 did — the count mismatch above was the whole detector + // there), so the declaration's own body answers directly. + if (sigDecl !== undefined && ts.isFunctionLike(sigDecl) && bodyReadsArguments(sigDecl)) { + return null; + } + } + const params: IrType[] = []; + let typedRest = false; + for (const p of sig.getParameters()) { + const decl = checker.valueDeclarationOf(p); + const rest = decl !== undefined && ts.isParameter(decl) && decl.dotDotDotToken !== undefined; + + // arity ABI selected by lambdaSignature. Treating an inferred any[] + // declaration as a typed packed-array closure would bypass its boxed + // spread/error semantics and change coverage classification. + if (rest && decl !== undefined && isJsSourceFile(decl.getSourceFile())) return null; + const optional = + decl !== undefined && + ts.isParameter(decl) && + (decl.questionToken !== undefined || decl.initializer !== undefined); + let pt = mapType(checker.getTypeOfSymbol(p), ctx); + if ( + decl && + ts.isParameter(decl) && + ts.isSetAccessorDeclaration(decl.parent) && + isJsSourceFile(decl.getSourceFile()) + ) + pt = DYN; + // An inferred JavaScript binding pattern consumes an iterable; it + // does not require the caller's array to have exactly this length. + const executor = decl && ts.isParameter(decl) ? decl.parent : null; + const construction = executor?.parent; + const constructorSymbol = + construction && ts.isNewExpression(construction) + ? checker.getSymbolAtLocation(construction.expression) + : undefined; + const nativeExecutor = + executor && + (ts.isArrowFunction(executor) || ts.isFunctionExpression(executor)) && + construction && + ts.isNewExpression(construction) && + construction.arguments?.[0] === executor && + ts.isIdentifier(construction.expression) && + construction.expression.text === "Promise" && + constructorSymbol && + checker + .declarationsOf(constructorSymbol) + .some((site) => ctx.isStdlibFile(site.getSourceFile())); + if ( + !nativeExecutor && + decl && + ts.isParameter(decl) && + !decl.dotDotDotToken && + !ts.isSetAccessorDeclaration(decl.parent) && + isJsSourceFile(decl.getSourceFile()) && + !decl.type && + !/@(?:param|type)\b/.test( + decl.getSourceFile().text.slice(decl.parent?.pos ?? decl.pos, decl.getStart()), + ) + ) + pt = DYN; + if (pt) pt = jsOpenObjectType(decl, pt, ctx.shapes, ctx.unions); + // Belt and braces for non-strict type worlds: an optional param's + // ABI slot is always the undefined-armed union (strictNullChecks + // already spells it that way; arm it here if the world didn't). + if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn") { + const armed = withUndefinedArm(pt, ctx.unions); + if (!armed) return null; + pt = armed; + } + if (!pt) return null; + if (rest) { + if (pt.kind !== "array") return null; + typedRest = true; + } + // `(value: void) => void` (Promise's resolve) is callable with + // no arguments — a void param is dropped, not a mapping failure. + if (pt.kind === "void") continue; + params.push(pt); + } + const retT = checker.getReturnTypeOfSignature(sig); + // `() => never` (a throw-only lambda's inferred type) is assignable to + // `() => void` and its calls never produce a value — map the return + // like declaredReturnType does for declarations. JS null/undefined + // returns can read mutable fields, so callable values use the same + // checked-native return ABI as their implementations. + const sigDecl = checker.signatureDeclaration(sig); + const jsUnitReturn = + sigDecl !== undefined && + isJsSourceFile(sigDecl.getSourceFile()) && + (retT.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0; + let ret = + jsUnitReturn || (retT.flags & ts.TypeFlags.Any) !== 0 + ? DYN + : retT.flags & ts.TypeFlags.Never + ? VOID + : mapType(retT, ctx); + if (sigDecl && ts.isGetAccessorDeclaration(sigDecl) && isJsSourceFile(sigDecl.getSourceFile())) + ret = DYN; + if (sigDecl && isJsSourceFile(sigDecl.getSourceFile()) && ret?.kind === "array") ret = DYN; + if ( + sigDecl && + ts.isFunctionLike(sigDecl) && + isJsSourceFile(sigDecl.getSourceFile()) && + !sigDecl.type && + !/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) && + ret?.kind === "record" + ) + ret = DYN; + if ( + sigDecl && + ts.isFunctionLike(sigDecl) && + isJsSourceFile(sigDecl.getSourceFile()) && + !sigDecl.type && + !/@returns?\b/.test(sigDecl.getSourceFile().text.slice(sigDecl.pos, sigDecl.getStart())) && + (ret?.kind === "date" || + (ret?.kind === "union" && + ctx.unions.get(ret.unionId)?.arms.some((arm) => arm.kind === "func"))) + ) + ret = DYN; + if ( + ret && + sigDecl && + isJsSourceFile(sigDecl.getSourceFile()) && + functionCanReturnUndefined(sigDecl) + ) + ret = withUndefinedArm(ret, ctx.unions) ?? ret; + if (!ret) return null; + return typedRest + ? { kind: "func", params, ret, rest: true, restAbi: "typed" } + : funcOf(params, ret); +} + /** Retain a literal discriminator before widening erases its values. A * structural match alone is insufficient: `{ kind: "loop", body: ... }` * also fits a smaller `{ kind: "empty" }` record. Choosing that layout @@ -5551,6 +5557,164 @@ export function erasedGenericMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType return erased?.kind === "func" ? erased : undefined; } +/** An OVERLOADED callable member type (`on(event: "a", h: (x: number) => + * void): () => void; on(event: "b", h: (x: string) => void): () => void` + * in a program interface or type literal): two or more call signatures and + * nothing else (no data properties, construct signatures, or index + * signatures), every one declared in TypeScript program source. Such a + * member keeps a record slot holding its erased runtime function + * (erasedOverloadMemberType); calls convert per their resolved overload + * (lowerRecordFieldCall). */ +export function isOverloadedCallableMemberType(t: ts.Type, checker: ts.TypeChecker): boolean { + const sigs = checker.getCallSignatures(t); + if (sigs.length < 2) return false; + if ( + checker.getConstructSignatures(t).length > 0 || + checker.getPropertiesOfType(t).length > 0 || + checker.getIndexInfosOfType(t).length > 0 + ) + return false; + return sigs.every((sig) => { + const decl = checker.signatureDeclaration(sig); + if (decl === undefined) return false; + if (!ts.isMethodSignatureDeclaration(decl) && !ts.isCallSignatureDeclaration(decl)) + return false; + const file = decl.getSourceFile(); + return !file.isDeclarationFile && !isJsSourceFile(file); + }); +} + +/** Scalar arms an overload join may combine into one union. */ +const OVERLOAD_JOIN_SCALARS = new Set(["f64", "string", "bool", "undefinedT", "nullT"]); + +/** The join of one position's types across an overload set: equal types + * stay; scalar mixes form their union; anything else is `unknown` when + * every joined type crosses the checked-dynamic boundary in both directions + * (the call site converts into the slot, the implementation converts out + * of it). Differing callbacks (`(x: number) => void` beside `(x: string) => + * void`) therefore travel as checked function values, which any + * implementation's own callback type adapts from. Null when no join + * exists. */ +function joinOverloadTypes(types: readonly IrType[], ctx: TypeMapperCtx): IrType | null { + const first = types[0]!; + if (types.every((t) => typeEquals(t, first))) return first; + const getRecord = (id: string) => ctx.shapes.get(id); + const getUnion = (id: string) => ctx.unions.get(id); + const arms = new Map(); + let scalar = true; + for (const t of types) { + const parts = t.kind === "union" ? (ctx.unions.get(t.unionId)?.arms ?? []) : [t]; + if (parts.length === 0) scalar = false; + for (const part of parts) { + if (!OVERLOAD_JOIN_SCALARS.has(part.kind)) scalar = false; + arms.set(typeKey(part), part); + } + } + if (scalar && [...arms.values()].some((arm) => !isUnitType(arm))) { + const sorted = [...arms.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return { kind: "union", unionId: ctx.unions.intern(sorted) }; + } + if ( + types.every( + (t) => + t.kind === "dyn" || + (t.kind !== "void" && + canConvertToDyn(t, getRecord, getUnion) && + canDynCheckTo(t, getRecord, getUnion)), + ) + ) + return DYN; + return null; +} + +/** An overloaded member's runtime type (isOverloadedCallableMemberType). + * JavaScript stores ONE function under the member; overload signatures only + * constrain call sites. The slot's type joins the overloads per parameter + * position and result (joinOverloadTypes), and a position some overloads + * omit is optional. Each overload's own type parameters erase to `unknown` + * first. Calls pick their overload statically, convert arguments into the + * slot's parameter types and the result back to the overload's; an + * implementation converts into the slot like any function value. Undefined + * when the set does not join (rest parameters, a void result beside a + * value result, a component outside the checked-dynamic boundary): the + * member keeps its fence. */ +export function erasedOverloadMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType | undefined { + const funcs: (IrType & { kind: "func" })[] = []; + for (const sig of ctx.checker.getCallSignatures(t)) { + const own = new Set(sig.getTypeParameters()); + const outer = ctx.resolveTypeParam; + const mapped = mapCallSignature( + sig, + own.size === 0 + ? ctx + : { + ...ctx, + erasingSignature: sig, + resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)), + }, + ); + if (mapped?.kind !== "func" || mapped.rest === true || mapped.argumentsAll === true) + return undefined; + funcs.push(mapped); + } + const arity = Math.max(...funcs.map((f) => f.params.length)); + const params: IrType[] = []; + for (let i = 0; i < arity; i++) { + const present = funcs.filter((f) => i < f.params.length).map((f) => f.params[i]!); + let param = joinOverloadTypes(present, ctx); + if (param !== null && present.length < funcs.length && param.kind !== "dyn") + param = withUndefinedArm(param, ctx.unions); + if (param === null) return undefined; + params.push(param); + } + const voids = funcs.filter((f) => f.ret.kind === "void").length; + if (voids !== 0 && voids !== funcs.length) return undefined; + const ret = + voids === funcs.length + ? VOID + : joinOverloadTypes( + funcs.map((f) => f.ret), + ctx, + ); + if (ret === null) return undefined; + return funcOf(params, ret); +} + +/** Why an overloaded member's set has no erased type + * (erasedOverloadMemberType): the first overload, and within it the first + * parameter or result, that does not map; otherwise the join itself. A + * message tail after "its member 'name'". */ +function describeOverloadSetBlocker(t: ts.Type, ctx: TypeMapperCtx): string { + const { checker } = ctx; + for (const sig of checker.getCallSignatures(t)) { + const own = new Set(sig.getTypeParameters()); + const outer = ctx.resolveTypeParam; + const sigCtx: TypeMapperCtx = + own.size === 0 + ? ctx + : { + ...ctx, + erasingSignature: sig, + resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)), + }; + const mapped = mapCallSignature(sig, sigCtx); + if (mapped?.kind === "func" && mapped.rest !== true && mapped.argumentsAll !== true) continue; + const decl = checker.signatureDeclaration(sig); + const spelled = decl ? decl.getText().replace(/\s+/g, " ").replace(/;$/, "") : ""; + const which = spelled ? `overload '${spelled}'` : "one overload"; + if (mapped?.kind === "func") + return `is an overload set, and its ${which} has a rest parameter (an overload set stores one fixed-arity function)`; + for (const param of sig.getParameters()) { + const paramTs = checker.getTypeOfSymbol(param); + if (!mapType(paramTs, sigCtx)) + return `is an overload set, and its ${which} has parameter '${param.name}' of type '${checker.typeToString(paramTs)}', which does not compile`; + } + const retTs = checker.getReturnTypeOfSignature(sig); + return `is an overload set, and its ${which} returns '${checker.typeToString(retTs)}', which does not compile`; + } + return "is an overload set whose signatures do not join into one stored function (a void result beside a value result, or positions whose differing types do not cross the checked-dynamic boundary)"; +} + /** The computed pieces of a record shape mapRecordTypeInner hands back for * the OUTER frame to intern — or, when a back-reference minted a recursive * placeholder for the type mid-construction, to finalize INTO that @@ -5932,9 +6096,14 @@ function mapRecordTypeInner( // can hold them — see isGenericCallableMemberType): the shape keeps // its data fields, and calls of the member monomorphize per call // site against the defining object literal's declaration. + // OVERLOADED members hold their erased runtime function (see + // erasedOverloadMemberType); a set that does not join fails the + // shape as before. let pt = isGenericCallableMemberType(fieldTs, checker) ? erasedGenericMemberType(fieldTs, ctx) - : mapType(fieldTs, ctx); + : isOverloadedCallableMemberType(fieldTs, checker) + ? (erasedOverloadMemberType(fieldTs, ctx) ?? null) + : mapType(fieldTs, ctx); if (pt === undefined) continue; // tsgo PANICS computing `readonly []` through the symbol-type query // (the TupleType conversion — the facade's panic fence answers @@ -6255,6 +6424,10 @@ export function describeRecordMemberBlocker(widened: ts.Type, ctx: TypeMapperCtx return null; } const fieldTs = checker.getTypeOfSymbol(p); + if (isOverloadedCallableMemberType(fieldTs, checker)) { + if (erasedOverloadMemberType(fieldTs, ctx) !== undefined) continue; + return `the record shape is supported, but its member '${p.name}' ${describeOverloadSetBlocker(fieldTs, ctx)}`; + } let pt = isGenericCallableMemberType(fieldTs, checker) ? erasedGenericMemberType(fieldTs, ctx) : mapType(fieldTs, ctx); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-interface-overload-events.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-interface-overload-events.ts.json new file mode 100644 index 000000000..28b4381d2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6180-interface-overload-events.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6180-interface-overload-events.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-interface-overload-arity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-interface-overload-arity.ts.json new file mode 100644 index 000000000..919c56254 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6181-interface-overload-arity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6181-interface-overload-arity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-interface-overload-types.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-interface-overload-types.ts.json new file mode 100644 index 000000000..249d2084e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6182-interface-overload-types.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6182-interface-overload-types.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-interface-overload-flow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-interface-overload-flow.ts.json new file mode 100644 index 000000000..b63d2780c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6183-interface-overload-flow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6183-interface-overload-flow.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/overloaded-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/overloaded-members.ts.json new file mode 100644 index 000000000..33ea8a976 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/overloaded-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/overloaded-members.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6180-interface-overload-events.ts b/tests/corpus/6180-interface-overload-events.ts new file mode 100644 index 000000000..d76a118bc --- /dev/null +++ b/tests/corpus/6180-interface-overload-events.ts @@ -0,0 +1,95 @@ +// An interface method with literal-discriminated overloads (the event +// registry shape: on("x", (e: X) => R)) holds ONE function at runtime, as +// JavaScript does. Each call site resolves its overload statically; handlers +// whose types differ per overload cross into the stored function as checked +// function values, and the implementation receives them in its own +// callback type. Events keep their identity across that boundary. +export {}; + +interface SaveEvent { + type: "save"; + path: string; + cancel: boolean; +} +interface LoadEvent { + type: "load"; + path: string; +} +interface Context { + cwd: string; +} +interface Api { + on(event: "save", handler: (e: SaveEvent, ctx: Context) => void): () => void; + on(event: "load", handler: (e: LoadEvent, ctx: Context) => boolean): () => void; + on(event: "tick", handler: (n: number) => void): () => void; + name: string; +} + +let nextId = 0; +const saves: { id: number; h: (e: SaveEvent, ctx: Context) => void }[] = []; +const loads: { id: number; h: (e: LoadEvent, ctx: Context) => boolean }[] = []; +const ticks: { id: number; h: (n: number) => void }[] = []; + +function createApi(name: string): Api { + return { + name, + on(event: "save" | "load" | "tick", handler: any): () => void { + const id = ++nextId; + const count = + event === "save" + ? saves.push({ id, h: handler }) + : event === "load" + ? loads.push({ id, h: handler }) + : ticks.push({ id, h: handler }); + console.log(`${name}: registered ${event} #${id} (${count})`); + return () => { + if (event === "save") saves.splice(saves.findIndex((x) => x.id === id), 1); + else if (event === "load") loads.splice(loads.findIndex((x) => x.id === id), 1); + else ticks.splice(ticks.findIndex((x) => x.id === id), 1); + console.log(`${name}: removed ${event} #${id}`); + }; + }, + }; +} + +// An extension sees only the interface. +function extension(pi: Api): () => void { + pi.on("save", (e, ctx) => { + e.cancel = e.path.endsWith(".lock"); + console.log(`save ${ctx.cwd}/${e.path}`); + }); + pi.on("load", (e) => e.path.length > 3); + return pi.on("tick", (n) => console.log("tick", n * 2, pi.name)); +} + +const api = createApi("main"); +const stopTicks = extension(api); +const ctx: Context = { cwd: "/work" }; +for (const path of ["a.txt", "b.lock"]) { + const ev: SaveEvent = { type: "save", path, cancel: false }; + for (const { h } of saves) h(ev, ctx); + console.log(path, "cancelled:", ev.cancel); +} +for (const { h } of loads) console.log(h({ type: "load", path: "ab" }, ctx), h({ type: "load", path: "abcd" }, ctx)); +for (const { h } of ticks) h(21); +stopTicks(); +console.log("ticks left", ticks.length); + +// A named handler value converts the same way as a lambda. +function onTick(n: number): void { + console.log("named tick", n); +} +const off = api.on("tick", onTick); +for (const { h } of ticks) h(7); +off(); + +// A handler that throws propagates through the boundary unchanged. +api.on("load", (e) => { + if (e.path === "") throw new Error("empty path"); + return true; +}); +try { + for (const { h } of loads) h({ type: "load", path: "" }, ctx); +} catch (err) { + console.log("caught", (err as Error).message); +} diff --git a/tests/corpus/6181-interface-overload-arity.ts b/tests/corpus/6181-interface-overload-arity.ts new file mode 100644 index 000000000..ae4dc5192 --- /dev/null +++ b/tests/corpus/6181-interface-overload-arity.ts @@ -0,0 +1,56 @@ +// Arity overloads on interface and type-literal members: positions some +// overloads omit are optional in the stored function, so one implementation +// with optional parameters serves every overload, and a call omitting a +// trailing argument passes undefined exactly as Node does. +export {}; + +interface Formatter { + label(): string; + label(prefix: string): string; + label(prefix: string, width: number): string; +} +const formatter: Formatter = { + label(prefix?: string, width?: number): string { + const text = prefix ?? "none"; + return width === undefined ? text : text.padEnd(width, ".") + "|"; + }, +}; +console.log(formatter.label()); +console.log(formatter.label("p")); +console.log(formatter.label("q", 6)); + +// Overloads spelled as call signatures of a type-literal member. +type Logger = { + log: { + (message: string): void; + (level: number, message: string): void; + }; + lines: string[]; +}; +const lines: string[] = []; +const logger: Logger = { + lines, + log: (a: string | number, b?: string) => { + lines.push(typeof a === "number" ? `[${a}] ${b}` : a); + }, +}; +logger.log("plain"); +logger.log(2, "leveled"); +console.log(logger.lines.join("; ")); + +// A single-signature method with an optional parameter fills its slot the +// same way. +interface Greeter { + greet(name?: string): string; +} +const greeter: Greeter = { + greet(name?: string): string { + return `hello ${name ?? "stranger"}`; + }, +}; +console.log(greeter.greet(), greeter.greet("ada")); + +// Arguments are evaluated once, left to right, before the call. +let n = 0; +const next = (): string => `#${++n}`; +console.log(formatter.label(next(), 4 + n), n); diff --git a/tests/corpus/6182-interface-overload-types.ts b/tests/corpus/6182-interface-overload-types.ts new file mode 100644 index 000000000..17ce95d17 --- /dev/null +++ b/tests/corpus/6182-interface-overload-types.ts @@ -0,0 +1,61 @@ +// Type overloads with one shared implementation: the stored function takes +// the join of the overload parameter types (a scalar union here) and each +// call converts its result back to the resolved overload's type. Records, +// generic overloads, and implementations typed with unions or `any` share +// the same slot. +export {}; + +interface Converter { + conv(x: number): string; + conv(x: string): number; + conv(x: boolean): boolean; +} +const converter: Converter = { + conv(x: number | string | boolean): any { + return typeof x === "number" ? `n${x}` : typeof x === "string" ? x.length : !x; + }, +}; +const s: string = converter.conv(12); +const len: number = converter.conv("abcd"); +const flipped: boolean = converter.conv(false); +console.log(s.toUpperCase(), len + 1, flipped); + +interface Point { + x: number; + y: number; +} +interface Named { + name: string; +} +interface Factory { + make(kind: "point", a: number, b: number): Point; + make(kind: "named", a: string): Named; +} +const factory: Factory = { + make(kind: string, a: number | string, b?: number): any { + return kind === "point" ? { x: a, y: b } : { name: a }; + }, +}; +const p = factory.make("point", 3, 4); +const named = factory.make("named", "zed"); +console.log(Math.hypot(p.x, p.y), named.name.length, p, named); + +// Generic overloads erase their own type parameters to `unknown`. +interface Store { + get(key: "names", fallback: string[]): string[]; + get(key: "count"): number; + at(items: T[], i: number): T; + at(items: string, i: number): string; +} +const data: Record = { names: ["a", "b"], count: 3 }; +const store: Store = { + get(key: string, fallback?: unknown): any { + return data[key] ?? fallback; + }, + at(items: any, i: number): any { + return items[i]; + }, +}; +console.log(store.get("names", []).join("|"), store.get("count") * 2); +console.log(store.at([10, 20, 30], 1) + 1, store.at("xyz", 2).toUpperCase()); + diff --git a/tests/corpus/6183-interface-overload-flow.ts b/tests/corpus/6183-interface-overload-flow.ts new file mode 100644 index 000000000..d2f3151dc --- /dev/null +++ b/tests/corpus/6183-interface-overload-flow.ts @@ -0,0 +1,89 @@ +// Values of an interface with an overloaded member flow like any record: +// through arrays, factory callbacks, and function results. A class that +// implements the interface dispatches to its own overloaded method. +export {}; + +interface Bus { + emit(kind: "num", value: number): void; + emit(kind: "text", value: string): void; + label: string; +} +type Plugin = (bus: Bus) => void; + +const log: string[] = []; +function makeBus(label: string): Bus { + return { + label, + emit(kind: "num" | "text", value: number | string): void { + log.push(`${label}:${kind}:${typeof value}:${value}`); + }, + }; +} +const plugins: Plugin[] = [ + (bus) => bus.emit("num", 1), + (bus) => { + bus.emit("text", "two"); + bus.emit("num", bus.label.length + 2); + }, +]; +const buses: Bus[] = [makeBus("x"), makeBus("yy")]; +for (const bus of buses) for (const plugin of plugins) plugin(bus); +console.log(log.join("\n")); +console.log(typeof buses[1]!.emit, JSON.stringify(buses[0])); + +interface Picker { + pick(kind: "s"): string; + pick(kind: "n"): number; +} +class Impl implements Picker { + private readonly base: number; + constructor(base: number) { + this.base = base; + } + pick(kind: "s"): string; + pick(kind: "n"): number; + pick(kind: "s" | "n"): string | number { + return kind === "s" ? `str${this.base}` : this.base * 7; + } +} +function use(p: Picker): string { + return `${p.pick("s").length}/${p.pick("n") + 1}`; +} +console.log(use(new Impl(1)), use(new Impl(10))); +const literal: Picker = { + pick(kind: "s" | "n"): any { + return kind === "s" ? "literal" : -1; + }, +}; +console.log(use(literal)); + +// Receivers of every shape resolve the overload the same way: a union of +// records sharing the member, an optional chain, and a call result. +interface Measure { + kind: "len"; + of(x: number): string; + of(x: string): number; +} +interface Sign { + kind: "sign"; + of(x: number): string; + of(x: string): number; +} +const measure: Measure = { + kind: "len", + of(x: number | string): any { + return typeof x === "number" ? `#${x}` : x.length; + }, +}; +const sign: Sign = { + kind: "sign", + of(x: number | string): any { + return typeof x === "number" ? (x < 0 ? "-" : "+") : -x.length; + }, +}; +for (const m of [measure, sign] as (Measure | Sign)[]) console.log(m.kind, m.of(-4).length, m.of("xyz") + 1); +const maybe: Measure | undefined = buses.length > 5 ? undefined : measure; +const none: Measure | undefined = buses.length > 5 ? measure : undefined; +console.log(maybe?.of(5), maybe?.of("abc"), none?.of(1), none?.of("q")); +const pickMeasure = (): Measure => measure; +console.log(pickMeasure().of(7).toUpperCase()); diff --git a/tests/diagnostics/overloaded-members.ts b/tests/diagnostics/overloaded-members.ts new file mode 100644 index 000000000..07ffa9b63 --- /dev/null +++ b/tests/diagnostics/overloaded-members.ts @@ -0,0 +1,25 @@ +// Overloaded interface members compile as one stored function whose type +// joins the overloads; calls resolve per site. Two fences remain: reading +// the member as a VALUE (its type is still the overload set), and a set +// whose overloads do not join (here, a rest parameter beside a fixed one). +interface Api { + on(event: "a", handler: (x: number) => void): void; + on(event: "b", handler: (x: string) => void): void; +} +const api: Api = { + on(event: string, handler: (x: any) => void): void { + handler(event === "a" ? 1 : "one"); + }, +}; +api.on("a", (x) => console.log(x + 1)); +const detached = api.on; +console.log(typeof detached); + +interface Emitter { + emit(event: "many", ...values: number[]): void; + emit(event: "one", value: string): void; +} +function fire(e: Emitter): void { + e.emit("many", 1, 2); +} +console.log(typeof fire); diff --git a/tests/harness/__snapshots__/overloaded-members.ts.txt b/tests/harness/__snapshots__/overloaded-members.ts.txt new file mode 100644 index 000000000..17e608785 --- /dev/null +++ b/tests/harness/__snapshots__/overloaded-members.ts.txt @@ -0,0 +1,35 @@ +overloaded-members.ts:15:7 - error SC2007: values of type '{ (event: "a", handler: (x: number) => void): void; (event: "b", handler: (x: string) => void): void; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature + + 14 | api.on("a", (x) => console.log(x + 1)); + 15 | const detached = api.on; + | ^~~~~~~~ + 16 | console.log(typeof detached); + + hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow + +overloaded-members.ts:16:20 - error SC2004: uses of 'detached' inherit the blocker on its declaration + + 15 | const detached = api.on; + 16 | console.log(typeof detached); + | ^~~~~~~~ + 17 | + + hint: the declaration of 'detached' did not compile — fix the diagnostic reported there and these sites clear with it + +overloaded-members.ts:22:15 - error SC2009: values of type 'Emitter' cannot be compiled: the record shape is supported, but its member 'emit' is an overload set, and its overload 'emit(event: "many", ...values: number[]): void' has a rest parameter (an overload set stores one fixed-arity function) + + 21 | } + 22 | function fire(e: Emitter): void { + | ^ + 23 | e.emit("many", 1, 2); + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +overloaded-members.ts:25:20 - error SC2004: uses of 'fire' inherit the blocker on its declaration + + 24 | } + 25 | console.log(typeof fire); + | ^~~~ + 26 | + + hint: the declaration of 'fire' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file From 0e9e908942bc0bf7cb6030e36d60ca43dc1c920d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:19:55 -0700 Subject: [PATCH 32/34] Short-circuit optional chain tails over unchecked reads `a[0]?.b.c` read `.c` from undefined when the element was missing: the tail only joined the guard when the checker typed the receiver nullable, and an array or index-signature read is typed present while it lowers to an undefined-armed value. The tail check now also accepts receivers that can lower to a hidden undefined; present receivers re-dispatch the plain lowering. A non-null assertion stays inside the chain (`a?.b!.c`), and parentheses end it for checked-dynamic reads (`(a?.b).c` throws). Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/optional-chains.ts | 29 ++++-- .../compiler/src/frontend/lowering/lowerer.ts | 63 +++++++----- ...optional-chain-unchecked-receivers.ts.json | 6 ++ ...6230-optional-chain-unchecked-receivers.ts | 98 +++++++++++++++++++ 4 files changed, 163 insertions(+), 33 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6230-optional-chain-unchecked-receivers.ts.json create mode 100644 tests/corpus/6230-optional-chain-unchecked-receivers.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 7da847062..ceedc3e94 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -26,7 +26,8 @@ import type { Lowerer } from "../lowerer.js"; * deeper — `x?.trim().toLowerCase()` reads nothing past the guard when x * is nullish, so the whole tail must lower inside it. Walks * property/element accesses and call steps only (parens and `!` break - * the chain per the grammar: `(a?.b).c` throws on undefined in JS); + * the chain per the grammar: `(a?.b).c` throws on undefined in JS, while + * an unparenthesized `a?.b!.c` stays one chain, as TypeScript emits it); * the node's OWN token — and a call's immediate callee token — are the * plain entries' cases, not tails, and handled markers make the chain * lowering's re-dispatch walk past its own step. `f?.()` steps stay out @@ -41,6 +42,10 @@ function chainTailDot( // An active chain's bound receiver read is a LEAF — its own token // was consumed by the chain that bound it. if (lowerer.chainRecvByNode.has(cur)) return null; + if (ts.isNonNullExpression(cur)) { + cur = cur.expression; + continue; + } if (ts.isCallExpression(cur)) { // `f?.()` steps route through their own entry (or are being // handled); a deeper token under one is that chain's business. @@ -91,20 +96,29 @@ export function isOptionalChainTail(lowerer: Lowerer, expr: ts.Expression): bool if (!tail) return false; const recvT = lowerer.mapTypeOf(lowerer.typeOf(tail.expression)); if (recvT?.kind === "dyn") return true; - if (recvT?.kind !== "union") return false; - const def = lowerer.unions.get(recvT.unionId); - return !!def && def.arms.some(isUnitType); + if (recvT?.kind === "union") { + const def = lowerer.unions.get(recvT.unionId); + if (def?.arms.some(isUnitType)) return true; + } + // The checker calls the receiver present, but an unchecked read (`a[0]` + // past the end, a missing index-signature key) can still lower to an + // undefined-armed value, and `a[0]?.b.c` must then short-circuit `.c` + // too. The chain lowering inspects the lowered receiver: a present value + // re-dispatches the plain lowering, so only genuinely optional receivers + // change shape. + return lowerer.isRuntimeOptionalOrigin(tail.expression); } /** True when an EARLIER step of this access chain carries `?.` — JS * short-circuits the whole tail (`a?.b.c` reads nothing when a is * nullish), so a dyn tail read must answer undefined instead of * throwing. Walks the receiver spine only; the argument of the access - * itself is not part of the guard. */ + * itself is not part of the guard. Parentheses end the chain + * (`(a?.b).c` reads `.c` from undefined); `!` does not. */ export function hasOptionalChainGuard(expr: ts.Expression): boolean { let cur: ts.Expression = expr; for (;;) { - if (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur)) { + if (ts.isNonNullExpression(cur)) { cur = cur.expression; continue; } @@ -393,7 +407,8 @@ export function lowerOptionalChain( for (let cur: ts.Expression = expr; cur !== dotNode;) { const next: ts.Expression = ts.isCallExpression(cur) ? cur.expression - : (cur as ts.PropertyAccessExpression | ts.ElementAccessExpression).expression; + : (cur as ts.PropertyAccessExpression | ts.ElementAccessExpression | ts.NonNullExpression) + .expression; if (next === dotNode) break; if (!lowerer.chainNarrowedType.has(next)) { lowerer.chainNarrowedType.set( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2b86d6617..ee38fa2c1 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2075,6 +2075,42 @@ export class Lowerer { }; } + /** True when `node`'s lowered value can hold a hidden `undefined` the + * checker type does not admit: an unchecked element or member read, a call + * whose lowered return admits undefined, or a binding whose storage the + * optional-read analysis marked. Whether the value actually carries the + * undefined arm is only known once it is lowered + * (runtimeOptionalSourceValue); this is the syntactic precondition. */ + isRuntimeOptionalOrigin(node: ts.Expression): boolean { + let origin: ts.Expression = node; + while (ts.isParenthesizedExpression(origin)) origin = origin.expression; + if (ts.isElementAccessExpression(origin) || ts.isPropertyAccessExpression(origin)) return true; + if (ts.isCallExpression(origin)) { + const name = ts.isPropertyAccessExpression(origin.expression) + ? origin.expression.name + : origin.expression; + const symbol = ts.isIdentifier(name) ? this.resolveValueSymbol(name) : undefined; + const signature = symbol ? this.fnSigsBySymbol.get(symbol) : undefined; + return ( + this.runtimeOptionalFunctionReturns.has(origin) || + (signature?.returnType.kind === "union" && + this.armTag(signature.returnType.unionId, UNDEFINED_T) >= 0) + ); + } + if (ts.isIdentifier(origin)) { + const local = this.resolveLocal(origin); + const root = local ? this.runtimeOptionalRootOf(local) : null; + const global = this.globalOf(origin); + return ( + (!!root && this.runtimeOptionalStorageLocals.has(root)) || + (!!global && + (this.isRuntimeOptionalGlobal(global) || + (global.type.kind === "union" && this.armTag(global.type.unionId, UNDEFINED_T) >= 0))) + ); + } + return false; + } + /** Recover the original optional union when a checked single-arm helper * was installed before a strict property consumer could name its member. * Recovery covers member reads and storage roots marked by the @@ -2083,32 +2119,7 @@ export class Lowerer { runtimeOptionalSourceValue(node: ts.Expression, value: IrExpr): IrExpr | null { const slot = this.deferredFieldSlot(value); if (slot) return slot; - let origin: ts.Expression = node; - while (ts.isParenthesizedExpression(origin)) origin = origin.expression; - let optionalOrigin = - ts.isElementAccessExpression(origin) || ts.isPropertyAccessExpression(origin); - if (ts.isCallExpression(origin)) { - const name = ts.isPropertyAccessExpression(origin.expression) - ? origin.expression.name - : origin.expression; - const symbol = ts.isIdentifier(name) ? this.resolveValueSymbol(name) : undefined; - const signature = symbol ? this.fnSigsBySymbol.get(symbol) : undefined; - optionalOrigin = - this.runtimeOptionalFunctionReturns.has(origin) || - (signature?.returnType.kind === "union" && - this.armTag(signature.returnType.unionId, UNDEFINED_T) >= 0); - } - if (ts.isIdentifier(origin)) { - const local = this.resolveLocal(origin); - const root = local ? this.runtimeOptionalRootOf(local) : null; - const global = this.globalOf(origin); - optionalOrigin = - (!!root && this.runtimeOptionalStorageLocals.has(root)) || - (!!global && - (this.isRuntimeOptionalGlobal(global) || - (global.type.kind === "union" && this.armTag(global.type.unionId, UNDEFINED_T) >= 0))); - } - if (!optionalOrigin) return null; + if (!this.isRuntimeOptionalOrigin(node)) return null; // A checked JavaScript indexed read can already be boxed. The checker // calls the element present, but that inference is not a runtime guard. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6230-optional-chain-unchecked-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6230-optional-chain-unchecked-receivers.ts.json new file mode 100644 index 000000000..338ab8c6e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6230-optional-chain-unchecked-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6230-optional-chain-unchecked-receivers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6230-optional-chain-unchecked-receivers.ts b/tests/corpus/6230-optional-chain-unchecked-receivers.ts new file mode 100644 index 000000000..b80f6134a --- /dev/null +++ b/tests/corpus/6230-optional-chain-unchecked-receivers.ts @@ -0,0 +1,98 @@ +// An optional chain short-circuits EVERY later step when its guarded +// receiver is nullish, even when the checker calls that receiver present: +// an array read past the end or a missing index-signature key is undefined +// at runtime, so `choices[0]?.delta.content` must not read `.content` from +// undefined (the streaming SSE shape). Covers property, element, call, +// non-null-assertion, and nested tails, plus the forms that must still throw. +type Delta = { content?: string; parts: number[] }; +type Choice = { + delta: Delta; + pick(): { c: string }; + maybe?: () => { c: string }; +}; + +const none: Choice[] = []; +const some: Choice[] = [ + { delta: { content: "hi", parts: [7, 8] }, pick: () => ({ c: "picked" }) }, + { delta: { parts: [] }, pick: () => ({ c: "second" }), maybe: () => ({ c: "maybe" }) }, +]; + +// Property tails. +console.log("prop", none[0]?.delta.content, some[0]?.delta.content, some[1]?.delta.content); +console.log("prop ===", none[0]?.delta.content === undefined); +console.log("prop ??", none[0]?.delta.content ?? "none", some[0]?.delta.content ?? "none"); +console.log("prop len", none[0]?.delta.parts.length, some[0]?.delta.parts.length); + +// Element tails, with lazy index side effects on the short-circuit. +let evals = 0; +function at(i: number): number { + evals++; + return i; +} +console.log("elem", none[0]?.delta.parts[at(0)], "evals", evals); +console.log("elem", some[0]?.delta.parts[at(1)], "evals", evals); +console.log("elem ?.[]", none?.[0]?.delta.parts[0], some?.[0]?.delta.parts[0]); + +// Call tails: method call results and optional calls inside the chain. +console.log("call", none[0]?.pick().c, some[0]?.pick().c); +console.log("call ?.()", none[0]?.maybe?.().c, some[0]?.maybe?.().c, some[1]?.maybe?.().c); +let pushed = 0; +function sideEffect(): number { + pushed++; + return 1; +} +none[0]?.delta.parts.push(sideEffect()); +some[1]?.delta.parts.push(sideEffect()); +console.log("call args", pushed, some[1]!.delta.parts.length); + +// A non-null assertion stays inside the chain (`a?.b!.c` is one chain). +console.log("assert", none[0]?.delta!.content, some[0]?.delta!.content); +console.log("assert ===", none[0]?.delta!.parts.length === undefined); + +// Index-signature records: a missing key is undefined. +const byId: { [id: string]: Choice } = { a: some[0]! }; +console.log("index", byId["zz"]?.delta.content, byId["a"]?.delta.content); +console.log("index dot", byId.zz?.delta.parts.length, byId.a?.delta.parts.length); + +// Nested receivers and nested guards. +const grid: Choice[][] = [[], some]; +console.log("nested", grid[0]?.[0]?.delta.content, grid[1]?.[0]?.delta.content); +console.log("nested", grid[5]?.[0].delta.content, grid[1]?.[1]?.delta.content); + +// Optional members holding arrays. +const holder: { list?: Choice[] } = {}; +const full: { list?: Choice[] } = { list: some }; +console.log("member", holder.list?.[0].delta.content, full.list?.[0].delta.content); + +// Strings and conditions. +const words: string[] = []; +const padded = [" ab "]; +console.log("string", words[0]?.trim().length, padded[0]?.trim().length); +console.log("typeof", typeof none[0]?.delta.content, typeof some[0]?.delta.content); +if (none[0]?.delta.content) console.log("unreachable"); +else console.log("cond ok"); +console.log(`template ${none[0]?.delta.content} ${some[0]?.delta.content}`); + +// Typed JSON.parse results: an empty choices array short-circuits too. +type Chunk = { choices: { delta: Delta }[] }; +const streamChunk = JSON.parse('{"choices":[]}') as Chunk; +console.log("json", streamChunk.choices[0]?.delta.content ?? "(no delta)"); +const textChunk = JSON.parse('{"choices":[{"delta":{"content":"tok","parts":[]}}]}') as Chunk; +console.log("json", textChunk.choices[0]?.delta.content ?? "(no delta)"); + +// A PRESENT receiver followed by a missing step still throws: the guard +// covers only the receiver it tests. +function message(f: () => unknown): string { + try { + f(); + return "no throw"; + } catch (e) { + return (e as Error).message; + } +} +console.log("throws", message(() => some?.[5].delta)); +console.log("throws", message(() => none[0].delta)); +// Parentheses end the chain: the outer read sees undefined. +const loose = JSON.parse("{}"); +console.log("parens", message(() => (loose.missing?.deep).deeper)); +console.log("no parens", message(() => loose.missing?.deep.deeper)); From 238d782fdc94874f0293f3cd20321fbb1112dc61 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 17:17:00 -0500 Subject: [PATCH 33/34] Keep the CommonJS caches on their owners and recount 6110's fences The exports-assignment cache moves from a module-level WeakMap onto the Lowerer (cjsExportsAssignments), and the inline require() calls that close a require cycle move from a module-level WeakSet into preflight's result: checkPreflight stores them on the LoadResult, and lowering receives them with the other preflight facts (LowerOptions.inlineRequireCycleCalls). 6110's call and apply wrappers now compile `root === module.exports` and `typeof module.exports`, which print what Node prints when the wrappers' untaken branches run. The class base's `console.log(module.exports)` is the one statement left as a runtime fence, and its body is lowered once, so the program's deferred-fence count is 1. --- .../frontend/lowering/cjs-exports-object.ts | 9 +++--- .../frontend/lowering/cjs-inline-require.ts | 3 +- .../compiler/src/frontend/lowering/lowerer.ts | 18 ++++++++++++ packages/compiler/src/frontend/pipeline.ts | 1 + packages/compiler/src/frontend/program.ts | 29 ++++++++++--------- .../6110-cjs-speculative-wrapper-fences.cjs | 14 ++++----- 6 files changed, 47 insertions(+), 27 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts index c3edafddc..d72792ae7 100644 --- a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts +++ b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts @@ -148,17 +148,16 @@ function truthinessOnly(node: ts.Expression): boolean { } /** Which of the module's exports bindings the file ever assigns (any - * nesting, compound operators included). */ -const assignmentsCache = new WeakMap(); - + * nesting, compound operators included), cached per file on the lowerer + * (Lowerer.cjsExportsAssignments). */ function exportsAssignments( lowerer: Lowerer, sf: ts.SourceFile, ): { exports: boolean; moduleExports: boolean } { - const cached = assignmentsCache.get(sf); + const cached = lowerer.cjsExportsAssignments.get(sf); if (cached !== undefined) return cached; const out = { exports: false, moduleExports: false }; - assignmentsCache.set(sf, out); + lowerer.cjsExportsAssignments.set(sf, out); ts.walkPreorder(sf, (node) => { // A fresh `exports = module.exports = {}` reset leaves both bindings // naming one (new, empty) exports object for the rest of the module. diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts index 75474565b..7f31337f5 100644 --- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts +++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts @@ -25,7 +25,6 @@ import { cjsExportTargetLiteral, isCjsJsFile, isJsSourceFile, - isInlineRequireCycleCall, isModuleExportsAccess, locOf, npmStaticDepSf7, @@ -109,7 +108,7 @@ export function inlineNamespaceRequireOf( /** `value` evaluated after the required module's run-once %init (and its * module-registry link) at the require's own position. */ export function withRequireInit(lowerer: Lowerer, req: InlineRequire, value: IrExpr): IrExpr { - if (isInlineRequireCycleCall(req.call)) { + if (lowerer.inlineRequireCycleCalls.has(req.call)) { lowerer.unsupported( "SC1090", req.call, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 6da1ede27..55447e1e0 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -753,6 +753,8 @@ export interface LowerOptions { * resolution walk and named-import link checks): the program * compiles to that startup crash. */ startupCrash?: StartupCrash | null; + /** LoadResult.inlineRequireCycleCalls. */ + inlineRequireCycleCalls?: ReadonlySet | undefined; /** LIBRARY mode's reachability roots: the profile-mapped exports of the * entry module. Executable builds root at the entry's top level alone * (an unreferenced export dead-strips); a library's exports are called from @@ -845,6 +847,8 @@ export interface LowererMode { * module init — Node refuses the whole graph before anything evaluates, * so nothing runs. */ startupCrash?: StartupCrash | null; + /** LoadResult.inlineRequireCycleCalls. */ + inlineRequireCycleCalls?: ReadonlySet | undefined; /** The build's outbound native FFI declarations. */ ffiImports?: readonly IrFfiImport[]; /** LowerOptions.libraryCallbacks (see there). */ @@ -958,6 +962,7 @@ export function lowerToIr( projectCheckJs: options.projectCheckJs ?? false, targetPlatform, startupCrash, + inlineRequireCycleCalls: options.inlineRequireCycleCalls, ffiImports, libraryCallbacks, forkTargets, @@ -981,6 +986,7 @@ export function lowerToIr( projectCheckJs: options.projectCheckJs ?? false, targetPlatform, startupCrash, + inlineRequireCycleCalls: options.inlineRequireCycleCalls, ffiImports, libraryCallbacks, forkTargets, @@ -1011,6 +1017,7 @@ export function lowerToIr( reachable, targetPlatform, startupCrash, + inlineRequireCycleCalls: options.inlineRequireCycleCalls, ffiImports, libraryCallbacks, forkTargets, @@ -1069,6 +1076,7 @@ export function lowerToIr( reachable, remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, + inlineRequireCycleCalls: options.inlineRequireCycleCalls, targetPlatform, ffiImports, libraryCallbacks, @@ -1628,6 +1636,15 @@ export class Lowerer { readonly checker: ts.TypeChecker; readonly diags: ScrDiagnostic[] = []; readonly fnSigsBySymbol = new Map(); + /** Per file: whether it assigns `exports` and `module.exports` + * (cjs-exports-object.ts). */ + readonly cjsExportsAssignments = new Map< + ts.SourceFile, + { exports: boolean; moduleExports: boolean } + >(); + /** Inline require() calls whose module edge closes a require cycle + * (LoadResult.inlineRequireCycleCalls). */ + readonly inlineRequireCycleCalls: ReadonlySet; /** Immutable callable aliases and compiler-generated adapters. The map * is symbol-keyed, so shadowing and cross-module imports keep the same * exact binding discipline as globals/functions. */ @@ -2859,6 +2876,7 @@ export class Lowerer { this.alreadyFlushed = mode.alreadyFlushed ?? new Set(); this.targetPlatform = mode.targetPlatform ?? process.platform; this.startupCrash = mode.startupCrash ?? null; + this.inlineRequireCycleCalls = mode.inlineRequireCycleCalls ?? new Set(); this.projectCheckJs = mode.projectCheckJs ?? false; this.ffiImports = mode.ffiImports ?? []; this.libraryCallbacks = mode.libraryCallbacks ?? false; diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 48d7e45a0..f7ab33429 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -593,6 +593,7 @@ function loadFrontend( ...opts, frontendServices: finalLoad.services, startupCrash: finalLoad.startupCrash ?? null, + inlineRequireCycleCalls: finalLoad.inlineRequireCycleCalls, projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false, externalTypes: finalLoad.externalTypes, externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 47543007f..8433338d0 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -338,6 +338,12 @@ export interface LoadResult { * last. Empty until checkPreflight runs (and empty when preflight fails * structurally). */ moduleOrder: ts.SourceFile[]; + /** Inline require() calls whose edge closes a require cycle. Node answers + * them with the still-evaluating module's partial exports, which the + * static export globals do not model; their lowering refuses them by name + * at run time instead of refusing the whole program. Filled by + * checkPreflight. */ + inlineRequireCycleCalls?: ReadonlySet; /** Node's startup refusal, when the graph carries an import edge Node * rejects before ANY module evaluates — a RESOLUTION refusal (a bare * specifier nothing installed resolves, an exports/imports map that @@ -975,9 +981,10 @@ export function loadProgram( * preflight diagnostics. Preserve the caller's complete load shape when * mutating it, including its owned services and disposal callback. */ export function checkPreflight(load: T): ScrDiagnostic[] { - const { diags, moduleOrder, startupCrash } = preflight7(load); + const { diags, moduleOrder, startupCrash, inlineRequireCycleCalls } = preflight7(load); load.moduleOrder = moduleOrder; load.startupCrash = startupCrash; + load.inlineRequireCycleCalls = inlineRequireCycleCalls; return diags; } @@ -2447,17 +2454,6 @@ export interface CycleEdge { call?: ts.CallExpression; } -/** Inline require() calls whose edge closes a require cycle. Node answers - * them with the still-evaluating module's partial exports, which the static - * export globals do not model; their lowering refuses them by name at run - * time instead of refusing the whole program (the inline call kept its - * runtime fence before it became a module edge). */ -const inlineRequireCycleCalls = new WeakSet(); - -export function isInlineRequireCycleCall(call: ts.Node): boolean { - return inlineRequireCycleCalls.has(call); -} - /** True when the reference at `node` can only evaluate AFTER the module * graph finished initializing: inside a function-like BODY, inside a * parameter (default initializers evaluate at call time), or inside an @@ -4133,6 +4129,7 @@ function preflight7(load: LoadResult): { diags: ScrDiagnostic[]; moduleOrder: ts.SourceFile[]; startupCrash: StartupCrash | null; + inlineRequireCycleCalls: ReadonlySet; } { const { program, entry } = load; const diags: ScrDiagnostic[] = [...load.configDiags]; @@ -5118,6 +5115,7 @@ function preflight7(load: LoadResult): { return all.some((e) => e.call !== undefined) ? all.filter((e) => e.call === undefined) : all; }); const order: ts.SourceFile[] = []; + const inlineRequireCycleCalls = new Set(); const state = new Map(); const stack: string[] = []; const visit = (sf: ts.SourceFile): void => { @@ -5214,7 +5212,12 @@ function preflight7(load: LoadResult): { esmNamedImportLinkCheck(program, entry, order, diags), ); - return { diags, moduleOrder: order, startupCrash: resolveCrash ?? linkCrash }; + return { + diags, + moduleOrder: order, + startupCrash: resolveCrash ?? linkCrash, + inlineRequireCycleCalls, + }; } /* ── the CJS named-import link check ───────────────────────────────────── diff --git a/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs index 1b3546518..fbb1a65fc 100644 --- a/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs +++ b/tests/corpus/6110-cjs-speculative-wrapper-fences.cjs @@ -1,12 +1,12 @@ -// @deferred-fences: 9 +// @deferred-fences: 1 // Function expressions lowered speculatively — the `.call(this)` and // `.apply` receivers of CommonJS wrappers, and a computed class base — keep -// every statement of their bodies. A statement with no static lowering -// (here `module.exports` read as a value) compiles to its runtime fence -// instead of vanishing; these never run, so the binary matches Node. -// Coverage counts the three fenced statements nine times: each lowering of -// a wrapper body counts its statements, and the `.call` and `.apply` -// wrapper bodies are each lowered four times. +// every statement of their bodies. The wrappers' `root === module.exports` +// and `typeof module.exports` compile; a statement with no static lowering +// (here `module.exports` passed as a value in the class base) compiles to +// its runtime fence instead of vanishing. It never runs, so the binary +// matches Node. Coverage counts that one fenced statement once: the class +// base's body is lowered once. const verbose = process.argv.length > 99; (function () { var root = this; From 4c27ab8bd7fbd72f9683eeb1b314c5430ee9b5e0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 17:17:22 -0500 Subject: [PATCH 34/34] Compile classes that extend a generic class factory's result `class Counter extends base(42) {}` with `function base(initial: T) { return class { value: T = initial; }; }` failed with SC1090 "extending computed expressions": the call's result type is the factory's local class instantiated with T = number, which only maps inside the factory's own generic instance. A call of a generic function whose result is one of its local classes (or an instance of one) now keys the instance on its type bindings and maps the result inside that instance, where the local class takes the instance's name and bindings. The class value then works as a base (`extends base(42)`), as a `const` binding, through `new`, and for method calls and field reads on its instances. Each instantiation has its own class, so `base(1) === base(1)` is false and `instanceof` tells them apart, as in Node. Calls inside another generic instance bind through the declaration; calls elsewhere also record the instantiated checker type, so later uses of it name the same class. Fixes #1241 Co-authored-by: BaoLei <50226652+blurname@users.noreply.github.com> --- .../local-class-expressions.mdx | 2 +- .../frontend/lowering/generic-functions.ts | 148 +++++++++++++++++- .../src/frontend/lowering/lower-classes.ts | 6 + .../src/frontend/lowering/lower-stmts.ts | 4 + .../compiler/src/frontend/lowering/lowerer.ts | 12 +- packages/compiler/src/frontend/type-mapper.ts | 10 +- ...6051-generic-class-factory-results.ts.json | 6 + .../6051-generic-class-factory-results.ts | 111 +++++++++++++ tests/diagnostics/local-class-boundaries.ts | 4 - .../local-class-boundaries.ts.txt | 9 +- tests/harness/corpus-costs.json | 2 + 11 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6051-generic-class-factory-results.ts.json create mode 100644 tests/corpus/6051-generic-class-factory-results.ts diff --git a/docs/content/limitations/types-and-class-definitions/local-class-expressions.mdx b/docs/content/limitations/types-and-class-definitions/local-class-expressions.mdx index 1f60c6101..d18ecac60 100644 --- a/docs/content/limitations/types-and-class-definitions/local-class-expressions.mdx +++ b/docs/content/limitations/types-and-class-definitions/local-class-expressions.mdx @@ -1 +1 @@ -- **Local class expressions:** Static members, decorators, private brands, generic members, explicitly generic factories returning local constructors, and references through the class's internal name are unsupported. Supported expressions preserve fresh constructor identity and shared captures across constructors, methods, accessors, and arrow fields. Local inheritance requires a compiled base class. Constructors retain identity, `typeof`, `name`, and `length` through `unknown`; cast back to their exact type before construction. Construction and static-member access directly through `unknown` are unsupported. +- **Local class expressions:** Static members, decorators, private brands, generic members, and references through the class's internal name are unsupported. Supported expressions preserve fresh constructor identity and shared captures across constructors, methods, accessors, and arrow fields. Local inheritance requires a compiled base class. Constructors retain identity, `typeof`, `name`, and `length` through `unknown`; cast back to their exact type before construction. Construction and static-member access directly through `unknown` are unsupported. diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index bd785a256..49bb33882 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -15,7 +15,7 @@ import { isJsSourceFile, locOf } from "../program.js"; import { typeKey } from "../type-mapper.js"; import { PoisonError, dynFallbackType, neverTaintedJsType, newFnCtx } from "./lowerer.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -import { findGenericMethodOn, type ClassInfo } from "./lower-classes.js"; +import { findGenericMethodOn, storedClassValueType, type ClassInfo } from "./lower-classes.js"; import { nsPathPrefix } from "./lower-namespaces.js"; import { declSymbolOf } from "./lower-modules.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; @@ -292,6 +292,8 @@ export function genericCallInstance( (_param, i) => expr.arguments[i] ?? expr, ); const retTs = lowerer.checker.getReturnTypeOfSignature(rsig); + const local = localClassResultInstance(lowerer, expr, info, rsig, params, retTs); + if (local) return local; const returnType = lowerer.mapTypeOf(retTs); if (!returnType) { fenceGenericSignatureResult(lowerer, expr, retTs); @@ -331,6 +333,150 @@ export function genericCallInstance( ); } +/** A generic factory whose result is one of its own local classes + * (`function base(v: T) { return class { value: T = v }; }`). Each + * instance has its own class, so the instance is keyed on its bindings and + * the result maps inside it, where the local class takes the instance's + * name and bindings. */ +function localClassResultInstance( + lowerer: Lowerer, + expr: ts.CallExpression, + info: GenericFnInfo, + rsig: ts.Signature, + params: ParamShape[], + retTs: ts.Type, +): GenericInstance | null { + if (!localClassOfResult(lowerer, info, retTs)) return null; + const tsBindings = new Map(); + const bindings = lowerer.inferTypeParamBindings(expr, info, rsig, tsBindings); + const bindingKey = info.typeParams + .map((tp) => { + const bound = bindings.get(tp); + return bound ? typeKey(bound) : "?"; + }) + .join(","); + const pending: IrType = { kind: "void" }; + const inst = internGenericInstance(lowerer, expr, info, params, pending, () => bindings, { + extraKey: `@class<${bindingKey}>`, + tsBindings, + }); + if (inst.returnType !== pending) return inst; + const returnType = withInstanceScope(lowerer, info, inst, () => lowerer.mapTypeOf(retTs)); + if (!returnType) { + fenceGenericSignatureResult(lowerer, expr, retTs); + lowerer.badType(expr, retTs); + } + inst.returnType = returnType; + // Values of the class outside the factory (`const b = new B()`, `b.read()`) + // carry the instantiated checker type; record which class it names. A + // call inside another generic instance can have a type that depends on + // that instance's parameters, which no single class answers. + if (lowerer.typeParamBindings !== null) return inst; + const instance = + returnType.kind === "classval" + ? lowerer.checker + .getConstructSignatures(retTs) + .map((sig) => lowerer.checker.getReturnTypeOfSignature(sig)) + : [retTs]; + const className = + returnType.kind === "classval" || returnType.kind === "object" ? returnType.className : null; + if (className !== null) + for (const type of instance) lowerer.localClassInstanceTypes.set(type.id, className); + return inst; +} + +/** The class value a call of a generic factory returns when the result is + * one of the factory's local classes (`class C extends base(42) {}`), or + * null for any other call. */ +export function genericLocalClassResult(lowerer: Lowerer, expr: ts.CallExpression): IrType | null { + if (!ts.isIdentifier(expr.expression)) return null; + const info = lowerer.genericFnOf(expr.expression); + if (!info || info.implicitParams) return null; + const rsig = lowerer.checker.getResolvedSignature(expr); + if (!rsig || !localClassOfResult(lowerer, info, lowerer.checker.getReturnTypeOfSignature(rsig))) + return null; + return genericCallInstance(lowerer, expr, info).returnType; +} + +/** The type of a `const` binding whose initializer calls a generic factory + * returning one of its local classes (`const B = base(7)`), or constructs + * such a class (`const b = new B()`); null for any other binding. */ +export function genericLocalClassBinding(lowerer: Lowerer, node: ts.Node): IrType | null { + const decl = node.parent; + if ( + !ts.isIdentifier(node) || + !decl || + !ts.isVariableDeclaration(decl) || + decl.name !== node || + !decl.initializer || + (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) === 0 + ) + return null; + // An `any` binding keeps the checked-dynamic representation. + if ((lowerer.typeOf(node).flags & ts.TypeFlags.Any) !== 0) return null; + let init = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (ts.isCallExpression(init)) return genericLocalClassResult(lowerer, init); + if (!ts.isNewExpression(init)) return null; + const constructed = storedClassValueType(lowerer, init.expression); + if (constructed?.kind !== "classval") return null; + const classDecl = lowerer.classes.get(constructed.className)?.decl; + return classDecl && insideGenericFunction(classDecl) + ? { kind: "object", className: constructed.className } + : null; +} + +/** Whether `node` is declared inside a function with type parameters. */ +function insideGenericFunction(node: ts.Node): boolean { + for (let parent = node.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) + if (ts.isFunctionLike(parent) && (parent.typeParameters?.length ?? 0) > 0) return true; + return false; +} + +/** The class declared inside a generic function that `type` is the + * constructor or instance type of, or null. */ +function localClassOfResult( + lowerer: Lowerer, + info: GenericFnInfo, + type: ts.Type, +): ts.ClassLikeDeclaration | null { + const symbol = type.getSymbol(); + if (!symbol || !(symbol.flags & ts.SymbolFlags.Class)) return null; + const decl = lowerer.checker.valueDeclarationOf(symbol); + if (!decl || !(ts.isClassExpression(decl) || ts.isClassDeclaration(decl))) return null; + for (let parent = decl.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) + if (parent === info.decl) return decl; + return null; +} + +/** Runs `fn` under an instance's type bindings and local-class names, as + * its body lowers. */ +function withInstanceScope( + lowerer: Lowerer, + info: GenericFnInfo, + inst: GenericInstance, + fn: () => T, +): T { + const prevBindings = lowerer.typeParamBindings; + const prevTsBindings = lowerer.typeParamTsBindings; + const previousPath = lowerer.genericInstantiationPath; + const prevContext = lowerer.instantiationContext; + lowerer.typeParamBindings = inst.bindings; + lowerer.typeParamTsBindings = inst.tsBindings ?? null; + lowerer.genericInstantiationPath = inst.path; + lowerer.instantiationContext = `instantiating '${info.baseName}' with ${inst.typeArgsText}`; + lowerer.localClassInstantiations.push({ owner: info.decl, name: inst.name }); + try { + return fn(); + } finally { + lowerer.localClassInstantiations.pop(); + lowerer.typeParamBindings = prevBindings; + lowerer.typeParamTsBindings = prevTsBindings; + lowerer.genericInstantiationPath = previousPath; + lowerer.instantiationContext = prevContext; + } +} + /** The one instance table both instantiation routes share: key identity IS * signature identity, so a call (`identity(1)`) and a pinned VALUE * (`const f: (x: number) => number = identity`) reuse one compiled diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f3a4830c3..22ab24e63 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -50,6 +50,7 @@ import { implicitAnyParamSymbolsOf, implicitCallInstance, implicitMonoFile, + genericLocalClassResult, type GenericFnInfo, } from "./generic-functions.js"; import { @@ -228,6 +229,11 @@ export function storedClassValueType( if (value?.kind === "classval") return value; } } + // A generic factory's own local class has one class per instance. + if (ts.isCallExpression(expression)) { + const local = genericLocalClassResult(lowerer, expression); + if (local?.kind === "classval") return local; + } const type = lowerer.typeOf(expression); const mapped = lowerer.mapTypeOf(type); if (mapped?.kind === "classval" && !lowerer.classes.has(mapped.className)) { diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 57e6c2311..7f3be4d44 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -107,6 +107,7 @@ import { implicitLocalFnInfoOf, implicitLocalFnNodeOf, implicitMethodCallInfersReturn, + genericLocalClassBinding, } from "./generic-functions.js"; import { bindingNeverReassigned, @@ -4913,6 +4914,9 @@ export function lowerVarDecl( ) { type = init.type; } + // `const B = factory(1)` inside a generic instance: the factory's local + // class for the instance the call names. + if (!type && !isLet) type = genericLocalClassBinding(lowerer, decl.name); if (!type) { // The JS declaration fallback (irTypeOf's story): the binding holds // the checked-dynamic kind when even the initializer's type has no diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2b86d6617..b5cedad7a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -369,6 +369,7 @@ import { lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, + genericLocalClassBinding, } from "./generic-functions.js"; import { bindingNeverReassigned } from "./binding-analysis.js"; import type { InstantiationPath } from "./instantiation-path.js"; @@ -1849,6 +1850,9 @@ export class Lowerer { * and erased instances). */ erasingDepth = 0; localClassInstantiations: { owner: ts.FunctionLikeDeclaration; name: string }[] = []; + /** Instance types (by checker type id) of the local classes generic + * factory calls return, and the per-instance class each names. */ + readonly localClassInstanceTypes = new Map(); /** The ts-level twin of typeParamBindings, non-null while a CALL-keyed * instance body lowers: type-parameter symbol → the bound CHECKER type, * consulted where the mapped IrType has already widened away information @@ -2882,7 +2886,11 @@ export class Lowerer { this.mixinTypeContext && this.mixinTypeContext.classNode === decl ? { kind: "object", className: this.mixinTypeContext.className } : null, - localClassInstance: (decl) => { + localClassInstance: (decl, instanceType) => { + // A generic factory's local class, by the instance type a call of + // the factory produced (localClassResultInstance). + const instanceClass = instanceType && this.localClassInstanceTypes.get(instanceType.id); + if (instanceClass) return { kind: "object", className: instanceClass }; // Computed heritage can close over parameters of its factory. // A speculative checker query outside that factory must not lower // the extends expression in the caller's lexical scope. @@ -7962,6 +7970,8 @@ export class Lowerer { if (!mapped) { const argumentsType = jsArgumentsFunctionType(this, t); if (argumentsType !== null) return argumentsType; + const localClass = genericLocalClassBinding(this, node); + if (localClass !== null) return localClass; // The checked-dynamic declaration fallback (dynFallbackType): a // JAVASCRIPT binding of any inference residue, or a TypeScript // binding of genuine checker-`any`, becomes the checked-dynamic diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 89a5d78c1..c6e70577c 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1035,7 +1035,7 @@ export interface TypeMapperCtx { * inside members, self-referential member types), null outside any * mixin context (the type alone cannot name a call site). */ mixinClassInstance?: (decl: ts.ClassLikeDeclaration) => IrType | null; - localClassInstance?: (decl: ts.ClassLikeDeclaration) => IrType | null; + localClassInstance?: (decl: ts.ClassLikeDeclaration, instanceType?: ts.Type) => IrType | null; /** Live class protocols retain identity and receiver dispatch rather than * copying an instance into an unrelated record layout. */ classImplementedProtocol?: (type: ts.Type) => boolean; @@ -1950,7 +1950,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } } if (classExprNeedsContext(classDecl)) { - const local = ctx.localClassInstance?.(classDecl); + const local = ctx.localClassInstance?.(classDecl, widened); if (local) contextResolutions++; return local ?? null; } @@ -1989,7 +1989,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } } if (classExprNeedsContext(classDecl)) { - const local = ctx.localClassInstance?.(classDecl); + const ctorSigs = checker.getConstructSignatures(widened); + const local = ctx.localClassInstance?.( + classDecl, + ctorSigs.length === 1 ? checker.getReturnTypeOfSignature(ctorSigs[0]!) : undefined, + ); if (local?.kind === "object") { contextResolutions++; return { kind: "classval", className: local.className }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6051-generic-class-factory-results.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6051-generic-class-factory-results.ts.json new file mode 100644 index 000000000..fc148dcea --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6051-generic-class-factory-results.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6051-generic-class-factory-results.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6051-generic-class-factory-results.ts b/tests/corpus/6051-generic-class-factory-results.ts new file mode 100644 index 000000000..542619946 --- /dev/null +++ b/tests/corpus/6051-generic-class-factory-results.ts @@ -0,0 +1,111 @@ +// Classes extending the result of a generic class factory, and the +// factory's class values and instances: each instantiation of the factory +// has its own class, as each call does in Node. +function base(initial: T) { + return class { + value: T = initial; + read(): T { + return this.value; + } + describe(): string { + return `value=${String(this.value)}`; + } + }; +} + +class Counter extends base(42) { + bump(): number { + this.value += 1; + return this.value; + } +} +const counter = new Counter(); +console.log(counter.value, counter.bump(), counter.read(), counter.describe()); + +class Label extends base("hello") { + shout(): string { + return this.read().toUpperCase(); + } + describe(): string { + return `label ${super.describe()}`; + } +} +const label = new Label(); +console.log(label.shout(), label.describe(), label.value.length); + +const NumberBox = base(7); +const box = new NumberBox(); +console.log(box.read() * 2, box instanceof NumberBox, counter instanceof NumberBox); +console.log(base(1) === base(1), typeof NumberBox); +console.log(Counter.name, Label.name, counter instanceof Counter); + +function pair(first: A, second: B) { + return class { + first: A = first; + second: B = second; + swap(): [B, A] { + return [this.second, this.first]; + } + }; +} +class Entry extends pair("id", 3) { + label: string; + constructor(label: string) { + super(); + this.label = label; + } + render(): string { + const [n, s] = this.swap(); + return `${this.label}:${s}:${n * 10}`; + } +} +console.log(new Entry("row").render(), new Entry("col").first); + +interface Shape { + name: string; + size: number; +} +function model(defaults: S) { + return class { + data: S; + constructor(data?: S) { + this.data = data ?? defaults; + } + get(): S { + return this.data; + } + }; +} +class ShapeModel extends model({ name: "square", size: 2 }) { + area(): number { + return this.data.size * this.data.size; + } +} +const square = new ShapeModel(); +const big = new ShapeModel({ name: "big", size: 5 }); +console.log(square.get().name, square.area(), big.get().name, big.area()); + +function tagged(tag: string, payload: T) { + class Tagged { + tag = tag; + payload = payload; + toString(): string { + return `${this.tag}(${String(this.payload)})`; + } + } + return new Tagged(); +} +const first = tagged("a", 1); +const second = tagged("b", true); +console.log(first.toString(), second.toString(), first.payload + 1, second.payload === true); + +const arrowFactory = (initial: T) => + class { + item: T = initial; + }; +class FromArrow extends arrowFactory("arrow") {} +function wrap(value: U): U { + const Made = base(value); + return new Made().read(); +} +console.log(new FromArrow().item, wrap("w"), wrap(9), wrap([1, 2])); diff --git a/tests/diagnostics/local-class-boundaries.ts b/tests/diagnostics/local-class-boundaries.ts index 5b509931d..a82dfb869 100644 --- a/tests/diagnostics/local-class-boundaries.ts +++ b/tests/diagnostics/local-class-boundaries.ts @@ -7,8 +7,4 @@ privateBrand(); selfName(); derived(); generic(); -function genericFactory(value: T) { - return class Stored { read(): T { return value; } }; -} -genericFactory(7); // End of unsupported local-class forms. diff --git a/tests/harness/__snapshots__/local-class-boundaries.ts.txt b/tests/harness/__snapshots__/local-class-boundaries.ts.txt index e3e27ad95..04dbc336c 100644 --- a/tests/harness/__snapshots__/local-class-boundaries.ts.txt +++ b/tests/harness/__snapshots__/local-class-boundaries.ts.txt @@ -16,11 +16,4 @@ local-class-boundaries.ts:5:29 - error SC1090: function-local classes with stati 4 | function derived() { return class extends Base {}; } 5 | function generic() { return class { read(value: T): T { return value; } }; } | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 6 | privateBrand(); - -local-class-boundaries.ts:13:1 - error SC2001: values of type 'typeof Stored' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') - - 12 | } - 13 | genericFactory(7); - | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 14 | // End of unsupported local-class forms. \ No newline at end of file + 6 | privateBrand(); \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 18147374d..b34eeecb0 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2211,6 +2211,7 @@ "6042-regex-match-object-destructuring.ts": 3393, "6043-string-index-narrowed-union.ts": 2983, "605-closures-forward-capture-tdz.ts": 3277, + "6051-generic-class-factory-results.ts": 4363, "6100-record-get-or-create.ts": 3771, "6101-record-get-or-create-unchecked/main.ts": 2107, "6103-typeof-builtin-bindings.cjs": 2202, @@ -5251,6 +5252,7 @@ "6042-regex-match-object-destructuring.ts": 3091, "6043-string-index-narrowed-union.ts": 3627, "605-closures-forward-capture-tdz.ts": 4638, + "6051-generic-class-factory-results.ts": 46581, "6100-record-get-or-create.ts": 3173, "6101-record-get-or-create-unchecked/main.ts": 3228, "6103-typeof-builtin-bindings.cjs": 1796,