Skip to content

Commit 723e719

Browse files
ushironokoclaude
andcommitted
Add description() metadata action and map it in the JSON Schema emitter
Downstream tool-parameter schemas need per-field descriptions to survive AOT JSON Schema generation; until now the emitter dropped any metadata with a "not representable" warning. description() is a pure pass-through transformation (same runtime shape as readonly), so the compiled fast path is unaffected, and the emitter folds it onto the accumulated schema last-wins. Closes #42 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXctopVx69rLttyhKbeoVE
1 parent fd485ae commit 723e719

6 files changed

Lines changed: 135 additions & 3 deletions

File tree

.changeset/large-timers-search.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@tskm/core": minor
3+
"@tskm/compiler": patch
4+
---
5+
6+
Add a `description(text)` metadata action and map it to the JSON Schema `description` keyword
7+
8+
`description()` is a pure pass-through transformation (no runtime validation effect, same shape as `readonly`). The JSON Schema emitter now folds it onto the accumulated schema as the `description` keyword; piping it multiple times is last-wins. Per-property descriptions inside `object()` land on the property schema.

packages/compiler/src/jsonschema.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ import { resolveWorker, runWorker, type SchemaWorkerEnvelope } from "./worker-ha
2020
* exported schema objects, walks them, and writes `*.schema.json`.
2121
*
2222
* Refinements that JSON Schema can express (min/max length, min/max value, integer,
23-
* multipleOf, email/url format, regex pattern) are mapped; `transform`/`brand` and
24-
* other non-representable actions are dropped with a warning. `lazy` becomes
25-
* `$ref`/`$defs`.
23+
* multipleOf, email/url format, regex pattern, description metadata) are mapped;
24+
* `transform`/`brand` and other non-representable actions are dropped with a
25+
* warning. `lazy` becomes `$ref`/`$defs`.
2626
*/
2727

2828
export type JsonSchema = { [key: string]: unknown }
@@ -305,6 +305,11 @@ function applyItem(
305305
out.pattern = requirement.source
306306
}
307307
return
308+
case "description":
309+
if (typeof requirement === "string") {
310+
out.description = requirement
311+
}
312+
return
308313
default:
309314
ctx.warnings.push(
310315
`tskm: pipe item "${String(type)}" is not representable in JSON Schema; skipped.`,

packages/compiler/test/jsonschema.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,67 @@ describe("schemaToJsonSchema — pipe refinements", () => {
152152
})
153153
})
154154

155+
describe("schemaToJsonSchema — description metadata", () => {
156+
const desc = (requirement: string) => ({
157+
kind: "transformation",
158+
type: "description",
159+
requirement,
160+
})
161+
162+
it("description on a string -> description keyword, no warning", () => {
163+
const result = schemaToJsonSchema(pipe(s.string(), desc("a plain string")))
164+
expect(result.schema).toEqual({ type: "string", description: "a plain string" })
165+
expect(result.warnings).toEqual([])
166+
})
167+
168+
it("description on a property lands on the property, not the parent", () => {
169+
const schema = s.object({
170+
id: pipe(s.string(), desc("unique identifier")),
171+
name: s.string(),
172+
})
173+
const { schema: out } = schemaToJsonSchema(schema)
174+
expect(out).toEqual({
175+
type: "object",
176+
properties: {
177+
id: { type: "string", description: "unique identifier" },
178+
name: { type: "string" },
179+
},
180+
required: ["id", "name"],
181+
additionalProperties: false,
182+
})
183+
})
184+
185+
it("composes with constraint actions in the same pipe", () => {
186+
const schema = pipe(
187+
s.string(),
188+
{ kind: "validation", type: "min_length", requirement: 1 },
189+
desc("non-empty text"),
190+
)
191+
expect(schemaToJsonSchema(schema).schema).toEqual({
192+
type: "string",
193+
minLength: 1,
194+
description: "non-empty text",
195+
})
196+
})
197+
198+
it("stacks last-wins when piped multiple times", () => {
199+
const schema = pipe(s.string(), desc("first"), desc("second"))
200+
expect(schemaToJsonSchema(schema).schema).toEqual({
201+
type: "string",
202+
description: "second",
203+
})
204+
})
205+
206+
it("ignores a non-string requirement instead of emitting garbage", () => {
207+
const schema = pipe(s.string(), {
208+
kind: "transformation",
209+
type: "description",
210+
requirement: 42,
211+
})
212+
expect(schemaToJsonSchema(schema).schema).toEqual({ type: "string" })
213+
})
214+
})
215+
155216
describe("schemaToJsonSchema — lazy recursion", () => {
156217
it("terminates a self-referential lazy and emits $ref/$defs", () => {
157218
// A node referencing itself through a lazy getter — the classic recursive shape.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { OutputDataset } from "../types/dataset.ts"
2+
import type { BaseTransformation } from "../types/schema.ts"
3+
4+
export interface DescriptionAction<TInput> extends BaseTransformation<TInput, TInput> {
5+
readonly type: "description"
6+
readonly reference: typeof description
7+
readonly requirement: string
8+
}
9+
10+
// @__NO_SIDE_EFFECTS__
11+
export function description<TInput>(requirement: string): DescriptionAction<TInput> {
12+
return {
13+
kind: "transformation",
14+
type: "description",
15+
reference: description,
16+
async: false,
17+
requirement,
18+
"~run"(dataset) {
19+
// `description` is metadata-only; the runtime value is unchanged.
20+
return dataset as OutputDataset<TInput>
21+
},
22+
}
23+
}

packages/tskm/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export { type Brand, type BrandAction, brand } from "./actions/brand.ts"
55
export { type CheckAction, check } from "./actions/check.ts"
66
export { type CheckActionAsync, checkAsync } from "./actions/checkAsync.ts"
7+
export { type DescriptionAction, description } from "./actions/description.ts"
78
export { type EmailAction, email } from "./actions/email.ts"
89
export { type IntegerAction, integer } from "./actions/integer.ts"
910
export { type LengthAction, length } from "./actions/length.ts"

packages/tskm/test/actions-misc.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
brand,
44
check,
55
checkAsync,
6+
description,
7+
minLength,
68
number,
79
parse,
810
parseAsync,
@@ -238,6 +240,38 @@ describe("brand", () => {
238240
})
239241
})
240242

243+
describe("description", () => {
244+
it("builds an action with the expected static shape", () => {
245+
const action = description<string>("user-visible name")
246+
expect(action.kind).toBe("transformation")
247+
expect(action.type).toBe("description")
248+
expect(action.reference).toBe(description)
249+
expect(action.async).toBe(false)
250+
expect(action.requirement).toBe("user-visible name")
251+
})
252+
253+
it("passes the runtime value through unchanged", () => {
254+
const schema = pipe(string(), description<string>("a plain string"))
255+
expect(parse(schema, "abc")).toBe("abc")
256+
})
257+
258+
it("preserves the object reference produced by a prior transform", () => {
259+
const obj = { a: 1 }
260+
const schema = pipe(
261+
string(),
262+
transform(() => obj),
263+
description<{ a: number }>("wrapped object"),
264+
)
265+
expect(parse(schema, "x")).toBe(obj)
266+
})
267+
268+
it("composes with validations without affecting them", () => {
269+
const schema = pipe(string(), minLength(2), description<string>("at least two chars"))
270+
expect(parse(schema, "ab")).toBe("ab")
271+
expect(safeParse(schema, "a").success).toBe(false)
272+
})
273+
})
274+
241275
describe("readonly", () => {
242276
it("builds an action with the expected static shape", () => {
243277
const action = readonly<string>()

0 commit comments

Comments
 (0)