Keep checked dictionary protocols and invalidate direct calls after method writes

This commit is contained in:
Chris Tate
2026-10-23 20:45:37 -05:00
parent cc325de274
commit 6dfde9919c
5 changed files with 80 additions and 7 deletions
@@ -170,14 +170,19 @@ export function collectClassMethodMutations(
// A string index whose values cannot be functions excludes callable
// method slots. Class instances have no implicit index signature, so such
// a dictionary is only a class view when the class declares that index
// signature, and then every method must be assignable to its value type.
const functionFreeDictionary = (value: ts.Expression): boolean =>
lowerer.checker
.getIndexInfosOfType(lowerer.typeOf(value))
// signature, and then every method must be assignable to its value type,
// or when the dictionary keeps checked identity: it then holds the
// existing objects it receives, class instances included.
const functionFreeDictionary = (value: ts.Expression): boolean => {
const type = lowerer.typeOf(value);
if (lowerer.classImplementedProtocol(type)) return false;
return lowerer.checker
.getIndexInfosOfType(type)
.some(
(index) =>
(index.keyType.flags & ts.TypeFlags.StringLike) !== 0 && functionFree(index.type),
);
};
const literalLoopKeys = (key: ts.Expression): (string | number)[] | null => {
if (!ts.isIdentifier(key)) return null;
const symbol = lowerer.checker.getSymbolAtLocation(key);
@@ -983,11 +983,13 @@ export class ClassProtocols {
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
const previous = this.answers.get(type);
if (previous !== undefined) return previous;
// A dictionary keeps checked identity when the protocols already mark
// it (a literal whose methods read `this`) or when it receives existing
// objects (markDictionaryViews).
const dictionaryValue = this.dictionaryValueType(type);
const answer =
dictionaryValue !== undefined
? (this.index(), this.dictionaryViews.has(dictionaryValue))
: this.protocol(type);
this.protocol(type) ||
(dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue));
this.answers.set(type, answer);
return answer;
}
@@ -178,3 +178,20 @@ test("a store into a generic dictionary of dictionaries keeps checked dispatch",
.replace("const settings: Settings =", "const settings: Settings<string> =");
expect(await callersOf(source, "%dyn.class.properties")).not.toEqual([]);
});
test("a runtime-key store into a dictionary view of a class instance keeps checked dispatch", async () => {
const source = `
class Counter {
n = 0;
tick(): number { return ++this.n; }
}
const counter = new Counter();
const view: {} = counter;
const slots: Record<string, string> = view;
const key: string = String(process.argv.length);
slots[key] = "replaced";
console.log(counter.tick());
`;
// The method has a slot the write can replace, which calls consult.
expect(await callersOf(source, "%Counter.tick")).toContain("%method.value:%Counter.tick");
});
@@ -75,3 +75,15 @@ console.log(table["a"]);`),
);
expect(kinds["table"]).toBe("record");
});
test("a dictionary literal whose methods read this keeps checked identity", async () => {
const kinds = await globalKinds(
program(`interface Table { [key: string]: () => number }
const methods: Table = {
base() { return 2; },
twice() { return this.base() * 2; },
};
console.log(methods.twice());`),
);
expect(kinds["methods"]).toBe("dyn");
});
@@ -53,9 +53,46 @@ const slots: Record<string, string> = greeterView;
slots[process.argv.length > 99 ? "never" : "greet"] = "replaced";
slots["name"] = "renamed";
console.log(typeof greeter.greet, greeter.name, JSON.stringify(greeter));
// A write under a runtime string key replaces a method too, and a direct
// call reaches the replacement, which is not a function.
class Counter {
n = 0;
tick(): number {
return ++this.n;
}
}
const counter = new Counter();
const counterView: {} = counter;
const counterSlots: Record<string, string> = counterView;
const methodKey: string = process.argv.length > 99 ? "never" : "tick";
counterSlots[methodKey] = "replaced";
try {
console.log(counter.tick());
} catch (error) {
console.log((error as Error).name, (error as Error).message);
}
try {
console.log(greeter.greet());
} catch (error) {
console.log((error as Error).name, (error as Error).message);
}
// Deleting through the dictionary removes the key from the object.
const pruned: { a: string; b?: string } = { a: "x", b: "y" };
const prunedTable: Record<string, string | undefined> = pruned;
delete prunedTable[process.argv.length > 99 ? "never" : "b"];
console.log(JSON.stringify(pruned), "b" in pruned);
// A dictionary literal whose methods read `this` keeps its live object.
interface Table {
[key: string]: () => number;
}
const table: Table = {
base() {
return 2;
},
twice() {
return this.base() * 2;
},
};
console.log(table.twice(), Object.keys(table).join(","));