mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
Lower a finite-literal keyed read over a subset of the declared fields
`filter[resourceType]` where the receiver is a record with several optional members and the key type is a finite union of literal keys (`"extensions" | "skills" | ...`) refused with SC1090 whenever the checker's answer type did not cover every declared field. That is the common settings-record shape: the key domain names four of the members and the rest have unrelated types. The key type is a finite domain, so the read now dispatches over exactly the fields it can name, comparing the key against each canonical property spelling. The answer is the join of the reachable fields' types — precisely the checker's answer type — and a key outside the domain reads undefined, as a missing property does. Nothing is emitted for a field the key type cannot name, so no widening to the full record leaks into downstream lowering. The dispatch is gated on the shape having no index signature, on the access not being a call (`o[k]()` keeps its fence for inherited methods), on the reachable fields being a proper subset, and on the join holding an undefined arm so a smuggled key has an answer. A key domain that names every field keeps the single keyed-read helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
91deb8b5a0
commit
5bcabf9dd5
@@ -1,6 +1,7 @@
|
||||
/* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */
|
||||
import { OBJECT_PROTOTYPE_MEMBERS } from "../../shape-inference/domain.js";
|
||||
import { lowerUnionTupleRead } from "./union-fields.js";
|
||||
import { constituentTypes } from "../../ts7/checker.js";
|
||||
import * as ts from "../../ts7/adapter.js";
|
||||
import {
|
||||
lowerOptionalIndex,
|
||||
@@ -1448,6 +1449,40 @@ export function lowerRecordKeyRead(
|
||||
const read = lowerer.mapTypeOf(lowerer.typeOf(expr));
|
||||
if (read !== null && read.kind === "union" && recordKeyResultOk(lowerer, shape, read))
|
||||
declared = read;
|
||||
else if (!isCalledAccess(expr)) {
|
||||
// A key type that is a FINITE union of literal keys names a subset of
|
||||
// the declared fields (`filter[resourceType]` over a settings record
|
||||
// whose other members have their own types). The answer joins only
|
||||
// the reachable fields' types — the checker's own answer type — and
|
||||
// the read dispatches over those keys; a key the type system does not
|
||||
// admit is a smuggled miss, which reads undefined as a missing
|
||||
// property does (the join must hold the arm to say so).
|
||||
const domain = literalKeyDomain(lowerer, keyNode);
|
||||
const reachable =
|
||||
domain === null ? [] : shape.fields.filter((field) => domain.includes(field.name));
|
||||
if (domain !== null && reachable.length > 0 && reachable.length < shape.fields.length) {
|
||||
const join = joinedFieldType(lowerer, reachable);
|
||||
if (
|
||||
join !== null &&
|
||||
join.kind === "union" &&
|
||||
lowerer.armTag(join.unionId, UNDEFINED_T) >= 0 &&
|
||||
recordKeyResultOk(lowerer, { ...shape, fields: reachable }, join)
|
||||
) {
|
||||
return lowerFiniteKeyedRead(
|
||||
lowerer,
|
||||
obj,
|
||||
shapeId,
|
||||
shape,
|
||||
domain,
|
||||
key,
|
||||
join,
|
||||
loc,
|
||||
includeUndefined,
|
||||
expr,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fields of different types answer a checked-dynamic value where nothing
|
||||
// typed the read: in JavaScript, and where the checker types it 'any'
|
||||
@@ -1561,6 +1596,133 @@ function recordKeyTypeLiteralText(lowerer: Lowerer, node: ts.Expression): string
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The FINITE key domain a key type proves: the canonical property spelling
|
||||
* of each string/number literal arm of a union-typed key, or of a key type
|
||||
* that IS one literal. Null when the type keeps any arm that is not a
|
||||
* literal (a widened string, a template, a symbol) — such a key can name
|
||||
* any field, so no finite dispatch exists. */
|
||||
function literalKeyDomain(lowerer: Lowerer, node: ts.Expression): string[] | null {
|
||||
let t: ts.Type = lowerer.typeOf(node);
|
||||
if (t.flags & ts.TypeFlags.TypeParameter) {
|
||||
const bound = lowerer.typeParamTsResolver(t);
|
||||
if (!bound) return null;
|
||||
t = bound;
|
||||
}
|
||||
const arms = (t.flags & ts.TypeFlags.Union) !== 0 ? constituentTypes(t) : [t];
|
||||
const domain: string[] = [];
|
||||
for (const arm of arms) {
|
||||
if (arm.isStringLiteralType()) {
|
||||
domain.push(arm.value);
|
||||
continue;
|
||||
}
|
||||
if (arm.isNumberLiteralType()) {
|
||||
const n = arm.value;
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
domain.push(String(n));
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return domain.length > 0 ? domain : null;
|
||||
}
|
||||
|
||||
/* The interned join of the given fields' types, deduped and sorted (the
|
||||
* canonical union form). Null when an arm has no value representation (a
|
||||
* bare 'void', a generator, a caught value) — the caller keeps its fence. */
|
||||
function joinedFieldType(lowerer: Lowerer, fields: readonly { type: IrType }[]): IrType | null {
|
||||
const arms: IrType[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (t: IrType): boolean => {
|
||||
if (t.kind === "union") {
|
||||
const def = lowerer.unions.get(t.unionId);
|
||||
if (!def) return false;
|
||||
return def.arms.every(push);
|
||||
}
|
||||
if (t.kind === "void" || t.kind === "generator" || t.kind === "caught") return false;
|
||||
const key = typeKey(t);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
arms.push(t);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (const field of fields) if (!push(field.type)) return null;
|
||||
if (arms.length === 0) return null;
|
||||
arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
|
||||
return arms.length === 1 ? arms[0]! : { kind: "union", unionId: lowerer.unions.intern(arms) };
|
||||
}
|
||||
|
||||
/** `r[k]` where the key type is a FINITE union of literal keys naming a
|
||||
* subset of `r`'s declared fields (`filter[resourceType]` over a settings
|
||||
* record whose remaining members have their own types). The declared
|
||||
* fields answer through a chain of key comparisons, each hit wrapped into
|
||||
* the joined answer type; a key outside the domain reads undefined, as a
|
||||
* missing property does. This is the per-key form of the keyed read the
|
||||
* backend emits as a string switch — nothing is emitted for a field the
|
||||
* key type cannot name, so the join stays exactly the checker's answer. */
|
||||
function lowerFiniteKeyedRead(
|
||||
lowerer: Lowerer,
|
||||
obj: IrExpr,
|
||||
shapeId: string,
|
||||
shape: IrRecordShape,
|
||||
domain: readonly string[],
|
||||
key: IrExpr,
|
||||
answer: IrType & { kind: "union" },
|
||||
loc: SrcLoc,
|
||||
includeUndefined: boolean,
|
||||
expr: ts.ElementAccessExpression,
|
||||
): IrExpr {
|
||||
const stmts: IrStmt[] = [];
|
||||
const settle = (value: IrExpr, name: string): IrExpr => {
|
||||
if (isSafeToRepeat(value)) return value;
|
||||
const temp = lowerer.declareHiddenLocal(name, value.type);
|
||||
stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc });
|
||||
return varRef(temp.id, temp.type, loc);
|
||||
};
|
||||
// JavaScript evaluates the receiver before the key.
|
||||
const receiver = settle(obj, "%keyedReceiver");
|
||||
const stableKey = settle(key, "%keyedKey");
|
||||
let result: IrExpr = {
|
||||
kind: "unionWrap",
|
||||
unionId: answer.unionId,
|
||||
tag: lowerer.armTag(answer.unionId, UNDEFINED_T),
|
||||
value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc },
|
||||
type: answer,
|
||||
loc,
|
||||
};
|
||||
for (let i = domain.length - 1; i >= 0; i--) {
|
||||
const name = domain[i]!;
|
||||
const field = shape.fields.find((f) => f.name === name);
|
||||
if (!field) continue;
|
||||
const get: IrExpr = {
|
||||
kind: "recordGet",
|
||||
obj: receiver,
|
||||
shapeId,
|
||||
field: name,
|
||||
type: field.type,
|
||||
loc,
|
||||
};
|
||||
result = {
|
||||
kind: "ternary",
|
||||
cond: {
|
||||
kind: "strEq",
|
||||
negated: false,
|
||||
left: stableKey,
|
||||
right: strLit(name, loc),
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
then: lowerer.coerceToExpected(get, answer),
|
||||
else_: result,
|
||||
type: answer,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
const read: IrExpr =
|
||||
stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: answer, loc };
|
||||
return includeUndefined ? read : lowerer.maybeNarrow(read, expr);
|
||||
}
|
||||
|
||||
/** The literal index of a tuple access, or null when the expression isn't
|
||||
* a non-negative integer literal (parentheses tolerated — tsc's own
|
||||
* literal-index typing accepts them). */
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/record-subset-keyed-read.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// A runtime keyed read whose key type is a FINITE set of literal keys naming
|
||||
// a subset of the record's declared fields (`filter[resourceType]` over a
|
||||
// settings record whose remaining members have their own types). The answer
|
||||
// joins the reachable fields' types — the checker's own answer type — and a
|
||||
// key the type system does not admit reads undefined, as a missing property
|
||||
// does.
|
||||
|
||||
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
|
||||
|
||||
interface PackageFilter {
|
||||
autoload?: boolean;
|
||||
extensions?: string[];
|
||||
prompts?: string[];
|
||||
skills?: string[];
|
||||
themes?: string[];
|
||||
}
|
||||
|
||||
const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"];
|
||||
|
||||
function list(filter: PackageFilter): void {
|
||||
for (const resourceType of RESOURCE_TYPES) {
|
||||
const patterns = (filter[resourceType] ?? []) as string[];
|
||||
console.log(resourceType, patterns.join(","));
|
||||
}
|
||||
}
|
||||
list({ autoload: true, extensions: [".ts", ".tsx"], skills: ["a"] });
|
||||
|
||||
// The same read behind a call, so the key is no identifier.
|
||||
function filterOf(): PackageFilter {
|
||||
return { prompts: ["p"] };
|
||||
}
|
||||
function first(key: () => ResourceType): string[] {
|
||||
return filterOf()[key()] ?? [];
|
||||
}
|
||||
console.log(first(() => "prompts"), first(() => "themes"));
|
||||
|
||||
// Numeric-literal keys name mapped fields by their canonical spelling.
|
||||
interface Counts {
|
||||
1?: number;
|
||||
2?: string;
|
||||
3?: boolean;
|
||||
}
|
||||
type Slot = 1 | 2;
|
||||
function slot(counts: Counts, index: Slot): number | string | undefined {
|
||||
return counts[index];
|
||||
}
|
||||
const counts: Counts = { 1: 10, 2: "two", 3: true };
|
||||
for (const index of [1, 2] as Slot[]) {
|
||||
console.log(index, slot(counts, index));
|
||||
}
|
||||
|
||||
// A key domain that covers EVERY field keeps the single keyed-read helper.
|
||||
interface Nested {
|
||||
enabled?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
interface Settings {
|
||||
auto?: boolean;
|
||||
list?: string[];
|
||||
nested?: Nested;
|
||||
}
|
||||
function anyKey(settings: Settings, key: keyof Settings): boolean | string[] | Nested | undefined {
|
||||
return settings[key];
|
||||
}
|
||||
const settings: Settings = { auto: true, list: ["x"], nested: { limit: 2 } };
|
||||
const nested = anyKey(settings, "nested");
|
||||
console.log(
|
||||
anyKey(settings, "auto"),
|
||||
anyKey(settings, "list"),
|
||||
nested !== undefined && !Array.isArray(nested) && typeof nested !== "boolean"
|
||||
? nested.limit
|
||||
: "none",
|
||||
);
|
||||
|
||||
// An index-signature record answers its slot type for a union key.
|
||||
interface Bag {
|
||||
[key: string]: string | number | undefined;
|
||||
known: string;
|
||||
}
|
||||
function bagRead(bag: Bag, key: "known" | "other"): string | number | undefined {
|
||||
return bag[key];
|
||||
}
|
||||
const bag: Bag = { known: "k", other: 7 };
|
||||
console.log(bagRead(bag, "known"), bagRead(bag, "other"));
|
||||
|
||||
// A reachable field that is itself a record.
|
||||
interface Spec {
|
||||
headers?: { [key: string]: string };
|
||||
port: number;
|
||||
name: string;
|
||||
}
|
||||
function headersOf(spec: Spec, key: "headers"): { [key: string]: string } | undefined {
|
||||
return spec[key];
|
||||
}
|
||||
const headers = headersOf({ headers: { a: "b" }, port: 1, name: "n" }, "headers");
|
||||
console.log(headers === undefined ? "missing" : headers["a"]);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 4000, "san": 2400 }
|
||||
Reference in New Issue
Block a user