From eccb061a121e56527a6663e04cdad9df2c406b73 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:51:27 -0500 Subject: [PATCH] Find kept record sources through index signatures and record fields The capture into an index-signature record consults a record's kept source view only when some checked conversion in the program can keep one. That scan accepted a closed record target and looked through union arms, promises and arrays, but stopped at an index-signature record or a record whose fields hold closed records. A conversion into `Record` keeps each value's undeclared keys, so a program whose only such conversion had that shape dropped them in the capture. The scan now looks through record fields and index-signature values too, memoizing records by shape as it does unions. --- .../src/frontend/lowering/record-sources.ts | 52 ++++++++++++------- .../record-source-keys-index-values.ts.json | 6 +++ .../corpus/record-source-keys-index-values.ts | 19 +++++++ .../record-source-keys-index-values.ts.json | 1 + 4 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json create mode 100644 tests/corpus/record-source-keys-index-values.ts create mode 100644 tests/harness/corpus-costs/record-source-keys-index-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/record-sources.ts b/packages/compiler/src/frontend/lowering/record-sources.ts index 1e7ce254d..692b4e1f8 100644 --- a/packages/compiler/src/frontend/lowering/record-sources.ts +++ b/packages/compiler/src/frontend/lowering/record-sources.ts @@ -40,9 +40,9 @@ export function recordSourceTest(value: IrExpr, loc: SrcLoc): IrExpr { return { kind: "libCall", fn: "record.sourceHas", args: [value], type: BOOL, loc }; } -/** A union revisited while it is being visited answers as if its cycle arm - * were empty (`keeps: false`), so the caller's answer is incomplete until the - * outer union settles. `cycled` marks that incompleteness: such an answer is +/** A union or record revisited while it is being visited answers as if its + * cycle member were empty (`keeps: false`), so the caller's answer is + * incomplete until the outer one settles. `cycled` marks that incompleteness: such an answer is * never memoized, because another entry point into the same cycle could reach * the missing arm. */ interface KeepsSourceAnswer { @@ -50,8 +50,8 @@ interface KeepsSourceAnswer { cycled: boolean; } -/** Shared work list of the whole-program scan: settled union answers, and the - * unions on the current path. */ +/** Shared work list of the whole-program scan: settled union and record + * answers, and the unions and records on the current path, by id. */ export interface KeepsSourceScan { memo: Map; visiting: Set; @@ -59,33 +59,47 @@ export interface KeepsSourceScan { /** Whether a checked conversion into `target` can leave a record holding * undeclared source keys. The conversion reads one record at a time, so the - * records nested in a union arm, a promise, or an array count too. */ + * records nested in a union arm, a promise, an array, a record field or an + * index signature's values count too (`Record` keeps + * the sources of its closed values). Unions and records are memoized by id; + * one revisited on the current path answers `cycled`. */ function conversionKeepsSource( lowerer: Lowerer, target: IrType, scan: KeepsSourceScan, ): KeepsSourceAnswer { - if (target.kind === "record") - return { keeps: recordMayKeepSource(lowerer, target), cycled: false }; if (target.kind === "promise") return conversionKeepsSource(lowerer, target.inner, scan); if (target.kind === "array") return conversionKeepsSource(lowerer, target.elem, scan); - if (target.kind !== "union") return { keeps: false, cycled: false }; - const known = scan.memo.get(target.unionId); + if (target.kind === "record" && recordMayKeepSource(lowerer, target)) + return { keeps: true, cycled: false }; + let id: string; + let members: IrType[]; + if (target.kind === "union") { + id = target.unionId; + members = lowerer.unions.get(target.unionId)?.arms ?? []; + } else if (target.kind === "record") { + id = target.shapeId; + const shape = lowerer.shapes.get(target.shapeId); + members = [ + ...(shape?.fields.map((field) => field.type) ?? []), + ...(shape?.indexValue !== undefined ? [shape.indexValue] : []), + ]; + } else return { keeps: false, cycled: false }; + const known = scan.memo.get(id); if (known !== undefined) return { keeps: known, cycled: false }; - if (scan.visiting.has(target.unionId)) return { keeps: false, cycled: true }; - const union = lowerer.unions.get(target.unionId); - if (union === undefined) return { keeps: false, cycled: false }; - scan.visiting.add(target.unionId); + if (scan.visiting.has(id)) return { keeps: false, cycled: true }; + scan.visiting.add(id); let keeps = false; let cycled = false; - for (const arm of union.arms) { - const answer = conversionKeepsSource(lowerer, arm, scan); + for (const member of members) { + const answer = conversionKeepsSource(lowerer, member, scan); keeps = keeps || answer.keeps; cycled = cycled || answer.cycled; + if (keeps) break; } - scan.visiting.delete(target.unionId); - if (!cycled) scan.memo.set(target.unionId, keeps); - return { keeps, cycled }; + scan.visiting.delete(id); + if (!cycled || keeps) scan.memo.set(id, keeps); + return { keeps, cycled: cycled && !keeps }; } /** Whether `fn` holds a validated conversion that can note a record source: diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json new file mode 100644 index 000000000..72d9a1314 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-source-keys-index-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-source-keys-index-values.ts b/tests/corpus/record-source-keys-index-values.ts new file mode 100644 index 000000000..92d6077e2 --- /dev/null +++ b/tests/corpus/record-source-keys-index-values.ts @@ -0,0 +1,19 @@ +// A checked conversion into an index-signature record whose values are +// closed records keeps each value's undeclared source keys, so converting +// such a value into an index-signature record later copies them. This +// program has no checked conversion into a closed record itself, so the +// capture learns that kept sources are possible from the index signature's +// values. Node prints "extra,x". +type H = Record; +interface T { + transformHeaders?: (h: H) => H | Promise; +} +const parsed: Record = JSON.parse('{"p":{"x":"1","extra":"e"}}'); +async function viaArrow(o: O | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} +viaArrow({ transformHeaders: async (h) => parsed["p"]! }).then((r) => + console.log(Object.keys(r).sort().join(",")), +); diff --git a/tests/harness/corpus-costs/record-source-keys-index-values.ts.json b/tests/harness/corpus-costs/record-source-keys-index-values.ts.json new file mode 100644 index 000000000..143bf22a5 --- /dev/null +++ b/tests/harness/corpus-costs/record-source-keys-index-values.ts.json @@ -0,0 +1 @@ +{ "plain": 1959, "san": 3000 }