From 5e7b4676b8f8a5f3f4bd61257772b8e7dae6f18a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 27 Sep 2026 10:01:01 -0300 Subject: [PATCH] fix(codemode): keep authored non-finite number alternatives --- packages/codemode/src/tool-schema.ts | 30 +++++++--- packages/codemode/test/signature.test.ts | 71 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index e92b70c66cf..199124bfa04 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -12,9 +12,17 @@ const renderKey = (name: string): string => (identifierSegment.test(name) ? name const effectNumberSentinel = (schema: JsonSchema) => schema.type === "string" && Array.isArray(schema.enum) && - (schema.enum.length === 1 || - (schema.enum.length === 3 && ["NaN", "Infinity", "-Infinity"].every((value) => schema.enum?.includes(value)))) && - schema.enum.every((value) => value === "NaN" || value === "Infinity" || value === "-Infinity") + schema.enum.length === 1 && + (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") + +// Effect's JSON codec encodes every unchecked `number` as `finite | nonFiniteLiterals`, sharing one literal-union +// AST. Code Mode decodes the Type side, which rejects those strings, so that exact AST is extracted into a reserved +// definition and rendered as `number`, while authored literal unions keep their alternatives. +const effectNumberJson = SchemaAST.toEncoded(Schema.toCodecJson(Schema.Number).ast) +const effectNonFiniteNumbers = SchemaAST.isUnion(effectNumberJson) + ? effectNumberJson.types.find(SchemaAST.isUnion) + : undefined +const nonFiniteNumberDefinition = "codemode/NonFiniteNumber" const definitionName = (ref: string): string | undefined => { const tokens = JsonPointer.parseUriFragment(ref) @@ -33,6 +41,7 @@ const MAX_RENDER_DEPTH = 8 type RenderContext = { readonly definitions: Readonly> readonly pretty: boolean + readonly numberSentinel: (schema: JsonSchema) => boolean } const hasUnresolvedRef = ( @@ -148,7 +157,7 @@ const renderSchema = ( if (alternatives) { if ( alternatives.some((item) => item.type === "number") && - alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) + alternatives.every((item) => item.type === "number" || ctx.numberSentinel(item)) ) return "number" if ( @@ -207,11 +216,18 @@ const renderSchema = ( export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => { try { const visible = decoded ? Schema.toType(schema) : schema - const document = Schema.toJsonSchemaDocument(visible, { onExcessProperty: "error" }) as { + const document = Schema.toJsonSchemaDocument(visible, { + onExcessProperty: "error", + referencePolicy: (input) => (input.ast === effectNonFiniteNumbers ? nonFiniteNumberDefinition : input.identifier), + }) as { readonly schema: JsonSchema readonly definitions?: Readonly> } - return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) + return renderSchema(document.schema, { + definitions: document.definitions ?? {}, + pretty, + numberSentinel: (item) => item.$ref !== undefined && definitionName(item.$ref) === nonFiniteNumberDefinition, + }) } catch { return "unknown" } @@ -219,7 +235,7 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { try { - return renderSchema(schema, { definitions: {}, pretty }) + return renderSchema(schema, { definitions: {}, pretty, numberSentinel: effectNumberSentinel }) } catch { return "unknown" } diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 84a2ae9efe6..89aa3bb1645 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -669,6 +669,77 @@ describe("union schemas render every alternative", () => { } }) + test("Effect number encodings render as number wherever they appear", () => { + const Amount = Schema.Number.annotate({ identifier: "Amount" }) + const schema = Schema.Struct({ + plain: Schema.Number, + count: Schema.Int, + bounded: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 })), + positive: Schema.Number.check(Schema.isGreaterThan(0)), + maybe: Schema.optionalKey(Schema.Number), + nullable: Schema.NullOr(Schema.Number), + list: Schema.Array(Schema.Number), + scores: Schema.Record(Schema.String, Schema.Number), + nested: Schema.Struct({ first: Amount, second: Amount }), + }) + const tool = Tool.make({ description: "Numbers", input: schema, output: schema, execute: Effect.succeed }) + const compact = [ + "{ plain: number", + "count: number", + "bounded: number", + "positive: number", + "maybe?: number", + "nullable: number | null", + "list: Array", + "scores: { [key: string]: number }", + "nested: { first: number; second: number } }", + ].join("; ") + expect(inputTypeScript(tool)).toBe(compact) + expect(outputTypeScript(tool)).toBe(compact) + for (const rendered of [inputTypeScript(tool, true), outputTypeScript(tool, true)]) { + expect(rendered).toContain(" /** @integer */\n count: number,") + expect(rendered).toContain(" /** @integer @minimum 1 @maximum 10 */\n bounded: number,") + expect(rendered).not.toContain("Infinity") + } + const named = Tool.make({ description: "n", input: Amount, output: Amount, execute: Effect.succeed }) + expect(inputTypeScript(named)).toBe("number") + expect(outputTypeScript(named)).toBe("number") + }) + + test("explicit non-finite string alternatives stay visible", () => { + const literals = ["NaN", "Infinity", "-Infinity"] as const + const union = Schema.Union([Schema.Finite, Schema.Literals(literals)]) + const tool = Tool.make({ description: "n", input: union, output: union, execute: Effect.succeed }) + expect(inputTypeScript(tool)).toBe('number | "NaN" | "Infinity" | "-Infinity"') + expect(outputTypeScript(tool)).toBe('number | "NaN" | "Infinity" | "-Infinity"') + for (const value of literals) expect(decodeInput(tool, value)).toBe(value) + + const mixed = Schema.Struct({ + amount: Schema.Union([Schema.Number, Schema.Literals(literals)]), + missing: Schema.Union([Schema.Finite, Schema.Literal("NaN")]), + }) + const mixedTool = Tool.make({ description: "n", input: mixed, output: mixed, execute: Effect.succeed }) + const rendered = '{ amount: number | "NaN" | "Infinity" | "-Infinity"; missing: number | "NaN" }' + expect(inputTypeScript(mixedTool)).toBe(rendered) + expect(outputTypeScript(mixedTool)).toBe(rendered) + expect(decodeInput(mixedTool, { amount: "-Infinity", missing: "NaN" })).toEqual({ + amount: "-Infinity", + missing: "NaN", + }) + + const raw = { anyOf: [{ type: "number" }, { type: "string", enum: [...literals] }] } + const rawTool = Tool.make({ description: "n", input: raw, output: raw, execute: Effect.succeed }) + expect(jsonSchemaToTypeScript(raw)).toBe('number | "NaN" | "Infinity" | "-Infinity"') + expect(inputTypeScript(rawTool)).toBe('number | "NaN" | "Infinity" | "-Infinity"') + expect(outputTypeScript(rawTool)).toBe('number | "NaN" | "Infinity" | "-Infinity"') + expect(decodeInput(rawTool, "Infinity")).toBe("Infinity") + expect( + jsonSchemaToTypeScript({ + anyOf: [{ type: "number" }, ...literals.map((value) => ({ type: "string", enum: [value] }))], + }), + ).toBe("number") + }) + test("keeps unrelated and partial grouped string enums alongside numbers", () => { expect( jsonSchemaToTypeScript({