mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-25 06:24:27 +08:00
Read missing record keys as undefined before every absence guard
`let e = map[k]; if (e === undefined) { e = {}; map[k] = e } e[c] = v` on a `Record<string, Record<string, string>>` compiled, then aborted with "record has no key (typed slot…)" where Node prints the result. Without noUncheckedIndexedAccess a keyed record read keeps its typed trap unless the next statement tests the binding for absence, and only `if (!e)` counted. Comparisons with the missing value now count too (`=== undefined`, `== null`, `!== void 0`, `typeof e === "undefined"` and their mirrored forms) for index-signature record reads whose checker type lacks undefined. Module-scope declarations get the same treatment: their pre-registered global takes the undefined arm before any body reads it, and the initializer lowers as the absence probe. Array reads, annotated bindings and noUncheckedIndexedAccess programs compile as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dcdca2b810
commit
ce87ba722d
@@ -4334,16 +4334,21 @@ export function lowerVarDecl(
|
||||
if (wrapped) return { kind: "assign", localId: g.id, value: wrapped, loc: locOf(decl) };
|
||||
return null;
|
||||
}
|
||||
const raw = lowerVariableInitializer(
|
||||
lowerer,
|
||||
decl.initializer,
|
||||
g.type.kind === "dyn" &&
|
||||
(isNativeBuiltinValueInitializer(lowerer, decl.initializer) ||
|
||||
ts.isArrayLiteralExpression(decl.initializer) ||
|
||||
ts.isObjectLiteralExpression(decl.initializer))
|
||||
? DYN
|
||||
: undefined,
|
||||
);
|
||||
const guardedRead = isGuardedRecordRead(lowerer, decl)
|
||||
? lowerAbsenceProbe(lowerer, decl.initializer)
|
||||
: null;
|
||||
const raw =
|
||||
guardedRead ??
|
||||
lowerVariableInitializer(
|
||||
lowerer,
|
||||
decl.initializer,
|
||||
g.type.kind === "dyn" &&
|
||||
(isNativeBuiltinValueInitializer(lowerer, decl.initializer) ||
|
||||
ts.isArrayLiteralExpression(decl.initializer) ||
|
||||
ts.isObjectLiteralExpression(decl.initializer))
|
||||
? DYN
|
||||
: undefined,
|
||||
);
|
||||
if (
|
||||
isJsSourceFile(decl.getSourceFile()) &&
|
||||
!decl.type &&
|
||||
@@ -5139,29 +5144,111 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
|
||||
}
|
||||
|
||||
function immediatelyGuardedAbsenceProbe(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
|
||||
if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return false;
|
||||
const guard = immediateAbsenceGuard(lowerer, decl);
|
||||
// `if (!x)`: the falsy test sees a missing value.
|
||||
if (guard === "falsy") return true;
|
||||
// `if (x === undefined)` and its spellings compare the binding with the
|
||||
// missing value itself, so the read must be able to produce it. Only
|
||||
// index-signature record reads take this form: array reads already
|
||||
// carry their undefined arm, and their ordinary lowering keeps its
|
||||
// bounds proofs.
|
||||
return guard === "compare" && isIndexSignatureRecordRead(lowerer, decl);
|
||||
}
|
||||
|
||||
/** A file-scope or block-scope `let e = map[k]` over an index-signature
|
||||
* record whose next statement guards `e` for absence (`if (e ===
|
||||
* undefined)`, `if (!e)`, ...): the get-or-create idiom. Its read keeps the
|
||||
* missing key as undefined even without noUncheckedIndexedAccess. */
|
||||
export function isGuardedRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
|
||||
return immediateAbsenceGuard(lowerer, decl) !== null && isIndexSignatureRecordRead(lowerer, decl);
|
||||
}
|
||||
|
||||
function isIndexSignatureRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
|
||||
if (!decl.initializer) return false;
|
||||
let init = decl.initializer;
|
||||
while (ts.isParenthesizedExpression(init)) init = init.expression;
|
||||
if (!ts.isElementAccessExpression(init)) return false;
|
||||
// Under noUncheckedIndexedAccess the checker already spells the missing
|
||||
// arm, and the ordinary read carries it.
|
||||
const read = lowerer.typeOf(init);
|
||||
if (
|
||||
(read.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 ||
|
||||
(read.isUnionType() &&
|
||||
ts.constituentTypes(read).some((t) => (t.flags & ts.TypeFlags.Undefined) !== 0))
|
||||
)
|
||||
return false;
|
||||
const receiver = lowerer.mapTypeOf(lowerer.typeOf(init.expression));
|
||||
const shape = receiver?.kind === "record" ? lowerer.shapes.get(receiver.shapeId) : undefined;
|
||||
return !!shape && !shape.tuple && !!shape.indexValue && shape.indexValue.kind !== "dyn";
|
||||
}
|
||||
|
||||
/** How the statement right after an unannotated declaration tests the
|
||||
* declared binding for absence: `if (!x)` ("falsy"), or a comparison with
|
||||
* the missing value — `x === undefined`, `x == null`, `x !== void 0`,
|
||||
* `typeof x === "undefined"` and their mirrored and negated spellings
|
||||
* ("compare"). Null for anything else. */
|
||||
function immediateAbsenceGuard(
|
||||
lowerer: Lowerer,
|
||||
decl: ts.VariableDeclaration,
|
||||
): "falsy" | "compare" | null {
|
||||
if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return null;
|
||||
const statement = decl.parent?.parent;
|
||||
if (!statement || !ts.isVariableStatement(statement)) return false;
|
||||
if (!statement || !ts.isVariableStatement(statement)) return null;
|
||||
const container = statement.parent;
|
||||
const statements =
|
||||
ts.isBlock(container) || ts.isSourceFile(container) ? container.statements : null;
|
||||
if (!statements) return false;
|
||||
if (!statements) return null;
|
||||
const index = statements.indexOf(statement);
|
||||
const next = index >= 0 ? statements[index + 1] : undefined;
|
||||
if (!next || !ts.isIfStatement(next)) return false;
|
||||
let condition = next.expression;
|
||||
while (ts.isParenthesizedExpression(condition)) condition = condition.expression;
|
||||
if (
|
||||
!ts.isPrefixUnaryExpression(condition) ||
|
||||
condition.operator !== ts.SyntaxKind.ExclamationToken
|
||||
)
|
||||
return false;
|
||||
let operand = condition.operand;
|
||||
while (ts.isParenthesizedExpression(operand)) operand = operand.expression;
|
||||
return (
|
||||
ts.isIdentifier(operand) &&
|
||||
lowerer.resolveValueSymbol(operand) === lowerer.resolveValueSymbol(decl.name)
|
||||
);
|
||||
if (!next || !ts.isIfStatement(next)) return null;
|
||||
const binding = lowerer.resolveValueSymbol(decl.name);
|
||||
const peel = (node: ts.Expression): ts.Expression => {
|
||||
while (ts.isParenthesizedExpression(node)) node = node.expression;
|
||||
return node;
|
||||
};
|
||||
const isBinding = (node: ts.Expression): boolean => {
|
||||
const operand = peel(node);
|
||||
return ts.isIdentifier(operand) && lowerer.resolveValueSymbol(operand) === binding;
|
||||
};
|
||||
const condition = peel(next.expression);
|
||||
if (ts.isPrefixUnaryExpression(condition))
|
||||
return condition.operator === ts.SyntaxKind.ExclamationToken && isBinding(condition.operand)
|
||||
? "falsy"
|
||||
: null;
|
||||
if (!ts.isBinaryExpression(condition)) return null;
|
||||
const op = condition.operatorToken.kind;
|
||||
const strict =
|
||||
op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
||||
op === ts.SyntaxKind.ExclamationEqualsEqualsToken;
|
||||
const loose =
|
||||
op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken;
|
||||
if (!strict && !loose) return null;
|
||||
const isUndefined = (node: ts.Expression): boolean => {
|
||||
const operand = peel(node);
|
||||
if (ts.isVoidExpression(operand)) return true;
|
||||
return (
|
||||
ts.isIdentifier(operand) &&
|
||||
operand.text === "undefined" &&
|
||||
(lowerer.typeOf(operand).flags & ts.TypeFlags.Undefined) !== 0
|
||||
);
|
||||
};
|
||||
const isNull = (node: ts.Expression): boolean => peel(node).kind === ts.SyntaxKind.NullKeyword;
|
||||
const missing = (node: ts.Expression): boolean => isUndefined(node) || (loose && isNull(node));
|
||||
const isTypeofBinding = (node: ts.Expression): boolean => {
|
||||
const operand = peel(node);
|
||||
return ts.isTypeOfExpression(operand) && isBinding(operand.expression);
|
||||
};
|
||||
const isUndefinedTag = (node: ts.Expression): boolean => {
|
||||
const operand = peel(node);
|
||||
return ts.isStringLiteralLike(operand) && operand.text === "undefined";
|
||||
};
|
||||
const { left, right } = condition;
|
||||
return (isBinding(left) && missing(right)) ||
|
||||
(missing(left) && isBinding(right)) ||
|
||||
(isTypeofBinding(left) && isUndefinedTag(right)) ||
|
||||
(isUndefinedTag(left) && isTypeofBinding(right))
|
||||
? "compare"
|
||||
: null;
|
||||
}
|
||||
|
||||
function falsyExitRuntimeOptionalLocals(lowerer: Lowerer, stmt: ts.IfStatement): IrLocal[] {
|
||||
|
||||
@@ -525,6 +525,7 @@ import {
|
||||
lowerForOf,
|
||||
lowerForStatement,
|
||||
matchGroupsPatternSource,
|
||||
isGuardedRecordRead,
|
||||
} from "./lower-stmts.js";
|
||||
import {
|
||||
type FieldTarget,
|
||||
@@ -3908,6 +3909,33 @@ export class Lowerer {
|
||||
}
|
||||
}
|
||||
|
||||
/** A file-scope get-or-create read (`let e = map[k]; if (e ===
|
||||
* undefined) ...` over an index-signature record) keeps a missing key as
|
||||
* undefined (isGuardedRecordRead). Its pre-registered global takes the
|
||||
* undefined arm before any body reads it; the declaration lowers its
|
||||
* initializer as the matching absence probe. */
|
||||
analyzeGuardedRecordReadGlobals(parts: FileParts[]): void {
|
||||
for (const { sf } of parts) {
|
||||
for (const statement of sf.statements) {
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
for (const decl of statement.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(decl.name) || !isGuardedRecordRead(this, decl)) continue;
|
||||
const global = this.globalOf(decl.name);
|
||||
const symbol = this.resolveValueSymbol(decl.name);
|
||||
if (!global || !symbol || global.tdz || this.runtimeOptionalBindingTypes.has(symbol))
|
||||
continue;
|
||||
const widened =
|
||||
global.type.kind === "union"
|
||||
? this.withUndefinedArmOf(global.type)
|
||||
: this.withUndefinedArm(global.type);
|
||||
if (!widened || typeEquals(widened, global.type)) continue;
|
||||
global.type = widened;
|
||||
this.runtimeOptionalBindingTypes.set(symbol, widened);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void {
|
||||
const provenance = RuntimeOptionalProvenance.fromEnvironment();
|
||||
// The site whose value the current promotion follows (debug only).
|
||||
@@ -6058,6 +6086,7 @@ export class Lowerer {
|
||||
this.collectProgram(parts);
|
||||
this.analyzeRuntimeOptionalArrayReads(parts);
|
||||
this.analyzeDateJsonResults(parts);
|
||||
this.analyzeGuardedRecordReadGlobals(parts);
|
||||
// Decorated classes analyze AFTER the whole collection pass: a
|
||||
// decorator's return type may name a subclass declared below the
|
||||
// class, and reference lowering needs each class's rebindability
|
||||
@@ -6443,6 +6472,7 @@ export class Lowerer {
|
||||
this.collectProgram(parts);
|
||||
this.analyzeRuntimeOptionalArrayReads(parts);
|
||||
this.analyzeDateJsonResults(parts);
|
||||
this.analyzeGuardedRecordReadGlobals(parts);
|
||||
// Decorated classes analyze post-collection here too: the %init seeds
|
||||
// lower the decoration calls, whose edges (decorator bodies, construct
|
||||
// thunks) reachable emit must see.
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6100-record-get-or-create.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Get-or-create over nested index-signature records without
|
||||
// noUncheckedIndexedAccess: a missing key reads as undefined when the next
|
||||
// statement tests the binding for absence, in every spelling of that test,
|
||||
// at module scope and inside functions. A write with a computed key through
|
||||
// a binding that may hold a missing element throws Node's TypeError after
|
||||
// the key and the value evaluated.
|
||||
type ExportMap = Record<string, Record<string, string>>;
|
||||
|
||||
interface Target {
|
||||
subpath: string;
|
||||
condition: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
function build(targets: Target[]): { exports: ExportMap; unmapped: string[] } {
|
||||
const exports: ExportMap = {};
|
||||
const unmapped: string[] = [];
|
||||
for (const t of targets) {
|
||||
if (t.target === "") {
|
||||
unmapped.push(t.subpath);
|
||||
continue;
|
||||
}
|
||||
let entry = exports[t.subpath];
|
||||
if (entry === undefined) {
|
||||
entry = {};
|
||||
exports[t.subpath] = entry;
|
||||
}
|
||||
entry[t.condition] = `./${t.target}`;
|
||||
entry["types"] = `./${t.target}`;
|
||||
}
|
||||
return { exports: exports, unmapped: unmapped };
|
||||
}
|
||||
|
||||
const result = build([
|
||||
{ subpath: ".", condition: "import", target: "src/index.ts" },
|
||||
{ subpath: ".", condition: "require", target: "src/index.ts" },
|
||||
{ subpath: "./x", condition: "default", target: "" },
|
||||
{ subpath: "./y", condition: "node", target: "src/y.ts" },
|
||||
]);
|
||||
console.log(JSON.stringify(result.exports), result.unmapped.join(","));
|
||||
|
||||
function group(words: string[]): Record<string, string[]> {
|
||||
const byInitial: Record<string, string[]> = {};
|
||||
for (const w of words) {
|
||||
const k = w.slice(0, 1);
|
||||
let list = byInitial[k];
|
||||
if (list == null) {
|
||||
list = [];
|
||||
byInitial[k] = list;
|
||||
}
|
||||
list.push(w);
|
||||
}
|
||||
return byInitial;
|
||||
}
|
||||
console.log(JSON.stringify(group(["apple", "avocado", "banana", "blueberry", "cherry"])));
|
||||
|
||||
function count(words: string[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const w of words) {
|
||||
const n = counts[w];
|
||||
if (typeof n === "undefined") counts[w] = 1;
|
||||
else counts[w] = n + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
console.log(JSON.stringify(count(["a", "b", "a", "c", "a"])));
|
||||
|
||||
function lookup(table: Record<string, string>, key: string): string {
|
||||
const hit = table[key];
|
||||
if (hit !== undefined) return hit;
|
||||
return `<${key} missing>`;
|
||||
}
|
||||
console.log(lookup({ a: "A" }, "a"), lookup({ a: "A" }, "b"));
|
||||
|
||||
function rows(table: Record<string, Record<string, number>>, key: string, col: string): number {
|
||||
const row = table[key];
|
||||
if (row != undefined) return row[col] ?? -1;
|
||||
return -2;
|
||||
}
|
||||
console.log(rows({ r: { c: 3 } }, "r", "c"), rows({ r: { c: 3 } }, "r", "d"), rows({}, "x", "c"));
|
||||
|
||||
// Module scope.
|
||||
const nested: Record<string, Record<string, number>> = {};
|
||||
const key = process.argv.length > 99 ? "unused" : "k";
|
||||
let slot = nested[key];
|
||||
if (slot === undefined) {
|
||||
slot = {};
|
||||
nested[key] = slot;
|
||||
}
|
||||
slot[key] = 1;
|
||||
slot["fixed"] = 2;
|
||||
console.log(JSON.stringify(nested));
|
||||
|
||||
const names: Record<string, string> = { present: "yes" };
|
||||
const missing = names[key];
|
||||
if (!missing) console.log("falsy guard:", missing);
|
||||
const present = names["present"];
|
||||
if (present === void 0) console.log("unreachable");
|
||||
else console.log("present:", present);
|
||||
|
||||
// A write with a computed key through a binding that may hold a missing
|
||||
// array element throws Node's TypeError naming the key, after evaluating
|
||||
// the key and the value.
|
||||
function probe(tables: Record<string, string>[]): void {
|
||||
const firstTable = tables[0];
|
||||
const lastTable = tables[5];
|
||||
const pick = (k: string): string => {
|
||||
console.log("key", k);
|
||||
return k;
|
||||
};
|
||||
firstTable[pick("b")] = pick("second");
|
||||
console.log(JSON.stringify(firstTable));
|
||||
try {
|
||||
lastTable[pick("c")] = pick("value");
|
||||
} catch (e) {
|
||||
console.log((e as Error).name, (e as Error).message);
|
||||
}
|
||||
}
|
||||
probe([{ a: "first" }]);
|
||||
Reference in New Issue
Block a user