Walk a for..of over an iterable beside never[]

`for (const name of registry.get(key) ?? [])` (mcp's server-tool registry)
types the head `Set<string> | never[]` and had no lowering: the iterable
union rule knows Iterable/generator unions and the indexed numeric arms, so
the loop fell through to the generic SC2001 fence on the union type.

`never[]` is the empty array literal's own type: its only value is the empty
array, which yields no iterations. `T[] | never[]` reduces in the checker
(never is assignable to T), so the reachable shape is exactly one inhabited
non-array iterable beside `never[]`. That loop is the existing container walk
on the arm the runtime tag selects:

    if (unionIsTag(head, setArm)) { <lowerForOfSet on unionNarrow(head)> }

for Set, Map and string — no new representation, and the empty-array arm is
an empty `else`. A union of two inhabited iterables
(`Set<string> | string[]`) still fences: both arms hold values and their
walks yield different element kinds through different protocols.

Corpus 8416-iterable-never-array-arm pins Set/Map/string registries with
present and missing keys, a live set mutated between loops, and an explicit
`Set<string> | never[]` annotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 13:17:18 -07:00
co-authored by Claude Opus 5.5
parent c3954d4dd4
commit 54cd19e52a
5 changed files with 204 additions and 0 deletions
@@ -10632,6 +10632,72 @@ function isUntypedIterable(lowerer: Lowerer, node: ts.Expression): boolean {
return lowerer.mapTypeOf(type)?.kind === "dyn";
}
/** `for (const x of map.get(k) ?? [])`: the receiver's checker type is one
* non-array iterable beside `never[]` — the array literal's type, whose only
* value is the empty array (`never` is uninhabited), so that arm yields
* nothing at all. The loop walks the iterable arm under its tag test and the
* empty-array arm runs zero iterations. An ARRAY iterable never needs this:
* `T[] | never[]` reduces in the checker to `T[]`, never being assignable to
* T. A union of two inhabited iterables has no single element type and keeps
* its existing fence. */
function lowerForOfEmptyArrayArm(
lowerer: Lowerer,
stmt: ts.ForOfStatement,
iterable: IrExpr,
iterSrc: ts.Expression,
labels?: string[],
): IrStmt | null {
if (iterable.type.kind !== "union" || stmt.awaitModifier !== undefined) return null;
const { checker } = lowerer;
const widened = lowerer.typeOf(iterSrc);
if (!widened.isUnionType()) return null;
const parts = ts.constituentTypes(widened);
if (parts.length !== 2) return null;
const neverArrays = parts.filter((part) => {
if (!checker.isArrayType(part)) return false;
const elem = checker.getTypeArguments(part as ts.TypeReference)[0];
return elem !== undefined && (elem.flags & ts.TypeFlags.Never) !== 0;
});
if (neverArrays.length !== 1) return null;
const other = parts.find((part) => part !== neverArrays[0]);
if (other === undefined) return null;
const armT = lowerer.mapTypeOf(other);
if (armT === null) return null;
if (armT.kind !== "set" && armT.kind !== "map" && armT.kind !== "string") return null;
const tag = lowerer.armTag(iterable.type.unionId, armT);
if (tag < 0) return null;
const loc = locOf(stmt);
const narrowed: IrExpr = {
kind: "unionNarrow",
unionId: iterable.type.unionId,
tag,
value: iterable,
type: armT,
loc,
};
const loop: IrStmt =
armT.kind === "set"
? lowerForOfSet(lowerer, stmt, narrowed, armT)
: armT.kind === "map"
? lowerForOfMap(lowerer, stmt, narrowed, armT)
: lowerForOfString(lowerer, stmt, narrowed, labels);
return {
kind: "if",
cond: {
kind: "unionIsTag",
unionId: iterable.type.unionId,
tag,
negated: false,
value: iterable,
type: BOOL,
loc,
},
then: [loop],
else_: null,
loc,
};
}
export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
// Labels consume HERE (nested statements must never see them). The
// plain array/string paths carry them; the container/matchAll/stdin
@@ -10915,6 +10981,10 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
const iterableUnion = iterableUnionOf(lowerer, iterable.type);
if (iterableUnion) return lowerForOfIterable(lowerer, stmt, iterable, iterableUnion, labels);
}
{
const emptyArrayArm = lowerForOfEmptyArrayArm(lowerer, stmt, iterable, iterSrc, labels);
if (emptyArrayArm) return emptyArrayArm;
}
let tupleSource: {
shapeId: string;
fields: { name: string; type: IrType }[];
@@ -0,0 +1,84 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
import type { IrModule } from "../src/ir/ir.js";
/** Lower one program to IR; a fence answers its diagnostic codes instead. */
async function lower(
source: string,
): Promise<{ ok: true; module: IrModule } | { ok: false; codes: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-iterable-union-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) };
return { ok: true, module: deserializeModule(await readFile(outPath, "utf8")) };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** Every IR node kind present in the module, as a set. */
function nodeKinds(module: IrModule): Set<string> {
const kinds = new Set<string>();
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (value === null || typeof value !== "object") return;
const node = value as { kind?: unknown };
if (typeof node.kind === "string") kinds.add(node.kind);
for (const child of Object.values(value)) visit(child);
};
visit(module.functions);
return kinds;
}
test("a for-of over an iterable beside never[] walks the iterable on its tag", async () => {
const result = await lower(`
function setTotal(registry: Map<string, Set<string>>, key: string): number {
let total = 0;
for (const name of registry.get(key) ?? []) total += name.length;
return total;
}
function mapTotal(registry: Map<string, Map<string, number>>, key: string): number {
let total = 0;
for (const [name, value] of registry.get(key) ?? []) total += name.length + value;
return total;
}
function stringTotal(registry: Map<string, string>, key: string): number {
let total = 0;
for (const ch of registry.get(key) ?? []) total += ch.length;
return total;
}
console.log(setTotal(new Map(), "k"), mapTotal(new Map(), "k"), stringTotal(new Map(), "k"));
export {};
`);
if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`);
expect(validateModule(result.module)).toEqual([]);
const kinds = nodeKinds(result.module);
// The loop is guarded by the arm's tag; the empty-array arm yields nothing.
expect(kinds.has("unionIsTag")).toBe(true);
expect(kinds.has("unionNarrow")).toBe(true);
});
test("a union of two inhabited iterables still has no common iteration", async () => {
const result = await lower(`
function total(registry: Map<string, Set<string> | string[]>, key: string): number {
let sum = 0;
for (const item of registry.get(key) ?? []) sum += item.length;
return sum;
}
console.log(total(new Map(), "k"));
export {};
`);
// Both arms can hold values, and the two walks yield at different element
// kinds through different protocols: the union keeps its fence.
expect(result.ok).toBe(false);
if (!result.ok) expect(result.codes).toEqual(["SC2001"]);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8419-iterable-never-array-arm.ts"
],
"diags": []
}
@@ -0,0 +1,43 @@
// `for (const x of registry.get(key) ?? [])`: the receiver's type is the
// iterable beside `never[]` — the array literal's type, whose only value is
// the empty array, so that arm runs zero iterations.
function setLength(registry: Map<string, Set<string>>, key: string): number {
let total = 0;
for (const name of registry.get(key) ?? []) total += name.length;
return total;
}
function mapTotal(registry: Map<string, Map<string, number>>, key: string): number {
let total = 0;
for (const [name, value] of registry.get(key) ?? []) total += name.length + value;
return total;
}
function stringLength(registry: Map<string, string>, key: string): number {
let total = 0;
for (const ch of registry.get(key) ?? []) total += ch.length;
return total;
}
function main(): void {
const sets = new Map<string, Set<string>>();
sets.set("a", new Set(["xy", "z"]));
console.log("set", setLength(sets, "a"), setLength(sets, "missing"));
sets.get("a")?.add("q");
console.log("set live", setLength(sets, "a"));
const maps = new Map<string, Map<string, number>>();
maps.set("a", new Map([["xy", 3]]));
console.log("map", mapTotal(maps, "a"), mapTotal(maps, "missing"));
const strings = new Map<string, string>();
strings.set("a", "xyz");
console.log("string", stringLength(strings, "a"), stringLength(strings, "missing"));
// The same shape through an explicit union annotation.
const empty: Set<string> | never[] = sets.get("missing") ?? [];
let seen = 0;
for (const item of empty) seen += item.length;
console.log("annotated", seen);
}
main();
@@ -0,0 +1 @@
{ "plain": 3000, "san": 4100 }