gen-cordis-catalog.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. /**
  2. * Generate the Cordis event and service catalogs from static declarations.
  3. * The walk enforces event modes plus JSDoc parameter/return completeness;
  4. * inherited Cordis services come from the curated table below. `--check`
  5. * verifies both committed artifacts.
  6. */
  7. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  8. import { resolve } from 'node:path'
  9. import ts from 'typescript'
  10. import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
  11. const root = resolve(import.meta.dirname, '..')
  12. const OUT_EVENTS = 'docs/cordis-catalog/events.md'
  13. const OUT_SERVICES = 'docs/cordis-catalog/services.md'
  14. /** The fenced-block info string for generated signature blocks (skipped by
  15. * doc-typecheck, since a bare signature fragment is not standalone-compilable). */
  16. const FENCE = 'ts cordis-catalog'
  17. /**
  18. * One primary core-data-structures page per signature type, shared by the
  19. * Cordis and config catalogs; union names intentionally do not reuse the
  20. * type-equivalence manifest's map-symbol entries.
  21. */
  22. // TODO(catalog-type-links): verify or generate link-map coverage.
  23. export const LINK_MAP: Record<string, string> = {
  24. Agent: 'core.md',
  25. ContentBlock: 'core.md',
  26. Message: 'core.md',
  27. MessageSource: 'core.md',
  28. GenerateOptions: 'core.md',
  29. LlmCallConfig: 'core.md',
  30. SessionEvent: 'core.md',
  31. SessionStartSource: 'core.md',
  32. StreamChunk: 'llm-streaming.md',
  33. TurnEndReason: 'session.md',
  34. ToolDefinition: 'tools.md',
  35. ToolExecution: 'tools.md',
  36. ToolExecutionInput: 'tools.md',
  37. ToolExecutionResult: 'tools.md',
  38. ToolExecutionToken: 'tools.md',
  39. ApprovalOutcome: 'approval.md',
  40. ApprovalPolicy: 'approval.md',
  41. ApprovalRequest: 'approval.md',
  42. BashExecRequest: 'bash.md',
  43. BashExecSpec: 'bash.md',
  44. BashRunResult: 'bash.md',
  45. BashTask: 'bash.md',
  46. BashTaskRead: 'bash.md',
  47. ConfinedArgv: 'sandbox.md',
  48. SandboxMode: 'sandbox.md',
  49. SandboxPolicy: 'sandbox.md',
  50. CodeRunRequest: 'code-runtime.md',
  51. CodeRunResult: 'code-runtime.md',
  52. FsEditOutcome: 'filesystem.md',
  53. FsEditRequest: 'filesystem.md',
  54. FsInfo: 'filesystem.md',
  55. FsTarget: 'filesystem.md',
  56. FsVersion: 'filesystem.md',
  57. FsWriteIntent: 'filesystem.md',
  58. FsWriteOutcome: 'filesystem.md',
  59. FsPolicyExec: 'filesystem.md',
  60. FileReadOutcome: 'filesystem.md',
  61. }
  62. /** One harness event, extracted from an `interface Events` block. */
  63. interface EventEntry {
  64. /** Scoped name, e.g. `agent/request`. */
  65. name: string
  66. /** The scope prefix, e.g. `agent` (everything before the first `/`). */
  67. scope: string
  68. /** Full signature text (the method-signature member, JSDoc stripped). */
  69. signature: string
  70. /** Dispatch mode from the `@mode` tag. */
  71. mode: Mode
  72. /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
  73. doc: string
  74. /** Source pointer `packages/…/file.ts:line` of the declaration. */
  75. source: string
  76. }
  77. /** One harness service, extracted from an `interface Context` block. */
  78. interface ServiceEntry {
  79. /** The `ctx.<key>` name, e.g. `llm`. */
  80. key: string
  81. /** The service class/interface name, e.g. `LlmService`. */
  82. type: string
  83. /** Whether the service class is abstract (a seam interface). */
  84. abstract: boolean
  85. /** Class-level JSDoc prose, one line per paragraph. */
  86. doc: string
  87. /** Public method signatures (bodies stripped), in source order. */
  88. methods: string[]
  89. /** Source pointer of the class declaration. */
  90. source: string
  91. }
  92. /** A terse inherited-tier entry (pinned vendor surface). */
  93. interface InheritedEntry {
  94. name: string
  95. summary: string
  96. /** Source pointer `vendor/…:line`. */
  97. source: string
  98. }
  99. /** Find the `declare module 'cordis'` body in a source file, or null. */
  100. function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
  101. for (const stmt of sf.statements) {
  102. if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
  103. if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
  104. }
  105. }
  106. return null
  107. }
  108. /** The signature text of a method-signature member (everything but a body). */
  109. function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
  110. const full = member.getText(sf)
  111. const body = (member as { body?: ts.Node }).body
  112. const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full
  113. return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
  114. }
  115. /** Walk every harness `interface Events` block and extract its events, hard-
  116. * erroring (aggregated) on any JSDoc-completeness violation: a missing/
  117. * contradicted `@mode`, missing description prose, or an undocumented payload
  118. * parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */
  119. export function collectEvents(scanRoot: string = root): EventEntry[] {
  120. const entries: EventEntry[] = []
  121. const violations: string[] = []
  122. for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
  123. const abs = resolve(scanRoot, rel)
  124. const text = readFileSync(abs, 'utf8')
  125. if (!text.includes('interface Events')) continue
  126. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
  127. const body = cordisModuleBody(sf)
  128. if (!body) continue
  129. for (const stmt of body.statements) {
  130. if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
  131. for (const member of stmt.members) {
  132. if (!ts.isMethodSignature(member)) continue
  133. const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
  134. const signature = memberSignature(member, sf)
  135. const raw = rawJsDoc(text, member)
  136. const { doc, mode } = parseJsDoc(raw)
  137. const src = pointer(rel, sf, member)
  138. const where = `event '${name}' (${src})`
  139. if (!mode) {
  140. violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
  141. }
  142. // Conclusive structural check: a trailing `next: () => …` parameter is a
  143. // waterfall. (emit vs parallel vs serial is not structurally
  144. // distinguishable, so it is trusted from the tag.)
  145. const last = member.parameters.at(-1)
  146. const hasNext = !!last && last.name.getText(sf) === 'next'
  147. if (mode && hasNext && mode !== 'waterfall') {
  148. violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
  149. }
  150. if (mode && !hasNext && mode === 'waterfall') {
  151. violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
  152. }
  153. if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
  154. // Payload parameters need a non-empty @param. The `this` receiver is not
  155. // payload, and a waterfall's trailing `next` is covered by its mode.
  156. const { params } = parseTags(raw)
  157. checkParams(where, 'event', member.parameters, params, sf,
  158. p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
  159. if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
  160. }
  161. }
  162. }
  163. reportViolations('gen-cordis-catalog', violations)
  164. return entries
  165. }
  166. /** Walk every harness `interface Context` block + its service class, hard-
  167. * erroring (aggregated) on any JSDoc-completeness violation: a class or public
  168. * method without JSDoc prose, an undocumented parameter, a stale `@param`, a
  169. * missing `@returns` on a non-void method, or an inferred (unannotated) return
  170. * type the pure-AST walk cannot classify.
  171. * `scanRoot` defaults to the repo root; tests pass a fixture dir. */
  172. export function collectServices(scanRoot: string = root): ServiceEntry[] {
  173. const entries: ServiceEntry[] = []
  174. const violations: string[] = []
  175. for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
  176. const abs = resolve(scanRoot, rel)
  177. const text = readFileSync(abs, 'utf8')
  178. if (!text.includes('interface Context')) continue
  179. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
  180. const body = cordisModuleBody(sf)
  181. if (!body) continue
  182. // The ctx key → type mapping(s) declared in this file's interface Context.
  183. const keyToType = new Map<string, string>()
  184. for (const stmt of body.statements) {
  185. if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
  186. for (const member of stmt.members) {
  187. if (!ts.isPropertySignature(member) || !member.type) continue
  188. const key = member.name.getText(sf)
  189. keyToType.set(key, member.type.getText(sf))
  190. }
  191. }
  192. if (keyToType.size === 0) continue
  193. // Find each service class declared in the same file and emit an entry.
  194. for (const [key, type] of keyToType) {
  195. const cls = sf.statements.find(
  196. (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
  197. )
  198. if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
  199. const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
  200. const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
  201. if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
  202. const methods: string[] = []
  203. for (const member of cls.members) {
  204. if (!ts.isMethodDeclaration(member)) continue
  205. // Only instance methods callable through `ctx.<key>` are surface;
  206. // private, protected, and static methods are not.
  207. const nonPublic = member.modifiers?.some(m =>
  208. m.kind === ts.SyntaxKind.PrivateKeyword
  209. || m.kind === ts.SyntaxKind.ProtectedKeyword
  210. || m.kind === ts.SyntaxKind.StaticKeyword)
  211. || ts.isPrivateIdentifier(member.name)
  212. if (nonPublic) continue
  213. const memberName = member.name.getText(sf)
  214. if (memberName.startsWith('[')) continue // computed/symbol members
  215. methods.push(memberSignature(member, sf))
  216. const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
  217. const raw = rawJsDoc(text, member)
  218. if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
  219. if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
  220. const { params, returns } = parseTags(raw)
  221. // Every parameter needs a non-empty @param (`this` receiver exempt),
  222. // and a non-void ANNOTATED result needs a non-empty @returns — the
  223. // shared checkers carry the exact contract.
  224. checkParams(where, 'service', member.parameters, params, sf,
  225. p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
  226. checkReturns(where, member.type, returns, sf, violations)
  227. }
  228. entries.push({
  229. key,
  230. type,
  231. abstract,
  232. doc: clsDoc,
  233. methods,
  234. source: pointer(rel, sf, cls),
  235. })
  236. }
  237. }
  238. reportViolations('gen-cordis-catalog', violations)
  239. return entries.sort((a, b) => a.key.localeCompare(b.key))
  240. }
  241. /**
  242. * The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and
  243. * hand-summarized because (a) it is pinned vendor source that changes only on a
  244. * deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members
  245. * with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would
  246. * wrongly surface as services, and (c) the internal/* events carry no JSDoc to
  247. * render. Source pointers are verified against vendor by `verify-md-links`'
  248. * sibling check is N/A; keep them current on a vendor bump.
  249. */
  250. const INHERITED_EVENTS: InheritedEntry[] = [
  251. { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
  252. { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
  253. { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
  254. { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
  255. { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
  256. { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
  257. { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
  258. { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
  259. { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
  260. { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
  261. { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
  262. { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
  263. { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
  264. { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
  265. { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
  266. ]
  267. export const INHERITED_SERVICES: InheritedEntry[] = [
  268. { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
  269. { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
  270. { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
  271. { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
  272. { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
  273. { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
  274. { name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
  275. { name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
  276. { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
  277. { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
  278. ]
  279. /** Render the cross-link "Types:" line for a signature, or '' if none apply. */
  280. function typeLinks(signature: string): string {
  281. const seen = new Set<string>()
  282. for (const name of Object.keys(LINK_MAP)) {
  283. if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name)
  284. }
  285. if (seen.size === 0) return ''
  286. const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`)
  287. return `Types: ${links.join(' · ')}`
  288. }
  289. /** Render one harness event entry. */
  290. function renderEvent(e: EventEntry): string[] {
  291. const out = [`### \`${e.name}\` — ${e.mode}`, '']
  292. if (e.doc) out.push(e.doc, '')
  293. out.push('```' + FENCE, e.signature, '```', '')
  294. const links = typeLinks(e.signature)
  295. if (links) out.push(links, '')
  296. out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
  297. return out
  298. }
  299. /** Render one harness service entry. */
  300. function renderService(s: ServiceEntry): string[] {
  301. const kind = s.abstract ? ' (abstract seam)' : ''
  302. const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, '']
  303. if (s.doc) out.push(s.doc, '')
  304. if (s.methods.length) {
  305. out.push('```' + FENCE, ...s.methods, '```', '')
  306. const links = typeLinks(s.methods.join('\n'))
  307. if (links) out.push(links, '')
  308. }
  309. out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
  310. return out
  311. }
  312. /** The shared generated-file banner comment. */
  313. const BANNER = [
  314. '<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
  315. ' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
  316. '',
  317. ]
  318. /** The shared GENERATED + freshness-gate + fence notice paragraph. */
  319. const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.'
  320. /** Render the events catalog (pure, deterministic given sorted inputs). */
  321. function renderEvents(events: EventEntry[]): string {
  322. const lines: string[] = [
  323. ...BANNER,
  324. '# Cordis Events Catalog',
  325. '',
  326. 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
  327. '',
  328. GATE_NOTICE,
  329. '',
  330. 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.',
  331. '',
  332. 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
  333. '',
  334. ]
  335. const scopes = [...new Set(events.map(e => e.scope))].sort()
  336. for (const scope of scopes) {
  337. lines.push(`## \`${scope}/*\``, '')
  338. for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
  339. lines.push(...renderEvent(e))
  340. }
  341. }
  342. lines.push(
  343. '## Inherited events (cordis core + loader/hmr/timer)',
  344. '',
  345. 'The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier\'s prominence.',
  346. '',
  347. )
  348. for (const e of INHERITED_EVENTS) {
  349. lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
  350. }
  351. lines.push('')
  352. return lines.join('\n')
  353. }
  354. /** Render the services catalog (pure, deterministic given sorted inputs). */
  355. function renderServices(services: ServiceEntry[]): string {
  356. const lines: string[] = [
  357. ...BANNER,
  358. '# Cordis Services Catalog',
  359. '',
  360. 'Every `ctx.<key>` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
  361. '',
  362. GATE_NOTICE,
  363. '',
  364. 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.',
  365. '',
  366. ]
  367. for (const s of services) lines.push(...renderService(s))
  368. lines.push(
  369. '## Inherited `ctx` members (cordis core + loader/hmr/timer)',
  370. '',
  371. 'The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier\'s prominence.',
  372. '',
  373. )
  374. for (const s of INHERITED_SERVICES) {
  375. lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
  376. }
  377. lines.push('')
  378. return lines.join('\n')
  379. }
  380. /** CLI entry: `--write` (default) writes both catalogs, `--check` fails if
  381. * either is stale. Guarded behind an entry-point check so importing this module
  382. * for tests neither regenerates the committed files nor calls process.exit. */
  383. function main(): void {
  384. const outputs: [string, string][] = [
  385. [OUT_EVENTS, renderEvents(collectEvents())],
  386. [OUT_SERVICES, renderServices(collectServices())],
  387. ]
  388. if (process.argv.includes('--check')) {
  389. const stale: string[] = []
  390. for (const [out, content] of outputs) {
  391. let committed: string | null = null
  392. try {
  393. committed = readFileSync(resolve(root, out), 'utf8')
  394. } catch {
  395. // Only ENOENT (not yet generated) is expected; a present-but-unreadable
  396. // file is not a state this repo produces. Either way the remedy is the
  397. // same — regenerate — so treat a read failure as "stale".
  398. committed = null
  399. }
  400. if (committed !== content) stale.push(out)
  401. }
  402. if (stale.length === 0) {
  403. console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`)
  404. process.exit(0)
  405. }
  406. console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
  407. process.exit(1)
  408. }
  409. for (const [out, content] of outputs) writeFileSync(resolve(root, out), content)
  410. console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`)
  411. }
  412. // Run only when invoked as a script, not when imported by a test.
  413. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  414. main()
  415. }