diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 44bd92744..534e03c07 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -153,7 +153,8 @@ export type TypeFailureFamily = | "component" | "stdlib-nominal" | "record-member" - | "unsupported"; + | "unsupported" + | "dyn-field-policy"; /** A type failure's structured payload: what the commit points of the * localized-failure design inspect (PoisonError.failure carries the same diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f15e0dc86..e25c689b5 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2541,6 +2541,19 @@ export function collectClassShapeInner( if (initializerSymbol && (initializerSymbol.flags & ts.SymbolFlags.Enum) !== 0) continue; const type = lowerer.irTypeOf(member.name); if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name)); + if ( + type.kind === "dyn" && + !lowerer.liftDynFieldPolicy && + !isJsSourceFile(ts.getSourceFileOfNode(member)) && + !lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) && + (member.type !== undefined || + (lowerer.typeOf(member.name).flags & ts.TypeFlags.Any) === 0) + ) { + lowerer.dynFieldPolicyFence( + member.name, + `${dynFieldTypeSpelling(lowerer, member.name)}-typed static fields`, + ); + } staticFields.push({ name: member.name.text, type, @@ -3044,9 +3057,22 @@ export function collectClassShapeInner( if (base && !fields.has(member.name.text) && lowerer.dynConvertible(type)) type = DYN; } if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name)); - // A field whose type maps to the checked dynamic (`any`, an - // option bag, a degraded type) stores it natively, as JavaScript - // classes and constructor-assigned fields do. + // Bundled JS declares fields without annotations before assigning + // them in the constructor. Use the same native checked-dynamic + // storage as implicit constructor-assigned JS fields. In + // TypeScript the refusal is a policy fence that a flush retry lifts + // (Lowerer.liftDynFieldPolicy). + if ( + type.kind === "dyn" && + !lowerer.liftDynFieldPolicy && + !isJsSourceFile(ts.getSourceFileOfNode(member)) && + !lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) + ) { + lowerer.dynFieldPolicyFence( + member.name, + `${dynFieldTypeSpelling(lowerer, member.name)}-typed class fields`, + ); + } const undefinedInitializer = type.kind === "dyn" && !member.initializer ? locOf(member) : undefined; if (fields.has(member.name.text)) { @@ -3177,6 +3203,17 @@ export function collectClassShapeInner( const shape = lowerer.paramShape(p); const type = shape.bodyType ?? shape.type; if (type.kind === "void") lowerer.badType(p.name, lowerer.typeOf(p.name)); + // The class-field dyn rule verbatim (KEEP NARROW). + if ( + type.kind === "dyn" && + !lowerer.liftDynFieldPolicy && + !lowerer.classFieldStoresDyn(lowerer.typeOf(p)) + ) { + lowerer.dynFieldPolicyFence( + p.name, + `${dynFieldTypeSpelling(lowerer, p)}-typed class fields`, + ); + } // `override x` (and any same-named inherited member) would // redeclare a base slot — the declared-field rule verbatim. if (fields.has(name)) { @@ -8919,6 +8956,14 @@ export function throwingSetterFn(lowerer: Lowerer, info: ClassInfo, prop: string }; } +/** The declared type a refused checked-dynamic class field names in its + * diagnostic (`'any'`, `'WeakMap'`): `unknown` and + * `object` fields store checked-dynamic values, so the refusal is about + * whatever else the declaration spells. */ +function dynFieldTypeSpelling(lowerer: Lowerer, node: ts.Node): string { + return `'${lowerer.checker.typeToString(lowerer.typeOf(node))}'`; +} + /** The base a constructor chain actually CALLS into: generic FAMILIES are * never constructed (no `%.constructor` exists), so an * instantiation's construction-relevant base is the family's own base — diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 3cce51817..877d6da63 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -764,15 +764,16 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // Localized failure's statement commit point (design §3.3): a // degradable type failure at a local, loop variable or module // global degrades that type and the statement lowers again. - const slot = - attempt < MAX_DEGRADE_RETRIES && !deferFences - ? lowerer.degradeAtCommit(e, STATEMENT_SLOTS) - : null; - if (slot === null) throw e; + // A declaration this statement flushed inside a probe (where + // no retry runs) can retry here too. + if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e; + const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS); + if (slot === null && !lowerer.reopenFlushedAtCommit(diagsBefore)) throw e; // A failed attempt adds nothing to `out` but the TDZ boxes // forward captures declared (they stay declared), so the // retry appends after them. - pendingSlots.push(slot); + lowerer.dropAttemptDiags(diagsBefore); + if (slot !== null) pendingSlots.push(slot); } } for (const slot of pendingSlots) lowerer.noteDynamicSlot(slot); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 00b62dcc4..e578e741f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -988,6 +988,9 @@ export interface LowererMode { * remainder maps them alike, so the declarations reachable emit * registered with dynamic slots collect there too). */ degradedTypes?: ReadonlyMap; + /** Localized failure: the declarations reachable emit registered with + * dynamic slots (the coverage remainder registers them too). */ + retriedSymbols?: ReadonlySet; /** LowerOptions.externalTypes, threaded through every lowering pass. */ externalTypes?: ReadonlyMap; /** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */ @@ -1396,6 +1399,7 @@ export function lowerToIr( remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, degradedTypes: resultLowerer.degradedTypes, + retriedSymbols: resultLowerer.retriedSymbols, inlineRequireCycleCalls, targetPlatform, ffiImports, @@ -3779,6 +3783,10 @@ export class Lowerer { readonly degradedTypes = new Map(); /** The dynamic slots the commit points created: one SC6104 each. */ readonly dynamicSlots: DynamicSlot[] = []; + /** Declarations retryDeferred registered. */ + readonly retriedSymbols = new Set(); + /** LowererMode.retriedSymbols. */ + readonly priorRetriedSymbols: ReadonlySet; /** Bodies run() skipped because their declaration had not registered. */ readonly skippedBodies = new Set(); /** Declarations retryDeferred registered, in order: run() lowers the @@ -3790,6 +3798,9 @@ export class Lowerer { ts.Symbol, { symbolOf: () => ts.Symbol | undefined; collect: () => void } >(); + /** True while a flush retry collects a declaration: the dyn class + * field policy (dynFieldPolicyFence) is lifted for reached classes. */ + liftDynFieldPolicy = false; /** Collection diagnostics that a flush retry may simply run again (a * derived class whose base had not registered yet). */ readonly retriableDiags = new WeakSet(); @@ -3878,6 +3889,7 @@ export class Lowerer { // The mode's options, sorted by name; the values derived from them follow. this.alreadyFlushed = mode.alreadyFlushed ?? new Set(); for (const [id, degraded] of mode.degradedTypes ?? []) this.degradedTypes.set(id, degraded); + this.priorRetriedSymbols = mode.retriedSymbols ?? new Set(); this.externalTypes = mode.externalTypes ?? new Map(); this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null; this.ffiImports = mode.ffiImports ?? []; @@ -4949,7 +4961,14 @@ export class Lowerer { } collectProgram(parts: FileParts[]): void { - return collectProgram(this, parts); + collectProgram(this, parts); + // The coverage remainder registers the declarations reachable emit + // registered with dynamic slots (retryDeferred), so it neither reports + // them as unreached nor blocks their uses. + for (const symbol of this.priorRetriedSymbols) { + const diags = this.deferredDiags.get(symbol); + if (diags !== undefined) this.retryDeferred(symbol, diags); + } } /** Names every file's %init and registers the run-once guard globals @@ -9346,6 +9365,9 @@ export class Lowerer { this.diagSink.push(d); return; } + // A probe's copy of a declaration's flushed diagnostics, committed + // after a flush retry registered the declaration: nothing to report. + if (pending && this.retriedSymbols.has(pending.symbol)) return; if (pending) { this.commitFlush(pending.symbol, pending.source); const group = this.rejectedGroups.get(pending.symbol); @@ -9434,7 +9456,7 @@ export class Lowerer { const failure = e.failure; const type = e.failedType; if (failure === undefined || type === undefined) return null; - if (!failure.degradable) return null; + if (!failure.degradable || failure.family === "dyn-field-policy") return null; if (!slots.has(failure.slot)) return null; const id = (type as { id?: number }).id; // Already degraded and still failing: no progress, commit as today. @@ -9467,6 +9489,51 @@ export class Lowerer { }); } + /** Localized failure, statement commit point for declarations a probe + * flushed: a statement committing the flushed diagnostics of a deferred + * declaration (inside a capture buffer the flush could not retry) reopens + * it here, outside every probe. When the declaration's diagnostics are + * all retriable, they leave the build, the declaration retries + * (retryDeferred), and on success the caller lowers the statement again. + * Returns whether some declaration registered. */ + reopenFlushedAtCommit(diagsBefore: number): boolean { + if (this.tierMode !== "auto" || this.diagSink !== null) return false; + const groups = new Set(); + for (let i = diagsBefore; i < this.diags.length; i++) { + const group = this.diags[i]!.rejects; + if (group !== undefined && !group.startsWith("statement @")) groups.add(group); + } + if (groups.size === 0) return false; + let registered = false; + for (const [symbol, group] of [...this.rejectedGroups]) { + if (!groups.has(group) || !this.deferredCollectors.has(symbol)) continue; + const committed = this.diags.filter((d) => d.rejects === group); + this.diags.splice(0, this.diags.length, ...this.diags.filter((d) => d.rejects !== group)); + this.flushedSymbols.delete(symbol); + this.rejectedGroups.delete(symbol); + if (this.retryDeferred(symbol, committed)) { + registered = true; + continue; + } + // Not retriable: the declaration's diagnostics commit as before. + const last = this.deferredDiags.get(symbol) ?? committed; + this.deferredDiags.delete(symbol); + this.flushedSymbols.add(symbol); + this.noteRejected(symbol, last); + for (const d of last) this.pushDiag(d); + } + return registered; + } + + /** A failed attempt's own diagnostics leave the build before its unit + * lowers again; flushed declaration diagnostics (`rejects`) stay, since + * the retry will not flush them a second time. */ + dropAttemptDiags(diagsBefore: number): void { + const kept = this.diags.slice(diagsBefore).filter((d) => d.rejects !== undefined); + this.diags.length = diagsBefore; + this.diags.push(...kept); + } + /** Records a dynamic slot whose unit lowered with it (one per site). */ noteDynamicSlot(slot: DynamicSlot): void { const seen = this.dynamicSlots.some( @@ -9478,6 +9545,22 @@ export class Lowerer { if (!seen) this.dynamicSlots.push(slot); } + /** The SC1090 refusal of checked-dynamic class fields in TypeScript + * (classFieldStoresDyn): a policy fence, not a missing representation. + * A flush retry lifts it (liftDynFieldPolicy), so a reached class with + * an 'any' field registers. */ + dynFieldPolicyFence(node: ts.Node, feature: string): never { + const diag = unsupportedDiag("SC1090", locOf(node), feature); + const failure = typeFailureOf( + node, + this.checker.typeToString(this.typeOf(node)), + "dyn-field-policy", + ); + diag.typeFailure = failure; + this.pushDiag(diag); + throw new PoisonError(failure, this.typeOf(node)); + } + badType(node: ts.Node, type: ts.Type): never { const widened = this.checker.getBaseTypeOfLiteralType(type); // Types declared by the ADOPTED @types/node (Buffer, NodeJS.Timeout, @@ -12454,6 +12537,12 @@ export class Lowerer { continue; } const failure = diag.typeFailure; + // The dyn class field policy lifts during the retry; nothing to + // degrade. + if (failure?.family === "dyn-field-policy") { + retriable = true; + continue; + } const type = failure === undefined ? undefined : this.failedTypes.get(failure); const id = (type as { id?: number } | undefined)?.id; if ( @@ -12474,7 +12563,16 @@ export class Lowerer { slots.push({ loc: diag.loc, failure, code: diag.code, reason: diag.message }); } this.deferredDiags.delete(symbol); - if (this.collectDeferring(collector.symbolOf, collector.collect) === null) { + const lifted = this.liftDynFieldPolicy; + this.liftDynFieldPolicy = true; + let registered: boolean; + try { + registered = this.collectDeferring(collector.symbolOf, collector.collect) === null; + } finally { + this.liftDynFieldPolicy = lifted; + } + if (registered) { + this.retriedSymbols.add(symbol); this.deferredCollectors.delete(symbol); for (const [name, deferred] of this.deferredClassByName) if (deferred === symbol) this.deferredClassByName.delete(name); diff --git a/packages/compiler/src/frontend/lowering/type-failure.ts b/packages/compiler/src/frontend/lowering/type-failure.ts index 126777973..3502a9bbf 100644 --- a/packages/compiler/src/frontend/lowering/type-failure.ts +++ b/packages/compiler/src/frontend/lowering/type-failure.ts @@ -34,6 +34,7 @@ const DEGRADABLE_FAMILIES: ReadonlySet = new Set