From 895763f3b651caf9b27c7cadff9545bc4f31035f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 07:38:24 -0500 Subject: [PATCH 1/2] Keep a give-up statement's first-attempt diagnostics --- .../compiler/src/frontend/lowering/lower-stmts.ts | 14 ++++++++++++-- packages/compiler/src/frontend/lowering/lowerer.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 7c0f05bfc..12b7aabcc 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -759,6 +759,11 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // out of tdzPredeclared; a retry after a later declarator degrades // must initialize the same box again. const tdzBefore = new Map(lowerer.ctx.tdzPredeclared); + // The first attempt's diagnostics: a statement that still fails + // after degrading reports them, since a retry does not repeat what + // lowered once (a poisoned generic instance) and its own diagnostics + // can miss the failure's cause. + let firstAttempt: ScrDiagnostic[] | null = null; try { for (let attempt = 0; ; attempt++) { const hits = new Set(); @@ -773,7 +778,12 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // global degrades that type and the statement lowers again. // A declaration this statement flushed inside a probe (where // no retry runs) can retry here too. - if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e; + firstAttempt ??= lowerer.diags.slice(diagsBefore); + const giveUp = (): unknown => { + if (attempt > 0) lowerer.restoreFirstAttemptDiags(diagsBefore, firstAttempt!); + return e; + }; + if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw giveUp(); const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS); // Pushed-down types the failed attempt mapped widen to the // whole slot before anything else is given up. @@ -782,7 +792,7 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir !lowerer.reopenFlushedAtCommit(diagsBefore) && !lowerer.widenPartials(hits) ) - throw e; + throw giveUp(); // A failed attempt adds nothing to `out` but the TDZ boxes // forward captures declared (they stay declared), so the // retry appends after them. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cfa7987a6..9184a4e36 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -9853,6 +9853,19 @@ export class Lowerer { this.diags.push(...kept); } + /** A unit that still fails after its retries commits its first + * attempt's own diagnostics (`first`, taken before the first + * degradation) in place of the last attempt's. The diagnostics the + * retries carry (dropAttemptDiags) stay as they are now: a declaration + * reopened since may have registered. */ + restoreFirstAttemptDiags(diagsBefore: number, first: readonly ScrDiagnostic[]): void { + const carried = (d: ScrDiagnostic): boolean => + d.rejects !== undefined || this.loweredOnceDiags.has(d); + const kept = this.diags.slice(diagsBefore).filter(carried); + this.diags.length = diagsBefore; + this.diags.push(...first.filter((d) => !carried(d)), ...kept); + } + /** Lowers one unit whose body-level poison skips it (a generic * instance), with localized failure's commit point: a degradable type * failure at one of its declaration slots degrades the type and the unit From acb2d23df58ee197ef6de025cd2ece650d76ee3e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 07:59:53 -0500 Subject: [PATCH 2/2] Attribute slot causes to the owner only for types without their own slot --- .../src/frontend/lowering/dynamic-tier.ts | 6 +++-- .../coverage-fixtures/dynamic-slot-shared.ts | 23 +++++++++++++++++++ tests/harness/coverage.test.ts | 9 ++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/coverage-fixtures/dynamic-slot-shared.ts diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts index 9c5a10760..6307f58fa 100644 --- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts +++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts @@ -223,8 +223,10 @@ function slotCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null { ? lowerer.checker.getTypeOfSymbol(symbol) : lowerer.checker.getTypeAtLocation(n); const id = (type as { id?: number }).id ?? -1; - // A whole degraded type, or a dynamic piece of a pushed-down one. - const owner = lowerer.lenientPieces.get(id); + // A whole degraded type, or a dynamic piece of a pushed-down one. A type + // that is also a slot of its own belongs to that slot, not to a container + // it is a piece of. + const owner = lowerer.degradedTypes.has(id) ? undefined : lowerer.lenientPieces.get(id); if (!lowerer.degradedTypes.has(id) && owner === undefined) return null; const text = ts.getTextOfNode(n); const declaredType = lowerer.checker.typeToString(type); diff --git a/tests/coverage-fixtures/dynamic-slot-shared.ts b/tests/coverage-fixtures/dynamic-slot-shared.ts new file mode 100644 index 000000000..21624a479 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-slot-shared.ts @@ -0,0 +1,23 @@ +// A type that degrades as a piece of a container (`Plugin.hook`) and as a +// slot of its own (`current`): the operations on `current` belong to +// `current`, not to the container. +type Hook = (() => number) | AbortSignal | Map string>; + +interface Plugin { + name: string; + hook: Hook; +} + +const plugins: Plugin[] = [{ name: "one", hook: () => 1 }]; + +let current: Hook = () => 2; + +function run(rounds: number): number { + let total = 0; + for (let i = 0; i < rounds; i++) { + if (typeof current === "function") total += current(); + } + return total; +} + +console.log(run(10), plugins.length); diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index bba240619..d8e6e156d 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -496,6 +496,15 @@ test("dynamic slots rank by their dynamic operations and name themselves in SC61 expect(hot?.message).toContain("`hook` is a dynamic piece of `Plugin[]` (declared `Hook`"); }); +test("a type that is a dynamic piece and a slot of its own counts toward its own slot", () => { + const { coverage } = analyze(fixture("dynamic-slot-shared.ts")); + const slots = coverage.dynamicSlots ?? []; + expect(slots.find((s) => s.name === "current")?.operations).toBeGreaterThan(0); + expect(slots.find((s) => s.name === "plugins")?.operations ?? 0).toBe(0); + const hot = coverage.performance?.find((d) => d.code === "SC6101"); + expect(hot?.message).toContain("`current` is a dynamic slot (declared `Hook`"); +}); + test("CLI: --tier and --explain-dynamic", () => { const scriptcCli = join(repoRoot, "packages/cli/src/main.ts"); const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-"));