gen-config-catalog.ts 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  1. /**
  2. * Generate (and verify) the plugin config catalog in docs/config-catalog.md.
  3. *
  4. * The page is the DEPLOYMENT-axis reference: for every harness package a
  5. * `cordis.yml` entry can load, the exact config surface its `apply` function or
  6. * service constructor receives — pasted VERBATIM from source (the `export
  7. * interface Config` declaration with its JSDoc), plus resolved links for every
  8. * type the declaration references. It complements the wiring-axis cordis
  9. * catalogs (events + services, what a plugin AUTHOR listens to and calls) the
  10. * same way the tool catalog complements them for the model-facing axis.
  11. *
  12. * The catalog is FULLY GENERATED from source — never hand-edit it. Like the
  13. * cordis catalog (and unlike the tool catalog, which must boot plugins), this
  14. * is a pure-AST pass: every config type is a static declaration and every
  15. * schemastery schema is a static `z.object`/`z.intersect` literal, so
  16. * generation cannot drift and a regenerate-and-diff freshness check (`--check`)
  17. * gates staleness. Because generation enumerates every package under
  18. * `packages/<group>/<pkg>`, a brand-new plugin cannot be silently
  19. * undocumented: it must classify as configurable, config-free, seam, or
  20. * library, and an unclassifiable entry hard-errors the generator.
  21. *
  22. * `tsx scripts/gen-config-catalog.ts` → write the catalog
  23. * `tsx scripts/gen-config-catalog.ts --check` → exit 1 if the committed
  24. * catalog is stale (CI /
  25. * pre-push gate)
  26. *
  27. * What the walk enforces (aggregated into one error, like the sibling
  28. * generators):
  29. *
  30. * - CLASSIFICATION is total. Every package entry resolves, mirroring the
  31. * cordis Loader's `unwrapExports` (`exports.default ?? exports`), to a
  32. * loadable plugin (default class / `apply` function), an abstract seam
  33. * class, or a plain library. Anything else is an error, not a skip.
  34. * - The CONFIG TYPE is the declared type of the plugin's second parameter
  35. * (`apply(ctx, config)` / `constructor(ctx, config)`) — the type cordis
  36. * actually passes — and it must resolve to a declaration inside the owning
  37. * package (entry file or a package-local relative import).
  38. * - Every property of a pasted declaration carries non-empty JSDoc prose: the
  39. * paste IS the documentation, so an undocumented field is a gate failure,
  40. * the same forcing function the events catalog applies via `@mode`.
  41. * - Every type NAME a pasted declaration references resolves: pasted
  42. * transitively when package-local, linked when it is another plugin's
  43. * config type / a core-data-structures entry / a workspace or external
  44. * import. An unresolvable name is an error, and so is a NAME COLLISION —
  45. * two distinct declarations, or a declaration and an import, sharing one
  46. * name across the closure (a verbatim fence has a single flat namespace) —
  47. * never a silent skip.
  48. * - The runtime schemastery schema (`Config` export or `static Config`),
  49. * when present, is walked statically — `z.object` keys, nested object/array
  50. * compositions as key PATHS (`agents[].id`), and `z.intersect` composition
  51. * across packages — and every schema-validated key path must be locatable
  52. * on the declared config type, resolving package-local and
  53. * workspace-imported types, re-export chains, intersections, utility
  54. * wrappers, and indexed access. The paste cannot hide a loader-accepted
  55. * field, top-level or nested. A path that crosses a type the walk cannot
  56. * enumerate (an external package's type) is skipped, never mis-reported,
  57. * and nested keys under dynamic-key shapes (`z.dict`) or union alternatives
  58. * contribute no paths. The reverse direction is deliberately NOT checked: a
  59. * declared field may be a runtime-only seam the schema excludes (e.g. the
  60. * ACP bridge's test-injected `stream`).
  61. *
  62. * Config fences use the ` ```ts config-catalog ` info string: doc-typecheck
  63. * recognizes it and skips compilation (a lone interface referencing imported
  64. * types is not standalone-compilable, like the ` ```ts cordis-catalog `
  65. * signature blocks).
  66. */
  67. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  68. import { dirname, resolve } from 'node:path'
  69. import ts from 'typescript'
  70. import { LINK_MAP } from './gen-cordis-catalog.ts'
  71. import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
  72. const root = resolve(import.meta.dirname, '..')
  73. const OUT = 'docs/config-catalog.md'
  74. /** The fenced-block info string for pasted config declarations (skipped by
  75. * doc-typecheck, since a lone declaration referencing imports is not
  76. * standalone-compilable). */
  77. const FENCE = 'ts config-catalog'
  78. /** TypeScript/Node global type names a config declaration may reference
  79. * without importing; never treated as unresolved. Extend when a new global
  80. * legitimately appears — the generator hard-errors on unknown names, so an
  81. * omission is loud, not silent. */
  82. const GLOBAL_TYPES = new Set([
  83. 'Array', 'ReadonlyArray', 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit',
  84. 'Promise', 'Map', 'Set', 'Date', 'Error', 'RegExp', 'Exclude', 'Extract', 'NonNullable',
  85. 'ReturnType', 'Parameters', 'AbortSignal', 'URL', 'Buffer', 'NodeJS', 'Iterable', 'AsyncIterable',
  86. ])
  87. /** How a package classifies for the catalog. */
  88. type Kind = 'config' | 'no-config' | 'seam' | 'library'
  89. /** One name a pasted declaration references but the paste does not contain. */
  90. interface TypeRef {
  91. /** The name as it appears in the pasted text (the local import alias). */
  92. alias: string
  93. /** The name the source module exports it under (pre-alias). */
  94. imported: string
  95. /** The import module specifier (package name or external module). */
  96. specifier: string
  97. }
  98. /** One verbatim declaration paste. */
  99. interface Paste {
  100. /** Full source text: leading JSDoc (when present) through the closing token. */
  101. text: string
  102. /** Source pointer `packages/…/file.ts:line` of the declaration. */
  103. source: string
  104. }
  105. /** One package's catalog entry. */
  106. export interface CatalogEntry {
  107. /** npm package name, e.g. `@deepseek-ai/dsh-agent-loop`. */
  108. pkg: string
  109. /** Repo-relative package dir, e.g. `packages/core/agent-loop`. */
  110. dir: string
  111. /** Repo-relative entry file, `<dir>/src/index.ts`. */
  112. entry: string
  113. kind: Kind
  114. /** Service keys the plugin `inject`s (empty when none declared). */
  115. inject: string[]
  116. /** Seam/service class name (kinds `seam` and class-based plugins). */
  117. className?: string
  118. /** Name of the config type (kind `config`). */
  119. configTypeName?: string
  120. /** Verbatim declaration pastes, the config type first (kind `config`). */
  121. pastes?: Paste[]
  122. /** References the pastes leave unresolved locally (kind `config`). */
  123. refs?: TypeRef[]
  124. /** Top-level keys and nested key paths (`agents[].id`) of the runtime
  125. * schema, `null` when no schema exists (kind `config`). */
  126. schemaKeys?: string[] | null
  127. /** Package names whose schemas an intersect composes (kind `config`). */
  128. schemaComposes?: string[]
  129. }
  130. /** A parsed source file plus its import map (local name → origin). */
  131. interface FileCtx {
  132. abs: string
  133. rel: string
  134. text: string
  135. sf: ts.SourceFile
  136. /** Local binding name → `{ imported, specifier }`; default imports record
  137. * `imported: 'default'`. */
  138. imports: Map<string, { imported: string; specifier: string }>
  139. }
  140. /** Throw one aggregate error for every violation the walk collected. */
  141. function report(violations: string[]): void {
  142. if (violations.length === 0) return
  143. throw new Error(
  144. `gen-config-catalog: ${violations.length} violation(s):\n`
  145. + violations.map(v => ` ${v}`).join('\n'),
  146. )
  147. }
  148. /** Parse a source file and index its import declarations. */
  149. function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCtx {
  150. const cached = cache.get(abs)
  151. if (cached) return cached
  152. const text = readFileSync(abs, 'utf8')
  153. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
  154. const imports = new Map<string, { imported: string; specifier: string }>()
  155. for (const stmt of sf.statements) {
  156. if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
  157. const specifier = stmt.moduleSpecifier.text
  158. const clause = stmt.importClause
  159. if (!clause) continue
  160. if (clause.name) imports.set(clause.name.text, { imported: 'default', specifier })
  161. if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
  162. for (const el of clause.namedBindings.elements) {
  163. imports.set(el.name.text, { imported: (el.propertyName ?? el.name).text, specifier })
  164. }
  165. }
  166. if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
  167. imports.set(clause.namedBindings.name.text, { imported: '*', specifier })
  168. }
  169. }
  170. const ctx = { abs, rel, text, sf, imports }
  171. cache.set(abs, ctx)
  172. return ctx
  173. }
  174. /** A type declaration a paste can contain. */
  175. type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration
  176. /** Find an interface/type-alias declaration by name in a file, or null. */
  177. function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null {
  178. for (const stmt of ctx.sf.statements) {
  179. if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt
  180. }
  181. return null
  182. }
  183. /**
  184. * Resolve a type name from a file to its declaration (following package-local
  185. * relative imports transitively) or to the import that brings it in. Returns
  186. * `null` when the name is neither declared, imported, nor a known global.
  187. */
  188. function resolveTypeName(
  189. ctx: FileCtx,
  190. name: string,
  191. cache: Map<string, FileCtx>,
  192. violations: string[],
  193. ): { decl: TypeDecl; ctx: FileCtx } | { ref: TypeRef } | null {
  194. const local = findTypeDecl(ctx, name)
  195. if (local) return { decl: local, ctx }
  196. const imp = ctx.imports.get(name)
  197. if (!imp) return null
  198. if (imp.specifier.startsWith('.')) {
  199. if (!imp.specifier.endsWith('.ts')) {
  200. violations.push(`${ctx.rel}: relative import '${imp.specifier}' lacks the explicit .ts extension the repo convention requires.`)
  201. return null
  202. }
  203. if (imp.imported !== name) {
  204. violations.push(`${ctx.rel}: '${name}' aliases '${imp.imported}' across a package-local import; the catalog pastes declarations verbatim, so keep package-local config types unaliased.`)
  205. return null
  206. }
  207. const abs = resolve(dirname(ctx.abs), imp.specifier)
  208. const rel = ctx.rel.slice(0, ctx.rel.lastIndexOf('/') + 1) + imp.specifier.replace(/^\.\//, '')
  209. const target = loadFile(abs, rel, cache)
  210. return resolveTypeName(target, imp.imported, cache, violations)
  211. }
  212. return { ref: { alias: name, imported: imp.imported, specifier: imp.specifier } }
  213. }
  214. /** Collect every type NAME referenced in type positions under a node. */
  215. function collectTypeNames(node: ts.Node, out: Set<string>): void {
  216. const visit = (n: ts.Node): void => {
  217. if (ts.isTypeReferenceNode(n)) {
  218. let head: ts.EntityName = n.typeName
  219. while (ts.isQualifiedName(head)) head = head.left
  220. out.add(head.text)
  221. } else if (ts.isExpressionWithTypeArguments(n) && ts.isIdentifier(n.expression)) {
  222. out.add(n.expression.text) // heritage clause: `extends X`
  223. }
  224. ts.forEachChild(n, visit)
  225. }
  226. visit(node)
  227. }
  228. /** The verbatim paste text of a declaration: leading JSDoc through the end. */
  229. function pasteText(ctx: FileCtx, decl: TypeDecl): string {
  230. const raw = rawJsDoc(ctx.text, decl)
  231. const start = raw ? ctx.text.indexOf(raw, decl.getFullStart()) : decl.getStart(ctx.sf)
  232. return ctx.text.slice(start, decl.end)
  233. }
  234. /** Enforce non-empty JSDoc prose on every property of a pasted declaration,
  235. * recursing into nested type literals (e.g. an array-of-objects field). */
  236. function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): void {
  237. const walkMembers = (members: ts.NodeArray<ts.TypeElement>, path: string): void => {
  238. for (const member of members) {
  239. if (!ts.isPropertySignature(member)) continue
  240. const name = member.name.getText(ctx.sf)
  241. const where = `config field '${path}.${name}' (${pointer(ctx.rel, ctx.sf, member)})`
  242. if (!parseJsDoc(rawJsDoc(ctx.text, member)).doc) violations.push(`${where} has no JSDoc prose.`)
  243. if (member.type) walkNested(member.type, `${path}.${name}`)
  244. }
  245. }
  246. const walkNested = (type: ts.Node, path: string): void => {
  247. if (ts.isTypeLiteralNode(type)) walkMembers(type.members, path)
  248. else ts.forEachChild(type, (n) => { walkNested(n, path) })
  249. }
  250. if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text)
  251. else walkNested(decl.type, decl.name.text)
  252. }
  253. /** Cross-file resolution context for the schema-path check. */
  254. interface World {
  255. scanRoot: string
  256. cache: Map<string, FileCtx>
  257. /** Workspace package name → repo-relative package dir. */
  258. pkgDirByName: Map<string, string>
  259. }
  260. /** How a schema key path fared against the declared config type: definitely
  261. * present, definitely absent, or crossing a shape the walk cannot enumerate
  262. * (only `missing` is a violation — `unknown` must never mis-report). */
  263. type PathLookup = 'found' | 'missing' | 'unknown'
  264. /** One step of a schema key path: a named member, or an array-element hop. */
  265. type PathStep = { member: string } | { array: true }
  266. /** Parse a schema key path (`agents[].id`) into member/array steps. */
  267. function parsePath(path: string): PathStep[] {
  268. const steps: PathStep[] = []
  269. for (const seg of path.split('.')) {
  270. let name = seg
  271. let arrays = 0
  272. while (name.endsWith('[]')) {
  273. name = name.slice(0, -2)
  274. arrays += 1
  275. }
  276. steps.push({ member: name })
  277. for (let i = 0; i < arrays; i += 1) steps.push({ array: true })
  278. }
  279. return steps
  280. }
  281. /** Load a package-relative import target as a FileCtx. */
  282. function loadRelative(world: World, from: FileCtx, specifier: string): FileCtx {
  283. const abs = resolve(dirname(from.abs), specifier)
  284. const rel = from.rel.slice(0, from.rel.lastIndexOf('/') + 1) + specifier.replace(/^\.\//, '')
  285. return loadFile(abs, rel, world.cache)
  286. }
  287. /** Find a type declaration EXPORTED (directly or via re-export chains) from a
  288. * file, following `export … from './x.ts'` and `export * from './x.ts'`. */
  289. function findExportedTypeDecl(world: World, ctx: FileCtx, name: string, seen = new Set<string>()): { decl: TypeDecl; ctx: FileCtx } | null {
  290. const key = `${ctx.abs}#${name}`
  291. if (seen.has(key)) return null
  292. seen.add(key)
  293. const local = findTypeDecl(ctx, name)
  294. if (local) return { decl: local, ctx }
  295. for (const stmt of ctx.sf.statements) {
  296. if (!ts.isExportDeclaration(stmt) || !stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
  297. const spec = stmt.moduleSpecifier.text
  298. if (!spec.startsWith('.') || !spec.endsWith('.ts')) continue
  299. let lookFor: string | null = null
  300. if (!stmt.exportClause) {
  301. lookFor = name // export * from './x.ts'
  302. } else if (ts.isNamedExports(stmt.exportClause)) {
  303. const el = stmt.exportClause.elements.find(e => e.name.text === name)
  304. if (el) lookFor = (el.propertyName ?? el.name).text
  305. }
  306. if (lookFor === null) continue
  307. const hit = findExportedTypeDecl(world, loadRelative(world, ctx, spec), lookFor, seen)
  308. if (hit) return hit
  309. }
  310. return null
  311. }
  312. /** Resolve a referenced type NAME to its declaration: declared locally, via a
  313. * package-relative import, or via a workspace-package import (entry file +
  314. * re-export chains). `'unknown'` = external or otherwise out of reach. */
  315. function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: TypeDecl; ctx: FileCtx } | 'unknown' {
  316. const local = findTypeDecl(ctx, name)
  317. if (local) return { decl: local, ctx }
  318. const imp = ctx.imports.get(name)
  319. if (!imp) return 'unknown'
  320. if (imp.specifier.startsWith('.')) {
  321. if (!imp.specifier.endsWith('.ts')) return 'unknown'
  322. return findExportedTypeDecl(world, loadRelative(world, ctx, imp.specifier), imp.imported) ?? 'unknown'
  323. }
  324. const dir = world.pkgDirByName.get(imp.specifier)
  325. if (dir === undefined) return 'unknown'
  326. const entryRel = `${dir}/src/index.ts`
  327. let entry: FileCtx
  328. try {
  329. entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
  330. } catch {
  331. // A workspace package without a readable entry is reported by its own
  332. // classification pass; for a lookup it is merely out of reach.
  333. return 'unknown'
  334. }
  335. return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
  336. }
  337. /** Utility wrappers that pass a member lookup through to their type argument. */
  338. const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNullable'])
  339. /**
  340. * Walk a schema key path against a declared type. This is a PRESENCE check,
  341. * not a shape check: it answers "does the declared config type have a member
  342. * here", resolving interfaces (heritage included), type aliases, literals,
  343. * intersections, unions, arrays, indexed access, pass-through utility
  344. * wrappers, and type references across package-local and workspace imports.
  345. * Anything it cannot see through resolves `'unknown'`, never `'missing'`.
  346. */
  347. function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
  348. if (steps.length === 0) return 'found'
  349. // Guard recursion at NAMED declarations only — the sole way a walk can loop
  350. // (a recursive interface/alias). Structural nodes must not be guarded: a
  351. // first child shares `.pos` with its parent, so a span-keyed guard there
  352. // would mistake ordinary descent for a cycle.
  353. if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
  354. const key = `${ctx.abs}:${node.pos}:${steps.length}`
  355. if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop
  356. seen.add(key)
  357. }
  358. const step = steps[0]
  359. if (step === undefined) return 'found'
  360. // Combine branch results: any found wins, else any unknown taints, else missing.
  361. const combine = (results: PathLookup[]): PathLookup => {
  362. if (results.includes('found')) return 'found'
  363. if (results.includes('unknown')) return 'unknown'
  364. return 'missing'
  365. }
  366. const intoMembers = (members: ts.NodeArray<ts.TypeElement>): PathLookup | null => {
  367. if (!('member' in step)) return null
  368. for (const m of members) {
  369. if (!ts.isPropertySignature(m) || m.name.getText(ctx.sf) !== step.member) continue
  370. if (steps.length === 1) return 'found'
  371. return m.type ? lookupPath(world, ctx, m.type, steps.slice(1), seen) : 'unknown'
  372. }
  373. return null // not among these members; caller consults heritage/parts
  374. }
  375. if (ts.isInterfaceDeclaration(node)) {
  376. if (!('member' in step)) return 'unknown' // an array step cannot land on an interface
  377. const direct = intoMembers(node.members)
  378. if (direct !== null) return direct
  379. const bases: PathLookup[] = []
  380. for (const clause of node.heritageClauses ?? []) {
  381. for (const base of clause.types) {
  382. if (!ts.isIdentifier(base.expression)) {
  383. bases.push('unknown')
  384. continue
  385. }
  386. const resolved = declForTypeName(world, ctx, base.expression.text)
  387. bases.push(resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen))
  388. }
  389. }
  390. return bases.length ? combine(bases) : 'missing'
  391. }
  392. if (ts.isTypeAliasDeclaration(node)) return lookupPath(world, ctx, node.type, steps, seen)
  393. if (ts.isTypeLiteralNode(node)) {
  394. if (!('member' in step)) return 'unknown'
  395. return intoMembers(node.members) ?? 'missing'
  396. }
  397. if (ts.isParenthesizedTypeNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
  398. if (ts.isIntersectionTypeNode(node)) {
  399. return combine(node.types.map(t => lookupPath(world, ctx, t, steps, seen)))
  400. }
  401. if (ts.isUnionTypeNode(node)) {
  402. // Presence on a union is only definite when every branch agrees.
  403. const results = node.types.map(t => lookupPath(world, ctx, t, steps, seen))
  404. if (results.every(r => r === 'found')) return 'found'
  405. if (results.every(r => r === 'missing')) return 'missing'
  406. return 'unknown'
  407. }
  408. if (ts.isArrayTypeNode(node)) {
  409. return 'array' in step ? lookupPath(world, ctx, node.elementType, steps.slice(1), seen) : 'unknown'
  410. }
  411. if (ts.isTypeOperatorNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
  412. if (ts.isIndexedAccessTypeNode(node)) {
  413. const index = node.indexType
  414. if (ts.isLiteralTypeNode(index) && ts.isStringLiteral(index.literal)) {
  415. return lookupPath(world, ctx, node.objectType, [{ member: index.literal.text }, ...steps], seen)
  416. }
  417. return 'unknown'
  418. }
  419. if (ts.isTypeReferenceNode(node)) {
  420. let head: ts.EntityName = node.typeName
  421. while (ts.isQualifiedName(head)) head = head.left
  422. const name = head.text
  423. if (PASSTHROUGH_WRAPPERS.has(name) && node.typeArguments?.[0]) {
  424. return lookupPath(world, ctx, node.typeArguments[0], steps, seen)
  425. }
  426. if ((name === 'Array' || name === 'ReadonlyArray') && node.typeArguments?.[0]) {
  427. return 'array' in step ? lookupPath(world, ctx, node.typeArguments[0], steps.slice(1), seen) : 'unknown'
  428. }
  429. if (!ts.isIdentifier(node.typeName)) return 'unknown' // namespace-qualified: out of reach
  430. const resolved = declForTypeName(world, ctx, name)
  431. return resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen)
  432. }
  433. return 'unknown'
  434. }
  435. /** Unwrap `as` / `satisfies` / parenthesized wrappers around an expression. */
  436. function unwrapExpr(expr: ts.Expression): ts.Expression {
  437. let e = expr
  438. while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression
  439. return e
  440. }
  441. /**
  442. * Statically walk a schemastery schema expression to its key paths plus the
  443. * packages whose schemas an intersect composes. A key path is the top-level
  444. * key or a nested path through object/array compositions (`agents[].id`).
  445. * Handles the shapes the repo declares — `z.object({…})` (possibly behind
  446. * chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
  447. * anything else, so a schema the walk cannot see fails the gate instead of
  448. * silently thinning it. Nested values that are neither `object` nor `array`
  449. * compositions (primitives, unions, dynamic-key dicts) contribute no paths.
  450. */
  451. function walkSchemaExpr(
  452. ctx: FileCtx,
  453. expr: ts.Expression,
  454. where: string,
  455. violations: string[],
  456. ): { keys: string[]; composes: string[] } {
  457. const keys: string[] = []
  458. const composes: string[] = []
  459. // Nested paths under one object property's VALUE expression: recurse through
  460. // chained refinements toward the base call, descending into object/array.
  461. const collectValuePaths = (value: ts.Expression, base: string): void => {
  462. const call = unwrapExpr(value)
  463. if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return
  464. const method = call.expression.name.text
  465. if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
  466. for (const prop of call.arguments[0].properties) {
  467. if (!ts.isPropertyAssignment(prop)) continue
  468. const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
  469. keys.push(`${base}.${key}`)
  470. collectValuePaths(prop.initializer, `${base}.${key}`)
  471. }
  472. return
  473. }
  474. if (method === 'array' && call.arguments[0]) {
  475. collectValuePaths(call.arguments[0], `${base}[]`)
  476. return
  477. }
  478. const inner = unwrapExpr(call.expression.expression)
  479. if (ts.isCallExpression(inner)) collectValuePaths(inner, base)
  480. }
  481. const visit = (e: ts.Expression): void => {
  482. const call = unwrapExpr(e)
  483. if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
  484. violations.push(`${where}: schema expression is not a statically walkable schemastery call.`)
  485. return
  486. }
  487. const method = call.expression.name.text
  488. if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
  489. for (const prop of call.arguments[0].properties) {
  490. if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
  491. const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
  492. keys.push(key)
  493. if (ts.isPropertyAssignment(prop)) collectValuePaths(prop.initializer, key)
  494. } else {
  495. violations.push(`${where}: schema object property '${prop.getText(ctx.sf)}' is not a plain key.`)
  496. }
  497. }
  498. return
  499. }
  500. if (method === 'intersect' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
  501. for (const el of call.arguments[0].elements) {
  502. const part = unwrapExpr(el)
  503. if (ts.isPropertyAccessExpression(part) && part.name.text === 'Config' && ts.isIdentifier(part.expression)) {
  504. const imp = ctx.imports.get(part.expression.text)
  505. if (imp && !imp.specifier.startsWith('.')) { composes.push(imp.specifier); continue }
  506. }
  507. if (ts.isCallExpression(part)) { visit(part); continue }
  508. violations.push(`${where}: intersect element '${part.getText(ctx.sf)}' is neither a workspace plugin's Config nor an inline schema call.`)
  509. }
  510. return
  511. }
  512. // A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
  513. // the call the chain hangs off — keep unwrapping toward it.
  514. const base = unwrapExpr(call.expression.expression)
  515. if (ts.isCallExpression(base)) { visit(base); return }
  516. violations.push(`${where}: schema call '${method}' is not object/intersect and hangs off no walkable base call.`)
  517. }
  518. visit(expr)
  519. return { keys, composes }
  520. }
  521. /** Find a plugin's schemastery schema expression: an exported `const Config`
  522. * in the entry file, else a `static Config` on the plugin class. */
  523. function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): ts.Expression | null {
  524. for (const stmt of ctx.sf.statements) {
  525. if (!ts.isVariableStatement(stmt)) continue
  526. if (!stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) continue
  527. for (const decl of stmt.declarationList.declarations) {
  528. if (ts.isIdentifier(decl.name) && decl.name.text === 'Config' && decl.initializer) return decl.initializer
  529. }
  530. }
  531. for (const member of pluginClass?.members ?? []) {
  532. if (!ts.isPropertyDeclaration(member) || member.name.getText() !== 'Config') continue
  533. if (!member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)) continue
  534. if (member.initializer) return member.initializer
  535. }
  536. return null
  537. }
  538. /** Read an `inject` service-key list: `export const inject = […]` in the entry
  539. * file, else `static inject = […]` on the plugin class. */
  540. function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] {
  541. const fromArray = (expr: ts.Expression, where: string): string[] => {
  542. if (!ts.isArrayLiteralExpression(expr)) {
  543. violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`)
  544. return []
  545. }
  546. return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf))
  547. }
  548. for (const stmt of ctx.sf.statements) {
  549. if (!ts.isVariableStatement(stmt)) continue
  550. for (const decl of stmt.declarationList.declarations) {
  551. if (ts.isIdentifier(decl.name) && decl.name.text === 'inject' && decl.initializer) {
  552. return fromArray(decl.initializer, ctx.rel)
  553. }
  554. }
  555. }
  556. for (const member of pluginClass?.members ?? []) {
  557. if (ts.isPropertyDeclaration(member) && member.name.getText() === 'inject' && member.initializer) {
  558. return fromArray(member.initializer, ctx.rel)
  559. }
  560. }
  561. return []
  562. }
  563. /** Resolve the entry file's default export to its class/function declaration
  564. * (mirroring the Loader's `unwrapExports`), or null when there is none. */
  565. function defaultExport(ctx: FileCtx): ts.ClassDeclaration | ts.FunctionDeclaration | null {
  566. for (const stmt of ctx.sf.statements) {
  567. if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
  568. const name = stmt.expression.text
  569. for (const s of ctx.sf.statements) {
  570. if ((ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s)) && s.name?.text === name) return s
  571. }
  572. return null
  573. }
  574. if ((ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt))
  575. && stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)) return stmt
  576. }
  577. return null
  578. }
  579. /** Find the exported `apply` function declaration in the entry file, or null. */
  580. function applyExport(ctx: FileCtx): ts.FunctionDeclaration | null {
  581. for (const stmt of ctx.sf.statements) {
  582. if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'apply'
  583. && stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) return stmt
  584. }
  585. return null
  586. }
  587. /**
  588. * Walk every `packages/<group>/<pkg>` entry and build the catalog entries.
  589. * Hard-errors (aggregated) on any violation listed in the module doc.
  590. * `scanRoot` defaults to the repo root; tests pass a fixture dir.
  591. */
  592. export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
  593. const violations: string[] = []
  594. const cache = new Map<string, FileCtx>()
  595. const entries: CatalogEntry[] = []
  596. // Pre-pass: package name → dir, so schema-path lookups can follow
  597. // workspace-package imports while individual packages are still being walked.
  598. const pkgDirByName = new Map<string, string>()
  599. const manifests: { dir: string; pkg: string }[] = []
  600. for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) {
  601. const dir = manifestRel.slice(0, -'/package.json'.length)
  602. const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[] }
  603. const pkg = manifest.name
  604. if (!pkg) {
  605. violations.push(`${manifestRel} has no "name".`)
  606. continue
  607. }
  608. if (manifest.os !== undefined && manifest.cpu !== undefined) {
  609. // A per-platform native-binary package (npm os/cpu selection) ships no
  610. // JavaScript at all — nothing to classify, no Config to catalog.
  611. continue
  612. }
  613. pkgDirByName.set(pkg, dir)
  614. manifests.push({ dir, pkg })
  615. }
  616. const world: World = { scanRoot, cache, pkgDirByName }
  617. for (const { dir, pkg } of manifests) {
  618. const entryRel = `${dir}/src/index.ts`
  619. let ctx: FileCtx
  620. try {
  621. ctx = loadFile(resolve(scanRoot, entryRel), entryRel, cache)
  622. } catch {
  623. // A package without src/index.ts cannot be classified — that is the
  624. // violation itself; nothing else in this loop body can run without it.
  625. violations.push(`${pkg}: entry ${entryRel} is missing or unreadable.`)
  626. continue
  627. }
  628. // Classify, mirroring the Loader's unwrapExports: the default export IS
  629. // the plugin when present; else an exported `apply` makes the module
  630. // namespace the plugin; else the package is a plain library.
  631. const dflt = defaultExport(ctx)
  632. const apply = applyExport(ctx)
  633. let pluginClass: ts.ClassDeclaration | null = null
  634. let configParam: ts.ParameterDeclaration | undefined
  635. let kind: Kind
  636. let className: string | undefined
  637. if (dflt && ts.isClassDeclaration(dflt)) {
  638. className = dflt.name?.text
  639. if (dflt.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword)) {
  640. kind = 'seam'
  641. } else {
  642. pluginClass = dflt
  643. const ctor = dflt.members.find(ts.isConstructorDeclaration)
  644. configParam = ctor?.parameters[1]
  645. kind = configParam ? 'config' : 'no-config'
  646. }
  647. } else if (dflt) {
  648. configParam = dflt.parameters[1]
  649. kind = configParam ? 'config' : 'no-config'
  650. } else if (apply) {
  651. configParam = apply.parameters[1]
  652. kind = configParam ? 'config' : 'no-config'
  653. } else {
  654. kind = 'library'
  655. }
  656. const entry: CatalogEntry = {
  657. pkg,
  658. dir,
  659. entry: entryRel,
  660. kind,
  661. inject: kind === 'library' || kind === 'seam' ? [] : findInject(ctx, pluginClass, violations),
  662. ...className !== undefined ? { className } : {},
  663. }
  664. entries.push(entry)
  665. if (kind !== 'config' || !configParam) continue
  666. // Resolve the config type and paste its package-local transitive closure.
  667. if (!configParam.type || !ts.isTypeReferenceNode(configParam.type) || !ts.isIdentifier(configParam.type.typeName)) {
  668. violations.push(`${pkg}: config parameter type (${pointer(entryRel, ctx.sf, configParam)}) is not a plain type-name reference; declare a named config type.`)
  669. continue
  670. }
  671. const typeName = configParam.type.typeName.text
  672. entry.configTypeName = typeName
  673. const pastes: Paste[] = []
  674. const refs = new Map<string, TypeRef>()
  675. // A bare name is the fence's whole namespace: two DIFFERENT declarations
  676. // (or a declaration in one file and an import in another) sharing a name
  677. // cannot both render unambiguously, so every resolution is identity-checked
  678. // by source pointer and a collision is a violation, never a silent skip.
  679. const pastedDeclByName = new Map<string, string>()
  680. const queue: { name: string; from: FileCtx }[] = [{ name: typeName, from: ctx }]
  681. for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
  682. const { name, from } = item
  683. const resolved = resolveTypeName(from, name, cache, violations)
  684. if (resolved === null) {
  685. violations.push(`${pkg}: config declaration references '${name}' (via ${from.rel}), which is neither declared in the package, imported, nor a known global type.`)
  686. continue
  687. }
  688. if ('ref' in resolved) {
  689. if (name === typeName) {
  690. violations.push(`${pkg}: config type '${name}' is imported from '${resolved.ref.specifier}'; a plugin's config type must live in its own package.`)
  691. continue
  692. }
  693. if (pastedDeclByName.has(name)) {
  694. violations.push(`${pkg}: '${name}' resolves to a package-local declaration (${pastedDeclByName.get(name) ?? ''}) in one file and an import from '${resolved.ref.specifier}' in another; rename one so the fence is unambiguous.`)
  695. continue
  696. }
  697. const existing = refs.get(name)
  698. if (existing && (existing.specifier !== resolved.ref.specifier || existing.imported !== resolved.ref.imported)) {
  699. violations.push(`${pkg}: '${name}' is imported from both '${existing.specifier}' (${existing.imported}) and '${resolved.ref.specifier}' (${resolved.ref.imported}) across the pasted closure; disambiguate the aliases.`)
  700. continue
  701. }
  702. refs.set(name, resolved.ref)
  703. continue
  704. }
  705. const declKey = pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl)
  706. const prior = pastedDeclByName.get(name)
  707. if (prior === declKey) continue // same declaration reached again — benign
  708. if (prior !== undefined) {
  709. violations.push(`${pkg}: type name '${name}' resolves to two different declarations (${prior} and ${declKey}) across the pasted closure; rename one — a verbatim fence cannot carry two same-named declarations.`)
  710. continue
  711. }
  712. if (refs.has(name)) {
  713. violations.push(`${pkg}: '${name}' resolves to an import from '${refs.get(name)?.specifier ?? ''}' in one file and a package-local declaration (${declKey}) in another; rename one so the fence is unambiguous.`)
  714. continue
  715. }
  716. pastedDeclByName.set(name, declKey)
  717. pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: declKey })
  718. checkMemberDocs(resolved.ctx, resolved.decl, violations)
  719. const names = new Set<string>()
  720. collectTypeNames(resolved.decl, names)
  721. for (const n of names) {
  722. if (GLOBAL_TYPES.has(n)) continue
  723. queue.push({ name: n, from: resolved.ctx })
  724. }
  725. }
  726. entry.pastes = pastes
  727. entry.refs = [...refs.values()].sort((a, b) => a.alias.localeCompare(b.alias))
  728. // Statically walk the runtime schema (when one exists) for the subset check.
  729. const schemaExpr = findSchemaExpr(ctx, pluginClass)
  730. if (schemaExpr) {
  731. const { keys, composes } = walkSchemaExpr(ctx, unwrapExpr(schemaExpr), `${pkg} (${entryRel})`, violations)
  732. entry.schemaKeys = keys
  733. entry.schemaComposes = composes
  734. } else {
  735. entry.schemaKeys = null
  736. }
  737. }
  738. // Second phase: fold composed schemas' key paths in, then walk every
  739. // schema-validated path against the declared config type. Only a definite
  740. // miss is a violation — a path through a shape the walk cannot enumerate
  741. // stays silent rather than mis-reporting.
  742. const byName = new Map(entries.map(e => [e.pkg, e]))
  743. for (const entry of entries) {
  744. if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
  745. const seen = new Set<string>()
  746. const foldComposed = (e: CatalogEntry): string[] => {
  747. if (seen.has(e.pkg)) return []
  748. seen.add(e.pkg)
  749. const keys = [...e.schemaKeys ?? []]
  750. for (const composed of e.schemaComposes ?? []) {
  751. const target = byName.get(composed)
  752. if (!target) {
  753. violations.push(`${entry.pkg}: schema intersects '${composed}', which is not a workspace package the walk collected.`)
  754. continue
  755. }
  756. keys.push(...foldComposed(target))
  757. }
  758. return keys
  759. }
  760. const allKeys = foldComposed(entry)
  761. const mainPaste = entry.pastes?.[0]
  762. const mainFile = mainPaste?.source.split(':')[0]
  763. const mainCtx = mainFile !== undefined ? cache.get(resolve(scanRoot, mainFile)) : undefined
  764. const mainDecl = mainCtx && entry.configTypeName !== undefined ? findTypeDecl(mainCtx, entry.configTypeName) : null
  765. if (!mainCtx || !mainDecl) {
  766. violations.push(`${entry.pkg}: cannot locate config type '${entry.configTypeName ?? ''}' for the schema-path check.`)
  767. continue
  768. }
  769. for (const keyPath of allKeys) {
  770. if (lookupPath(world, mainCtx, mainDecl, parsePath(keyPath), new Set()) === 'missing') {
  771. violations.push(`${entry.pkg}: schema validates key '${keyPath}' but config type '${entry.configTypeName ?? ''}' declares no such member — the catalog paste would hide a loader-accepted field.`)
  772. }
  773. }
  774. }
  775. report(violations)
  776. return entries.sort((a, b) => a.pkg.localeCompare(b.pkg))
  777. }
  778. /** GitHub-style anchor slug for a `## \`pkg\`` heading. */
  779. function slug(heading: string): string {
  780. return heading.toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/ /g, '-')
  781. }
  782. /** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
  783. function requiresLine(inject: string[]): string {
  784. return inject.length ? `Requires: ${inject.map(k => `\`${k}\``).join(' · ')}` : ''
  785. }
  786. /** Render one reference as a link: another plugin's config type → its section,
  787. * a curated core-data-structures name → its page, any other workspace type →
  788. * its source file, an external type → named with its module, unlinked. */
  789. function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
  790. const target = byName.get(ref.specifier)
  791. if (target?.kind === 'config' && ref.imported === target.configTypeName) {
  792. return `[\`${ref.alias}\`](#${slug(target.pkg)})`
  793. }
  794. const page = LINK_MAP[ref.imported]
  795. if (page) return `[\`${ref.alias}\`](core-data-structures/${page})`
  796. if (target) return `[\`${ref.alias}\`](../${target.entry})`
  797. return `\`${ref.alias}\` (\`${ref.specifier}\`)`
  798. }
  799. /** Render one configurable plugin's section. */
  800. function renderConfigEntry(entry: CatalogEntry, byName: Map<string, CatalogEntry>): string[] {
  801. const out = [`## \`${entry.pkg}\``, '']
  802. const requires = requiresLine(entry.inject)
  803. if (requires) out.push(requires, '')
  804. out.push('```' + FENCE, ...(entry.pastes ?? []).map(p => p.text).join('\n\n').split('\n'), '```', '')
  805. if (entry.refs && entry.refs.length > 0) {
  806. out.push(`Depends on: ${entry.refs.map(r => refLink(r, byName)).join(' · ')}`, '')
  807. }
  808. const source = entry.pastes?.[0]?.source ?? entry.entry
  809. out.push(`Source: [\`${source}\`](../${source.split(':')[0]})`, '')
  810. return out
  811. }
  812. /** Render one terse list line (the no-config / seam / library sections). */
  813. function renderTerse(entry: CatalogEntry, detail: string): string {
  814. const requires = entry.inject.length ? ` — requires ${entry.inject.map(k => `\`${k}\``).join(' · ')}` : ''
  815. return `- \`${entry.pkg}\`${detail}${requires} ([\`${entry.entry}\`](../${entry.entry}))`
  816. }
  817. /** Render the full catalog (pure, deterministic given sorted entries). */
  818. export function render(entries: CatalogEntry[]): string {
  819. const byName = new Map(entries.map(e => [e.pkg, e]))
  820. const lines: string[] = [
  821. '<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.',
  822. ' Run `pnpm run gen-config-catalog` to regenerate. -->',
  823. '',
  824. '# Plugin Config Catalog',
  825. '',
  826. 'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
  827. '',
  828. 'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
  829. '',
  830. 'A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.',
  831. '',
  832. ]
  833. for (const entry of entries.filter(e => e.kind === 'config')) {
  834. lines.push(...renderConfigEntry(entry, byName))
  835. }
  836. lines.push(
  837. '## Loadable plugins with no config',
  838. '',
  839. 'These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.',
  840. '',
  841. ...entries.filter(e => e.kind === 'no-config').map(e => renderTerse(e, '')),
  842. '',
  843. '## Seam packages (not directly loadable)',
  844. '',
  845. 'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).',
  846. '',
  847. ...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
  848. '',
  849. '## Library packages (no plugin entry)',
  850. '',
  851. 'Imported as libraries by other packages; a `cordis.yml` cannot load them.',
  852. '',
  853. ...entries.filter(e => e.kind === 'library').map(e => renderTerse(e, '')),
  854. '',
  855. )
  856. return lines.join('\n')
  857. }
  858. /** CLI entry: default writes the catalog, `--check` fails if the committed
  859. * copy is stale. Guarded behind an entry-point check so importing this module
  860. * for tests neither regenerates the committed file nor calls process.exit. */
  861. function main(): void {
  862. const content = render(collectConfigCatalog())
  863. if (process.argv.includes('--check')) {
  864. let committed: string | null = null
  865. try {
  866. committed = readFileSync(resolve(root, OUT), 'utf8')
  867. } catch {
  868. // Only ENOENT (not yet generated) is expected; a present-but-unreadable
  869. // file is not a state this repo produces. Either way the remedy is the
  870. // same — regenerate — so treat a read failure as "stale".
  871. committed = null
  872. }
  873. if (committed === content) {
  874. console.log(`gen-config-catalog: ${OUT} is up to date.`)
  875. process.exit(0)
  876. }
  877. console.error(`gen-config-catalog: ${OUT} is stale. Run \`pnpm run gen-config-catalog\` and commit ${OUT}.`)
  878. process.exit(1)
  879. }
  880. writeFileSync(resolve(root, OUT), content)
  881. console.log(`gen-config-catalog: wrote ${OUT}.`)
  882. }
  883. // Run only when invoked as a script, not when imported by a test.
  884. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  885. main()
  886. }