feat(codemode): compact schema constraint comments (#46521)

This commit is contained in:
Aiden Cline 2026-08-31 23:06:49 -05:00 committed by GitHub
parent 23fde448ec
commit 4a3e25beab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 73 deletions

View file

@ -79,15 +79,19 @@ const docTags = (schema: JsonSchema): Array<string> => {
}
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
const jsdoc = (description: string | undefined, summary: string, pad: string): string => {
const lines = (description ?? "").split("\n").map((line) => line.replace(/\s+$/, ""))
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
const inline = lines.length === 1 ? `${lines[0]}${lines[0].endsWith(".") ? "" : "."} ${summary}` : summary
const content =
summary && lines.length === 1 && !summary.includes("\n") && pad.length + inline.length + 7 <= 120
? [inline]
: [...lines, ...(summary ? summary.split("\n") : [])]
if (content.length === 0) return ""
const escaped = content.map((line) => line.replaceAll("*/", "* /"))
if (escaped.length === 1 && pad.length + escaped[0].length + 7 <= 120) return `${pad}/** ${escaped[0]} */\n`
const body = escaped.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
@ -167,7 +171,7 @@ const renderSchema = (
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]).join(" "), pad)}${pad}${field(entry)},`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`

View file

@ -36,7 +36,7 @@ const lookupOrder = Tool.make({
})
describe("pretty signature rendering", () => {
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
test("described fields get compact JSDoc; undescribed and unconstrained fields get none", () => {
expect(inputTypeScript(listIssues, true)).toBe(
[
"{",
@ -44,16 +44,9 @@ describe("pretty signature rendering", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" /** Results per page. @default 30 */",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}",
@ -105,7 +98,7 @@ describe("pretty signature rendering", () => {
)
})
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
test("constraints and annotations share compact tagged JSDoc", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
@ -119,19 +112,10 @@ describe("pretty signature rendering", () => {
)
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
expect(pretty).toContain(
[
" /**",
' * @default ["a","b"]',
" * @minItems 2",
" * @maxItems 5",
" */",
" tags?: Array<string>",
].join("\n"),
)
expect(pretty).toContain(' /** @default ["a","b"] @minItems 2 @maxItems 5 */\n tags?: Array<string>')
})
test("skips an unserializable default rather than emitting a broken tag", () => {
test("skips an unserializable default rather than emitting a broken summary", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { size: { type: "number", default: 1n } } },
true,
@ -151,9 +135,18 @@ describe("pretty signature rendering", () => {
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
] as const)("renders constraint %j without changing the compact type", (value, tag, type) => {
[{ type: "string", minLength: 0, maxLength: 0 }, "@minLength 0 @maxLength 0", "string"],
[{ type: "array", minItems: 0, maxItems: 0 }, "@minItems 0 @maxItems 0", "Array<unknown>"],
[
{ type: "array", minItems: 1, maxItems: 10, uniqueItems: true },
"@minItems 1 @maxItems 10 @uniqueItems true",
"Array<unknown>",
],
] as const)("renders constraint %j without changing the compact type", (value, summary, type) => {
const schema = { type: "object", properties: { value } }
expect(jsonSchemaToTypeScript(schema, true)).toBe(["{", ` /** ${tag} */`, ` value?: ${type},`, "}"].join("\n"))
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", ` /** ${summary} */`, ` value?: ${type},`, "}"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
})
@ -188,30 +181,19 @@ describe("pretty signature rendering", () => {
)
})
test.each([false, null, ""])("preserves default %j alongside constraint tags", (value) => {
test.each([false, null, ""])("preserves default %j alongside constraints", (value) => {
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
` * @default ${JSON.stringify(value)}\n * @minLength 0\n`,
` /** @default ${JSON.stringify(value)} @minLength 0 */\n`,
)
})
test("escapes comment terminators in tag values", () => {
test("escapes comment terminators in summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
true,
),
).toBe(
[
"{",
" /**",
' * @default "* /"',
" * @format * /",
" * @pattern ^a* /b$",
" */",
" value?: string,",
"}",
].join("\n"),
)
).toBe(["{", ' /** @default "* /" @format * / @pattern ^a* /b$ */', " value?: string,", "}"].join("\n"))
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
@ -236,6 +218,88 @@ describe("pretty signature rendering", () => {
)
})
test("preserves inclusive and exclusive numeric bounds together", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
value: {
type: "integer",
minimum: -10,
maximum: 10,
exclusiveMinimum: -5,
exclusiveMaximum: 5,
multipleOf: 2,
},
},
},
true,
),
).toContain(
" /** @integer @minimum -10 @maximum 10 @exclusiveMinimum -5 @exclusiveMaximum 5 @multipleOf 2 */\n value?: number,",
)
})
test.each([
["Maximum attempts", "Maximum attempts."],
["Maximum attempts.", "Maximum attempts."],
["Maximum attempts!", "Maximum attempts!."],
])("combines a short description (%s) with its summary", (description, expected) => {
expect(
jsonSchemaToTypeScript(
{ properties: { attempts: { description, type: "integer", minimum: 1, default: 3 } } },
true,
),
).toContain(` /** ${expected} @default 3 @integer @minimum 1 */\n`)
})
test("keeps multiline descriptions intact and appends a compact summary", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
attempts: {
description: "\nMaximum attempts\n\nIncludes the initial request.\n",
type: "integer",
minimum: 1,
},
},
},
true,
),
).toBe(
[
"{",
" /**",
" * Maximum attempts",
" *",
" * Includes the initial request.",
" * @integer @minimum 1",
" */",
" attempts?: number,",
"}",
].join("\n"),
)
})
test("uses a block for long descriptions without truncating or rewriting them", () => {
const description = "A detailed description. ".repeat(8).trim()
expect(jsonSchemaToTypeScript({ properties: { name: { type: "string", description, minLength: 1 } } }, true)).toBe(
["{", " /**", ` * ${description}`, " * @minLength 1", " */", " name?: string,", "}"].join("\n"),
)
})
test("preserves pattern backslashes and prefixes every line of multiline summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", pattern: "^\\d+\n*/$", default: "a\nb" } } },
true,
),
).toBe(
["{", " /**", ' * @default "a\\nb" @pattern ^\\d+', " * * /$", " */", " value?: string,", "}"].join("\n"),
)
})
test("stays total on cyclic $refs and pathological nesting in both modes", () => {
const cyclic = {
$ref: "#/$defs/Node",
@ -486,22 +550,11 @@ describe("JSDoc signatures in catalogs and search results", () => {
})
const type = [
"{",
" /**",
" * @integer",
" * @minimum 0",
" * @maximum 10",
" */",
" /** @integer @minimum 0 @maximum 10 */",
" count: number,",
" /**",
" * @minLength 1",
" * @maxLength 20",
" * @pattern ^[a-z]+$",
" */",
" /** @minLength 1 @maxLength 20 @pattern ^[a-z]+$ */",
" name: string,",
" /**",
" * @minItems 1",
" * @maxItems 5",
" */",
" /** @minItems 1 @maxItems 5 */",
" labels: Array<string>,",
"}",
].join("\n")
@ -522,7 +575,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
}
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and summaries", async () => {
const { items } = await search("list issues repository")
const item = items.find(({ path }) => path === "tools.github.list_issues")!
expect(item.signature).toBe(
@ -532,16 +585,9 @@ describe("JSDoc signatures in catalogs and search results", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" /** Results per page. @default 30 */",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}): Promise<unknown>",

View file

@ -136,8 +136,8 @@ describe("CodeModeInstructions.render", () => {
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" /**\n * @integer\n * @exclusiveMinimum 0\n */\n limit?: number,")
expect(partial).toContain(" /**\n * @integer\n * @minimum 0\n */\n offset?: number,")
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")
expect(partial).toContain(" /** @integer @minimum 0 */\n offset?: number,")
expect(partial).not.toContain("tools.orders.lookup(input:")
})