diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 50366eeb6..322cc09fa 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -129,7 +129,13 @@ export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean * parameters are ignored. A source with MORE parameters than the slot is * clean when every extra trailing one is omittable — the adapter passes * that absent value explicitly (funcCoerceAdapter's omittedParams), the - * same way a call through the slot would. */ + * same way a call through the slot would. A parameter the slot coercion + * converts through a STRUCTURAL width counts (cleanParamPair), which + * recurses through a nested function type: a callback parameter of the + * value taking a narrower record where the slot's takes its `unknown` view + * converts the argument the same way a top-level call does. The result side + * likewise accepts what funcCoerceAdapter converts (cleanResult), including + * a promise whose payload widens. */ export function cleanFuncAdaptable( lowerer: Lowerer, src: IrType & { kind: "func" }, @@ -137,15 +143,35 @@ export function cleanFuncAdaptable( ): boolean { if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; - for (let i = dst.params.length; i < src.params.length; i++) { - if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + // Recursive function types (`type Handler = (h: Handler) => void`) reach + // the same pair again one parameter down: assume it converts, the same + // coinductive answer recordWidthPlan gives its own back-edges. + const key = `funcClean:${typeKey(src)}:${typeKey(dst)}`; + if (lowerer.coercions.planning.has(key)) return true; + lowerer.coercions.planning.add(key); + try { + for (let i = dst.params.length; i < src.params.length; i++) { + if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + } + for (let i = 0; i < src.params.length && i < dst.params.length; i++) { + if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false; + } + return cleanResult(lowerer, src.ret, dst.ret); + } finally { + lowerer.coercions.planning.delete(key); } - for (let i = 0; i < src.params.length && i < dst.params.length; i++) { - if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; - } - if (dst.ret.kind === "void") return true; - if (lowerer.coercibleValue(src.ret, dst.ret)) return true; - return src.ret.kind === "void" && dst.ret.kind === "dyn"; +} + +/** Whether the slot's coercion converts the value's RESULT without a + * trap-only path: the dispositions funcCoerceAdapter's result side accepts + * (a void slot drops the value; coercibleValue; a structural width, which + * includes the promise payload widening and a nested function adaptation; + * the void-into-unknown drop). */ +function cleanResult(lowerer: Lowerer, actual: IrType, expected: IrType): boolean { + if (expected.kind === "void") return true; + if (lowerer.coercibleValue(actual, expected)) return true; + if (actual.kind === "void" && expected.kind === "dyn") return true; + return lowerer.widthLiftPlan(actual, expected) !== null; } /** Whether the slot's argument type `actual` converts into the function's diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json new file mode 100644 index 000000000..36ec7deb6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-func-field-width.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-func-field-width.ts b/tests/corpus/record-func-field-width.ts new file mode 100644 index 000000000..1b9f7bd41 --- /dev/null +++ b/tests/corpus/record-func-field-width.ts @@ -0,0 +1,109 @@ +// A record field holding a function adapts when the slot's function type is a +// structural width of the field's: a parameter record whose `unknown` members +// gain shapes (the checked-dynamic extraction), a callback parameter whose +// record widens the same way one level down, and a promise payload that widens. +// +// `wrapToolDefinition` states the definition's state and details parameters as +// `any`/default `unknown`, so passing a concrete definition crosses the +// `execute` and `renderCall` fields of the record. + +interface Truncation { + content: string; + truncated: boolean; +} + +interface ToolResult { + content: { type: string; text: string }[]; + details: D; + isError: boolean | undefined; +} + +type UpdateCallback = (partial: ToolResult) => void; + +interface ToolDefinition { + name: string; + execute(id: string, onUpdate: UpdateCallback | undefined): Promise>; + renderCall(context: { state: S; label: string }): string; +} + +interface BashState { + startedAt: number; + executionStarted: boolean; +} + +interface BashDetails { + truncation: Truncation | undefined; + fullOutputPath: string | undefined; +} + +function wrapToolDefinition( + definition: ToolDefinition, +): ToolDefinition { + return { + name: definition.name, + execute: definition.execute, + renderCall: definition.renderCall, + }; +} + +function bashDefinition(name: string): ToolDefinition { + return { + name, + execute: (id, onUpdate) => { + const result: ToolResult = { + content: [{ type: "text", text: `ran ${id}` }], + details: { truncation: { content: "tail", truncated: true }, fullOutputPath: undefined }, + isError: undefined, + }; + onUpdate?.(result); + return Promise.resolve(result); + }, + renderCall: (context) => `${context.label} after ${context.state.startedAt}ms`, + }; +} + +// The `execute` field: the slot's update callback takes `details: unknown` +// where the value's takes the concrete record, and the result promise payload +// widens the same way. +function widenDetails(): ToolDefinition { + return wrapToolDefinition(bashDefinition("bash")); +} + +// The `renderCall` field: the slot's context record has `state: unknown` +// where the value's has the concrete state record. +function widenState(definition: ToolDefinition): ToolDefinition { + return wrapToolDefinition(definition); +} + +// The same crossing when the argument is an object literal carrying optional +// members the slot's shape also declares. +interface WithHints { + annotations: { readOnlyHint: boolean | undefined } | undefined; + defaultActive: boolean | undefined; +} + +function widenLiteral(): ToolDefinition { + const hints: WithHints = { annotations: { readOnlyHint: true }, defaultActive: undefined }; + const definition: ToolDefinition & WithHints = { + ...bashDefinition("literal"), + ...hints, + }; + console.log("hints:", definition.annotations?.readOnlyHint, definition.defaultActive); + return wrapToolDefinition(definition); +} + +const widened = widenDetails(); +widened + .execute("ls", (partial) => { + console.log("update:", partial.content[0]?.text, partial.isError); + }) + .then((result) => { + console.log("result:", result.details, result.isError); + }); + +const rendered = widenState(bashDefinition("grep")); +console.log("render:", rendered.renderCall({ state: { startedAt: 12, executionStarted: true }, label: "grep" })); + +const literal = widenLiteral(); +console.log("literal render:", literal.renderCall({ state: { startedAt: 3, executionStarted: false }, label: "ls" })); +console.log("name:", literal.name); diff --git a/tests/harness/corpus-costs/record-func-field-width.ts.json b/tests/harness/corpus-costs/record-func-field-width.ts.json new file mode 100644 index 000000000..31e3551a8 --- /dev/null +++ b/tests/harness/corpus-costs/record-func-field-width.ts.json @@ -0,0 +1 @@ +{ "plain": 3050, "san": 4250 }