From 957886186c6ed0f83d32016245e942a48bf51bb0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 16 Jul 2026 14:31:55 -0500 Subject: [PATCH 1/6] feat(codemode): project OpenAPI schema directions --- packages/codemode/README.md | 4 +- packages/codemode/src/openapi/TODO.md | 1 - packages/codemode/src/openapi/index.ts | 7 +- packages/codemode/src/openapi/spec.ts | 245 ++++++++++++++++-- packages/codemode/test/openapi.test.ts | 344 ++++++++++++++++++++++++- 5 files changed, 576 insertions(+), 25 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 51634d91ba6a..622fcaa574e8 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -108,7 +108,9 @@ const runtime = CodeMode.make({ tools: { opencode: api.tools } }) It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never -model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in +model-visible; generated tools require `HttpClient.HttpClient` in the environment. `readOnly` properties are omitted +from request signatures and `writeOnly` properties from response signatures. These JSON Schemas are model-facing, not +runtime filters: nested value bodies and server responses pass through unchanged. See the option docstrings in `src/openapi/types.ts` for full semantics. ## Outputs diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md index cbcfe81a68c8..e2e0b882217d 100644 --- a/packages/codemode/src/openapi/TODO.md +++ b/packages/codemode/src/openapi/TODO.md @@ -9,7 +9,6 @@ The initial adapter intentionally skips operations it cannot execute correctly. - Base URLs containing query strings or fragments. - Runtime response-schema validation and full content negotiation. - Binary response values and explicit byte-oriented return types. -- Request/response projection for `readOnly` and `writeOnly` properties. - SSE, WebSocket, and other streaming transports. - Recovery of responses rejected by a status-filtering `HttpClient`. - Configurable request and response size limits. diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index e1f2c64166f9..4649d884ef11 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -38,7 +38,8 @@ export const fromSpec = (options: Options): Result => { const document = options.spec const schemes = securitySchemes(document) const defaultSecurity = securityRequirements(document.security) - const definitions = componentDefinitions(document) + const requestDefinitions = componentDefinitions(document, "request") + const responseDefinitions = componentDefinitions(document, "response") const paths = isRecord(document.paths) ? document.paths : {} const used = new Set() const namespaces = new Set() @@ -57,7 +58,7 @@ export const fromSpec = (options: Options): Result => { summary: nonEmptyString(operationValue.summary), description: nonEmptyString(operationValue.description), } - const output = operationOutput(document, operationValue, definitions) + const output = operationOutput(document, operationValue, responseDefinitions) if (!output.ok) { skipped.push({ method: operation.method, path, reason: output.reason }) continue @@ -102,7 +103,7 @@ export const fromSpec = (options: Options): Result => { segments, make({ description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, - input: inputSchema(input.fields, definitions), + input: inputSchema(input.fields, requestDefinitions), output: output.value, run: (input) => invoke(plan, input), }), diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index b74b3e69f209..63131f849a4f 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -27,35 +27,245 @@ export const nonEmptyString = (value: unknown): string | undefined => export const own = (record: Readonly>, key: string): T | undefined => Object.hasOwn(record, key) ? record[key] : undefined +const resolvePointer = (root: unknown, ref: string): unknown => + ref + .slice(2) + .split("/") + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((item, segment) => (isRecord(item) ? own(item, segment) : undefined), root) + export const resolve = (document: Document, value: unknown): unknown => { const next = (current: unknown, seen: ReadonlySet): unknown => { if (!isRecord(current)) return current - const ref = nonEmptyString(current.$ref) + const ref = nonEmptyString(own(current, "$ref")) if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current - const target = ref - .slice(2) - .split("/") - .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) - .reduce((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document) + const target = resolvePointer(document, ref) return target === undefined ? current : next(target, new Set([...seen, ref])) } return next(value, new Set()) } -const projectSchema = (document: Document, value: unknown): JsonSchema => { - if (!isRecord(value)) return {} +type SchemaDirection = "request" | "response" +type SchemaContext = { readonly document: Document; readonly root: unknown } +type SchemaTarget = SchemaContext & { readonly value: unknown } + +const schemaAnchor = ( + value: unknown, + anchor: string, + seen: ReadonlySet = new Set(), + nested = false, +): unknown => { + if (!isRecord(value) || seen.has(value)) return undefined + if (nested && own(value, "$id") !== undefined) return undefined + if (own(value, "$anchor") === anchor) return value + const nextSeen = new Set([...seen, value]) + const maps = [ + own(value, "properties"), + own(value, "patternProperties"), + own(value, "dependentSchemas"), + own(value, "$defs"), + own(value, "definitions"), + ] + for (const map of maps) { + if (!isRecord(map)) continue + for (const child of Object.values(map)) { + const found = schemaAnchor(child, anchor, nextSeen, true) + if (found !== undefined) return found + } + } + for (const child of [ + ...asArray(own(value, "allOf")), + ...asArray(own(value, "anyOf")), + ...asArray(own(value, "oneOf")), + ...asArray(own(value, "prefixItems")), + own(value, "items"), + own(value, "contains"), + own(value, "additionalProperties"), + own(value, "unevaluatedProperties"), + own(value, "propertyNames"), + own(value, "not"), + own(value, "if"), + own(value, "then"), + own(value, "else"), + ]) { + const found = schemaAnchor(child, anchor, nextSeen, true) + if (found !== undefined) return found + } + return undefined +} + +const schemaReference = (context: SchemaContext, value: Record): SchemaTarget | undefined => { + const ref = nonEmptyString(own(value, "$ref")) + if (ref === undefined) return undefined + if (ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")) { + const target = resolvePointer(context.root, ref) + return target === undefined ? undefined : { ...context, root: context.root, value: target } + } + if (ref.startsWith("#/")) { + const target = resolvePointer(context.document, ref) + return target === undefined ? undefined : { document: context.document, root: target, value: target } + } + if (ref.startsWith("#") && ref.length > 1) { + const target = schemaAnchor(context.root, ref.slice(1)) + return target === undefined ? undefined : { ...context, value: target } + } + return undefined +} + +const hiddenProperty = ( + context: SchemaContext, + value: unknown, + direction: SchemaDirection, + seen: ReadonlySet = new Set(), +): boolean => { + if (!isRecord(value)) return false + const current = own(value, "$id") === undefined ? context : { ...context, root: value } + if (own(value, direction === "request" ? "readOnly" : "writeOnly") === true) return true + const target = schemaReference(current, value) + if (isRecord(target?.value) && !seen.has(target.value)) { + if (hiddenProperty(target, target.value, direction, new Set([...seen, target.value]))) return true + } + const allOf = asArray(own(value, "allOf")) + if (allOf.some((item) => hiddenProperty(current, item, direction, seen))) return true + for (const keyword of ["anyOf", "oneOf"] as const) { + const alternatives = asArray(own(value, keyword)) + if (alternatives.length > 0 && alternatives.every((item) => hiddenProperty(current, item, direction, seen))) { + return true + } + } + return false +} + +const hiddenPropertyNames = ( + context: SchemaContext, + value: unknown, + direction: SchemaDirection, + seen: ReadonlySet = new Set(), +): ReadonlySet => { + if (!isRecord(value)) return new Set() + const current = own(value, "$id") === undefined ? context : { ...context, root: value } + const declaredProperties = own(value, "properties") + const hidden = new Set( + isRecord(declaredProperties) + ? Object.entries(declaredProperties) + .filter(([, property]) => hiddenProperty(current, property, direction)) + .map(([name]) => name) + : [], + ) + const target = schemaReference(current, value) + if (isRecord(target?.value) && !seen.has(target.value)) { + for (const name of hiddenPropertyNames(target, target.value, direction, new Set([...seen, target.value]))) { + hidden.add(name) + } + } + for (const item of asArray(own(value, "allOf"))) { + for (const name of hiddenPropertyNames(current, item, direction, seen)) hidden.add(name) + } + return hidden +} + +const directionalSchema = ( + context: SchemaContext, + value: unknown, + direction: SchemaDirection, + excluded: ReadonlySet = new Set(), +): unknown => { + if (!isRecord(value)) return value + const current = own(value, "$id") === undefined ? context : { ...context, root: value } + const declaredProperties = own(value, "properties") + const properties = isRecord(declaredProperties) ? declaredProperties : undefined + const hidden = new Set([...excluded, ...hiddenPropertyNames(current, value, direction)]) + const schemas = (items: unknown, inherited: ReadonlySet = new Set()): unknown => + Array.isArray(items) ? items.map((item) => directionalSchema(current, item, direction, inherited)) : items + const schemaMap = (items: unknown): unknown => + isRecord(items) + ? Object.fromEntries( + Object.entries(items).map(([name, item]) => [name, directionalSchema(current, item, direction)]), + ) + : items + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => { + if (key === "properties" && properties !== undefined) { + return [ + key, + Object.fromEntries( + Object.entries(properties) + .filter(([name]) => !hidden.has(name)) + .map(([name, property]) => [name, directionalSchema(current, property, direction)]), + ), + ] + } + if (key === "required" && Array.isArray(item)) { + return [key, item.filter((name) => typeof name !== "string" || !hidden.has(name))] + } + if (key === "allOf") return [key, schemas(item, hidden)] + if (key === "anyOf" || key === "oneOf") return [key, schemas(item, hidden)] + if (key === "prefixItems") return [key, schemas(item)] + if (key === "dependentRequired" && isRecord(item)) { + return [ + key, + Object.fromEntries( + Object.entries(item) + .filter(([name]) => !hidden.has(name)) + .map(([name, names]) => [ + name, + Array.isArray(names) ? names.filter((required) => !hidden.has(String(required))) : names, + ]), + ), + ] + } + if (key === "dependentSchemas" && isRecord(item)) { + return [ + key, + Object.fromEntries( + Object.entries(item) + .filter(([name]) => !hidden.has(name)) + .map(([name, schema]) => [name, directionalSchema(current, schema, direction, hidden)]), + ), + ] + } + if (key === "properties" || key === "patternProperties" || key === "$defs" || key === "definitions") { + return [key, schemaMap(item)] + } + if ( + key === "items" || + key === "contains" || + key === "additionalProperties" || + key === "unevaluatedProperties" || + key === "propertyNames" || + key === "not" + ) { + return [key, directionalSchema(current, item, direction)] + } + if (key === "if" || key === "then" || key === "else") { + return [key, directionalSchema(current, item, direction, hidden)] + } + return [key, item] + }), + ) +} + +const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema => { + const projected = directionalSchema({ document, root: value }, value, direction) + if (!isRecord(projected)) return {} const normalized = nonEmptyString(document.openapi)?.startsWith("3.0") - ? fromSchemaOpenApi3_0(value) - : fromSchemaOpenApi3_1(value) + ? fromSchemaOpenApi3_0(projected) + : fromSchemaOpenApi3_1(projected) return Object.keys(normalized.definitions).length === 0 ? normalized.schema : { ...normalized.schema, $defs: normalized.definitions } } -export const componentDefinitions = (document: Document): Readonly> => { +export const componentDefinitions = ( + document: Document, + direction: SchemaDirection, +): Readonly> => { const components = isRecord(document.components) ? document.components : {} const schemas = isRecord(components.schemas) ? components.schemas : {} - return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)])) + return Object.fromEntries( + Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value, direction)]), + ) } const withDefinitions = (schema: JsonSchema, definitions: Readonly>): JsonSchema => { @@ -157,7 +367,7 @@ const operationParameters = ( if (style === "deepObject" && !explode) { return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` } } - const base = projectSchema(document, resolved.schema) + const base = projectSchema(document, resolved.schema, "request") const description = nonEmptyString(resolved.description) unordered.push({ name, @@ -191,7 +401,8 @@ const operationBody = ( reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`, } } - const schema = resolve(document, selected.schema) + const resolvedSchema = resolve(document, selected.schema) + const schema = directionalSchema({ document, root: resolvedSchema }, resolvedSchema, "request") const required = resolved.required === true if (!isFlattenableObjectBody(schema, required)) { return { @@ -202,7 +413,7 @@ const operationBody = ( name: "body", location: "body", required, - schema: projectSchema(document, selected.schema), + schema: projectSchema(document, selected.schema, "request"), style: undefined, explode: undefined, }, @@ -221,7 +432,7 @@ const operationBody = ( name, location: "body" as const, required: required && requiredProperties.has(name), - schema: projectSchema(document, value), + schema: projectSchema(document, value, "request"), style: undefined, explode: undefined, })), @@ -339,7 +550,7 @@ export const operationOutput = ( continue } if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined } - outcomes.push(projectSchema(document, value.schema)) + outcomes.push(projectSchema(document, value.schema, "response")) } } if (outcomes.length === 0) return { ok: true, value: undefined } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 3901ea8836d9..c87d9861220c 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -59,6 +59,53 @@ const singleOperation = (operation: Record, method = "get"): Do }, }) +const directionalSpec = (openapi: string): Document => ({ + openapi, + paths: { + "/users": { + post: { + operationId: "users.create", + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } }, + }, + responses: { + 200: { + description: "Created", + content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } }, + }, + }, + }, + }, + }, + components: { + schemas: { + ReadOnlyID: { type: "string", readOnly: true }, + User: { + type: "object", + additionalProperties: false, + required: ["id", "name", "password", "profile", "generated"], + properties: { + id: { type: "string", readOnly: true }, + name: { type: "string" }, + password: { type: "string", writeOnly: true }, + profile: { + type: "object", + additionalProperties: false, + required: ["createdAt", "secret", "label"], + properties: { + createdAt: { type: "string", readOnly: true }, + secret: { type: "string", writeOnly: true }, + label: { type: "string" }, + }, + }, + generated: { $ref: "#/components/schemas/ReadOnlyID" }, + }, + }, + }, + }, +}) + describe("OpenAPI.fromSpec", () => { test("covers a representative API from generation through execution", async () => { const resolutions: Array = [] @@ -354,6 +401,297 @@ describe("OpenAPI.fromSpec", () => { expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } }) }) + test("projects read-only and write-only properties by schema direction", () => { + for (const version of ["3.0.3", "3.1.0"]) { + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create") + if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) { + throw new Error(`users.create was not generated for OpenAPI ${version}`) + } + + expect(inputTypeScript(tool)).toBe( + "{ name: string; password: string; profile: { secret: string; label: string } }", + ) + expect(outputTypeScript(tool)).toBe( + "{ id: string; name: string; profile: { createdAt: string; label: string }; generated: string }", + ) + + const requestDefinitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const responseDefinitions = isRecord(tool.output.$defs) ? tool.output.$defs : {} + const requestUser = isRecord(requestDefinitions.User) ? requestDefinitions.User : {} + const responseUser = isRecord(responseDefinitions.User) ? responseDefinitions.User : {} + expect(Object.keys(isRecord(requestUser.properties) ? requestUser.properties : {})).toEqual([ + "name", + "password", + "profile", + ]) + expect(requestUser.required).toEqual(["name", "password", "profile"]) + expect(Object.keys(isRecord(responseUser.properties) ? responseUser.properties : {})).toEqual([ + "id", + "name", + "profile", + "generated", + ]) + expect(responseUser.required).toEqual(["id", "name", "profile", "generated"]) + } + }) + + test("projects directional annotations through local refs, anchors, and compositions", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["local", "anchored", "union", "choice", "conditional", "name"], + properties: { + local: { $ref: "#/$defs/ReadOnlyValue" }, + anchored: { $ref: "#managed" }, + union: { anyOf: [{ $ref: "#/$defs/ReadOnlyValue" }] }, + choice: { oneOf: [{ $ref: "#/$defs/ReadOnlyValue" }] }, + conditional: { anyOf: [{ $ref: "#/$defs/ReadOnlyValue" }, { type: "number" }] }, + name: { type: "string" }, + }, + $defs: { + ReadOnlyValue: { type: "string", readOnly: true }, + AnchoredValue: { $anchor: "managed", type: "string", readOnly: true }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ conditional: unknown; name: string }") + }) + + test("keeps anchors inside their schema resource", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $id: "https://example.test/root", + type: "object", + additionalProperties: false, + required: ["value"], + properties: { value: { $ref: "#managed" } }, + $defs: { + Nested: { + $id: "nested", + $anchor: "managed", + type: "string", + readOnly: true, + }, + Root: { $anchor: "managed", type: "number" }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ value: unknown }") + }) + + test("resolves local directional refs inside nested schema resources", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + content: { + "application/json": { + schema: { + $id: "https://example.test/root", + type: "object", + required: ["nested"], + properties: { + nested: { + $id: "nested", + type: "object", + required: ["secret", "name"], + properties: { + secret: { $ref: "#managed" }, + name: { type: "string" }, + }, + $defs: { + Secret: { $anchor: "managed", type: "string", readOnly: true }, + }, + }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ body?: { nested: { name: string } } }") + }) + + test("ignores inherited directional annotations", () => { + const inherited: Record = { type: "string" } + Object.setPrototypeOf(inherited, { readOnly: true }) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation({ + parameters: [ + { + name: "filter", + in: "query", + required: true, + schema: { type: "object", properties: { value: inherited }, required: ["value"] }, + }, + ], + }), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }") + }) + + test("cleans required properties across allOf branches", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["id", "name"], + allOf: [ + { + type: "object", + required: ["id", "name"], + properties: { id: { type: "string", readOnly: true }, name: { type: "string" } }, + }, + ], + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const body = isRecord(properties.body) ? properties.body : {} + const allOf = Array.isArray(body.allOf) ? body.allOf : [] + const branch = isRecord(allOf[0]) ? allOf[0] : {} + + expect(body.required).toEqual(["name"]) + expect(branch.required).toEqual(["name"]) + expect(Object.keys(isRecord(branch.properties) ? branch.properties : {})).toEqual(["name"]) + }) + + test("cleans hidden requirements from unions, dependencies, and conditionals", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + required: ["id", "name"], + properties: { id: { type: "string", readOnly: true }, name: { type: "string" } }, + anyOf: [{ required: ["id"] }, { required: ["name"] }], + dependentRequired: { id: ["name"], name: ["id"] }, + dependentSchemas: { id: { required: ["name"] }, name: { required: ["id"] } }, + if: { required: ["name"] }, + then: { required: ["id"] }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const body = isRecord(properties.body) ? properties.body : {} + + expect(JSON.stringify(body)).not.toContain('"id"') + expect(JSON.stringify(body)).toContain('"name"') + }) + + test("keeps directional schemas model-facing while preserving runtime pass-through", async () => { + const client = recordingClient(() => + json({ + id: "server-id", + name: "Ada", + password: "returned-by-server", + profile: { createdAt: "today", secret: "returned-secret", label: "primary" }, + generated: "generated-id", + }), + ) + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create") + if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated") + + const result = await Effect.runPromise( + tool + .run({ + id: "ignored-top-level", + generated: "ignored-generated", + name: "Ada", + password: "request-secret", + profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" }, + }) + .pipe(Effect.provide(client.layer)), + ) + + expect(client.requests[0]?.body).toEqual({ + name: "Ada", + password: "request-secret", + profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" }, + }) + expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } }) + }) + test("documents that the opencode fixture is unauthenticated", async () => { const spec = await opencodeSpec() const components = isRecord(spec.components) ? spec.components : {} @@ -525,9 +863,9 @@ describe("OpenAPI.fromSpec", () => { expect(client.requests[0]?.url).toBe( `${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`, ) - await expect( - Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer))), - ).rejects.toThrow("Parameter 'tags' contains an unsupported nested value.") + await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + "Parameter 'tags' contains an unsupported nested value.", + ) await expect( Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.") From c430288d429fd0528c68779d89840084a17cd1dd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 16 Jul 2026 22:43:32 -0500 Subject: [PATCH 2/6] refactor(codemode): simplify directional schema projection --- packages/codemode/src/openapi/TODO.md | 1 + packages/codemode/src/openapi/spec.ts | 245 ++++++++----------------- packages/codemode/test/openapi.test.ts | 130 +------------ 3 files changed, 84 insertions(+), 292 deletions(-) diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md index e2e0b882217d..b4897e45756f 100644 --- a/packages/codemode/src/openapi/TODO.md +++ b/packages/codemode/src/openapi/TODO.md @@ -5,6 +5,7 @@ The initial adapter intentionally skips operations it cannot execute correctly. - Cookie parameters, authentication, and cookie-header merging. - Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization. - External references and complete nested `$defs` support. +- `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection. - Relative or templated server URLs and server variables. - Base URLs containing query strings or fragments. - Runtime response-schema validation and full content negotiation. diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index 63131f849a4f..0a55a71b1db5 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -45,201 +45,114 @@ export const resolve = (document: Document, value: unknown): unknown => { return next(value, new Set()) } +// Model-facing directional projection: `readOnly` properties are omitted from request +// schemas and `writeOnly` properties from response schemas, with `required` kept +// consistent. Runtime values pass through unchanged. Reference support is deliberately +// bounded to JSON pointers (`#/...`); `$anchor` and nested `$id` resource scoping are +// out of scope for an advisory schema. type SchemaDirection = "request" | "response" -type SchemaContext = { readonly document: Document; readonly root: unknown } -type SchemaTarget = SchemaContext & { readonly value: unknown } +type SchemaResource = { readonly value: unknown; readonly root: unknown } -const schemaAnchor = ( - value: unknown, - anchor: string, - seen: ReadonlySet = new Set(), - nested = false, -): unknown => { - if (!isRecord(value) || seen.has(value)) return undefined - if (nested && own(value, "$id") !== undefined) return undefined - if (own(value, "$anchor") === anchor) return value - const nextSeen = new Set([...seen, value]) - const maps = [ - own(value, "properties"), - own(value, "patternProperties"), - own(value, "dependentSchemas"), - own(value, "$defs"), - own(value, "definitions"), - ] - for (const map of maps) { - if (!isRecord(map)) continue - for (const child of Object.values(map)) { - const found = schemaAnchor(child, anchor, nextSeen, true) - if (found !== undefined) return found - } - } - for (const child of [ - ...asArray(own(value, "allOf")), - ...asArray(own(value, "anyOf")), - ...asArray(own(value, "oneOf")), - ...asArray(own(value, "prefixItems")), - own(value, "items"), - own(value, "contains"), - own(value, "additionalProperties"), - own(value, "unevaluatedProperties"), - own(value, "propertyNames"), - own(value, "not"), - own(value, "if"), - own(value, "then"), - own(value, "else"), - ]) { - const found = schemaAnchor(child, anchor, nextSeen, true) - if (found !== undefined) return found - } - return undefined -} +const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const -const schemaReference = (context: SchemaContext, value: Record): SchemaTarget | undefined => { - const ref = nonEmptyString(own(value, "$ref")) - if (ref === undefined) return undefined - if (ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")) { - const target = resolvePointer(context.root, ref) - return target === undefined ? undefined : { ...context, root: context.root, value: target } - } - if (ref.startsWith("#/")) { - const target = resolvePointer(context.document, ref) - return target === undefined ? undefined : { document: context.document, root: target, value: target } - } - if (ref.startsWith("#") && ref.length > 1) { - const target = schemaAnchor(context.root, ref.slice(1)) - return target === undefined ? undefined : { ...context, value: target } +// Local `$defs`/`definitions` pointers resolve against the schema being projected; +// other pointers resolve against the document and rebase local resolution onto the target. +const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => { + const next = (current: SchemaResource, seen: ReadonlySet): SchemaResource => { + if (!isRecord(current.value)) return current + const ref = nonEmptyString(own(current.value, "$ref")) + if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current + const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/") + const target = resolvePointer(local ? current.root : document, ref) + if (target === undefined) return current + return next({ value: target, root: local ? current.root : target }, new Set([...seen, ref])) } - return undefined + return next(resource, new Set()) } -const hiddenProperty = ( - context: SchemaContext, - value: unknown, +const isHidden = ( + document: Document, + resource: SchemaResource, direction: SchemaDirection, seen: ReadonlySet = new Set(), ): boolean => { - if (!isRecord(value)) return false - const current = own(value, "$id") === undefined ? context : { ...context, root: value } - if (own(value, direction === "request" ? "readOnly" : "writeOnly") === true) return true - const target = schemaReference(current, value) - if (isRecord(target?.value) && !seen.has(target.value)) { - if (hiddenProperty(target, target.value, direction, new Set([...seen, target.value]))) return true - } - const allOf = asArray(own(value, "allOf")) - if (allOf.some((item) => hiddenProperty(current, item, direction, seen))) return true - for (const keyword of ["anyOf", "oneOf"] as const) { - const alternatives = asArray(own(value, keyword)) - if (alternatives.length > 0 && alternatives.every((item) => hiddenProperty(current, item, direction, seen))) { - return true - } - } - return false + const resolved = resolveResource(document, resource) + if (!isRecord(resolved.value) || seen.has(resolved.value)) return false + if (own(resolved.value, hiddenKeyword[direction]) === true) return true + const nextSeen = new Set([...seen, resolved.value]) + return asArray(own(resolved.value, "allOf")).some((item) => + isHidden(document, { ...resolved, value: item }, direction, nextSeen), + ) } -const hiddenPropertyNames = ( - context: SchemaContext, - value: unknown, +// Hidden property names declared by a schema itself or inherited through `$ref` and +// `allOf` composition, so sibling `required` lists stay consistent after projection. +const hiddenNames = ( + document: Document, + resource: SchemaResource, direction: SchemaDirection, seen: ReadonlySet = new Set(), ): ReadonlySet => { - if (!isRecord(value)) return new Set() - const current = own(value, "$id") === undefined ? context : { ...context, root: value } - const declaredProperties = own(value, "properties") - const hidden = new Set( - isRecord(declaredProperties) - ? Object.entries(declaredProperties) - .filter(([, property]) => hiddenProperty(current, property, direction)) - .map(([name]) => name) - : [], - ) - const target = schemaReference(current, value) - if (isRecord(target?.value) && !seen.has(target.value)) { - for (const name of hiddenPropertyNames(target, target.value, direction, new Set([...seen, target.value]))) { - hidden.add(name) - } - } - for (const item of asArray(own(value, "allOf"))) { - for (const name of hiddenPropertyNames(current, item, direction, seen)) hidden.add(name) - } - return hidden + const resolved = resolveResource(document, resource) + if (!isRecord(resolved.value) || seen.has(resolved.value)) return new Set() + const nextSeen = new Set([...seen, resolved.value]) + const properties = own(resolved.value, "properties") + const declared = isRecord(properties) + ? Object.entries(properties) + .filter(([, property]) => isHidden(document, { ...resolved, value: property }, direction)) + .map(([name]) => name) + : [] + const inherited = asArray(own(resolved.value, "allOf")).flatMap((item) => [ + ...hiddenNames(document, { ...resolved, value: item }, direction, nextSeen), + ]) + return new Set([...declared, ...inherited]) } +const nestedSchemas = new Set([ + "items", + "contains", + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "not", + "if", + "then", + "else", +]) +const nestedSchemaLists = new Set(["anyOf", "oneOf", "prefixItems"]) +const nestedSchemaMaps = new Set(["patternProperties", "dependentSchemas", "$defs", "definitions"]) + const directionalSchema = ( - context: SchemaContext, - value: unknown, + document: Document, + resource: SchemaResource, direction: SchemaDirection, excluded: ReadonlySet = new Set(), ): unknown => { - if (!isRecord(value)) return value - const current = own(value, "$id") === undefined ? context : { ...context, root: value } - const declaredProperties = own(value, "properties") - const properties = isRecord(declaredProperties) ? declaredProperties : undefined - const hidden = new Set([...excluded, ...hiddenPropertyNames(current, value, direction)]) - const schemas = (items: unknown, inherited: ReadonlySet = new Set()): unknown => - Array.isArray(items) ? items.map((item) => directionalSchema(current, item, direction, inherited)) : items - const schemaMap = (items: unknown): unknown => - isRecord(items) - ? Object.fromEntries( - Object.entries(items).map(([name, item]) => [name, directionalSchema(current, item, direction)]), - ) - : items - + if (!isRecord(resource.value)) return resource.value + const hidden = new Set([...excluded, ...hiddenNames(document, resource, direction)]) + const project = (item: unknown, inherited: ReadonlySet = new Set()): unknown => + directionalSchema(document, { ...resource, value: item }, direction, inherited) return Object.fromEntries( - Object.entries(value).map(([key, item]) => { - if (key === "properties" && properties !== undefined) { + Object.entries(resource.value).map(([key, item]) => { + if (key === "properties" && isRecord(item)) { return [ key, Object.fromEntries( - Object.entries(properties) + Object.entries(item) .filter(([name]) => !hidden.has(name)) - .map(([name, property]) => [name, directionalSchema(current, property, direction)]), + .map(([name, property]) => [name, project(property)]), ), ] } if (key === "required" && Array.isArray(item)) { return [key, item.filter((name) => typeof name !== "string" || !hidden.has(name))] } - if (key === "allOf") return [key, schemas(item, hidden)] - if (key === "anyOf" || key === "oneOf") return [key, schemas(item, hidden)] - if (key === "prefixItems") return [key, schemas(item)] - if (key === "dependentRequired" && isRecord(item)) { - return [ - key, - Object.fromEntries( - Object.entries(item) - .filter(([name]) => !hidden.has(name)) - .map(([name, names]) => [ - name, - Array.isArray(names) ? names.filter((required) => !hidden.has(String(required))) : names, - ]), - ), - ] - } - if (key === "dependentSchemas" && isRecord(item)) { - return [ - key, - Object.fromEntries( - Object.entries(item) - .filter(([name]) => !hidden.has(name)) - .map(([name, schema]) => [name, directionalSchema(current, schema, direction, hidden)]), - ), - ] - } - if (key === "properties" || key === "patternProperties" || key === "$defs" || key === "definitions") { - return [key, schemaMap(item)] - } - if ( - key === "items" || - key === "contains" || - key === "additionalProperties" || - key === "unevaluatedProperties" || - key === "propertyNames" || - key === "not" - ) { - return [key, directionalSchema(current, item, direction)] - } - if (key === "if" || key === "then" || key === "else") { - return [key, directionalSchema(current, item, direction, hidden)] + // allOf branches share one object; hidden names apply across every branch. + if (key === "allOf" && Array.isArray(item)) return [key, item.map((entry) => project(entry, hidden))] + if (nestedSchemas.has(key)) return [key, project(item)] + if (nestedSchemaLists.has(key) && Array.isArray(item)) return [key, item.map((entry) => project(entry))] + if (nestedSchemaMaps.has(key) && isRecord(item)) { + return [key, Object.fromEntries(Object.entries(item).map(([name, entry]) => [name, project(entry)]))] } return [key, item] }), @@ -247,7 +160,7 @@ const directionalSchema = ( } const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema => { - const projected = directionalSchema({ document, root: value }, value, direction) + const projected = directionalSchema(document, { value, root: value }, direction) if (!isRecord(projected)) return {} const normalized = nonEmptyString(document.openapi)?.startsWith("3.0") ? fromSchemaOpenApi3_0(projected) @@ -402,7 +315,7 @@ const operationBody = ( } } const resolvedSchema = resolve(document, selected.schema) - const schema = directionalSchema({ document, root: resolvedSchema }, resolvedSchema, "request") + const schema = directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request") const required = resolved.required === true if (!isFlattenableObjectBody(schema, required)) { return { diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index c87d9861220c..e9ed42ba8204 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -435,7 +435,7 @@ describe("OpenAPI.fromSpec", () => { } }) - test("projects directional annotations through local refs, anchors, and compositions", () => { + test("projects directional annotations through local refs and allOf composition", () => { const tool = toolAt( OpenAPI.fromSpec({ baseUrl, @@ -448,18 +448,14 @@ describe("OpenAPI.fromSpec", () => { schema: { type: "object", additionalProperties: false, - required: ["local", "anchored", "union", "choice", "conditional", "name"], + required: ["local", "composed", "name"], properties: { local: { $ref: "#/$defs/ReadOnlyValue" }, - anchored: { $ref: "#managed" }, - union: { anyOf: [{ $ref: "#/$defs/ReadOnlyValue" }] }, - choice: { oneOf: [{ $ref: "#/$defs/ReadOnlyValue" }] }, - conditional: { anyOf: [{ $ref: "#/$defs/ReadOnlyValue" }, { type: "number" }] }, + composed: { allOf: [{ $ref: "#/$defs/ReadOnlyValue" }] }, name: { type: "string" }, }, $defs: { ReadOnlyValue: { type: "string", readOnly: true }, - AnchoredValue: { $anchor: "managed", type: "string", readOnly: true }, }, }, }, @@ -473,89 +469,7 @@ describe("OpenAPI.fromSpec", () => { ) if (!Tool.isDefinition(tool)) throw new Error("test was not generated") - expect(inputTypeScript(tool)).toBe("{ conditional: unknown; name: string }") - }) - - test("keeps anchors inside their schema resource", () => { - const tool = toolAt( - OpenAPI.fromSpec({ - baseUrl, - spec: singleOperation( - { - requestBody: { - required: true, - content: { - "application/json": { - schema: { - $id: "https://example.test/root", - type: "object", - additionalProperties: false, - required: ["value"], - properties: { value: { $ref: "#managed" } }, - $defs: { - Nested: { - $id: "nested", - $anchor: "managed", - type: "string", - readOnly: true, - }, - Root: { $anchor: "managed", type: "number" }, - }, - }, - }, - }, - }, - }, - "post", - ), - }).tools, - "test", - ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") - - expect(inputTypeScript(tool)).toBe("{ value: unknown }") - }) - - test("resolves local directional refs inside nested schema resources", () => { - const tool = toolAt( - OpenAPI.fromSpec({ - baseUrl, - spec: singleOperation( - { - requestBody: { - content: { - "application/json": { - schema: { - $id: "https://example.test/root", - type: "object", - required: ["nested"], - properties: { - nested: { - $id: "nested", - type: "object", - required: ["secret", "name"], - properties: { - secret: { $ref: "#managed" }, - name: { type: "string" }, - }, - $defs: { - Secret: { $anchor: "managed", type: "string", readOnly: true }, - }, - }, - }, - }, - }, - }, - }, - }, - "post", - ), - }).tools, - "test", - ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") - - expect(inputTypeScript(tool)).toBe("{ body?: { nested: { name: string } } }") + expect(inputTypeScript(tool)).toBe("{ name: string }") }) test("ignores inherited directional annotations", () => { @@ -623,42 +537,6 @@ describe("OpenAPI.fromSpec", () => { expect(Object.keys(isRecord(branch.properties) ? branch.properties : {})).toEqual(["name"]) }) - test("cleans hidden requirements from unions, dependencies, and conditionals", () => { - const tool = toolAt( - OpenAPI.fromSpec({ - baseUrl, - spec: singleOperation( - { - requestBody: { - content: { - "application/json": { - schema: { - type: "object", - required: ["id", "name"], - properties: { id: { type: "string", readOnly: true }, name: { type: "string" } }, - anyOf: [{ required: ["id"] }, { required: ["name"] }], - dependentRequired: { id: ["name"], name: ["id"] }, - dependentSchemas: { id: { required: ["name"] }, name: { required: ["id"] } }, - if: { required: ["name"] }, - then: { required: ["id"] }, - }, - }, - }, - }, - }, - "post", - ), - }).tools, - "test", - ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") - const properties = isRecord(tool.input.properties) ? tool.input.properties : {} - const body = isRecord(properties.body) ? properties.body : {} - - expect(JSON.stringify(body)).not.toContain('"id"') - expect(JSON.stringify(body)).toContain('"name"') - }) - test("keeps directional schemas model-facing while preserving runtime pass-through", async () => { const client = recordingClient(() => json({ From b14a88227fa14da0ced31dfb4f4c661b52e4a6e1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 16 Jul 2026 23:27:28 -0500 Subject: [PATCH 3/6] fix(codemode): honor $ref-sibling directional declarations --- packages/codemode/src/openapi/TODO.md | 1 + packages/codemode/src/openapi/spec.ts | 38 +++++--- packages/codemode/test/openapi.test.ts | 127 +++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md index b4897e45756f..9cd536172c20 100644 --- a/packages/codemode/src/openapi/TODO.md +++ b/packages/codemode/src/openapi/TODO.md @@ -6,6 +6,7 @@ The initial adapter intentionally skips operations it cannot execute correctly. - Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization. - External references and complete nested `$defs` support. - `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection. +- Use-site cleanup for `allOf` branches that reference shared component schemas: per-direction component definitions are projected globally, so a directional annotation declared only at one use site cannot remove the property from a referenced component's definition. - Relative or templated server URLs and server variables. - Base URLs containing query strings or fragments. - Runtime response-schema validation and full content negotiation. diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index 0a55a71b1db5..8a3ea5ef94b9 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -70,19 +70,26 @@ const resolveResource = (document: Document, resource: SchemaResource): SchemaRe return next(resource, new Set()) } +// OpenAPI 3.1 allows keywords as siblings of `$ref`, so a schema's own declarations +// are inspected before following the reference. const isHidden = ( document: Document, resource: SchemaResource, direction: SchemaDirection, seen: ReadonlySet = new Set(), ): boolean => { - const resolved = resolveResource(document, resource) - if (!isRecord(resolved.value) || seen.has(resolved.value)) return false - if (own(resolved.value, hiddenKeyword[direction]) === true) return true - const nextSeen = new Set([...seen, resolved.value]) - return asArray(own(resolved.value, "allOf")).some((item) => - isHidden(document, { ...resolved, value: item }, direction, nextSeen), - ) + if (!isRecord(resource.value) || seen.has(resource.value)) return false + if (own(resource.value, hiddenKeyword[direction]) === true) return true + const nextSeen = new Set([...seen, resource.value]) + if ( + asArray(own(resource.value, "allOf")).some((item) => + isHidden(document, { ...resource, value: item }, direction, nextSeen), + ) + ) { + return true + } + const target = resolveResource(document, resource) + return target.value !== resource.value && isHidden(document, target, direction, nextSeen) } // Hidden property names declared by a schema itself or inherited through `$ref` and @@ -93,19 +100,20 @@ const hiddenNames = ( direction: SchemaDirection, seen: ReadonlySet = new Set(), ): ReadonlySet => { - const resolved = resolveResource(document, resource) - if (!isRecord(resolved.value) || seen.has(resolved.value)) return new Set() - const nextSeen = new Set([...seen, resolved.value]) - const properties = own(resolved.value, "properties") + if (!isRecord(resource.value) || seen.has(resource.value)) return new Set() + const nextSeen = new Set([...seen, resource.value]) + const properties = own(resource.value, "properties") const declared = isRecord(properties) ? Object.entries(properties) - .filter(([, property]) => isHidden(document, { ...resolved, value: property }, direction)) + .filter(([, property]) => isHidden(document, { ...resource, value: property }, direction)) .map(([name]) => name) : [] - const inherited = asArray(own(resolved.value, "allOf")).flatMap((item) => [ - ...hiddenNames(document, { ...resolved, value: item }, direction, nextSeen), + const composed = asArray(own(resource.value, "allOf")).flatMap((item) => [ + ...hiddenNames(document, { ...resource, value: item }, direction, nextSeen), ]) - return new Set([...declared, ...inherited]) + const target = resolveResource(document, resource) + const referenced = target.value === resource.value ? [] : hiddenNames(document, target, direction, nextSeen) + return new Set([...declared, ...composed, ...referenced]) } const nestedSchemas = new Set([ diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index e9ed42ba8204..98f879ce248e 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -472,6 +472,133 @@ describe("OpenAPI.fromSpec", () => { expect(inputTypeScript(tool)).toBe("{ name: string }") }) + test("honors declarations that are siblings of a $ref", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + post: { + operationId: "test", + responses: { 200: { description: "Success" } }, + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["record"], + properties: { + record: { + $ref: "#/components/schemas/Base", + properties: { extra: { type: "string", readOnly: true }, note: { type: "string" } }, + required: ["extra", "note", "id"], + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Base: { + type: "object", + required: ["id", "name"], + properties: { id: { type: "string", readOnly: true }, name: { type: "string" } }, + }, + }, + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const record = isRecord(properties.record) ? properties.record : {} + const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const base = isRecord(definitions.Base) ? definitions.Base : {} + + expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["note"]) + expect(record.required).toEqual(["note"]) + expect(Object.keys(isRecord(base.properties) ? base.properties : {})).toEqual(["name"]) + expect(base.required).toEqual(["name"]) + }) + + test("projects cyclic component references without hanging", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + post: { + operationId: "test", + responses: { 200: { description: "Success" } }, + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/Node" } } }, + }, + }, + }, + }, + components: { + schemas: { + Node: { + type: "object", + required: ["id", "name", "child"], + properties: { + id: { type: "string", readOnly: true }, + name: { type: "string" }, + child: { $ref: "#/components/schemas/Node" }, + }, + }, + }, + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const node = isRecord(definitions.Node) ? definitions.Node : {} + + expect(Object.keys(isRecord(node.properties) ? node.properties : {})).toEqual(["name", "child"]) + expect(node.required).toEqual(["name", "child"]) + }) + + test("projects directional annotations inside parameter schemas", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation({ + parameters: [ + { + name: "filter", + in: "query", + required: true, + schema: { + type: "object", + required: ["state", "id"], + properties: { state: { type: "string" }, id: { type: "string", readOnly: true } }, + }, + }, + ], + }), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }") + }) + test("ignores inherited directional annotations", () => { const inherited: Record = { type: "string" } Object.setPrototypeOf(inherited, { readOnly: true }) From cc1fdb45bd93660d37da492638e7c8d62cf38566 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 17 Jul 2026 00:04:47 -0500 Subject: [PATCH 4/6] fix(codemode): keep directional projection linear and single-pass --- packages/codemode/src/openapi/TODO.md | 1 + packages/codemode/src/openapi/index.ts | 5 +- packages/codemode/src/openapi/spec.ts | 112 +++++++++++++++++-------- packages/codemode/test/openapi.test.ts | 82 ++++++++++++++++++ 4 files changed, 165 insertions(+), 35 deletions(-) diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md index 9cd536172c20..107ec75fef01 100644 --- a/packages/codemode/src/openapi/TODO.md +++ b/packages/codemode/src/openapi/TODO.md @@ -7,6 +7,7 @@ The initial adapter intentionally skips operations it cannot execute correctly. - External references and complete nested `$defs` support. - `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection. - Use-site cleanup for `allOf` branches that reference shared component schemas: per-direction component definitions are projected globally, so a directional annotation declared only at one use site cannot remove the property from a referenced component's definition. +- Hidden-name cleanup inside `then`/`else`/`dependentSchemas`/`dependentRequired`, which constrain the same instance as `allOf`; a hidden property may remain named in those keywords. - Relative or templated server URLs and server variables. - Base URLs containing query strings or fragments. - Runtime response-schema validation and full content negotiation. diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index 4649d884ef11..5ea688129e1d 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -3,6 +3,7 @@ import { make, type Definition } from "../tool.js" import { invoke } from "./runtime.js" import { componentDefinitions, + hasDirectionalSchemas, inputSchema, isRecord, methods, @@ -39,7 +40,9 @@ export const fromSpec = (options: Options): Result => { const schemes = securitySchemes(document) const defaultSecurity = securityRequirements(document.security) const requestDefinitions = componentDefinitions(document, "request") - const responseDefinitions = componentDefinitions(document, "response") + const responseDefinitions = hasDirectionalSchemas(document) + ? componentDefinitions(document, "response") + : requestDefinitions const paths = isRecord(document.paths) ? document.paths : {} const used = new Set() const namespaces = new Set() diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index 8a3ea5ef94b9..69101758bdd4 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -70,38 +70,71 @@ const resolveResource = (document: Document, resource: SchemaResource): SchemaRe return next(resource, new Set()) } +// Hidden-ness is memoized per schema object and direction so that diamond-shaped +// reference graphs (the same component referenced from many sites) stay linear; +// path-scoped visited sets would re-traverse shared subtrees exponentially. Entries +// are seeded before recursion so reference cycles terminate as not hidden. A schema +// reachable under multiple resolution roots reuses the first root's result. +type DirectionCache = { + readonly hidden: Map + readonly names: Map> +} +const projectionCaches = new WeakMap>() + +const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => { + const existing = projectionCaches.get(document) + if (existing !== undefined) return existing[direction] + const created = { + request: { hidden: new Map(), names: new Map>() }, + response: { hidden: new Map(), names: new Map>() }, + } + projectionCaches.set(document, created) + return created[direction] +} + +// Most documents never use directional keywords; one cached linear scan lets +// projection and the doubled per-direction component normalization be skipped entirely. +const directionalDocuments = new WeakMap() + +export const hasDirectionalSchemas = (document: Document): boolean => { + const cached = directionalDocuments.get(document) + if (cached !== undefined) return cached + const contains = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(contains) + if (!isRecord(value)) return false + if (own(value, "readOnly") === true || own(value, "writeOnly") === true) return true + return Object.values(value).some(contains) + } + const result = contains(document) + directionalDocuments.set(document, result) + return result +} + // OpenAPI 3.1 allows keywords as siblings of `$ref`, so a schema's own declarations // are inspected before following the reference. -const isHidden = ( - document: Document, - resource: SchemaResource, - direction: SchemaDirection, - seen: ReadonlySet = new Set(), -): boolean => { - if (!isRecord(resource.value) || seen.has(resource.value)) return false +const isHidden = (document: Document, resource: SchemaResource, direction: SchemaDirection): boolean => { + if (!isRecord(resource.value)) return false if (own(resource.value, hiddenKeyword[direction]) === true) return true - const nextSeen = new Set([...seen, resource.value]) - if ( - asArray(own(resource.value, "allOf")).some((item) => - isHidden(document, { ...resource, value: item }, direction, nextSeen), - ) - ) { - return true - } + const cache = projectionCache(document, direction).hidden + const cached = cache.get(resource.value) + if (cached !== undefined) return cached + cache.set(resource.value, false) const target = resolveResource(document, resource) - return target.value !== resource.value && isHidden(document, target, direction, nextSeen) + const result = + asArray(own(resource.value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction)) || + (target.value !== resource.value && isHidden(document, target, direction)) + cache.set(resource.value, result) + return result } // Hidden property names declared by a schema itself or inherited through `$ref` and // `allOf` composition, so sibling `required` lists stay consistent after projection. -const hiddenNames = ( - document: Document, - resource: SchemaResource, - direction: SchemaDirection, - seen: ReadonlySet = new Set(), -): ReadonlySet => { - if (!isRecord(resource.value) || seen.has(resource.value)) return new Set() - const nextSeen = new Set([...seen, resource.value]) +const hiddenNames = (document: Document, resource: SchemaResource, direction: SchemaDirection): ReadonlySet => { + if (!isRecord(resource.value)) return new Set() + const cache = projectionCache(document, direction).names + const cached = cache.get(resource.value) + if (cached !== undefined) return cached + cache.set(resource.value, new Set()) const properties = own(resource.value, "properties") const declared = isRecord(properties) ? Object.entries(properties) @@ -109,11 +142,13 @@ const hiddenNames = ( .map(([name]) => name) : [] const composed = asArray(own(resource.value, "allOf")).flatMap((item) => [ - ...hiddenNames(document, { ...resource, value: item }, direction, nextSeen), + ...hiddenNames(document, { ...resource, value: item }, direction), ]) const target = resolveResource(document, resource) - const referenced = target.value === resource.value ? [] : hiddenNames(document, target, direction, nextSeen) - return new Set([...declared, ...composed, ...referenced]) + const referenced = target.value === resource.value ? [] : hiddenNames(document, target, direction) + const result = new Set([...declared, ...composed, ...referenced]) + cache.set(resource.value, result) + return result } const nestedSchemas = new Set([ @@ -167,17 +202,22 @@ const directionalSchema = ( ) } -const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema => { - const projected = directionalSchema(document, { value, root: value }, direction) - if (!isRecord(projected)) return {} +const normalizeSchema = (document: Document, value: unknown): JsonSchema => { + if (!isRecord(value)) return {} const normalized = nonEmptyString(document.openapi)?.startsWith("3.0") - ? fromSchemaOpenApi3_0(projected) - : fromSchemaOpenApi3_1(projected) + ? fromSchemaOpenApi3_0(value) + : fromSchemaOpenApi3_1(value) return Object.keys(normalized.definitions).length === 0 ? normalized.schema : { ...normalized.schema, $defs: normalized.definitions } } +const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema => + normalizeSchema( + document, + hasDirectionalSchemas(document) ? directionalSchema(document, { value, root: value }, direction) : value, + ) + export const componentDefinitions = ( document: Document, direction: SchemaDirection, @@ -323,7 +363,9 @@ const operationBody = ( } } const resolvedSchema = resolve(document, selected.schema) - const schema = directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request") + const schema = hasDirectionalSchemas(document) + ? directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request") + : resolvedSchema const required = resolved.required === true if (!isFlattenableObjectBody(schema, required)) { return { @@ -349,11 +391,13 @@ const operationBody = ( return { ok: true, value: { + // Field schemas were already projected with the body as resolution root; a second + // directional pass rooted at the field would misresolve shadowed local $defs. fields: Object.entries(schema.properties).map(([name, value]) => ({ name, location: "body" as const, required: required && requiredProperties.has(name), - schema: projectSchema(document, value, "request"), + schema: normalizeSchema(document, value), style: undefined, explode: undefined, })), diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 98f879ce248e..db121be6915b 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -573,6 +573,88 @@ describe("OpenAPI.fromSpec", () => { expect(node.required).toEqual(["name", "child"]) }) + test("projects diamond-shaped reference graphs in linear time", () => { + // Each component references the next twice; without memoized hidden-ness this is 2^30 work. + const depth = 30 + const schemas = Object.fromEntries( + Array.from({ length: depth }, (_, index) => [ + `C${index}`, + index === depth - 1 + ? { type: "object", properties: { id: { type: "string", readOnly: true }, name: { type: "string" } } } + : { allOf: [{ $ref: `#/components/schemas/C${index + 1}` }, { $ref: `#/components/schemas/C${index + 1}` }] }, + ]), + ) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + post: { + operationId: "test", + responses: { 200: { description: "Success" } }, + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/C0" } } }, + }, + }, + }, + }, + components: { schemas }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {} + + expect(Object.keys(isRecord(leaf.properties) ? leaf.properties : {})).toEqual(["name"]) + }) + + test("does not misresolve shadowed local $defs when flattening body fields", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["record"], + $defs: { Value: { type: "string" } }, + properties: { + record: { + type: "object", + required: ["x"], + properties: { x: { $ref: "#/$defs/Value" } }, + // Shadows the body-level Value; must not affect the body-rooted projection. + $defs: { Value: { type: "string", readOnly: true } }, + }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const record = isRecord(properties.record) ? properties.record : {} + + expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["x"]) + expect(record.required).toEqual(["x"]) + }) + test("projects directional annotations inside parameter schemas", () => { const tool = toolAt( OpenAPI.fromSpec({ From cc227bc6eab8d4835a6e22340078d1435ed8e201 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 17 Jul 2026 09:39:12 -0500 Subject: [PATCH 5/6] fix(codemode): make directional projection cycle-safe and semantics-preserving - Resolve reference cycles with Tarjan strongly connected components so hidden-ness results are order-independent; only resolved components are cached and all members of a cycle share the root's value. - Stop projecting inside not/if/contains, whose assertion semantics would invert or shift if hidden constraints were removed; then/else are still projected. - Resolve $ref chains one hop at a time so sibling declarations on intermediate hops are honored. - Pin the fixes and the deliberate anyOf/oneOf scope bound with regression tests; rework the inherited-annotations test to exercise own-property discipline. --- packages/codemode/src/openapi/TODO.md | 2 + packages/codemode/src/openapi/spec.ts | 168 +++++++++++++++++-------- packages/codemode/test/openapi.test.ts | 168 ++++++++++++++++++++++++- 3 files changed, 282 insertions(+), 56 deletions(-) diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md index 107ec75fef01..05558ace07f4 100644 --- a/packages/codemode/src/openapi/TODO.md +++ b/packages/codemode/src/openapi/TODO.md @@ -8,6 +8,8 @@ The initial adapter intentionally skips operations it cannot execute correctly. - `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection. - Use-site cleanup for `allOf` branches that reference shared component schemas: per-direction component definitions are projected globally, so a directional annotation declared only at one use site cannot remove the property from a referenced component's definition. - Hidden-name cleanup inside `then`/`else`/`dependentSchemas`/`dependentRequired`, which constrain the same instance as `allOf`; a hidden property may remain named in those keywords. +- Projection inside `not`/`if`/`contains`, whose semantics would invert or shift if constraints were removed; those subschemas pass through unchanged, and a `$ref` from such a context to a projected `$defs` or component definition still observes hiding. +- Iterative traversal for pathologically deep schema nesting: the directional scan and projection recurse per level and overflow the stack around ten thousand levels, below the pre-existing converter limit of roughly fifty thousand; `fromSpec` throws a catchable `RangeError` either way. - Relative or templated server URLs and server variables. - Base URLs containing query strings or fragments. - Runtime response-schema validation and full content negotiation. diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index 69101758bdd4..9aee0246915c 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -56,42 +56,92 @@ type SchemaResource = { readonly value: unknown; readonly root: unknown } const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const // Local `$defs`/`definitions` pointers resolve against the schema being projected; -// other pointers resolve against the document and rebase local resolution onto the target. +// other pointers resolve against the document and rebase local resolution onto the +// target. Resolution is one hop at a time so that every link of a reference chain has +// its own sibling declarations inspected; chains and cycles terminate in the callers' +// cycle solver, which visits each hop object at most once. const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => { - const next = (current: SchemaResource, seen: ReadonlySet): SchemaResource => { - if (!isRecord(current.value)) return current - const ref = nonEmptyString(own(current.value, "$ref")) - if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current - const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/") - const target = resolvePointer(local ? current.root : document, ref) - if (target === undefined) return current - return next({ value: target, root: local ? current.root : target }, new Set([...seen, ref])) - } - return next(resource, new Set()) + if (!isRecord(resource.value)) return resource + const ref = nonEmptyString(own(resource.value, "$ref")) + if (ref === undefined || !ref.startsWith("#/")) return resource + const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/") + const target = resolvePointer(local ? resource.root : document, ref) + if (target === undefined) return resource + return { value: target, root: local ? resource.root : target } } -// Hidden-ness is memoized per schema object and direction so that diamond-shaped +// Hidden-ness and hidden property names are reachability folds over `$ref` and +// `allOf` edges, memoized per schema object and direction so that diamond-shaped // reference graphs (the same component referenced from many sites) stay linear; -// path-scoped visited sets would re-traverse shared subtrees exponentially. Entries -// are seeded before recursion so reference cycles terminate as not hidden. A schema -// reachable under multiple resolution roots reuses the first root's result. +// path-scoped visited sets would re-traverse shared subtrees exponentially. +// Documents are assumed immutable once projected: mutating a document previously +// passed to `fromSpec` yields stale cached results. A schema reachable under +// multiple resolution roots reuses the first root's result. +type Solver = { + readonly values: Map + // Discovery index per schema whose strongly connected component is unresolved. + readonly pending: Map + readonly stack: Array +} type DirectionCache = { - readonly hidden: Map - readonly names: Map> + readonly hidden: Solver + readonly names: Solver> } + +const emptySolver = (): Solver => ({ values: new Map(), pending: new Map(), stack: [] }) + const projectionCaches = new WeakMap>() const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => { const existing = projectionCaches.get(document) if (existing !== undefined) return existing[direction] const created = { - request: { hidden: new Map(), names: new Map>() }, - response: { hidden: new Map(), names: new Map>() }, + request: { hidden: emptySolver(), names: emptySolver>() }, + response: { hidden: emptySolver(), names: emptySolver>() }, } projectionCaches.set(document, created) return created[direction] } +// Reference cycles are resolved with Tarjan's strongly connected components: every +// member of a cycle reaches the same declarations, so the component root's value is +// final for each member. A frame whose cycles close in a still-active ancestor +// returns its provisional value uncached; only resolved components are cached, which +// keeps results independent of property and traversal order. +type CycleScope = { lowlink: number } + +const solveCycles = ( + solver: Solver, + key: unknown, + provisional: T, + scope: CycleScope, + compute: (inner: CycleScope) => T, +): T => { + const cached = solver.values.get(key) + if (cached !== undefined) return cached + const pending = solver.pending.get(key) + if (pending !== undefined) { + scope.lowlink = Math.min(scope.lowlink, pending) + return provisional + } + // Components pop as contiguous stack suffixes, so pending indices stay 0..size-1. + const index = solver.pending.size + const base = solver.stack.length + solver.pending.set(key, index) + solver.stack.push(key) + const inner: CycleScope = { lowlink: Infinity } + const value = compute(inner) + if (inner.lowlink < index) { + scope.lowlink = Math.min(scope.lowlink, inner.lowlink) + return value + } + for (const member of solver.stack.splice(base)) { + solver.pending.delete(member) + solver.values.set(member, value) + } + return value +} + // Most documents never use directional keywords; one cached linear scan lets // projection and the doubled per-direction component normalization be skipped entirely. const directionalDocuments = new WeakMap() @@ -112,53 +162,61 @@ export const hasDirectionalSchemas = (document: Document): boolean => { // OpenAPI 3.1 allows keywords as siblings of `$ref`, so a schema's own declarations // are inspected before following the reference. -const isHidden = (document: Document, resource: SchemaResource, direction: SchemaDirection): boolean => { - if (!isRecord(resource.value)) return false - if (own(resource.value, hiddenKeyword[direction]) === true) return true - const cache = projectionCache(document, direction).hidden - const cached = cache.get(resource.value) - if (cached !== undefined) return cached - cache.set(resource.value, false) - const target = resolveResource(document, resource) - const result = - asArray(own(resource.value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction)) || - (target.value !== resource.value && isHidden(document, target, direction)) - cache.set(resource.value, result) - return result +const isHidden = ( + document: Document, + resource: SchemaResource, + direction: SchemaDirection, + scope: CycleScope = { lowlink: Infinity }, +): boolean => { + const value = resource.value + if (!isRecord(value)) return false + if (own(value, hiddenKeyword[direction]) === true) return true + return solveCycles(projectionCache(document, direction).hidden, value, false, scope, (inner) => { + const target = resolveResource(document, resource) + return ( + asArray(own(value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction, inner)) || + (target.value !== value && isHidden(document, target, direction, inner)) + ) + }) } // Hidden property names declared by a schema itself or inherited through `$ref` and // `allOf` composition, so sibling `required` lists stay consistent after projection. -const hiddenNames = (document: Document, resource: SchemaResource, direction: SchemaDirection): ReadonlySet => { - if (!isRecord(resource.value)) return new Set() - const cache = projectionCache(document, direction).names - const cached = cache.get(resource.value) - if (cached !== undefined) return cached - cache.set(resource.value, new Set()) - const properties = own(resource.value, "properties") - const declared = isRecord(properties) - ? Object.entries(properties) - .filter(([, property]) => isHidden(document, { ...resource, value: property }, direction)) - .map(([name]) => name) - : [] - const composed = asArray(own(resource.value, "allOf")).flatMap((item) => [ - ...hiddenNames(document, { ...resource, value: item }, direction), - ]) - const target = resolveResource(document, resource) - const referenced = target.value === resource.value ? [] : hiddenNames(document, target, direction) - const result = new Set([...declared, ...composed, ...referenced]) - cache.set(resource.value, result) - return result +const hiddenNames = ( + document: Document, + resource: SchemaResource, + direction: SchemaDirection, + scope: CycleScope = { lowlink: Infinity }, +): ReadonlySet => { + const value = resource.value + if (!isRecord(value)) return new Set() + return solveCycles(projectionCache(document, direction).names, value, new Set(), scope, (inner) => { + const properties = own(value, "properties") + // Property hidden-ness runs in the separate hidden solver; each call completes + // fully before returning, so it never observes this solver's pending frames. + const declared = isRecord(properties) + ? Object.entries(properties) + .filter(([, property]) => isHidden(document, { ...resource, value: property }, direction)) + .map(([name]) => name) + : [] + const composed = asArray(own(value, "allOf")).flatMap((item) => [ + ...hiddenNames(document, { ...resource, value: item }, direction, inner), + ]) + const target = resolveResource(document, resource) + const referenced = target.value === value ? [] : hiddenNames(document, target, direction, inner) + return new Set([...declared, ...composed, ...referenced]) + }) } +// `not` negates its subschema and `if`/`contains` select instances rather than +// assert them, so removing hidden properties there would strengthen or shift the +// assertion (a projected `not` can become unsatisfiable). Those subschemas pass +// through unchanged; `then`/`else` are ordinary assertions and are still projected. const nestedSchemas = new Set([ "items", - "contains", "additionalProperties", "unevaluatedProperties", "propertyNames", - "not", - "if", "then", "else", ]) diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index db121be6915b..f4da5c31e0d8 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -530,6 +530,48 @@ describe("OpenAPI.fromSpec", () => { expect(base.required).toEqual(["name"]) }) + test("honors directional declarations on intermediate reference hops", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["secret", "name"], + properties: { + // Hidden only by the sibling declaration on the middle hop. + secret: { $ref: "#/components/schemas/Middle" }, + name: { type: "string" }, + }, + }, + }, + }, + }, + }, + "post", + ), + components: { + schemas: { + Middle: { $ref: "#/components/schemas/Plain", readOnly: true }, + Plain: { type: "string" }, + }, + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ name: string }") + }) + test("projects cyclic component references without hanging", () => { const tool = toolAt( OpenAPI.fromSpec({ @@ -613,6 +655,124 @@ describe("OpenAPI.fromSpec", () => { expect(Object.keys(isRecord(leaf.properties) ? leaf.properties : {})).toEqual(["name"]) }) + test("resolves hiding through reference cycles regardless of evaluation order", () => { + // `Wrap` is hidden only through the cycle member `Loop`; evaluating a property that + // enters the cycle at `Loop` first must not freeze a provisional result for `Wrap`. + const schemas = { + Wrap: { allOf: [{ $ref: "#/components/schemas/Loop" }] }, + Loop: { allOf: [{ $ref: "#/components/schemas/Wrap" }, { readOnly: true }] }, + } + const body = (properties: Record) => ({ + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: [...Object.keys(properties), "name"], + properties: { ...properties, name: { type: "string" } }, + }, + }, + }, + }) + for (const properties of [ + { a: { $ref: "#/components/schemas/Loop" }, b: { $ref: "#/components/schemas/Wrap" } }, + { a: { $ref: "#/components/schemas/Wrap" }, b: { $ref: "#/components/schemas/Loop" } }, + ]) { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { ...singleOperation({ requestBody: body(properties) }, "post"), components: { schemas } }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ name: string }") + } + }) + + test("keeps not, if, and contains subschemas unprojected", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["record"], + properties: { + record: { + type: "object", + // Removing `secret` here would turn `not` unsatisfiable and + // flip which branch of `if` applies; both must pass through. + not: { required: ["secret"], properties: { secret: { type: "string", readOnly: true } } }, + if: { required: ["kind"], properties: { kind: { type: "string", readOnly: true } } }, + }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const record: Record = isRecord(properties.record) ? properties.record : {} + + expect(record.not).toEqual({ required: ["secret"], properties: { secret: { type: "string", readOnly: true } } }) + expect(record.if).toEqual({ required: ["kind"], properties: { kind: { type: "string", readOnly: true } } }) + }) + + test("does not hide properties whose direction is declared only in anyOf or oneOf alternatives", () => { + // Deliberate scope bound: alternatives may apply, so a directional declaration on + // one alternative does not hide the property; the annotation is preserved as-is. + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["choice", "pick"], + properties: { + choice: { anyOf: [{ type: "string", readOnly: true }, { type: "number" }] }, + pick: { oneOf: [{ type: "string", readOnly: true }, { type: "number" }] }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const choice: Record = isRecord(properties.choice) ? properties.choice : {} + const pick: Record = isRecord(properties.pick) ? properties.pick : {} + + expect(Object.keys(properties)).toEqual(["choice", "pick"]) + expect(choice.anyOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }]) + expect(pick.oneOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }]) + }) + test("does not misresolve shadowed local $defs when flattening body fields", () => { const tool = toolAt( OpenAPI.fromSpec({ @@ -693,7 +853,13 @@ describe("OpenAPI.fromSpec", () => { name: "filter", in: "query", required: true, - schema: { type: "object", properties: { value: inherited }, required: ["value"] }, + schema: { + type: "object", + // The own annotation on `id` keeps projection active for the document, + // so `value` pins that prototype-inherited annotations are not read. + properties: { value: inherited, id: { type: "string", readOnly: true } }, + required: ["value", "id"], + }, }, ], }), From 458d711bcb4c233a03ae161cf1042e42c60789eb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 17 Jul 2026 11:26:46 -0500 Subject: [PATCH 6/6] refactor(codemode): trim projection comments to essentials --- packages/codemode/src/openapi/spec.ts | 55 ++++++++++----------------- 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index 9aee0246915c..c2443d03236c 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -45,21 +45,17 @@ export const resolve = (document: Document, value: unknown): unknown => { return next(value, new Set()) } -// Model-facing directional projection: `readOnly` properties are omitted from request -// schemas and `writeOnly` properties from response schemas, with `required` kept -// consistent. Runtime values pass through unchanged. Reference support is deliberately -// bounded to JSON pointers (`#/...`); `$anchor` and nested `$id` resource scoping are -// out of scope for an advisory schema. +// Model-facing directional projection: request schemas omit `readOnly` properties, +// response schemas omit `writeOnly` properties, and `required` stays consistent. +// Runtime values pass through unchanged. type SchemaDirection = "request" | "response" type SchemaResource = { readonly value: unknown; readonly root: unknown } const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const -// Local `$defs`/`definitions` pointers resolve against the schema being projected; -// other pointers resolve against the document and rebase local resolution onto the -// target. Resolution is one hop at a time so that every link of a reference chain has -// its own sibling declarations inspected; chains and cycles terminate in the callers' -// cycle solver, which visits each hop object at most once. +// Resolves one `$ref` hop so every link of a chain has its own sibling declarations +// inspected; cycles terminate in the callers' cycle solver. Local `$defs`/`definitions` +// pointers resolve against the schema being projected, other pointers rebase onto the target. const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => { if (!isRecord(resource.value)) return resource const ref = nonEmptyString(own(resource.value, "$ref")) @@ -70,13 +66,9 @@ const resolveResource = (document: Document, resource: SchemaResource): SchemaRe return { value: target, root: local ? resource.root : target } } -// Hidden-ness and hidden property names are reachability folds over `$ref` and -// `allOf` edges, memoized per schema object and direction so that diamond-shaped -// reference graphs (the same component referenced from many sites) stay linear; -// path-scoped visited sets would re-traverse shared subtrees exponentially. -// Documents are assumed immutable once projected: mutating a document previously -// passed to `fromSpec` yields stale cached results. A schema reachable under -// multiple resolution roots reuses the first root's result. +// Hidden-ness and hidden names are memoized per schema object and direction so +// diamond-shaped reference graphs stay linear. Documents are assumed immutable once +// projected; a schema reachable under multiple resolution roots reuses the first result. type Solver = { readonly values: Map // Discovery index per schema whose strongly connected component is unresolved. @@ -88,26 +80,24 @@ type DirectionCache = { readonly names: Solver> } -const emptySolver = (): Solver => ({ values: new Map(), pending: new Map(), stack: [] }) +const emptyCache = (): DirectionCache => ({ + hidden: { values: new Map(), pending: new Map(), stack: [] }, + names: { values: new Map(), pending: new Map(), stack: [] }, +}) const projectionCaches = new WeakMap>() const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => { const existing = projectionCaches.get(document) if (existing !== undefined) return existing[direction] - const created = { - request: { hidden: emptySolver(), names: emptySolver>() }, - response: { hidden: emptySolver(), names: emptySolver>() }, - } + const created = { request: emptyCache(), response: emptyCache() } projectionCaches.set(document, created) return created[direction] } -// Reference cycles are resolved with Tarjan's strongly connected components: every -// member of a cycle reaches the same declarations, so the component root's value is -// final for each member. A frame whose cycles close in a still-active ancestor -// returns its provisional value uncached; only resolved components are cached, which -// keeps results independent of property and traversal order. +// Tarjan's strongly connected components: cycle members all reach the same +// declarations, so the component root's value is final for every member. Only resolved +// components are cached, keeping results independent of traversal order. type CycleScope = { lowlink: number } const solveCycles = ( @@ -142,8 +132,7 @@ const solveCycles = ( return value } -// Most documents never use directional keywords; one cached linear scan lets -// projection and the doubled per-direction component normalization be skipped entirely. +// Most documents have no directional keywords; one cached scan skips projection entirely. const directionalDocuments = new WeakMap() export const hasDirectionalSchemas = (document: Document): boolean => { @@ -192,8 +181,6 @@ const hiddenNames = ( if (!isRecord(value)) return new Set() return solveCycles(projectionCache(document, direction).names, value, new Set(), scope, (inner) => { const properties = own(value, "properties") - // Property hidden-ness runs in the separate hidden solver; each call completes - // fully before returning, so it never observes this solver's pending frames. const declared = isRecord(properties) ? Object.entries(properties) .filter(([, property]) => isHidden(document, { ...resource, value: property }, direction)) @@ -208,10 +195,8 @@ const hiddenNames = ( }) } -// `not` negates its subschema and `if`/`contains` select instances rather than -// assert them, so removing hidden properties there would strengthen or shift the -// assertion (a projected `not` can become unsatisfiable). Those subschemas pass -// through unchanged; `then`/`else` are ordinary assertions and are still projected. +// `not`/`if`/`contains` subschemas pass through unprojected: they negate or select +// rather than assert, so removing hidden properties would invert their semantics. const nestedSchemas = new Set([ "items", "additionalProperties",