From 06e9672d9088f3ec59cfb5637cacd96cb148edb1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 10:32:32 -0500 Subject: [PATCH] Degrade a canonical form whose union mapped to the checked dynamic Localized failure's canonical fallback classified a failing type as an exact mapping whenever its canonical form mapped to something other than the checked dynamic. A record or array holding a union that mapCanonical collapses to the checked dynamic (an arm such as {} or unknown) then counted as exact, so its slot got no SC6104 warning and its dynamic operations no attribution. mapCanonical now notes such a collapse (CanonMapNotes), and canonicalType refuses the form when it happened, so the type degrades through the pushed-down form like any other. --- packages/compiler/src/frontend/canon-map.ts | 29 +++++++++++++++---- .../compiler/src/frontend/lowering/lowerer.ts | 13 +++++---- .../compiler/test/canon-normalize.test.ts | 21 +++++++++++++- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/frontend/canon-map.ts b/packages/compiler/src/frontend/canon-map.ts index 806c06113..25680d1e0 100644 --- a/packages/compiler/src/frontend/canon-map.ts +++ b/packages/compiler/src/frontend/canon-map.ts @@ -25,8 +25,21 @@ import { const MAX_DEPTH = 24; +/** What a mapping did besides its result. */ +export interface CanonMapNotes { + /** A union in the form mapped to the checked dynamic because an arm + * did (`string | {}`): the result holds a dynamic part the form did not + * spell as `unknown`. */ + collapsedToDynamic: boolean; +} + /** The IR type of a canonical node, or null when no rule applies. */ -export function mapCanonical(c: Canon, ctx: TypeMapperCtx, depth = 0): IrType | null { +export function mapCanonical( + c: Canon, + ctx: TypeMapperCtx, + depth = 0, + notes?: CanonMapNotes, +): IrType | null { if (depth > MAX_DEPTH) return null; const viaChecker = (): IrType | null => (c.src === undefined ? null : mapType(c.src, ctx)); switch (c.k) { @@ -55,13 +68,13 @@ export function mapCanonical(c: Canon, ctx: TypeMapperCtx, depth = 0): IrType | case "tuple": return viaChecker(); case "array": { - const elem = mapCanonical(c.elem, ctx, depth + 1); + const elem = mapCanonical(c.elem, ctx, depth + 1, notes); return elem === null ? null : { kind: "array", elem }; } case "union": { const byKey = new Map(); for (const arm of c.arms) { - const mapped = mapCanonical(arm, ctx, depth + 1); + const mapped = mapCanonical(arm, ctx, depth + 1, notes); if (mapped === null || mapped.kind === "void") return null; if (mapped.kind === "union") { const def = ctx.unions.get(mapped.unionId); @@ -71,7 +84,10 @@ export function mapCanonical(c: Canon, ctx: TypeMapperCtx, depth = 0): IrType | } const arms = [...byKey.values()]; if (arms.length === 1) return arms[0]!; - if (arms.some((a) => a.kind === "dyn")) return DYN; + if (arms.some((a) => a.kind === "dyn")) { + if (notes !== undefined) notes.collapsedToDynamic = true; + return DYN; + } arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); return { kind: "union", unionId: ctx.unions.intern(arms) }; } @@ -86,7 +102,7 @@ export function mapCanonical(c: Canon, ctx: TypeMapperCtx, depth = 0): IrType | if (props.some((p) => p.accessor)) return viaChecker(); // `{}` holds any non-nullish value: the checked dynamic, as mapType. if (props.length === 0) return DYN; - const structural = mapCanonicalRecord(props, ctx, depth); + const structural = mapCanonicalRecord(props, ctx, depth, notes); // An object whose members do not map structurally (an interface over // Map or Set, methods) keeps its per-type recognition. return structural ?? viaChecker(); @@ -100,10 +116,11 @@ function mapCanonicalRecord( props: readonly CanonProp[], ctx: TypeMapperCtx, depth: number, + notes: CanonMapNotes | undefined, ): IrType | null { const fields: { name: string; type: IrType }[] = []; for (const p of props) { - let t = mapCanonical(p.type(), ctx, depth + 1); + let t = mapCanonical(p.type(), ctx, depth + 1, notes); if (t === null) return null; if (p.optional && t.kind !== "dyn") { const armed = withUndefinedArm(t, ctx.unions); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 020861bdb..e72434c33 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -10,7 +10,7 @@ import { arrayRootOf, lowerWithArraySpecies, } from "./builtins/array-classes.js"; -import { canonShadowHook, mapCanonical } from "../canon-map.js"; +import { canonShadowHook, mapCanonical, type CanonMapNotes } from "../canon-map.js"; import { Normalizer } from "../canon.js"; import { compactDeclOrigins, @@ -10077,16 +10077,19 @@ export class Lowerer { } /** The IR type of a type's canonical form (canon-map.ts), or null when - * the form is irreducible, unbound, or only the checked dynamic. Runs - * only at localized failure's commit points, so a program that compiles - * today never consults it. */ + * the form is irreducible, unbound, only the checked dynamic, or holds a + * union that mapped to the checked dynamic (such a slot is dynamic in + * part, so it degrades like any other). Runs only at localized failure's + * commit points, so a program that compiles today never consults it. */ canonicalType(type: ts.Type): IrType | null { try { const resolve = this.typeCtx.resolveTypeParamTs; const c = new Normalizer(this.checker).canon(type, resolve ? (t) => resolve(t) : new Map()); if (c.k === "opaque" || c.k === "param") return null; const { typeMemo: _strictMemo, ...strict } = this.typeCtx; - const mapped = mapCanonical(c, { ...strict, canMemoizeType: () => false }); + const notes: CanonMapNotes = { collapsedToDynamic: false }; + const mapped = mapCanonical(c, { ...strict, canMemoizeType: () => false }, 0, notes); + if (notes.collapsedToDynamic) return null; return mapped === null || mapped.kind === "void" || mapped.kind === "dyn" ? null : mapped; } catch { return null; diff --git a/packages/compiler/test/canon-normalize.test.ts b/packages/compiler/test/canon-normalize.test.ts index c510b2790..96406513e 100644 --- a/packages/compiler/test/canon-normalize.test.ts +++ b/packages/compiler/test/canon-normalize.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { canonEqual, canonKey, Normalizer, type Canon } from "../src/frontend/canon.js"; -import { mapCanonical } from "../src/frontend/canon-map.js"; +import { mapCanonical, type CanonMapNotes } from "../src/frontend/canon-map.js"; import { mapType, ShapeRegistry, @@ -76,6 +76,9 @@ beforeAll(() => { // Accessor members: one type, and a getter and setter of different types. export interface Gauge { get level(): number; set level(next: number) } export interface Cell { get value(): string; set value(next: string | number) } + // A union with an arm that maps to the checked dynamic, inside a record. + export type Collapsing = { tag: string | {}; name: string }; + export type Tight = { tag: string; name: string }; // The same instantiations, each refined by an object type. export type RefinedMaps = | (Map & { tag: number })[] @@ -319,6 +322,22 @@ describe("mapCanonical", () => { expect(mapCanonical(new Normalizer(checker).canon(cell), ctx())).toBeNull(); expect(key(new Normalizer(checker).canon(cell))).toContain("get value"); }); + test("a union that maps to the checked dynamic inside a record is noted", () => { + const loose: CanonMapNotes = { collapsedToDynamic: false }; + const mapped = mapCanonical( + new Normalizer(checker).canon(aliases.get("Collapsing")!), + ctx(), + 0, + loose, + ); + expect(mapped?.kind).toBe("record"); + expect(loose.collapsedToDynamic).toBe(true); + const tight: CanonMapNotes = { collapsedToDynamic: false }; + expect( + mapCanonical(new Normalizer(checker).canon(aliases.get("Tight")!), ctx(), 0, tight)?.kind, + ).toBe("record"); + expect(tight.collapsedToDynamic).toBe(false); + }); test("shadow mode observes every successful top-level mapping", () => { const seen: string[] = []; const point = { kind: "object", className: "Point" } as const;