mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(codemode): keep authored non-finite number alternatives
This commit is contained in:
@@ -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<Record<string, JsonSchema>>
|
||||
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<Record<string, JsonSchema>>
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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<number>",
|
||||
"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({
|
||||
|
||||
Reference in New Issue
Block a user