Parcourir la source

feat(codemode): project OpenAPI schema directions (#37361)

Aiden Cline il y a 4 semaines
Parent
commit
87d5b27668

+ 3 - 1
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

+ 5 - 1
packages/codemode/src/openapi/TODO.md

@@ -5,11 +5,15 @@ 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.
+- 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.
 - 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.

+ 7 - 3
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,
@@ -38,7 +39,10 @@ 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 = hasDirectionalSchemas(document)
+    ? componentDefinitions(document, "response")
+    : requestDefinitions
   const paths = isRecord(document.paths) ? document.paths : {}
   const used = new Set<string>()
   const namespaces = new Set<string>()
@@ -57,7 +61,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 +106,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),
         }),

+ 233 - 14
packages/codemode/src/openapi/spec.ts

@@ -27,22 +27,225 @@ export const nonEmptyString = (value: unknown): string | undefined =>
 export const own = <T>(record: Readonly<Record<string, T>>, 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<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), root)
+
 export const resolve = (document: Document, value: unknown): unknown => {
   const next = (current: unknown, seen: ReadonlySet<string>): 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<unknown>((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 => {
+// 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
+
+// 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"))
+  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 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<T> = {
+  readonly values: Map<unknown, T>
+  // Discovery index per schema whose strongly connected component is unresolved.
+  readonly pending: Map<unknown, number>
+  readonly stack: Array<unknown>
+}
+type DirectionCache = {
+  readonly hidden: Solver<boolean>
+  readonly names: Solver<ReadonlySet<string>>
+}
+
+const emptyCache = (): DirectionCache => ({
+  hidden: { values: new Map(), pending: new Map(), stack: [] },
+  names: { values: new Map(), pending: new Map(), stack: [] },
+})
+
+const projectionCaches = new WeakMap<Document, Record<SchemaDirection, DirectionCache>>()
+
+const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => {
+  const existing = projectionCaches.get(document)
+  if (existing !== undefined) return existing[direction]
+  const created = { request: emptyCache(), response: emptyCache() }
+  projectionCaches.set(document, created)
+  return created[direction]
+}
+
+// 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 = <T>(
+  solver: Solver<T>,
+  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 have no directional keywords; one cached scan skips projection entirely.
+const directionalDocuments = new WeakMap<Document, boolean>()
+
+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,
+  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,
+  scope: CycleScope = { lowlink: Infinity },
+): ReadonlySet<string> => {
+  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")
+    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`/`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",
+  "unevaluatedProperties",
+  "propertyNames",
+  "then",
+  "else",
+])
+const nestedSchemaLists = new Set(["anyOf", "oneOf", "prefixItems"])
+const nestedSchemaMaps = new Set(["patternProperties", "dependentSchemas", "$defs", "definitions"])
+
+const directionalSchema = (
+  document: Document,
+  resource: SchemaResource,
+  direction: SchemaDirection,
+  excluded: ReadonlySet<string> = new Set(),
+): unknown => {
+  if (!isRecord(resource.value)) return resource.value
+  const hidden = new Set([...excluded, ...hiddenNames(document, resource, direction)])
+  const project = (item: unknown, inherited: ReadonlySet<string> = new Set()): unknown =>
+    directionalSchema(document, { ...resource, value: item }, direction, inherited)
+  return Object.fromEntries(
+    Object.entries(resource.value).map(([key, item]) => {
+      if (key === "properties" && isRecord(item)) {
+        return [
+          key,
+          Object.fromEntries(
+            Object.entries(item)
+              .filter(([name]) => !hidden.has(name))
+              .map(([name, property]) => [name, project(property)]),
+          ),
+        ]
+      }
+      if (key === "required" && Array.isArray(item)) {
+        return [key, item.filter((name) => typeof name !== "string" || !hidden.has(name))]
+      }
+      // 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]
+    }),
+  )
+}
+
+const normalizeSchema = (document: Document, value: unknown): JsonSchema => {
   if (!isRecord(value)) return {}
   const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
     ? fromSchemaOpenApi3_0(value)
@@ -52,10 +255,21 @@ const projectSchema = (document: Document, value: unknown): JsonSchema => {
     : { ...normalized.schema, $defs: normalized.definitions }
 }
 
-export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
+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,
+): Readonly<Record<string, JsonSchema>> => {
   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<Record<string, JsonSchema>>): JsonSchema => {
@@ -157,7 +371,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 +405,10 @@ 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 = hasDirectionalSchemas(document)
+    ? directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request")
+    : resolvedSchema
   const required = resolved.required === true
   if (!isFlattenableObjectBody(schema, required)) {
     return {
@@ -202,7 +419,7 @@ const operationBody = (
             name: "body",
             location: "body",
             required,
-            schema: projectSchema(document, selected.schema),
+            schema: projectSchema(document, selected.schema, "request"),
             style: undefined,
             explode: undefined,
           },
@@ -217,11 +434,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),
+        schema: normalizeSchema(document, value),
         style: undefined,
         explode: undefined,
       })),
@@ -339,7 +558,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 }

+ 594 - 3
packages/codemode/test/openapi.test.ts

@@ -59,6 +59,53 @@ const singleOperation = (operation: Record<string, unknown>, 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<string> = []
@@ -354,6 +401,550 @@ 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 and allOf composition", () => {
+    const tool = toolAt(
+      OpenAPI.fromSpec({
+        baseUrl,
+        spec: singleOperation(
+          {
+            requestBody: {
+              required: true,
+              content: {
+                "application/json": {
+                  schema: {
+                    type: "object",
+                    additionalProperties: false,
+                    required: ["local", "composed", "name"],
+                    properties: {
+                      local: { $ref: "#/$defs/ReadOnlyValue" },
+                      composed: { allOf: [{ $ref: "#/$defs/ReadOnlyValue" }] },
+                      name: { type: "string" },
+                    },
+                    $defs: {
+                      ReadOnlyValue: { type: "string", readOnly: true },
+                    },
+                  },
+                },
+              },
+            },
+          },
+          "post",
+        ),
+      }).tools,
+      "test",
+    )
+    if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
+
+    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("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({
+        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 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("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<string, unknown>) => ({
+      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<string, unknown> = 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<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
+    const pick: Record<string, unknown> = 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({
+        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({
+        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<string, unknown> = { 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",
+                // 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"],
+              },
+            },
+          ],
+        }),
+      }).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("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 +1116,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.")