@trpc/openapi
Version:
1 lines • 90 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","names":["value: unknown","schema: $ZodType","wrapperDefTypes: ReadonlySet<$ZodTypeDef['type']>","def: $ZodTypeDef","current: $ZodType","schema: unknown","map: DescriptionMap","prefix: string","ctx: {\n registry: $ZodRegistry<GlobalMeta>;\n map: DescriptionMap;\n seenLazy: Set<$ZodType>;\n }","path","mod: Record<string, unknown>","exportName: string","resolvedPath: string","routerOrRecord: AnyTRPCRouter | TRPCRouterRecord","result: Map<string, RuntimeDescriptions>","record: TRPCRouterRecord","inputDescs: DescriptionMap | null","outputDescs: DescriptionMap | null","value: AnyTRPCProcedure | TRPCRouterRecord","schema: SchemaObject","descs: DescriptionMap","schemas?: Record<string, SchemaObject>","propertyName: string","description: string","type: ts.Type","flag: ts.TypeFlags","sym: ts.Symbol","prop: ts.Symbol","schema: SchemaObject | null","schemas: Record<string, SchemaObject>","name: string","existing: Record<string, unknown>","schema: SchemaObject","s: SchemaObject","ctx: SchemaCtx","schema","flags: ts.TypeFlags","checker: ts.TypeChecker","type: ts.UnionType","depth: number","schemas: SchemaObject[]","nonNull: ts.Type[]","hasNull: boolean","type: ts.IntersectionType","properties: Record<string, SchemaObject>","required: string[]","additionalProperties: SchemaObject | boolean | undefined","result: SchemaObject","autoRegName: string | null","defType: ts.Type","inputType: ts.Type | null","type: ProcedureInfo['type']","path: string","def: ProcedureDef","initializer: ts.Expression | null","recovered: ts.Type | null","expr: ts.Expression","ctx: WalkCtx","inputSchema: SchemaObject | null","outputSchema: SchemaObject | null","candidate: string","parent: string","filePath: string","opts: WalkTypeOpts","recordType: ts.Type","prefix: string","startDir: string","options: ts.CompilerOptions","routerType: ts.Type","schemaCtx: SchemaCtx","keys: string[]","DEFAULT_ERROR_SCHEMA: SchemaObject","resultSchema: SchemaObject","proc: ProcedureInfo","method: 'get' | 'post'","operation: OperationObject","procedures: ProcedureInfo[]","options: GenerateOptions","meta: RouterMeta","paths: PathsObject","pathItem: PathItemObject","routerFilePath: string","walkCtx: WalkCtx"],"sources":["../src/schemaExtraction.ts","../src/generate.ts","../src/types.ts"],"sourcesContent":["import { pathToFileURL } from 'node:url';\nimport type {\n AnyTRPCProcedure,\n AnyTRPCRouter,\n TRPCRouterRecord,\n} from '@trpc/server';\nimport type {\n $ZodArrayDef,\n $ZodObjectDef,\n $ZodRegistry,\n $ZodShape,\n $ZodType,\n $ZodTypeDef,\n GlobalMeta,\n} from 'zod/v4/core';\nimport type { SchemaObject } from './types';\n\n/** Description strings extracted from Zod `.describe()` calls, keyed by dot-delimited property path. */\nexport interface DescriptionMap {\n /** Top-level description on the schema itself (empty-string key). */\n self?: string;\n /** Property-path → description, e.g. `\"name\"` or `\"address.street\"`. */\n properties: Map<string, string>;\n}\n\nexport interface RuntimeDescriptions {\n input: DescriptionMap | null;\n output: DescriptionMap | null;\n}\n\n// ---------------------------------------------------------------------------\n// Zod shape walking — extract .describe() strings\n// ---------------------------------------------------------------------------\n\n/**\n * Zod v4 stores `.describe()` strings in `globalThis.__zod_globalRegistry`,\n * a WeakMap-backed `$ZodRegistry<GlobalMeta>`. We access it via globalThis\n * because zod is an optional peer dependency.\n */\nfunction getZodGlobalRegistry(): $ZodRegistry<GlobalMeta> | null {\n const reg = (\n globalThis as { __zod_globalRegistry?: $ZodRegistry<GlobalMeta> }\n ).__zod_globalRegistry;\n return reg && typeof reg.get === 'function' ? reg : null;\n}\n\n/** Runtime check: does this value look like a `$ZodType` (has `_zod.def`)? */\nfunction isZodSchema(value: unknown): value is $ZodType {\n if (value == null || typeof value !== 'object') return false;\n const zod = (value as { _zod?: unknown })._zod;\n return zod != null && typeof zod === 'object' && 'def' in zod;\n}\n\n/** Get the object shape from a Zod object schema, if applicable. */\nfunction zodObjectShape(schema: $ZodType): $ZodShape | null {\n const def = schema._zod.def;\n if (def.type === 'object' && 'shape' in def) {\n return (def as $ZodObjectDef).shape;\n }\n return null;\n}\n\n/** Get the element schema from a Zod array schema, if applicable. */\nfunction zodArrayElement(schema: $ZodType): $ZodType | null {\n const def = schema._zod.def;\n if (def.type === 'array' && 'element' in def) {\n return (def as $ZodArrayDef).element;\n }\n return null;\n}\n\n/** Wrapper def types whose inner schema is accessible via `innerType` or `in`. */\nconst wrapperDefTypes: ReadonlySet<$ZodTypeDef['type']> = new Set([\n 'optional',\n 'nullable',\n 'nonoptional',\n 'default',\n 'prefault',\n 'catch',\n 'readonly',\n 'pipe',\n 'transform',\n 'promise',\n]);\n\n/**\n * Extract the wrapped inner schema from a wrapper def.\n * Most wrappers use `innerType`; `pipe` uses `in`.\n */\nfunction getWrappedInner(def: $ZodTypeDef): $ZodType | null {\n if ('innerType' in def) return (def as { innerType: $ZodType }).innerType;\n if ('in' in def) return (def as { in: $ZodType }).in;\n return null;\n}\n\n/** Unwrap wrapper types (optional, nullable, default, readonly, etc.) to get the inner schema. */\nfunction unwrapZodSchema(schema: $ZodType): $ZodType {\n let current: $ZodType = schema;\n const seen = new Set<$ZodType>();\n while (!seen.has(current)) {\n seen.add(current);\n const def = current._zod.def;\n if (!wrapperDefTypes.has(def.type)) break;\n const inner = getWrappedInner(def);\n if (!inner) break;\n current = inner;\n }\n return current;\n}\n\n/**\n * Walk a Zod schema and collect description strings at each property path.\n * Returns `null` if the value is not a Zod schema or has no descriptions.\n */\nexport function extractZodDescriptions(schema: unknown): DescriptionMap | null {\n if (!isZodSchema(schema)) return null;\n const registry = getZodGlobalRegistry();\n if (!registry) return null;\n\n const map: DescriptionMap = { properties: new Map() };\n let hasAny = false;\n\n // Check top-level description\n const topMeta = registry.get(schema);\n if (topMeta?.description) {\n map.self = topMeta.description;\n hasAny = true;\n }\n\n // Walk object shape\n walkZodShape(schema, '', { registry, map, seenLazy: new Set() });\n if (map.properties.size > 0) hasAny = true;\n\n return hasAny ? map : null;\n}\n\nfunction walkZodShape(\n schema: $ZodType,\n prefix: string,\n ctx: {\n registry: $ZodRegistry<GlobalMeta>;\n map: DescriptionMap;\n seenLazy: Set<$ZodType>;\n },\n): void {\n const unwrapped = unwrapZodSchema(schema);\n const def = unwrapped._zod.def;\n\n if (def.type === 'lazy' && 'getter' in def) {\n if (ctx.seenLazy.has(unwrapped)) {\n return;\n }\n ctx.seenLazy.add(unwrapped);\n const inner = (def as { getter: () => unknown }).getter();\n if (isZodSchema(inner)) {\n walkZodShape(inner, prefix, ctx);\n }\n return;\n }\n\n // If this is an array, check for a description on the element schema itself\n // (stored as `[]` in the path) and recurse into the element's shape.\n const element = zodArrayElement(unwrapped);\n if (element) {\n const unwrappedElement = unwrapZodSchema(element);\n const elemMeta = ctx.registry.get(element);\n const innerElemMeta =\n unwrappedElement !== element\n ? ctx.registry.get(unwrappedElement)\n : undefined;\n const elemDesc = elemMeta?.description ?? innerElemMeta?.description;\n if (elemDesc) {\n const itemsPath = prefix ? `${prefix}.[]` : '[]';\n ctx.map.properties.set(itemsPath, elemDesc);\n }\n walkZodShape(element, prefix, ctx);\n return;\n }\n\n const shape = zodObjectShape(unwrapped);\n if (!shape) return;\n\n for (const [key, fieldSchema] of Object.entries(shape)) {\n const path = prefix ? `${prefix}.${key}` : key;\n\n // Check for description on the field — may be on the wrapper or inner schema\n const meta = ctx.registry.get(fieldSchema);\n const unwrappedField = unwrapZodSchema(fieldSchema);\n const innerMeta =\n unwrappedField !== fieldSchema\n ? ctx.registry.get(unwrappedField)\n : undefined;\n const description = meta?.description ?? innerMeta?.description;\n if (description) {\n ctx.map.properties.set(path, description);\n }\n\n // Recurse into nested objects and arrays\n walkZodShape(unwrappedField, path, ctx);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Router detection & dynamic import\n// ---------------------------------------------------------------------------\n\n/** Check whether a value looks like a tRPC router instance at runtime. */\nfunction isRouterInstance(value: unknown): value is AnyTRPCRouter {\n if (value == null) return false;\n const obj = value as Record<string, unknown>;\n const def = obj['_def'];\n return (\n typeof obj === 'object' &&\n def != null &&\n typeof def === 'object' &&\n (def as Record<string, unknown>)['record'] != null &&\n typeof (def as Record<string, unknown>)['record'] === 'object'\n );\n}\n\n/**\n * Search a module's exports for a tRPC router instance.\n *\n * Tries (in order):\n * 1. Exact `exportName` match\n * 2. lcfirst variant (`AppRouter` → `appRouter`)\n * 3. First export that looks like a router\n */\nexport function findRouterExport(\n mod: Record<string, unknown>,\n exportName: string,\n): AnyTRPCRouter | null {\n // 1. Exact match\n if (isRouterInstance(mod[exportName])) {\n return mod[exportName];\n }\n\n // 2. lcfirst variant (e.g. AppRouter → appRouter)\n const lcFirst = exportName.charAt(0).toLowerCase() + exportName.slice(1);\n if (lcFirst !== exportName && isRouterInstance(mod[lcFirst])) {\n return mod[lcFirst];\n }\n\n // 3. Any export that looks like a router\n for (const value of Object.values(mod)) {\n if (isRouterInstance(value)) {\n return value;\n }\n }\n\n return null;\n}\n\n/**\n * Try to dynamically import the router file and extract a tRPC router\n * instance. Returns `null` if the import fails (e.g. no TS loader) or\n * no router export is found.\n */\nexport async function tryImportRouter(\n resolvedPath: string,\n exportName: string,\n): Promise<AnyTRPCRouter | null> {\n try {\n const mod = await import(pathToFileURL(resolvedPath).href);\n return findRouterExport(mod as Record<string, unknown>, exportName);\n } catch {\n // Dynamic import not available (no TS loader registered) — that's fine,\n // we fall back to type-checker-only schemas.\n return null;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Router walker — collect descriptions per procedure\n// ---------------------------------------------------------------------------\n\n/**\n * Walk a runtime tRPC router/record and collect Zod `.describe()` strings\n * keyed by procedure path.\n */\nexport function collectRuntimeDescriptions(\n routerOrRecord: AnyTRPCRouter | TRPCRouterRecord,\n prefix: string,\n result: Map<string, RuntimeDescriptions>,\n): void {\n // Unwrap router to its record; plain RouterRecords are used as-is.\n const record: TRPCRouterRecord = isRouterInstance(routerOrRecord)\n ? routerOrRecord._def.record\n : routerOrRecord;\n\n for (const [key, value] of Object.entries(record)) {\n const fullPath = prefix ? `${prefix}.${key}` : key;\n\n if (isProcedure(value)) {\n // Procedure — extract descriptions from input and output Zod schemas\n const def = value._def;\n let inputDescs: DescriptionMap | null = null;\n for (const input of def.inputs) {\n const descs = extractZodDescriptions(input);\n if (descs) {\n // Merge multiple .input() descriptions (last wins for conflicts)\n inputDescs ??= { properties: new Map() };\n inputDescs.self = descs.self ?? inputDescs.self;\n for (const [p, d] of descs.properties) {\n inputDescs.properties.set(p, d);\n }\n }\n }\n\n let outputDescs: DescriptionMap | null = null;\n // `output` exists at runtime on the procedure def (from the builder)\n // but is not part of the public Procedure type.\n const outputParser = (def as Record<string, unknown>)['output'];\n if (outputParser) {\n outputDescs = extractZodDescriptions(outputParser);\n }\n\n if (inputDescs || outputDescs) {\n result.set(fullPath, { input: inputDescs, output: outputDescs });\n }\n } else {\n // Sub-router or nested RouterRecord — recurse\n collectRuntimeDescriptions(value, fullPath, result);\n }\n }\n}\n\n/** Type guard: check if a RouterRecord value is a procedure (callable). */\nfunction isProcedure(\n value: AnyTRPCProcedure | TRPCRouterRecord,\n): value is AnyTRPCProcedure {\n return typeof value === 'function';\n}\n\n// ---------------------------------------------------------------------------\n// Apply descriptions to JSON schemas\n// ---------------------------------------------------------------------------\n\n/**\n * Overlay description strings from a `DescriptionMap` onto an existing\n * JSON schema produced by the TypeScript type checker. Mutates in place.\n */\nexport function applyDescriptions(\n schema: SchemaObject,\n descs: DescriptionMap,\n schemas?: Record<string, SchemaObject>,\n): void {\n if (descs.self) {\n schema.description = descs.self;\n }\n\n for (const [propPath, description] of descs.properties) {\n setNestedDescription({\n schema,\n pathParts: propPath.split('.'),\n description,\n schemas,\n });\n }\n}\n\nfunction resolveSchemaRef(\n schema: SchemaObject,\n schemas?: Record<string, SchemaObject>,\n): SchemaObject | null {\n const ref = schema.$ref;\n if (!ref) {\n return schema;\n }\n if (!schemas || !ref.startsWith('#/components/schemas/')) {\n return null;\n }\n\n const refName = ref.slice('#/components/schemas/'.length);\n return refName ? (schemas[refName] ?? null) : null;\n}\n\nfunction getArrayItemsSchema(schema: SchemaObject): SchemaObject | null {\n const items = schema.items;\n if (schema.type !== 'array' || items == null || items === false) {\n return null;\n }\n return items;\n}\n\nfunction getPropertySchema(\n schema: SchemaObject,\n propertyName: string,\n): SchemaObject | null {\n return schema.properties?.[propertyName] ?? null;\n}\n\nfunction setLeafDescription(schema: SchemaObject, description: string): void {\n if (schema.$ref) {\n const ref = schema.$ref;\n delete schema.$ref;\n schema.allOf = [{ $ref: ref }, ...(schema.allOf ?? [])];\n }\n schema.description = description;\n}\n\nfunction setNestedDescription({\n schema,\n pathParts,\n description,\n schemas,\n}: {\n schema: SchemaObject;\n pathParts: string[];\n description: string;\n schemas?: Record<string, SchemaObject>;\n}): void {\n if (pathParts.length === 0) return;\n\n const [head, ...rest] = pathParts;\n if (!head) return;\n\n // `[]` means \"array items\" — navigate to the `items` sub-schema\n if (head === '[]') {\n const items = getArrayItemsSchema(schema);\n if (!items) return;\n if (rest.length === 0) {\n setLeafDescription(items, description);\n } else {\n const target = resolveSchemaRef(items, schemas) ?? items;\n setNestedDescription({\n schema: target,\n pathParts: rest,\n description,\n schemas,\n });\n }\n return;\n }\n\n const propSchema = getPropertySchema(schema, head);\n if (!propSchema) return;\n\n if (rest.length === 0) {\n // Leaf — Zod .describe() takes priority over JSDoc\n setLeafDescription(propSchema, description);\n } else {\n // For arrays, step through `items` transparently\n const target = getArrayItemsSchema(propSchema) ?? propSchema;\n const resolvedTarget = resolveSchemaRef(target, schemas) ?? target;\n setNestedDescription({\n schema: resolvedTarget,\n pathParts: rest,\n description,\n schemas,\n });\n }\n}\n","import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as ts from 'typescript';\nimport {\n applyDescriptions,\n collectRuntimeDescriptions,\n tryImportRouter,\n type RuntimeDescriptions,\n} from './schemaExtraction';\nimport type {\n Document,\n OperationObject,\n PathItemObject,\n PathsObject,\n SchemaObject,\n} from './types';\n\ninterface ProcedureInfo {\n path: string;\n type: 'query' | 'mutation' | 'subscription';\n inputSchema: SchemaObject | null;\n outputSchema: SchemaObject | null;\n description?: string;\n}\n\n/** State extracted from the router's root config. */\ninterface RouterMeta {\n errorSchema: SchemaObject | null;\n schemas?: Record<string, SchemaObject>;\n}\n\nexport interface GenerateOptions {\n /**\n * The name of the exported router symbol.\n * @default 'AppRouter'\n */\n exportName?: string;\n /** Title for the generated OpenAPI `info` object. */\n title?: string;\n /** Version string for the generated OpenAPI `info` object. */\n version?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Flag helpers\n// ---------------------------------------------------------------------------\n\nconst PRIMITIVE_FLAGS =\n ts.TypeFlags.String |\n ts.TypeFlags.Number |\n ts.TypeFlags.Boolean |\n ts.TypeFlags.StringLiteral |\n ts.TypeFlags.NumberLiteral |\n ts.TypeFlags.BooleanLiteral;\n\nfunction hasFlag(type: ts.Type, flag: ts.TypeFlags): boolean {\n return (type.getFlags() & flag) !== 0;\n}\n\nfunction isPrimitive(type: ts.Type): boolean {\n return hasFlag(type, PRIMITIVE_FLAGS);\n}\n\nfunction isObjectType(type: ts.Type): boolean {\n return hasFlag(type, ts.TypeFlags.Object);\n}\n\nfunction isOptionalSymbol(sym: ts.Symbol): boolean {\n return (sym.flags & ts.SymbolFlags.Optional) !== 0;\n}\n\n// ---------------------------------------------------------------------------\n// JSON Schema conversion — shared state\n// ---------------------------------------------------------------------------\n\n/** Shared state threaded through the type-to-schema recursion. */\ninterface SchemaCtx {\n checker: ts.TypeChecker;\n visited: Set<ts.Type>;\n /** Collected named schemas for components/schemas. */\n schemas: Record<string, SchemaObject>;\n /** Map from TS type identity to its registered schema name. */\n typeToRef: Map<ts.Type, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Brand unwrapping\n// ---------------------------------------------------------------------------\n\n/**\n * If `type` is a branded intersection (primitive & object), return just the\n * primitive part. Otherwise return the type as-is.\n */\nfunction unwrapBrand(type: ts.Type): ts.Type {\n if (!type.isIntersection()) {\n return type;\n }\n const primitives = type.types.filter(isPrimitive);\n const hasObject = type.types.some(isObjectType);\n const [first] = primitives;\n if (first && hasObject) {\n return first;\n }\n return type;\n}\n\n// ---------------------------------------------------------------------------\n// Schema naming helpers\n// ---------------------------------------------------------------------------\n\nconst ANONYMOUS_NAMES = new Set(['__type', '__object', 'Object', '']);\nconst INTERNAL_COMPUTED_PROPERTY_SYMBOL = /^__@.*@\\d+$/;\n\n/** Try to determine a meaningful name for a TS type (type alias or interface). */\nfunction getTypeName(type: ts.Type): string | null {\n const aliasName = type.aliasSymbol?.getName();\n if (aliasName && !ANONYMOUS_NAMES.has(aliasName)) {\n return aliasName;\n }\n const symName = type.getSymbol()?.getName();\n if (symName && !ANONYMOUS_NAMES.has(symName) && !symName.startsWith('__')) {\n return symName;\n }\n return null;\n}\n\n// Skips asyncGenerator and branded symbols etc when creating types\n// Symbols can't be serialised\nfunction shouldSkipPropertySymbol(prop: ts.Symbol): boolean {\n return (\n prop.declarations?.some((declaration) => {\n const declarationName = ts.getNameOfDeclaration(declaration);\n if (!declarationName || !ts.isComputedPropertyName(declarationName)) {\n return false;\n }\n\n return INTERNAL_COMPUTED_PROPERTY_SYMBOL.test(prop.getName());\n }) ?? false\n );\n}\n\nfunction getReferencedSchema(\n schema: SchemaObject | null,\n schemas: Record<string, SchemaObject>,\n): SchemaObject | null {\n const ref = schema?.$ref;\n if (!ref?.startsWith('#/components/schemas/')) {\n return schema;\n }\n\n const refName = ref.slice('#/components/schemas/'.length);\n return refName ? (schemas[refName] ?? null) : schema;\n}\n\nfunction ensureUniqueName(\n name: string,\n existing: Record<string, unknown>,\n): string {\n if (!(name in existing)) {\n return name;\n }\n let i = 2;\n while (`${name}${i}` in existing) {\n i++;\n }\n return `${name}${i}`;\n}\n\nfunction schemaRef(name: string): SchemaObject {\n return { $ref: `#/components/schemas/${name}` };\n}\n\nfunction isSelfSchemaRef(schema: SchemaObject, name: string): boolean {\n return schema.$ref === schemaRef(name).$ref;\n}\n\nfunction isNonEmptySchema(s: SchemaObject): boolean {\n for (const _ in s) return true;\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Type → JSON Schema (with component extraction)\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a TS type to a JSON Schema. If the type has been pre-registered\n * (or has a meaningful TS name), it is stored in `ctx.schemas` and a `$ref`\n * is returned instead of an inline schema.\n *\n * Named types (type aliases, interfaces) are auto-registered before conversion\n * so that recursive references (including through unions and intersections)\n * resolve to a `$ref` instead of causing infinite recursion.\n */\nfunction typeToJsonSchema(\n type: ts.Type,\n ctx: SchemaCtx,\n depth = 0,\n): SchemaObject {\n // If this type is already registered as a named schema, return a $ref.\n const existingRef = ctx.typeToRef.get(type);\n if (existingRef) {\n const storedSchema = ctx.schemas[existingRef];\n if (\n storedSchema &&\n (isNonEmptySchema(storedSchema) || ctx.visited.has(type))\n ) {\n return schemaRef(existingRef);\n }\n\n // First encounter for a pre-registered placeholder: convert once, but keep\n // returning $ref for recursive edges while the type is actively visiting.\n ctx.schemas[existingRef] = storedSchema ?? {};\n const schema = convertTypeToSchema(type, ctx, depth);\n if (!isSelfSchemaRef(schema, existingRef)) {\n ctx.schemas[existingRef] = schema;\n }\n return schemaRef(existingRef);\n }\n\n const schema = convertTypeToSchema(type, ctx, depth);\n\n // If a recursive reference was detected during conversion (via handleCyclicRef\n // or convertPlainObject's auto-registration), the type is now registered in\n // typeToRef. If the stored schema is still the empty placeholder, fill it in\n // with the actual converted schema. Either way, return a $ref.\n const postConvertRef = ctx.typeToRef.get(type);\n if (postConvertRef) {\n const stored = ctx.schemas[postConvertRef];\n if (\n stored &&\n !isNonEmptySchema(stored) &&\n !isSelfSchemaRef(schema, postConvertRef)\n ) {\n ctx.schemas[postConvertRef] = schema;\n }\n return schemaRef(postConvertRef);\n }\n\n // Extract JSDoc from type alias symbol (e.g. `/** desc */ type Foo = string`)\n if (!schema.description && !schema.$ref && type.aliasSymbol) {\n const aliasJsDoc = getJsDocComment(type.aliasSymbol, ctx.checker);\n if (aliasJsDoc) {\n schema.description = aliasJsDoc;\n }\n }\n\n return schema;\n}\n\n// ---------------------------------------------------------------------------\n// Cyclic reference handling\n// ---------------------------------------------------------------------------\n\n/**\n * When we encounter a type we're already visiting, it's recursive.\n * Register it as a named schema and return a $ref.\n */\nfunction handleCyclicRef(type: ts.Type, ctx: SchemaCtx): SchemaObject {\n let refName = ctx.typeToRef.get(type);\n if (!refName) {\n const name = getTypeName(type) ?? 'RecursiveType';\n refName = ensureUniqueName(name, ctx.schemas);\n ctx.typeToRef.set(type, refName);\n ctx.schemas[refName] = {}; // placeholder — filled by the outer call\n }\n return schemaRef(refName);\n}\n\n// ---------------------------------------------------------------------------\n// Primitive & literal type conversion\n// ---------------------------------------------------------------------------\n\nfunction convertPrimitiveOrLiteral(\n type: ts.Type,\n flags: ts.TypeFlags,\n checker: ts.TypeChecker,\n): SchemaObject | null {\n if (flags & ts.TypeFlags.String) {\n return { type: 'string' };\n }\n if (flags & ts.TypeFlags.Number) {\n return { type: 'number' };\n }\n if (flags & ts.TypeFlags.Boolean) {\n return { type: 'boolean' };\n }\n if (flags & ts.TypeFlags.Null) {\n return { type: 'null' };\n }\n if (flags & ts.TypeFlags.Undefined) {\n return {};\n }\n if (flags & ts.TypeFlags.Void) {\n return {};\n }\n if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) {\n return {};\n }\n if (flags & ts.TypeFlags.Never) {\n return { not: {} };\n }\n if (flags & ts.TypeFlags.BigInt || flags & ts.TypeFlags.BigIntLiteral) {\n return { type: 'integer', format: 'bigint' };\n }\n\n if (flags & ts.TypeFlags.StringLiteral) {\n return { type: 'string', const: (type as ts.StringLiteralType).value };\n }\n if (flags & ts.TypeFlags.NumberLiteral) {\n return { type: 'number', const: (type as ts.NumberLiteralType).value };\n }\n if (flags & ts.TypeFlags.BooleanLiteral) {\n const isTrue = checker.typeToString(type) === 'true';\n return { type: 'boolean', const: isTrue };\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Union type conversion\n// ---------------------------------------------------------------------------\n\nfunction convertUnionType(\n type: ts.UnionType,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject {\n const members = type.types;\n\n // Strip undefined / void members (they make the field optional, not typed)\n const defined = members.filter(\n (m) => !hasFlag(m, ts.TypeFlags.Undefined | ts.TypeFlags.Void),\n );\n if (defined.length === 0) {\n return {};\n }\n\n const hasNull = defined.some((m) => hasFlag(m, ts.TypeFlags.Null));\n const nonNull = defined.filter((m) => !hasFlag(m, ts.TypeFlags.Null));\n\n // TypeScript represents `boolean` as `true | false`. Collapse boolean\n // literal pairs back into a single boolean, even when mixed with other types.\n // e.g. `string | true | false` → treat as `string | boolean`\n const boolLiterals = nonNull.filter((m) =>\n hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral),\n );\n const hasBoolPair =\n boolLiterals.length === 2 &&\n boolLiterals.some(\n (m) => ctx.checker.typeToString(unwrapBrand(m)) === 'true',\n ) &&\n boolLiterals.some(\n (m) => ctx.checker.typeToString(unwrapBrand(m)) === 'false',\n );\n\n // Build the effective non-null members, collapsing boolean literal pairs\n const effective = hasBoolPair\n ? nonNull.filter(\n (m) => !hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral),\n )\n : nonNull;\n\n // Pure boolean (or boolean | null) — no other types\n if (hasBoolPair && effective.length === 0) {\n return hasNull ? { type: ['boolean', 'null'] } : { type: 'boolean' };\n }\n\n // Collapse unions of same-type literals into a single `enum` array.\n // e.g. \"FOO\" | \"BAR\" → { type: \"string\", enum: [\"FOO\", \"BAR\"] }\n const collapsedEnum = tryCollapseLiteralUnion(effective, hasNull);\n if (collapsedEnum) {\n return collapsedEnum;\n }\n\n const schemas = effective\n .map((m) => typeToJsonSchema(m, ctx, depth + 1))\n .filter(isNonEmptySchema);\n\n // Re-inject the collapsed boolean\n if (hasBoolPair) {\n schemas.push({ type: 'boolean' });\n }\n\n if (hasNull) {\n schemas.push({ type: 'null' });\n }\n\n if (schemas.length === 0) {\n return {};\n }\n\n const [firstSchema] = schemas;\n if (schemas.length === 1 && firstSchema !== undefined) {\n return firstSchema;\n }\n\n // When all schemas are simple type-only schemas (no other properties),\n // collapse into a single `type` array. e.g. string | null → type: [\"string\", \"null\"]\n if (schemas.every(isSimpleTypeSchema)) {\n return { type: schemas.map((s) => s.type as string) };\n }\n\n // Detect discriminated unions: all oneOf members are objects sharing a common\n // required property whose value is a `const`. If found, add a `discriminator`.\n const discriminatorProp = detectDiscriminatorProperty(schemas);\n if (discriminatorProp) {\n return {\n oneOf: schemas,\n discriminator: { propertyName: discriminatorProp },\n };\n }\n\n return { oneOf: schemas };\n}\n\n/**\n * If every schema in a oneOf is an object with a common required property\n * whose value is a `const`, return that property name. Otherwise return null.\n */\nfunction detectDiscriminatorProperty(schemas: SchemaObject[]): string | null {\n if (schemas.length < 2) {\n return null;\n }\n\n // All schemas must be object types with properties\n if (!schemas.every((s) => s.type === 'object' && s.properties)) {\n return null;\n }\n\n // Find properties that exist in every schema, are required, and have a `const` value\n const first = schemas[0];\n if (!first?.properties) {\n return null;\n }\n const firstProps = Object.keys(first.properties);\n for (const prop of firstProps) {\n const allHaveConst = schemas.every((s) => {\n const propSchema = s.properties?.[prop];\n return (\n propSchema !== undefined &&\n propSchema.const !== undefined &&\n s.required?.includes(prop)\n );\n });\n if (allHaveConst) {\n return prop;\n }\n }\n\n return null;\n}\n\n/** A schema that is just `{ type: \"somePrimitive\" }` with no other keys. */\nfunction isSimpleTypeSchema(s: SchemaObject): boolean {\n const keys = Object.keys(s);\n return keys.length === 1 && keys[0] === 'type' && typeof s.type === 'string';\n}\n\n/**\n * If every non-null member is a string or number literal of the same kind,\n * collapse them into a single `{ type, enum }` schema.\n */\nfunction tryCollapseLiteralUnion(\n nonNull: ts.Type[],\n hasNull: boolean,\n): SchemaObject | null {\n if (nonNull.length <= 1) {\n return null;\n }\n\n const allLiterals = nonNull.every((m) =>\n hasFlag(m, ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral),\n );\n if (!allLiterals) {\n return null;\n }\n\n const [first] = nonNull;\n if (!first) {\n return null;\n }\n\n const isString = hasFlag(first, ts.TypeFlags.StringLiteral);\n const targetFlag = isString\n ? ts.TypeFlags.StringLiteral\n : ts.TypeFlags.NumberLiteral;\n const allSameKind = nonNull.every((m) => hasFlag(m, targetFlag));\n if (!allSameKind) {\n return null;\n }\n\n const values = nonNull.map((m) =>\n isString\n ? (m as ts.StringLiteralType).value\n : (m as ts.NumberLiteralType).value,\n );\n const baseType = isString ? 'string' : 'number';\n return {\n type: hasNull ? [baseType, 'null'] : baseType,\n enum: values,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Intersection type conversion\n// ---------------------------------------------------------------------------\n\nfunction convertIntersectionType(\n type: ts.IntersectionType,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject {\n // Branded types (e.g. z.string().brand<'X'>()) appear as an intersection of\n // a primitive with a phantom object. Strip the object members — they are\n // always brand metadata.\n const hasPrimitiveMember = type.types.some(isPrimitive);\n const nonBrand = hasPrimitiveMember\n ? type.types.filter((m) => !isObjectType(m))\n : type.types;\n\n const schemas = nonBrand\n .map((m) => typeToJsonSchema(m, ctx, depth + 1))\n .filter(isNonEmptySchema);\n\n if (schemas.length === 0) {\n return {};\n }\n const [onlySchema] = schemas;\n if (schemas.length === 1 && onlySchema !== undefined) {\n return onlySchema;\n }\n\n // When all members are plain inline object schemas (no $ref), merge them\n // into a single object instead of wrapping in allOf.\n if (schemas.every(isInlineObjectSchema)) {\n return mergeObjectSchemas(schemas);\n }\n\n return { allOf: schemas };\n}\n\n/** True when the schema is an inline `{ type: \"object\", ... }` (not a $ref). */\nfunction isInlineObjectSchema(s: SchemaObject): boolean {\n return s.type === 'object' && !s.$ref;\n}\n\n/**\n * Merge multiple `{ type: \"object\" }` schemas into one.\n * Falls back to `allOf` if any property names conflict across schemas.\n */\nfunction mergeObjectSchemas(schemas: SchemaObject[]): SchemaObject {\n // Check for property name conflicts before merging.\n const seen = new Set<string>();\n for (const s of schemas) {\n if (s.properties) {\n for (const prop of Object.keys(s.properties)) {\n if (seen.has(prop)) {\n // Conflicting property — fall back to allOf to preserve both definitions.\n return { allOf: schemas };\n }\n seen.add(prop);\n }\n }\n }\n\n const properties: Record<string, SchemaObject> = {};\n const required: string[] = [];\n let additionalProperties: SchemaObject | boolean | undefined;\n\n for (const s of schemas) {\n if (s.properties) {\n Object.assign(properties, s.properties);\n }\n if (s.required) {\n required.push(...s.required);\n }\n if (s.additionalProperties !== undefined) {\n additionalProperties = s.additionalProperties;\n }\n }\n\n const result: SchemaObject = { type: 'object' };\n if (Object.keys(properties).length > 0) {\n result.properties = properties;\n }\n if (required.length > 0) {\n result.required = required;\n }\n if (additionalProperties !== undefined) {\n result.additionalProperties = additionalProperties;\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Object type conversion\n// ---------------------------------------------------------------------------\n\nfunction convertWellKnownType(\n type: ts.Type,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject | null {\n const symName = type.getSymbol()?.getName();\n if (symName === 'Date') {\n return { type: 'string', format: 'date-time' };\n }\n if (symName === 'Uint8Array' || symName === 'Buffer') {\n return { type: 'string', format: 'binary' };\n }\n\n // Unwrap Promise<T>\n if (symName === 'Promise') {\n const [inner] = ctx.checker.getTypeArguments(type as ts.TypeReference);\n return inner ? typeToJsonSchema(inner, ctx, depth + 1) : {};\n }\n\n return null;\n}\n\nfunction convertArrayType(\n type: ts.Type,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject {\n const [elem] = ctx.checker.getTypeArguments(type as ts.TypeReference);\n const schema: SchemaObject = { type: 'array' };\n if (elem) {\n schema.items = typeToJsonSchema(elem, ctx, depth + 1);\n }\n return schema;\n}\n\nfunction convertTupleType(\n type: ts.Type,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject {\n const args = ctx.checker.getTypeArguments(type as ts.TypeReference);\n const schemas = args.map((a) => typeToJsonSchema(a, ctx, depth + 1));\n return {\n type: 'array',\n prefixItems: schemas,\n items: false,\n minItems: args.length,\n maxItems: args.length,\n };\n}\n\nfunction convertPlainObject(\n type: ts.Type,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject {\n const { checker } = ctx;\n const stringIndexType = type.getStringIndexType();\n const typeProps = type.getProperties();\n\n // Pure index-signature Record type (no named props)\n if (typeProps.length === 0 && stringIndexType) {\n return {\n type: 'object',\n additionalProperties: typeToJsonSchema(stringIndexType, ctx, depth + 1),\n };\n }\n\n // Auto-register types with a meaningful TS name BEFORE converting\n // properties, so that circular or shared refs discovered during recursion\n // resolve to a $ref via the `typeToJsonSchema` wrapper.\n let autoRegName: string | null = null;\n const tsName = getTypeName(type);\n const isNamedUnregisteredType =\n tsName !== null && typeProps.length > 0 && !ctx.typeToRef.has(type);\n if (isNamedUnregisteredType) {\n autoRegName = ensureUniqueName(tsName, ctx.schemas);\n ctx.typeToRef.set(type, autoRegName);\n ctx.schemas[autoRegName] = {}; // placeholder for circular ref guard\n }\n\n ctx.visited.add(type);\n const properties: Record<string, SchemaObject> = {};\n const required: string[] = [];\n\n for (const prop of typeProps) {\n if (shouldSkipPropertySymbol(prop)) {\n continue;\n }\n\n const propType = checker.getTypeOfSymbol(prop);\n const propSchema = typeToJsonSchema(propType, ctx, depth + 1);\n\n // Extract JSDoc comment from the property symbol as a description\n const jsDoc = getJsDocComment(prop, checker);\n if (jsDoc && !propSchema.description && !propSchema.$ref) {\n propSchema.description = jsDoc;\n }\n\n properties[prop.name] = propSchema;\n if (!isOptionalSymbol(prop)) {\n required.push(prop.name);\n }\n }\n\n ctx.visited.delete(type);\n\n const result: SchemaObject = { type: 'object' };\n if (Object.keys(properties).length > 0) {\n result.properties = properties;\n }\n if (required.length > 0) {\n result.required = required;\n }\n if (stringIndexType) {\n result.additionalProperties = typeToJsonSchema(\n stringIndexType,\n ctx,\n depth + 1,\n );\n } else if (Object.keys(properties).length > 0) {\n result.additionalProperties = false;\n }\n\n // autoRegName covers named types (early-registered). For anonymous\n // recursive types, a recursive call may have registered this type during\n // property conversion — check typeToRef as a fallback.\n const registeredName = autoRegName ?? ctx.typeToRef.get(type);\n if (registeredName) {\n ctx.schemas[registeredName] = result;\n return schemaRef(registeredName);\n }\n\n return result;\n}\n\nfunction convertObjectType(\n type: ts.Type,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject {\n const wellKnown = convertWellKnownType(type, ctx, depth);\n if (wellKnown) {\n return wellKnown;\n }\n\n if (ctx.checker.isArrayType(type)) {\n return convertArrayType(type, ctx, depth);\n }\n if (ctx.checker.isTupleType(type)) {\n return convertTupleType(type, ctx, depth);\n }\n\n return convertPlainObject(type, ctx, depth);\n}\n\n// ---------------------------------------------------------------------------\n// Core dispatcher\n// ---------------------------------------------------------------------------\n\n/** Core type-to-schema conversion (no ref handling). */\nfunction convertTypeToSchema(\n type: ts.Type,\n ctx: SchemaCtx,\n depth: number,\n): SchemaObject {\n if (ctx.visited.has(type)) {\n return handleCyclicRef(type, ctx);\n }\n\n const flags = type.getFlags();\n\n const primitive = convertPrimitiveOrLiteral(type, flags, ctx.checker);\n if (primitive) {\n return primitive;\n }\n\n if (type.isUnion()) {\n ctx.visited.add(type);\n const result = convertUnionType(type, ctx, depth);\n ctx.visited.delete(type);\n return result;\n }\n if (type.isIntersection()) {\n ctx.visited.add(type);\n const result = convertIntersectionType(type, ctx, depth);\n ctx.visited.delete(type);\n return result;\n }\n if (isObjectType(type)) {\n return convertObjectType(type, ctx, depth);\n }\n\n return {};\n}\n\n// ---------------------------------------------------------------------------\n// Router / procedure type walker\n// ---------------------------------------------------------------------------\n\n/** State shared across the router-walk recursion. */\ninterface WalkCtx {\n procedures: ProcedureInfo[];\n seen: Set<ts.Type>;\n schemaCtx: SchemaCtx;\n /** Runtime descriptions keyed by procedure path (when a router instance is available). */\n runtimeDescriptions: Map<string, RuntimeDescriptions>;\n}\n\n/**\n * Inspect `_def.type` and return the procedure type string, or null if this is\n * not a procedure (e.g. a nested router).\n */\nfunction getProcedureTypeName(\n defType: ts.Type,\n checker: ts.TypeChecker,\n): ProcedureInfo['type'] | null {\n const typeSym = defType.getProperty('type');\n if (!typeSym) {\n return null;\n }\n const typeType = checker.getTypeOfSymbol(typeSym);\n const raw = checker.typeToString(typeType).replace(/['\"]/g, '');\n if (raw === 'query' || raw === 'mutation' || raw === 'subscription') {\n return raw;\n }\n return null;\n}\n\nfunction isVoidLikeInput(inputType: ts.Type | null): boolean {\n if (!inputType) {\n return true;\n }\n\n const isVoidOrUndefinedOrNever = hasFlag(\n inputType,\n ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never,\n );\n if (isVoidOrUndefinedOrNever) {\n return true;\n }\n\n const isUnionOfVoids =\n inputType.isUnion() &&\n inputType.types.every((t) =>\n hasFlag(t, ts.TypeFlags.Void | ts.TypeFlags.Undefined),\n );\n return isUnionOfVoids;\n}\n\ninterface ProcedureDef {\n defType: ts.Type;\n typeName: string;\n path: string;\n description?: string;\n symbol: ts.Symbol;\n}\n\nfunction shouldIncludeProcedureInOpenAPI(type: ProcedureInfo['type']): boolean {\n return type !== 'subscription';\n}\n\nfunction getProcedureInputTypeName(type: ts.Type, path: string): string {\n const directName = getTypeName(type);\n if (directName) {\n return directName;\n }\n\n for (const sym of [type.aliasSymbol, type.getSymbol()].filter(\n (candidate): candidate is ts.Symbol => !!candidate,\n )) {\n for (const declaration of sym.declarations ?? []) {\n const declarationName = ts.getNameOfDeclaration(declaration)?.getText();\n if (\n declarationName &&\n !ANONYMOUS_NAMES.has(declarationName) &&\n !declarationName.startsWith('__')\n ) {\n return declarationName;\n }\n }\n }\n\n const fallbackName = path\n .split('.')\n .filter(Boolean)\n .map((segment) =>\n segment\n .split(/[^A-Za-z0-9]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(''),\n )\n .join('');\n\n return `${fallbackName || 'Procedure'}Input`;\n}\n\nfunction isUnknownLikeType(type: ts.Type): boolean {\n return hasFlag(type, ts.TypeFlags.Unknown | ts.TypeFlags.Any);\n}\n\nfunction isCollapsedProcedureInputType(type: ts.Type): boolean {\n return (\n isUnknownLikeType(type) ||\n (isObjectType(type) &&\n type.getProperties().length === 0 &&\n !type.getStringIndexType())\n );\n}\n\nfunction recoverProcedureInputType(\n def: ProcedureDef,\n checker: ts.TypeChecker,\n): ts.Type | null {\n let initializer: ts.Expression | null = null;\n for (const declaration of def.symbol.declarations ?? []) {\n if (ts.isPropertyAssignment(declaration)) {\n initializer = declaration.initializer;\n break;\n }\n if (ts.isVariableDeclaration(declaration) && declaration.initializer) {\n initializer = declaration.initializer;\n break;\n }\n }\n if (!initializer) {\n return null;\n }\n\n let recovered: ts.Type | null = null;\n // Walk the builder chain and keep the last `.input(...)` parser output type.\n const visit = (expr: ts.Expression): void => {\n if (!ts.isCallExpression(expr)) {\n return;\n }\n\n const callee = expr.expression;\n if (!ts.isPropertyAccessExpression(callee)) {\n return;\n }\n\n visit(callee.expression);\n if (callee.name.text !== 'input') {\n return;\n }\n\n const [parserExpr] = expr.arguments;\n if (!parserExpr) {\n return;\n }\n\n const parserType = checker.getTypeAtLocation(parserExpr);\n const standardSym = parserType.getProperty('~standard');\n if (!standardSym) {\n return;\n }\n\n const standardType = checker.getTypeOfSymbolAtLocation(\n standardSym,\n parserExpr,\n );\n const typesSym = standardType.getProperty('types');\n if (!typesSym) {\n return;\n }\n\n const typesType = checker.getNonNullableType(\n checker.getTypeOfSymbolAtLocation(typesSym, parserExpr),\n );\n const outputSym = typesType.getProperty('output');\n if (!outputSym) {\n return;\n }\n\n const outputType = checker.getTypeOfSymbolAtLocation(outputSym, parserExpr);\n if (!isUnknownLikeType(outputType)) {\n recovered = outputType;\n }\n };\n visit(initializer);\n\n return recovered;\n}\n\nfunction extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {\n const { schemaCtx } = ctx;\n const { checker } = schemaCtx;\n\n const $typesSym = def.defType.getProperty('$types');\n if (!$typesSym) {\n return;\n }\n const $typesType = checker.getTypeOfSymbol($typesSym);\n\n const inputSym = $typesType.getProperty('input');\n const outputSym = $typesType.getProperty('output');\n\n const inputType = inputSym ? checker.getTypeOfSymbol(inputSym) : null;\n const outputType = outputSym ? checker.getTypeOfSymbol(outputSym) : null;\n const resolvedInputType =\n inputType && isCollapsedProcedureInputType(inputType)\n ? (recoverProcedureInputType(def, checker) ?? inputType)\n : inputType;\n\n let inputSchema: SchemaObject | null = null;\n if (!resolvedInputType || isVoidLikeInput(resolvedInputType)) {\n // null is fine\n } else {\n // Pre-register recovered parser output types so recursive edges resolve to a\n // stable component ref instead of collapsing into `{}`.\n const ensureRecoveredInputRegistration = (type: ts.Type): void => {\n if (schemaCtx.typeToRef.has(type)) {\n return;\n }\n\n const refName = ensureUniqueName(\n getProcedureInputTypeName(type, def.path),\n schemaCtx.schemas,\n );\n schemaCtx.typeToRef.set(type, refName);\n schemaCtx.schemas[refName] = {};\n };\n\n if (resolvedInputType !== inputType) {\n ensureRecoveredInputRegistration(resolvedInputType);\n }\n\n const initialSchema = typeToJsonSchema(resolvedInputType, schemaCtx);\n if (\n !isNonEmptySchema(initialSchema) &&\n !schemaCtx.typeToRef.has(resolvedInputType)\n ) {\n ensureRecoveredInputRegistration(resolvedInputType);\n inputSchema = typeToJsonSchema(resolvedInputType, schemaCtx);\n } else {\n inputSchema = initialSchema;\n }\n }\n\n const outputSchema: SchemaObject | null = outputType\n ? typeToJsonSchema(outputType, schemaCtx)\n : null;\n\n // Overlay extracted schema descriptions onto the type-checker-generated schemas.\n const runtimeDescs = ctx.runtimeDescriptions.get(def.path);\n if (runtimeDescs) {\n const resolvedInputSchema = getReferencedSchema(\n inputSchema,\n schemaCtx.schemas,\n );\n const resolvedOutputSchema = getReferencedSchema(\n outputSchema,\n schemaCtx.schemas,\n );\n\n if (resolvedInputSchema && runtimeDescs.input) {\n applyDescriptions(\n resolvedInputSchema,\n runtimeDescs.input,\n schemaCtx.schemas,\n );\n }\n if (resolvedOutputSchema && runtimeDescs.output) {\n applyDescriptions(\n resolvedOutputSchema,\n runtimeDescs.output,\n schemaCtx.schemas,\n );\n }\n }\n\n ctx.procedures.push({\n path: def.path,\n type: def.typeName as 'query' | 'mutation' | 'subscription',\n inputSchema,\n outputSchema,\n description: def.description,\n });\n}\n\n/** Extract the JSDoc comment text from a symbol, if any. */\nfunction getJsDocComment(\n sym: ts.Symbol,\n checker: ts.TypeChecker,\n): string | undefined {\n const isWithinPath = (candidate: string, parent: string): boolean => {\n const rel = path.relative(parent, candidate);\n return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);\n };\n\n const normalize = (filePath: string): string => filePath.replace(/\\\\/g, '/');\n const workspaceRoot = normalize(process.cwd());\n\n const declarations = sym.declarations ?? [];\n const isExternalNodeModulesDeclaration =\n declarations.length > 0 &&\n declarations.every((declaration) => {\n const sourceFile = declaration.getSourceFile();\n if (!sourceFile.isDeclarationFile) {\n return false;\n }\n\n const declarationPath = normalize(sourceFile.fileName);\n if (!declarationPath.includes('/node_modules/')) {\n return false;\n }\n\n try {\n const realPath = normalize(fs.realpathSync.native(sourceFile.fileName));\n // Keep JSDoc for workspace packages linked into node_modules\n // (e.g. monorepos using pnpm/yarn workspaces).\n if (\n isWithinPath(realPath, workspaceRoot) &&\n !realPath.includes('/node_modules/')\n ) {\n return false;\n }\n } catch {\n // Fall back to treating the declaration as external.\n }\n\n return true;\n });\n if (isExternalNodeModulesDeclaration) {\n return undefined;\n }\n\n const p