Skip to content

Commit 9ee4099

Browse files
fix(docs): recursive field diff in check:openapi + the deep drift it found
A mutation test showed the doc<->contract field diff only compared top-level properties, so a typo inside the { data } envelope passed. The diff now descends through matching object properties and array items (both sides must expose a property set — passthrough contracts and prose-only docs end the descent instead of false-positive), with the Zod JSON-schema root doubling as the $defs context. Deep drift it immediately caught, fixed here: select-column config (options/multiple) missing from every tables column schema, AddColumnBody hand-rolling a third column shape (now composed from ColumnInput, with position/workflowGroupId as the per-op extensions the contracts actually admit), chunking strategyOptions undocumented, and the deployment lifecycle fields (activeDeployment/latestDeploymentAttempt) missing from DeploymentState. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz
1 parent d8021bf commit 9ee4099

4 files changed

Lines changed: 239 additions & 61 deletions

File tree

apps/docs/openapi-v2-knowledge.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,29 @@
10011001
"type": "string",
10021002
"description": "Chunking strategy applied during processing.",
10031003
"enum": ["auto", "text", "regex", "recursive", "sentence", "token"]
1004+
},
1005+
"strategyOptions": {
1006+
"type": "object",
1007+
"additionalProperties": false,
1008+
"description": "Strategy-specific tuning. `pattern`/`strictBoundaries` apply to the `regex` strategy; `separators` to `text`; `recipe` to `recursive`.",
1009+
"properties": {
1010+
"pattern": {
1011+
"type": "string",
1012+
"maxLength": 500
1013+
},
1014+
"separators": {
1015+
"type": "array",
1016+
"items": {
1017+
"type": "string"
1018+
}
1019+
},
1020+
"recipe": {
1021+
"enum": ["plain", "markdown", "code"]
1022+
},
1023+
"strictBoundaries": {
1024+
"type": "boolean"
1025+
}
1026+
}
10041027
}
10051028
}
10061029
},

apps/docs/openapi-v2-tables.json

