gen-cordis-catalog.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. /**
  2. * Generate (and verify) the cordis events + services catalog in
  3. * docs/cordis-catalog/events-and-services.md.
  4. *
  5. * The catalog is the WIRING-axis reference: every cordis event a plugin can
  6. * listen to (exact signature + dispatch mode) and every `ctx.<key>` service it
  7. * can call (exact public interface). It complements the core-data-structures
  8. * catalog (the VOCABULARY axis — the types these signatures move around).
  9. *
  10. * The catalog is FULLY GENERATED from source — never hand-edit it. The codebase
  11. * is disciplined enough that a pure-AST pass captures the whole truthful
  12. * surface: every event/service is a string literal that round-trips to a static
  13. * `interface Events` / `interface Context` declaration (no dynamically-named
  14. * events, no runtime-only services). So the committed file is a build artifact
  15. * and a regenerate-and-diff freshness check (`--check`) makes drift structurally
  16. * impossible. Because generation enumerates source rather than checking a
  17. * hand-written subset, a brand-new event cannot be silently undocumented — it
  18. * appears in the next regenerate, and an un-regenerated file fails `--check`.
  19. *
  20. * `tsx scripts/gen-cordis-catalog.ts` → write the catalog
  21. * `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if the committed file
  22. * is stale (CI / pre-push gate)
  23. *
  24. * The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in
  25. * full from source: signature, the `@mode` badge, and the declaration's JSDoc.
  26. * Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag
  27. * — the generator hard-errors on a missing tag, and where the signature shape is
  28. * conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
  29. * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED
  30. * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
  31. * also sees; it is rendered tersely (name + one-line + source pointer) from a
  32. * curated table in this script, NOT elevated to the harness tier's prominence.
  33. *
  34. * Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck
  35. * recognizes it and skips compilation (the signatures are fragments, not
  36. * standalone-compilable, like the ` ```ts type-equiv ` blocks).
  37. */
  38. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  39. import { resolve } from 'node:path'
  40. import ts from 'typescript'
  41. const root = resolve(import.meta.dirname, '..')
  42. const OUT = 'docs/cordis-catalog/events-and-services.md'
  43. /** The fenced-block info string for generated signature blocks (skipped by
  44. * doc-typecheck, since a bare signature fragment is not standalone-compilable). */
  45. const FENCE = 'ts cordis-catalog'
  46. /** A dispatch mode, rendered as the badge after an event name. */
  47. type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
  48. /**
  49. * Cross-link map: a type name that appears in a signature → the
  50. * core-data-structures page that documents it (path relative to OUT's folder).
  51. * Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json —
  52. * that manifest documents the `…Map` symbols (`ContentBlockMap`) while
  53. * signatures reference the derived UNION names (`ContentBlock`), and it lists a
  54. * few symbols on two pages. Here each name resolves to exactly one PRIMARY page.
  55. */
  56. const LINK_MAP: Record<string, string> = {
  57. Agent: 'core.md',
  58. ContentBlock: 'core.md',
  59. Message: 'core.md',
  60. MessageSource: 'core.md',
  61. GenerateOptions: 'core.md',
  62. SessionEvent: 'core.md',
  63. StreamChunk: 'llm-streaming.md',
  64. TurnEndReason: 'session.md',
  65. ToolDefinition: 'tools.md',
  66. ToolExecution: 'tools.md',
  67. ToolExecutionResult: 'tools.md',
  68. BashExecRequest: 'bash.md',
  69. BashExecSpec: 'bash.md',
  70. BashRunResult: 'bash.md',
  71. BashTask: 'bash.md',
  72. BashTaskRead: 'bash.md',
  73. FsEditOutcome: 'filesystem.md',
  74. FsEditRequest: 'filesystem.md',
  75. FsInfo: 'filesystem.md',
  76. FsTarget: 'filesystem.md',
  77. FsVersion: 'filesystem.md',
  78. FsWriteIntent: 'filesystem.md',
  79. FsWriteOutcome: 'filesystem.md',
  80. FsPolicyExec: 'filesystem.md',
  81. FileReadOutcome: 'filesystem.md',
  82. }
  83. /** One harness event, extracted from an `interface Events` block. */
  84. interface EventEntry {
  85. /** Scoped name, e.g. `agent/request`. */
  86. name: string
  87. /** The scope prefix, e.g. `agent` (everything before the first `/`). */
  88. scope: string
  89. /** Full signature text (the method-signature member, JSDoc stripped). */
  90. signature: string
  91. /** Dispatch mode from the `@mode` tag. */
  92. mode: Mode
  93. /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
  94. doc: string
  95. /** Source pointer `packages/…/file.ts:line` of the declaration. */
  96. source: string
  97. }
  98. /** One harness service, extracted from an `interface Context` block. */
  99. interface ServiceEntry {
  100. /** The `ctx.<key>` name, e.g. `llm`. */
  101. key: string
  102. /** The service class/interface name, e.g. `LlmService`. */
  103. type: string
  104. /** Whether the service class is abstract (a seam interface). */
  105. abstract: boolean
  106. /** Class-level JSDoc prose, one line per paragraph. */
  107. doc: string
  108. /** Public method signatures (bodies stripped), in source order. */
  109. methods: string[]
  110. /** Source pointer of the class declaration. */
  111. source: string
  112. }
  113. /** A terse inherited-tier entry (pinned vendor surface). */
  114. interface InheritedEntry {
  115. name: string
  116. summary: string
  117. /** Source pointer `vendor/…:line`. */
  118. source: string
  119. }
  120. /** Repo-relative source pointer `file:line` for a node's first character. */
  121. function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
  122. const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
  123. return `${rel}:${line + 1}`
  124. }
  125. /** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */
  126. function rawJsDoc(text: string, node: ts.Node): string {
  127. const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
  128. const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
  129. return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
  130. }
  131. /**
  132. * Parse a raw JSDoc block into description prose + the `@mode` tag (when
  133. * present). Output obeys the repo's markdown conventions so the generated file
  134. * passes verify-md-wrap: each prose paragraph collapses to ONE physical line,
  135. * and a `-` bullet list is preserved with each item on its own single line
  136. * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines
  137. * other than `@mode` end the current prose run.
  138. */
  139. function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
  140. const inner = raw
  141. .replace(/^\/\*\*/, '')
  142. .replace(/\*\/$/, '')
  143. .split('\n')
  144. .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
  145. let mode: Mode | null = null
  146. const blocks: string[] = []
  147. let para: string[] = []
  148. let list: string[] = []
  149. let item: string[] = []
  150. const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
  151. const flushItem = (): void => {
  152. if (item.length) list.push(join(item))
  153. item = []
  154. }
  155. const flushList = (): void => {
  156. flushItem()
  157. if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
  158. list = []
  159. }
  160. const flushPara = (): void => {
  161. flushList()
  162. if (para.length) blocks.push(join(para))
  163. para = []
  164. }
  165. for (const line of inner) {
  166. const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
  167. if (m) { mode = m[1] as Mode; continue }
  168. if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose
  169. if (line.trim() === '') { flushPara(); continue }
  170. if (/^-\s+/.test(line)) {
  171. // A list item starts: a pending paragraph (e.g. an intro line directly
  172. // above the list, no blank between) flushes FIRST so it renders above.
  173. flushItem()
  174. if (para.length) { blocks.push(join(para)); para = [] }
  175. item.push(line)
  176. continue
  177. }
  178. if (item.length) { item.push(line); continue } // continuation of current item
  179. para.push(line)
  180. }
  181. flushPara()
  182. const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
  183. return { doc, mode }
  184. }
  185. /** Find the `declare module 'cordis'` body in a source file, or null. */
  186. function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
  187. for (const stmt of sf.statements) {
  188. if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
  189. if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
  190. }
  191. }
  192. return null
  193. }
  194. /** The signature text of a method-signature member (everything but a body). */
  195. function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
  196. const full = member.getText(sf)
  197. const body = (member as { body?: ts.Node }).body
  198. const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full
  199. return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
  200. }
  201. /** Walk every harness `interface Events` block and extract its events.
  202. * `scanRoot` defaults to the repo root; tests pass a fixture dir. */
  203. export function collectEvents(scanRoot: string = root): EventEntry[] {
  204. const entries: EventEntry[] = []
  205. for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
  206. const abs = resolve(scanRoot, rel)
  207. const text = readFileSync(abs, 'utf8')
  208. if (!text.includes('interface Events')) continue
  209. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
  210. const body = cordisModuleBody(sf)
  211. if (!body) continue
  212. for (const stmt of body.statements) {
  213. if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
  214. for (const member of stmt.members) {
  215. if (!ts.isMethodSignature(member)) continue
  216. const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
  217. const signature = memberSignature(member, sf)
  218. const { doc, mode } = parseJsDoc(rawJsDoc(text, member))
  219. const src = pointer(rel, sf, member)
  220. if (!mode) {
  221. throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
  222. }
  223. // Conclusive structural check: a trailing `next: () => …` parameter is a
  224. // waterfall. (emit vs parallel vs serial is not structurally
  225. // distinguishable, so it is trusted from the tag.)
  226. const last = member.parameters.at(-1)
  227. const hasNext = !!last && last.name.getText(sf) === 'next'
  228. if (hasNext && mode !== 'waterfall') {
  229. throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
  230. }
  231. if (!hasNext && mode === 'waterfall') {
  232. throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
  233. }
  234. entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
  235. }
  236. }
  237. }
  238. return entries
  239. }
  240. /** Walk every harness `interface Context` block + its service class.
  241. * `scanRoot` defaults to the repo root; tests pass a fixture dir. */
  242. export function collectServices(scanRoot: string = root): ServiceEntry[] {
  243. const entries: ServiceEntry[] = []
  244. for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
  245. const abs = resolve(scanRoot, rel)
  246. const text = readFileSync(abs, 'utf8')
  247. if (!text.includes('interface Context')) continue
  248. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
  249. const body = cordisModuleBody(sf)
  250. if (!body) continue
  251. // The ctx key → type mapping(s) declared in this file's interface Context.
  252. const keyToType = new Map<string, string>()
  253. for (const stmt of body.statements) {
  254. if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
  255. for (const member of stmt.members) {
  256. if (!ts.isPropertySignature(member) || !member.type) continue
  257. const key = member.name.getText(sf)
  258. keyToType.set(key, member.type.getText(sf))
  259. }
  260. }
  261. if (keyToType.size === 0) continue
  262. // Find each service class declared in the same file and emit an entry.
  263. for (const [key, type] of keyToType) {
  264. const cls = sf.statements.find(
  265. (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
  266. )
  267. if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
  268. const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
  269. const methods: string[] = []
  270. for (const member of cls.members) {
  271. if (!ts.isMethodDeclaration(member)) continue
  272. // Only the PUBLIC callable surface a `ctx.<key>` consumer sees. Drop
  273. // private/protected (a protected method like `notifyTaskDone` is a
  274. // subclass hook, not something a plugin calls through `ctx.bash`) and
  275. // static (not reachable through the instance).
  276. const nonPublic = member.modifiers?.some(m =>
  277. m.kind === ts.SyntaxKind.PrivateKeyword
  278. || m.kind === ts.SyntaxKind.ProtectedKeyword
  279. || m.kind === ts.SyntaxKind.StaticKeyword)
  280. || ts.isPrivateIdentifier(member.name)
  281. if (nonPublic) continue
  282. const memberName = member.name.getText(sf)
  283. if (memberName.startsWith('[')) continue // computed/symbol members
  284. methods.push(memberSignature(member, sf))
  285. }
  286. entries.push({
  287. key,
  288. type,
  289. abstract,
  290. doc: parseJsDoc(rawJsDoc(text, cls)).doc,
  291. methods,
  292. source: pointer(rel, sf, cls),
  293. })
  294. }
  295. }
  296. return entries.sort((a, b) => a.key.localeCompare(b.key))
  297. }
  298. /**
  299. * The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and
  300. * hand-summarized because (a) it is pinned vendor source that changes only on a
  301. * deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members
  302. * with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would
  303. * wrongly surface as services, and (c) the internal/* events carry no JSDoc to
  304. * render. Source pointers are verified against vendor by `verify-md-links`'
  305. * sibling check is N/A; keep them current on a vendor bump.
  306. */
  307. const INHERITED_EVENTS: InheritedEntry[] = [
  308. { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
  309. { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
  310. { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
  311. { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
  312. { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
  313. { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
  314. { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
  315. { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
  316. { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
  317. { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
  318. { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
  319. { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
  320. { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
  321. { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
  322. { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
  323. ]
  324. const INHERITED_SERVICES: InheritedEntry[] = [
  325. { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
  326. { 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' },
  327. { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
  328. { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
  329. { 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' },
  330. { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
  331. { 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' },
  332. { 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' },
  333. { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
  334. { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
  335. ]
  336. /** Render the cross-link "Types:" line for a signature, or '' if none apply. */
  337. function typeLinks(signature: string): string {
  338. const seen = new Set<string>()
  339. for (const name of Object.keys(LINK_MAP)) {
  340. if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name)
  341. }
  342. if (seen.size === 0) return ''
  343. const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`)
  344. return `Types: ${links.join(' · ')}`
  345. }
  346. /** Render one harness event entry. */
  347. function renderEvent(e: EventEntry): string[] {
  348. const out = [`#### \`${e.name}\` — ${e.mode}`, '']
  349. if (e.doc) out.push(e.doc, '')
  350. out.push('```' + FENCE, e.signature, '```', '')
  351. const links = typeLinks(e.signature)
  352. if (links) out.push(links, '')
  353. out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
  354. return out
  355. }
  356. /** Render one harness service entry. */
  357. function renderService(s: ServiceEntry): string[] {
  358. const kind = s.abstract ? ' (abstract seam)' : ''
  359. const out = [`### \`ctx.${s.key}\` — \`${s.type}\`${kind}`, '']
  360. if (s.doc) out.push(s.doc, '')
  361. if (s.methods.length) {
  362. out.push('```' + FENCE, ...s.methods, '```', '')
  363. const links = typeLinks(s.methods.join('\n'))
  364. if (links) out.push(links, '')
  365. }
  366. out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
  367. return out
  368. }
  369. /** Render the full catalog (pure, deterministic given sorted inputs). */
  370. function render(events: EventEntry[], services: ServiceEntry[]): string {
  371. const lines: string[] = [
  372. '<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
  373. ' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
  374. '',
  375. '# Cordis Events & Services Catalog',
  376. '',
  377. 'An index reference to the **wiring** a plugin author works against: every cordis event you can listen to (exact signature + dispatch mode) and every `ctx.<key>` service you can call (exact public interface). It complements [core-data-structures/](../core-data-structures/core.md), which catalogs the *data structures* these signatures move around — this page is the verbs, that page is the nouns.',
  378. '',
  379. '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.',
  380. '',
  381. '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 surface a plugin also sees — pinned vendor source, summarized tersely.',
  382. '',
  383. '## Events',
  384. '',
  385. 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **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`).',
  386. '',
  387. ]
  388. const scopes = [...new Set(events.map(e => e.scope))].sort()
  389. for (const scope of scopes) {
  390. lines.push(`### \`${scope}/*\``, '')
  391. for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
  392. lines.push(...renderEvent(e))
  393. }
  394. }
  395. lines.push(
  396. '## Services',
  397. '',
  398. 'The `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
  399. '',
  400. )
  401. for (const s of services) lines.push(...renderService(s))
  402. lines.push(
  403. '## Inherited tier (cordis core + loader/hmr/timer)',
  404. '',
  405. 'The framework surface every plugin inherits, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the catalog is a complete picture of what `ctx` and the event bus offer, without elevating framework internals to the harness tier\'s prominence.',
  406. '',
  407. '### Inherited events',
  408. '',
  409. )
  410. for (const e of INHERITED_EVENTS) {
  411. lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
  412. }
  413. lines.push('', '### Inherited `ctx` members', '')
  414. for (const s of INHERITED_SERVICES) {
  415. lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
  416. }
  417. lines.push('')
  418. return lines.join('\n')
  419. }
  420. /** CLI entry: `--write` (default) writes the catalog, `--check` fails if stale.
  421. * Guarded behind an entry-point check so importing this module for tests neither
  422. * regenerates the committed file nor calls process.exit. */
  423. function main(): void {
  424. const content = render(collectEvents(), collectServices())
  425. if (process.argv.includes('--check')) {
  426. let committed: string | null = null
  427. try {
  428. committed = readFileSync(resolve(root, OUT), 'utf8')
  429. } catch {
  430. // Only ENOENT (not yet generated) is expected; a present-but-unreadable
  431. // file is not a state this repo produces. Either way the remedy is the
  432. // same — regenerate — so treat a read failure as "stale".
  433. committed = null
  434. }
  435. if (committed === content) {
  436. console.log(`gen-cordis-catalog: ${OUT} is up to date.`)
  437. process.exit(0)
  438. }
  439. console.error(`gen-cordis-catalog: ${OUT} is stale. Run \`pnpm run gen-cordis-catalog\` and commit ${OUT}.`)
  440. process.exit(1)
  441. }
  442. writeFileSync(resolve(root, OUT), content)
  443. console.log(`gen-cordis-catalog: wrote ${OUT}.`)
  444. }
  445. // Run only when invoked as a script, not when imported by a test.
  446. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  447. main()
  448. }