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.
This commit is contained in:
Chris Tate
2026-10-27 10:32:32 -05:00
parent 0927bfd295
commit 06e9672d90
3 changed files with 51 additions and 12 deletions
+23 -6
View File
@@ -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<string, IrType>();
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);
@@ -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;
+20 -1
View File
@@ -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<string, number> & { 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;