Lines changed: 80 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,6 +1648,17 @@
16481648
"workflowGroupId": {
16491649
"type": "string",
16501650
"description": "Set when the column is the output of a workflow group."
1651+
},
1652+
"options": {
1653+
"type": "array",
1654+
"items": {
1655+
"$ref": "#/components/schemas/SelectOption"
1656+
},
1657+
"description": "Declared options for a `select` column; absent on other types."
1658+
},
1659+
"multiple": {
1660+
"type": "boolean",
1661+
"description": "A `select` column that accepts multiple options per cell."
16511662
}
16521663
}
16531664
},
@@ -1677,6 +1688,21 @@
16771688
"type": "boolean",
16781689
"default": false,
16791690
"description": "Whether values in this column must be unique across all rows."
1691+
},
1692+
"id": {
1693+
"type": "string",
1694+
"description": "Stable column id. Server-assigned — normally omit."
1695+
},
1696+
"options": {
1697+
"type": "array",
1698+
"items": {
1699+
"$ref": "#/components/schemas/SelectOption"
1700+
},
1701+
"description": "Declared options for a `select` column; absent on other types."
1702+
},
1703+
"multiple": {
1704+
"type": "boolean",
1705+
"description": "A `select` column that accepts multiple options per cell."
16801706
}
16811707
}
16821708
},
@@ -1811,7 +1837,20 @@
18111837
"maxItems": 50,
18121838
"description": "Column definitions. A table must have between 1 and 50 columns.",
18131839
"items": {
1814-
"$ref": "#/components/schemas/ColumnInput"
1840+
"allOf": [
1841+
{
1842+
"$ref": "#/components/schemas/ColumnInput"
1843+
},
1844+
{
1845+
"type": "object",
1846+
"properties": {
1847+
"workflowGroupId": {
1848+
"type": "string",
1849+
"description": "Advanced: binds the column to a workflow group's output."
1850+
}
1851+
}
1852+
}
1853+
]
18151854
}
18161855
}
18171856
}
@@ -1833,38 +1872,22 @@
18331872
"description": "The workspace that owns the table."
18341873
},
18351874
"column": {
1836-
"type": "object",
1837-
"description": "The column definition to add.",
1838-
"required": ["name", "type"],
1839-
"properties": {
1840-
"name": {
1841-
"type": "string",
1842-
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
1843-
"maxLength": 50,
1844-
"description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.",
1845-
"example": "phone"
1846-
},
1847-
"type": {
1848-
"type": "string",
1849-
"enum": ["string", "number", "boolean", "date", "json"],
1850-
"description": "Data type of the column."
1875+
"allOf": [
1876+
{
1877+
"$ref": "#/components/schemas/ColumnInput"
18511878
},
1852-
"required": {
1853-
"type": "boolean",
1854-
"default": false,
1855-
"description": "Whether the column requires a value on insert."
1856-
},
1857-
"unique": {
1858-
"type": "boolean",
1859-
"default": false,
1860-
"description": "Whether values in this column must be unique across all rows."
1861-
},
1862-
"position": {
1863-
"type": "integer",
1864-
"minimum": 0,
1865-
"description": "Zero-based insert position in the column order. Appended at the end when omitted."
1879+
{
1880+
"type": "object",
1881+
"properties": {
1882+
"position": {
1883+
"type": "integer",
1884+
"minimum": 0,
1885+
"description": "Zero-based insert position in the column order. Appended at the end when omitted."
1886+
}
1887+
}
18661888
}
1867-
}
1889+
],
1890+
"description": "The column definition to add."
18681891
}
18691892
}
18701893
},
@@ -1906,6 +1929,17 @@
19061929
"unique": {
19071930
"type": "boolean",
19081931
"description": "Whether values in this column must be unique across all rows."
1932+
},
1933+
"options": {
1934+
"type": "array",
1935+
"items": {
1936+
"$ref": "#/components/schemas/SelectOption"
1937+
},
1938+
"description": "Declared options for a `select` column; absent on other types."
1939+
},
1940+
"multiple": {
1941+
"type": "boolean",
1942+
"description": "A `select` column that accepts multiple options per cell."
19091943
}
19101944
}
19111945
}
@@ -2428,6 +2462,21 @@
24282462
"description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected."
24292463
}
24302464
}
2465+
},
2466+
"SelectOption": {
2467+
"type": "object",
2468+
"required": ["id", "name"],
2469+
"properties": {
2470+
"id": {
2471+
"type": "string",
2472+
"description": "Stable option id — the value stored in cells."
2473+
},
2474+
"name": {
2475+
"type": "string",
2476+
"maxLength": 100,
2477+
"description": "Display name. Filters on select columns accept names (resolved case-insensitively)."
2478+
}
2479+
}
24312480
}
24322481
},
24332482
"responses": {

apps/docs/openapi-v2-workflows.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,14 @@
937937
"DeploymentState": {
938938
"type": "object",
939939
"description": "Base deployment state shared by deploy, undeploy, and rollback results.",
940-
"required": ["id", "isDeployed", "deployedAt", "warnings"],
940+
"required": [
941+
"id",
942+
"isDeployed",
943+
"deployedAt",
944+
"warnings",
945+
"activeDeployment",
946+
"latestDeploymentAttempt"
947+
],
941948
"properties": {
942949
"id": {
943950
"type": "string",
@@ -961,6 +968,14 @@
961968
"items": {
962969
"type": "string"
963970
}
971+
},
972+
"activeDeployment": {
973+
"type": ["object", "null"],
974+
"description": "Summary of the currently live deployment version, or null when none is active."
975+
},
976+
"latestDeploymentAttempt": {
977+
"type": ["object", "null"],
978+
"description": "Lifecycle status of the most recent deploy attempt (preparing/activating/active/failed/superseded) — poll this to a terminal state; deploys admit asynchronously, so HTTP success only means the attempt was accepted."
964979
}
965980
}
966981
},

scripts/check-openapi-specs.ts

Lines changed: 120 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,6 @@ function docPropertyNames(schema: unknown, spec: Json): Set<string> | null {
155155
return null
156156
}
157157

158-
/** Same union logic over the Zod-derived JSON schema (no `$ref`s inside). */
159-
function zodPropertyNames(schema: unknown): Set<string> | null {
160-
return docPropertyNames(schema, {} as Json)
161-
}
162-
163158
function toJsonSchema(schema: z.ZodType, io: 'input' | 'output'): Json | null {
164159
try {
165160
return z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as Json
@@ -285,6 +280,104 @@ function checkQueryParams(operation: Operation, contract: ContractLike, name: st
285280
}
286281
}
287282

283+
/** Property subschema lookup, searching `oneOf`/`anyOf`/`allOf` variants. */
284+
function propertyNode(schema: unknown, root: Json, prop: string): unknown {
285+
const node = deref(schema, root)
286+
if (!node || typeof node !== 'object') return undefined
287+
const record = node as Json
288+
const variants = (record.oneOf ?? record.anyOf ?? record.allOf) as unknown[] | undefined
289+
if (variants) {
290+
for (const variant of variants) {
291+
const found = propertyNode(variant, root, prop)
292+
if (found !== undefined) return found
293+
}
294+
return undefined
295+
}
296+
return (record.properties as Json | undefined)?.[prop]
297+
}
298+
299+
/** Deref + step through array wrappers so item objects compare directly. */
300+
function unwrapArrays(node: unknown, root: Json): unknown {
301+
let current = deref(node, root)
302+
for (let i = 0; i < 3; i++) {
303+
const record = current as Json | null
304+
if (record && typeof record === 'object' && record.type === 'array' && record.items) {
305+
current = deref(record.items, root)
306+
} else {
307+
break
308+
}
309+
}
310+
return current
311+
}
312+
313+
interface DiffContext {
314+
specFile: string
315+
label: string
316+
name: string
317+
where: 'body' | 'response'
318+
}
319+
320+
/**
321+
* Recursively diffs property-name sets between the Zod-derived JSON schema and
322+
* the documented one, descending through matching object properties and array
323+
* items. Comparison happens only where BOTH sides expose a property set — an
324+
* opaque side (records, `additionalProperties`, prose-only docs) ends the
325+
* descent instead of producing false positives. The Zod root doubles as the
326+
* `$defs` resolution context for recursive schemas.
327+
*/
328+
function diffSchemaFields(
329+
zodNode: unknown,
330+
zodRoot: Json,
331+
docNode: unknown,
332+
docRoot: Json,
333+
ctx: DiffContext,
334+
prefix: string,
335+
depth: number
336+
): void {
337+
if (depth > 4) return
338+
const zodObj = unwrapArrays(zodNode, zodRoot)
339+
const docObj = unwrapArrays(docNode, docRoot)
340+
const zodNames = docPropertyNames(zodObj, zodRoot)
341+
const docNames = docPropertyNames(docObj, docRoot)
342+
if (!zodNames || !docNames) return
343+
const fieldPath = (n: string) => (prefix ? `${prefix}.${n}` : n)
344+
/**
345+
* A `.passthrough()` contract deliberately under-declares its fields, so the
346+
* docs are allowed to document more than the Zod side names.
347+
*/
348+
const extra = (zodObj as Json).additionalProperties
349+
const zodIsPassthrough =
350+
extra === true || (!!extra && typeof extra === 'object' && Object.keys(extra).length === 0)
351+
for (const n of zodNames) {
352+
if (!docNames.has(n)) {
353+
fail(
354+
ctx.specFile,
355+
`${ctx.label}: ${ctx.where} field "${fieldPath(n)}" (${ctx.name}) not documented`
356+
)
357+
}
358+
}
359+
for (const n of docNames) {
360+
if (!zodNames.has(n) && !zodIsPassthrough) {
361+
fail(
362+
ctx.specFile,
363+
`${ctx.label}: documented ${ctx.where} field "${fieldPath(n)}" does not exist on ${ctx.name}`
364+
)
365+
}
366+
}
367+
for (const n of zodNames) {
368+
if (!docNames.has(n)) continue
369+
diffSchemaFields(
370+
propertyNode(zodObj, zodRoot, n),
371+
zodRoot,
372+
propertyNode(docObj, docRoot, n),
373+
docRoot,
374+
ctx,
375+
fieldPath(n),
376+
depth + 1
377+
)
378+
}
379+
}
380+
288381
function checkBodyAndResponse(operation: Operation, contract: ContractLike, name: string): void {
289382
const { specFile, path: p, method, op, spec } = operation
290383
const label = `${method.toUpperCase()} ${p}`
@@ -293,17 +386,17 @@ function checkBodyAndResponse(operation: Operation, contract: ContractLike, name
293386
'application/json'
294387
] as Json | undefined
295388
if (contract.body && docBodySchema?.schema) {
296-
const zodNames = zodPropertyNames(toJsonSchema(contract.body, 'input'))
297-
const docNames = docPropertyNames(docBodySchema.schema, spec)
298-
if (zodNames && docNames) {
299-
for (const n of zodNames) {
300-
if (!docNames.has(n)) fail(specFile, `${label}: body field "${n}" (${name}) not documented`)
301-
}
302-
for (const n of docNames) {
303-
if (!zodNames.has(n)) {
304-
fail(specFile, `${label}: documented body field "${n}" does not exist on ${name}`)
305-
}
306-
}
389+
const zodRoot = toJsonSchema(contract.body, 'input')
390+
if (zodRoot) {
391+
diffSchemaFields(
392+
zodRoot,
393+
zodRoot,
394+
docBodySchema.schema,
395+
spec,
396+
{ specFile, label, name, where: 'body' },
397+
'',
398+
0
399+
)
307400
}
308401
}
309402

@@ -313,19 +406,17 @@ function checkBodyAndResponse(operation: Operation, contract: ContractLike, name
313406
const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined
314407
const docSchema = ((docResponse?.content as Json)?.['application/json'] as Json)?.schema
315408
if (docSchema) {
316-
const zodNames = zodPropertyNames(toJsonSchema(contract.response.schema, 'output'))
317-
const docNames = docPropertyNames(docSchema, spec)
318-
if (zodNames && docNames) {
319-
for (const n of zodNames) {
320-
if (!docNames.has(n)) {
321-
fail(specFile, `${label}: response field "${n}" (${name}) not documented`)
322-
}
323-
}
324-
for (const n of docNames) {
325-
if (!zodNames.has(n)) {
326-
fail(specFile, `${label}: documented response field "${n}" does not exist on ${name}`)
327-
}
328-
}
409+
const zodRoot = toJsonSchema(contract.response.schema, 'output')
410+
if (zodRoot) {
411+
diffSchemaFields(
412+
zodRoot,
413+
zodRoot,
414+
docSchema,
415+
spec,
416+
{ specFile, label, name, where: 'response' },
417+
'',
418+
0
419+
)
329420
}
330421
}
331422
}

0 commit comments

Comments
 (0)