Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Versions before 0.8.0 are in the [git tags](https://github.com/leemour/cli-core/
Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
[`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).

## Unreleased

### Changed — may break callers

- Consumers switching exhaustively over `SchemaNode` or API parameter locations must handle explicit unions and RPC body parameters. Existing HTTP models generate the same artifacts.

### Added

- API generators preserve explicit unions and Boolean literals, validate nested references, and emit schema definitions for shared command input traversal. RPC parameters can belong to the JSON body; multipart references retain their format.

## 0.16.0 — 02.10.2026

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ The generated schemas import `@leemour/cli-core/codegen/runtime` at run time —
helpers, not the generator. Numbers are expected from a lossless JSON parser (`lossless-json`): a
64-bit integer comes out as its exact decimal string, and any other integer that does not fit a JS
number fails instead of rounding. Objects are loose, so a field the API added later passes through.
Unions retain every alternative, including recursive references and Boolean literals.
`definitionsGenerator` emits the same schema nodes for runtime input traversal; RPC parameters can be in the JSON body.
Enums and discriminated unions are **strict**: a value or subtype the snapshot does not know fails,
so decode a live stream (updates, webhooks) only where that is what you want.

Expand Down
8 changes: 7 additions & 1 deletion src/codegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
* manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and
* adds no dependency. The adapter from OpenAPI, Postman or anything else lives in the consumer.
*/
export { type CoverageOptions, coverageGenerator, type ManifestOperation, manifestGenerator } from "./manifest.js"
export {
type CoverageOptions,
coverageGenerator,
definitionsGenerator,
type ManifestOperation,
manifestGenerator,
} from "./manifest.js"
export type {
ApiBody,
ApiModel,
Expand Down
15 changes: 14 additions & 1 deletion src/codegen/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface ManifestOperation {
deprecated?: boolean
parameters: readonly {
name: string
in: "path" | "query" | "header"
in: "path" | "query" | "header" | "body"
required: boolean
description?: string
schema: SchemaNode
Expand Down Expand Up @@ -63,6 +63,19 @@ export const manifestGenerator =
return [{ path: options.path, content }]
}

export const definitionsGenerator =
(options: { path: string; coreImport?: string }): CodeGenerator =>
(model: ApiModel) => {
const tree = new SchemaTree(model)
const definitions = Object.fromEntries(tree.named.map(({ name, schema }) => [name, schema.schema]))
return [
{
path: options.path,
content: `import type { SchemaNode } from "${options.coreImport ?? "@leemour/cli-core/codegen"}"\n\nexport const definitions: Readonly<Record<string, SchemaNode>> = ${JSON.stringify(definitions, null, 2)}\n`,
},
]
}

const cell = (text: string | undefined): string => (text ?? "").replace(/\s+/g, " ").replace(/\|/g, "\\|").trim()

export interface CoverageOptions {
Expand Down
14 changes: 11 additions & 3 deletions src/codegen/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,25 @@ interface SchemaCommon {
export type SchemaNode = SchemaCommon &
(
| { type: "ref"; ref: string }
| { type: "string"; enum?: readonly string[]; minLength?: number; maxLength?: number; pattern?: string }
| {
type: "string"
format?: "binary" | "file-reference"
enum?: readonly string[]
minLength?: number
maxLength?: number
pattern?: string
}
| { type: "integer"; format?: "int32" | "int64"; minimum?: number; maximum?: number; enum?: readonly number[] }
| { type: "number"; minimum?: number; maximum?: number }
| { type: "boolean" }
| { type: "boolean"; enum?: readonly boolean[] }
| { type: "array"; items: SchemaNode; minItems?: number; maxItems?: number; uniqueItems?: boolean }
| {
type: "object"
properties: Readonly<Record<string, SchemaNode>>
required: readonly string[]
additionalProperties?: SchemaNode
}
| { type: "union"; of: readonly SchemaNode[] }
/** Inheritance: every member must resolve to an object, and the fields are merged. */
| { type: "allOf"; of: readonly SchemaNode[] }
/** Only where the source itself says "anything". An adapter never falls back to this. */
Expand All @@ -73,7 +81,7 @@ export interface ApiSchema {

export interface ApiParameter {
name: string
in: "path" | "query" | "header"
in: "path" | "query" | "header" | "body"
required: boolean
description?: string
schema: SchemaNode
Expand Down
19 changes: 19 additions & 0 deletions src/codegen/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const refsIn = (node: SchemaNode, found: string[] = []): string[] => {
for (const property of Object.values(node.properties)) refsIn(property, found)
if (node.additionalProperties) refsIn(node.additionalProperties, found)
break
case "union":
case "allOf":
for (const member of node.of) refsIn(member, found)
break
Expand Down Expand Up @@ -104,6 +105,24 @@ export const validateModel = (model: ApiModel): void => {
if (!operation.effect) problems.push(`operation "${operation.id}" is not classified as read, write or destructive`)
}

const validUnions = (node: SchemaNode, where: string): void => {
if ((node.type === "boolean" || node.type === "string" || node.type === "integer") && node.enum?.length === 0)
problems.push(`${where}: an enum has no values`)
if (node.type === "union" || node.type === "allOf") {
if (node.type === "union" && node.of.length === 0) problems.push(`${where}: a union has no members`)
for (const member of node.of) validUnions(member, where)
} else if (node.type === "array") validUnions(node.items, `${where}[]`)
else if (node.type === "object") {
for (const [name, property] of Object.entries(node.properties)) validUnions(property, `${where}.${name}`)
if (node.additionalProperties) validUnions(node.additionalProperties, where)
}
}
for (const schema of model.schemas) validUnions(schema.schema, `schema "${schema.id}"`)
for (const operation of model.operations) {
for (const parameter of operation.parameters) validUnions(parameter.schema, `operation "${operation.id}"`)
if (operation.requestBody?.schema) validUnions(operation.requestBody.schema, `operation "${operation.id}" request`)
if (operation.response?.schema) validUnions(operation.response.schema, `operation "${operation.id}" response`)
}
const schemaIds = new Set(model.schemas.map((schema) => schema.id))
const unresolved = (where: string, node: SchemaNode | undefined) => {
if (!node) return
Expand Down
4 changes: 3 additions & 1 deletion src/codegen/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ class TypeWriter {
case "number":
return "number"
case "boolean":
return "boolean"
return node.enum ? node.enum.map(String).join(" | ") : "boolean"
case "union":
return `(${node.of.map((member) => this.node(member, where)).join(" | ")})`
case "unknown":
return "unknown"
case "array": {
Expand Down
125 changes: 125 additions & 0 deletions src/codegen/unions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { execFileSync } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import * as v from "valibot"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
type ApiModel,
definitionsGenerator,
generate,
manifestGenerator,
typesGenerator,
valibotGenerator,
validateModel,
} from "./index.js"

const model: ApiModel = {
source: { kind: "other" },
schemas: [
{ id: "Address", schema: { type: "union", of: [{ type: "integer", format: "int64" }, { type: "string" }] } },
{ id: "File", schema: { type: "string", format: "binary" } },
{
id: "Node",
schema: {
type: "union",
of: [
{ type: "string" },
{
type: "object",
properties: { next: { type: "ref", ref: "Node" }, ready: { type: "boolean", enum: [true] } },
required: ["ready"],
},
],
},
},
],
operations: [
{
id: "readNode",
binding: { kind: "rpc", name: "readNode" },
tags: [],
parameters: [{ name: "address", in: "body", required: true, schema: { type: "ref", ref: "Address" } }],
response: { required: true, confidence: "contract", schema: { type: "ref", ref: "Node" } },
effect: "read",
source: { location: "methods/readNode" },
},
],
}

describe("generated unions", () => {
let root: string
let schemas: Record<string, v.GenericSchema>

beforeAll(async () => {
root = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), ".tmp-unions-"))
for (const artifact of generate(
model,
[
typesGenerator({ path: "types.ts" }),
valibotGenerator({ path: "schemas.ts", typesImport: "./types.js", runtimeImport: "../runtime.js" }),
manifestGenerator({ path: "manifest.ts", coreImport: "../index.js" }),
definitionsGenerator({ path: "definitions.ts", coreImport: "../index.js" }),
],
{ banner: ["synthetic union contract"] },
))
writeFileSync(join(root, artifact.path), artifact.content)
;({ schemas } = await import(join(root, "schemas.ts")))
})

afterAll(() => rmSync(root, { recursive: true, force: true }))

it("compiles recursive alternatives, Boolean literals and RPC body parameters together", () => {
writeFileSync(
join(root, "tsconfig.json"),
JSON.stringify({
extends: "../../../tsconfig.json",
compilerOptions: {
noEmit: true,
composite: false,
declaration: false,
declarationMap: false,
rootDir: "../..",
},
include: ["*.ts"],
}),
)
const tsc = join(root, "../../../node_modules/.bin", process.platform === "win32" ? "tsc.cmd" : "tsc")
expect(() => execFileSync(tsc, ["-p", join(root, "tsconfig.json")], { stdio: "pipe" })).not.toThrow()
}, 30_000)

it("preserves large integers and accepts each alternative while rejecting wrong literals", () => {
const address = schemas.Address as v.GenericSchema
expect(v.parse(address, { isLosslessNumber: true, value: "9007199254740993" })).toBe("9007199254740993")
expect(v.parse(address, "@example")).toBe("@example")
expect(v.safeParse(address, false).success).toBe(false)
const node = schemas.Node as v.GenericSchema
expect(v.safeParse(node, { ready: true, next: { ready: true, next: "end" } }).success).toBe(true)
expect(v.safeParse(node, { ready: false }).success).toBe(false)
const file = schemas.File as v.GenericSchema
expect(v.safeParse(file, "attach://photo").success).toBe(true)
expect(v.safeParse(file, "local-path").success).toBe(false)
})

it("emits runtime definitions with source formats and resolves response references", async () => {
const { definitions } = await import(join(root, "definitions.ts"))
expect(definitions.File).toEqual({ type: "string", format: "binary" })
const { operations } = await import(join(root, "manifest.ts"))
expect(operations[0].response.schema).toBe("Node")
expect(definitions.Node.type).toBe("union")
})

it("refuses empty alternatives and missing references inside nested unions", () => {
expect(() => validateModel({ ...model, schemas: [{ id: "Empty", schema: { type: "union", of: [] } }] })).toThrow(
/union has no members/,
)
expect(() =>
validateModel({
...model,
schemas: [
{ id: "Missing", schema: { type: "array", items: { type: "union", of: [{ type: "ref", ref: "Absent" }] } } },
],
}),
).toThrow(/unknown schema "Absent"/)
})
})
7 changes: 6 additions & 1 deletion src/codegen/valibot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class SchemaWriter {
case "string": {
if (node.enum) return `v.picklist(${JSON.stringify(node.enum)})`
const actions: string[] = []
if (node.format === "binary") actions.push('v.startsWith("attach://")')
if (node.minLength !== undefined) actions.push(`v.minLength(${node.minLength})`)
if (node.maxLength !== undefined) actions.push(`v.maxLength(${node.maxLength})`)
if (node.pattern !== undefined) {
Expand All @@ -58,7 +59,11 @@ class SchemaWriter {
this.helpers.add("number")
return `number(${range(node)})`
case "boolean":
return "v.boolean()"
if (!node.enum) return "v.boolean()"
if (node.enum.length === 1) return `v.literal(${node.enum[0]})`
return `v.union([${node.enum.map((value) => `v.literal(${value})`).join(", ")}])`
case "union":
return `v.union([${node.of.map((member) => this.node(member, where)).join(", ")}])`
case "unknown":
return "v.unknown()"
case "array": {
Expand Down
Loading