gen-cordis-catalog.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. /**
  2. * Generate the Cordis event and service catalogs from static declarations.
  3. * The walk enforces event modes, JSDoc parameter/return completeness, and
  4. * signature type-link coverage; inherited Cordis services come from the
  5. * curated table below. `--check` verifies both committed artifacts.
  6. */
  7. import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  8. import { dirname, resolve, sep } from 'node:path'
  9. import ts from 'typescript'
  10. import { renderCordisCoreApiPages } from './cordis-core-api.ts'
  11. import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
  12. import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
  13. const root = resolve(import.meta.dirname, '..')
  14. const OUT_EVENTS = 'docs/cordis-catalog/events.md'
  15. const OUT_SERVICES = 'docs/cordis-catalog/services.md'
  16. /** The fenced-block info string for generated signature blocks (skipped by
  17. * doc-typecheck, since a bare signature fragment is not standalone-compilable). */
  18. const FENCE = 'ts cordis-catalog'
  19. /**
  20. * One primary core-data-structures page per project type used by a generated
  21. * signature. This stays curated because union names intentionally do not
  22. * reuse the type-equivalence manifest's map-symbol entries and some symbols
  23. * appear on more than one page.
  24. */
  25. export const LINK_MAP: Record<string, string> = {
  26. Agent: 'core.md',
  27. AgentCancelCause: 'core.md',
  28. AgentOptions: 'core.md',
  29. AgentStatus: 'core.md',
  30. ContentBlock: 'core.md',
  31. ContinuationDecision: 'core.md',
  32. ContinuationStop: 'core.md',
  33. GenerateOptions: 'core.md',
  34. InboxPlacement: 'core.md',
  35. MessageId: 'core.md',
  36. HookContext: 'core.md',
  37. SettleReason: 'core.md',
  38. LlmCallConfig: 'core.md',
  39. LlmModelContext: 'core.md',
  40. LlmModelReasoningInfo: 'core.md',
  41. LlmResolvedModelInfo: 'core.md',
  42. LlmFailure: 'llm-streaming.md',
  43. LlmModelInfo: 'core.md',
  44. LlmProviderInfo: 'core.md',
  45. ResolvedRetryPolicy: 'llm-streaming.md',
  46. Message: 'core.md',
  47. MessageSource: 'core.md',
  48. UserMessage: 'session.md',
  49. PromptDecision: 'core.md',
  50. RequestErrorAction: 'core.md',
  51. RequestError: 'core.md',
  52. PreparedReferencedMessage: 'session-reference.md',
  53. SessionReferenceCandidate: 'session-reference.md',
  54. SessionReferenceInput: 'session-reference.md',
  55. SessionEvent: 'core.md',
  56. SessionId: 'core.md',
  57. SessionStartSource: 'core.md',
  58. SessionLogSnapshot: 'session-query.md',
  59. SessionSurfaceSnapshot: 'session-query.md',
  60. ApprovalOutcome: 'approval.md',
  61. ApprovalPolicy: 'approval.md',
  62. ApprovalRequest: 'approval.md',
  63. ApprovalService: 'approval.md',
  64. BashExecRequest: 'bash.md',
  65. BashExecSpec: 'bash.md',
  66. BashProcess: 'bash.md',
  67. BashRunResult: 'bash.md',
  68. DshEnvironment: 'subprocess.md',
  69. SubprocessHandle: 'subprocess.md',
  70. SubprocessOutcome: 'subprocess.md',
  71. SubprocessOutputRead: 'subprocess.md',
  72. SubprocessOutputReader: 'subprocess.md',
  73. SubprocessSpawnSpec: 'subprocess.md',
  74. CodeRunRequest: 'code-runtime.md',
  75. CodeRunResult: 'code-runtime.md',
  76. CompactionResult: 'compaction.md',
  77. CompactionTrigger: 'compaction.md',
  78. PruneResult: 'compaction.md',
  79. FileReadOutcome: 'filesystem.md',
  80. FsDirEntry: 'filesystem.md',
  81. FsEditOutcome: 'filesystem.md',
  82. FsEditRequest: 'filesystem.md',
  83. FsInfo: 'filesystem.md',
  84. FsPathInfo: 'filesystem.md',
  85. FsPolicyExec: 'filesystem.md',
  86. FsTarget: 'filesystem.md',
  87. FsVersion: 'filesystem.md',
  88. FsWriteIntent: 'filesystem.md',
  89. FsWriteOutcome: 'filesystem.md',
  90. CreateGoalRequest: 'goal.md',
  91. EditGoalRequest: 'goal.md',
  92. GoalBlockReason: 'goal.md',
  93. GoalChanged: 'goal.md',
  94. GoalRef: 'goal.md',
  95. GoalView: 'goal.md',
  96. CommandDefinition: 'commands.md',
  97. CommandDescriptor: 'commands.md',
  98. CommandResult: 'commands.md',
  99. CommandSurface: 'commands.md',
  100. LlmAdapter: 'llm-streaming.md',
  101. PreparedLlmCall: 'llm-streaming.md',
  102. LlmService: 'llm-streaming.md',
  103. StreamChunk: 'llm-streaming.md',
  104. CreateSessionOptions: 'persistence.md',
  105. SessionHeader: 'persistence.md',
  106. SessionLocation: 'persistence.md',
  107. SessionPersistenceSnapshot: 'persistence.md',
  108. ConfinedArgv: 'sandbox.md',
  109. SandboxExecutionPolicy: 'sandbox.md',
  110. SandboxMode: 'sandbox.md',
  111. SandboxPolicy: 'sandbox.md',
  112. PtyBackend: 'pty.md',
  113. PtyReadRequest: 'pty.md',
  114. PtyReadResult: 'pty.md',
  115. PtySendOperation: 'pty.md',
  116. PtySendRequest: 'pty.md',
  117. PtySessionId: 'pty.md',
  118. PtySessionSnapshot: 'pty.md',
  119. PtySignal: 'pty.md',
  120. PtySignalResult: 'pty.md',
  121. PtySpawnRequest: 'pty.md',
  122. PtySpawnResult: 'pty.md',
  123. SandboxPolicyRequest: 'sandbox.md',
  124. ScopeKey: 'scope.md',
  125. Scoped: 'scope.md',
  126. EpochHeader: 'session.md',
  127. Session: 'session.md',
  128. SessionEventMap: 'session.md',
  129. TurnEndReason: 'session.md',
  130. TurnTrigger: 'session.md',
  131. SessionEventReadRequest: 'session-query.md',
  132. SessionEventRecord: 'session-query.md',
  133. SessionEventResultFilter: 'session-query.md',
  134. SessionEventSearchDocument: 'session-query.md',
  135. SessionEventSearchHit: 'session-query.md',
  136. SessionEventSearchPage: 'session-query.md',
  137. SessionEventSearchRequest: 'session-query.md',
  138. SessionEventTrace: 'session-query.md',
  139. SessionEventTraceObservation: 'session-query.md',
  140. SessionEventTraceRequest: 'session-query.md',
  141. SessionEventWindow: 'session-query.md',
  142. SessionLineageTrace: 'session-query.md',
  143. SessionRecord: 'session-query.md',
  144. SessionResultFilter: 'session-query.md',
  145. SessionSearchExecContext: 'session-query.md',
  146. SessionSearchHit: 'session-query.md',
  147. SessionSearchPage: 'session-query.md',
  148. SessionSearchRequest: 'session-query.md',
  149. SessionTitleObservation: 'session-query.md',
  150. SessionTitleObservationResult: 'session-query.md',
  151. SessionTitleProvider: 'session-title.md',
  152. SessionTitleSnapshot: 'session-title.md',
  153. SkillDefinition: 'skills.md',
  154. SkillLookupOptions: 'skills.md',
  155. SkillProvider: 'skills.md',
  156. SkillRegistration: 'skills.md',
  157. SkillSummary: 'skills.md',
  158. SaveTextSpill: 'spill.md',
  159. SpillRef: 'spill.md',
  160. SubagentProvider: 'subagent.md',
  161. SubagentRun: 'subagent.md',
  162. SubagentService: 'subagent.md',
  163. SubagentStartRequest: 'subagent.md',
  164. AssembleContext: 'system-prompt.md',
  165. PromptSection: 'system-prompt.md',
  166. SystemPrompt: 'system-prompt.md',
  167. ToolProviderResult: 'system-prompt.md',
  168. TaskDoneListener: 'tasks.md',
  169. TaskId: 'tasks.md',
  170. TaskRead: 'tasks.md',
  171. TaskSnapshot: 'tasks.md',
  172. TaskStart: 'tasks.md',
  173. TokenMeasurement: 'token-meter.md',
  174. CodeDispatchLog: 'tools.md',
  175. PostToolDecision: 'tools.md',
  176. PreToolDecision: 'tools.md',
  177. ToolDefinition: 'tools.md',
  178. ToolExecution: 'tools.md',
  179. ToolDispatchExecution: 'tools.md',
  180. ToolExecutionInput: 'tools.md',
  181. ToolExecutionMode: 'tools.md',
  182. ToolExecutionResult: 'tools.md',
  183. ToolExecutionToken: 'tools.md',
  184. ToolGuard: 'tools.md',
  185. ToolRegistry: 'tools.md',
  186. ToolRestriction: 'tools.md',
  187. ToolSchema: 'tools.md',
  188. AskUserQuestionAnswer: 'user-interaction.md',
  189. AskUserQuestionRequest: 'user-interaction.md',
  190. UserInteractionProvider: 'user-interaction.md',
  191. WebFetchProvider: 'web.md',
  192. WebFetchRequest: 'web.md',
  193. WebFetchResult: 'web.md',
  194. WebSearchProvider: 'web.md',
  195. WebSearchRequest: 'web.md',
  196. WebSearchResult: 'web.md',
  197. WorkflowRun: 'workflow.md',
  198. WorkflowRunInfo: 'workflow.md',
  199. WorkflowStartRequest: 'workflow.md',
  200. }
  201. /** TypeScript lib and pinned framework types that have no repository-owned data page. */
  202. const FOUNDATION_TYPE_NAMES = new Set([
  203. 'AbortSignal',
  204. 'AsyncIterable',
  205. 'Context',
  206. 'Error',
  207. 'Partial',
  208. 'Pick',
  209. 'Promise',
  210. 'Readonly',
  211. ])
  212. /** Project types deliberately documented outside the core-data catalog. */
  213. const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
  214. AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
  215. BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
  216. InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
  217. ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
  218. InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
  219. AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
  220. BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
  221. BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
  222. CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
  223. CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
  224. Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
  225. DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
  226. DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md',
  227. DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',
  228. DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md',
  229. StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts',
  230. StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts',
  231. ProjectionDefinition: 'projection unit contract is owned by packages/session-projection/session-projection/README.md',
  232. SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts',
  233. ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts',
  234. ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts',
  235. ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts',
  236. CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts',
  237. InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
  238. LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
  239. WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts',
  240. WebRoute: 'route registration contract is owned by packages/host/webserver/src/index.ts',
  241. ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
  242. Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
  243. TuiOverlayRequest: 'service-local extension contract is owned by packages/ui/tui/README.md',
  244. TuiOverlaySession: 'service-local extension contract is owned by packages/ui/tui/README.md',
  245. InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
  246. PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
  247. PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
  248. PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
  249. ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
  250. SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
  251. SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
  252. SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
  253. TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
  254. WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
  255. WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
  256. WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
  257. Workspace: 'workspace entity contract is owned by packages/workspace/workspace/README.md',
  258. WorkspaceId: 'branded id is owned by packages/workspace/workspace/README.md',
  259. }
  260. /** Collect named references from parameter, generic-constraint/default, and return types. */
  261. function signatureTypeNames(member: ts.MethodSignature | ts.MethodDeclaration, sf: ts.SourceFile): string[] {
  262. const declared = new Set(member.typeParameters?.map(parameter => parameter.name.text) ?? [])
  263. const referenced = new Set<string>()
  264. const visit = (node: ts.Node): void => {
  265. if (ts.isTypeReferenceNode(node)) referenced.add(node.typeName.getText(sf))
  266. if (ts.isTypeQueryNode(node)) referenced.add(node.exprName.getText(sf))
  267. ts.forEachChild(node, visit)
  268. }
  269. for (const parameter of member.typeParameters ?? []) {
  270. if (parameter.constraint) visit(parameter.constraint)
  271. if (parameter.default) visit(parameter.default)
  272. }
  273. for (const parameter of member.parameters) {
  274. if (parameter.type) visit(parameter.type)
  275. }
  276. if (member.type) visit(member.type)
  277. return [...referenced].filter(name => !declared.has(name)).sort()
  278. }
  279. /** Append fail-closed signature type-link violations with actionable ownership choices. */
  280. function checkTypeLinks(
  281. where: string,
  282. member: ts.MethodSignature | ts.MethodDeclaration,
  283. sf: ts.SourceFile,
  284. violations: string[],
  285. ): void {
  286. for (const name of signatureTypeNames(member, sf)) {
  287. if (Object.hasOwn(LINK_MAP, name)
  288. || FOUNDATION_TYPE_NAMES.has(name)
  289. || Object.hasOwn(TYPE_LINK_EXEMPTIONS, name)) continue
  290. violations.push(
  291. `${where} references unclassified type '${name}'. Add it to LINK_MAP with its core-data-structures page, `
  292. + 'to FOUNDATION_TYPE_NAMES if TypeScript or Cordis owns it, or to TYPE_LINK_EXEMPTIONS with '
  293. + 'the non-catalog documentation owner.',
  294. )
  295. }
  296. }
  297. /** Throw one aggregated diagnostic for every unclassified signature type. */
  298. function reportTypeLinkViolations(gate: string, violations: string[]): void {
  299. if (violations.length === 0) return
  300. throw new Error(
  301. `${gate}: ${violations.length} signature type-link coverage violation(s):\n`
  302. + violations.map(violation => ` ${violation}`).join('\n'),
  303. )
  304. }
  305. /** One harness event, extracted from an `interface Events` block. */
  306. interface EventEntry {
  307. /** Scoped name, e.g. `agent/request`. */
  308. name: string
  309. /** The scope prefix, e.g. `agent` (everything before the first `/`). */
  310. scope: string
  311. /** Full signature text (the method-signature member, JSDoc stripped). */
  312. signature: string
  313. /** Original declaration JSDoc, dedented from its containing interface. */
  314. jsDoc: string
  315. /** Dispatch mode from the `@mode` tag. */
  316. mode: Mode
  317. /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
  318. doc: string
  319. /** Source pointer `packages/…/file.ts:line` of the declaration. */
  320. source: string
  321. }
  322. /** One public service method and the source contract attached to it. */
  323. interface ServiceMethodEntry {
  324. /** Public method signature (body stripped). */
  325. signature: string
  326. /** Original method JSDoc, dedented from its containing class. */
  327. jsDoc: string
  328. }
  329. /** One harness service, extracted from an `interface Context` block. */
  330. interface ServiceEntry {
  331. /** The `ctx.<key>` name, e.g. `llm`. */
  332. key: string
  333. /** The service class/interface name, e.g. `LlmService`. */
  334. type: string
  335. /** Whether the service class is abstract (a seam interface). */
  336. abstract: boolean
  337. /** Class-level JSDoc prose, one line per paragraph. */
  338. doc: string
  339. /** Public methods (bodies stripped), in source order. */
  340. methods: ServiceMethodEntry[]
  341. /** Source pointer of the class declaration. */
  342. source: string
  343. }
  344. /** A terse inherited-tier entry (pinned vendor surface). */
  345. interface InheritedEntry {
  346. name: string
  347. summary: string
  348. /** Source pointer `vendor/…:line`. */
  349. source: string
  350. }
  351. // cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
  352. /** The signature text of a method-signature member (everything but a body). */
  353. function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
  354. const full = member.getText(sf)
  355. const body = (member as { body?: ts.Node }).body
  356. const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full
  357. return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
  358. }
  359. /**
  360. * Copy a node's original JSDoc while removing only the indentation imposed by
  361. * its containing interface or class.
  362. */
  363. function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string {
  364. const raw = rawJsDoc(text, node)
  365. if (!raw) return ''
  366. const start = text.lastIndexOf(raw, node.getStart(sf))
  367. const { line } = sf.getLineAndCharacterOfPosition(start)
  368. const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
  369. const indent = text.slice(lineStart, start)
  370. return raw.split('\n')
  371. .map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
  372. .join('\n')
  373. }
  374. /** Walk every harness `interface Events` block and extract its events, hard-
  375. * erroring (aggregated) on any JSDoc-completeness violation: a missing/
  376. * contradicted `@mode`, missing description prose, or an undocumented payload
  377. * parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */
  378. export function collectEvents(scanRoot: string = root): EventEntry[] {
  379. const entries: EventEntry[] = []
  380. const violations: string[] = []
  381. const typeLinkViolations: string[] = []
  382. for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
  383. const abs = resolve(scanRoot, rel)
  384. const text = readFileSync(abs, 'utf8')
  385. if (!text.includes('interface Events')) continue
  386. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
  387. const body = cordisModuleBody(sf)
  388. if (!body) continue
  389. for (const { name, member } of eventMembers(body, sf)) {
  390. const signature = memberSignature(member, sf)
  391. const raw = rawJsDoc(text, member)
  392. const { doc, mode } = parseJsDoc(raw)
  393. const src = pointer(rel, sf, member)
  394. const where = `event '${name}' (${src})`
  395. checkTypeLinks(where, member, sf, typeLinkViolations)
  396. if (!mode) {
  397. violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial|bail' to its JSDoc (see AGENTS.md).`)
  398. }
  399. // Conclusive structural check: a trailing `next: () => …` parameter is a
  400. // waterfall. (emit vs parallel vs serial is not structurally
  401. // distinguishable, so it is trusted from the tag.)
  402. const last = member.parameters.at(-1)
  403. const hasNext = !!last && last.name.getText(sf) === 'next'
  404. if (mode && hasNext && mode !== 'waterfall') {
  405. violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
  406. }
  407. if (mode && !hasNext && mode === 'waterfall') {
  408. violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
  409. }
  410. if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
  411. // Payload parameters need a non-empty @param. The `this` receiver is not
  412. // payload, and a waterfall's trailing `next` is covered by its mode.
  413. const { params } = parseTags(raw)
  414. checkParams(where, 'event', member.parameters, params, sf,
  415. p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
  416. if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src })
  417. }
  418. }
  419. reportViolations('gen-cordis-catalog', violations)
  420. reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
  421. return entries
  422. }
  423. /** Walk every harness `interface Context` block + its service class, hard-
  424. * erroring (aggregated) on any JSDoc-completeness violation: a class or public
  425. * method without JSDoc prose, an undocumented parameter, a stale `@param`, a
  426. * missing `@returns` on a non-void method, or an inferred (unannotated) return
  427. * type the pure-AST walk cannot classify.
  428. * `scanRoot` defaults to the repo root; tests pass a fixture dir. */
  429. export function collectServices(scanRoot: string = root): ServiceEntry[] {
  430. const entries: ServiceEntry[] = []
  431. const violations: string[] = []
  432. const typeLinkViolations: string[] = []
  433. for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
  434. const abs = resolve(scanRoot, rel)
  435. const text = readFileSync(abs, 'utf8')
  436. if (!text.includes('interface Context')) continue
  437. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
  438. const body = cordisModuleBody(sf)
  439. if (!body) continue
  440. // Resolve each ctx key to its service class (shared walk) and emit an entry.
  441. for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
  442. const methods: ServiceMethodEntry[] = []
  443. for (const member of cls.members) {
  444. if (!ts.isMethodDeclaration(member)) continue
  445. // Only instance methods callable through `ctx.<key>` are surface;
  446. // private, protected, and static methods are not.
  447. const nonPublic = member.modifiers?.some(m =>
  448. m.kind === ts.SyntaxKind.PrivateKeyword
  449. || m.kind === ts.SyntaxKind.ProtectedKeyword
  450. || m.kind === ts.SyntaxKind.StaticKeyword)
  451. || ts.isPrivateIdentifier(member.name)
  452. if (nonPublic) continue
  453. const memberName = member.name.getText(sf)
  454. if (memberName.startsWith('[')) continue // computed/symbol members
  455. const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
  456. checkTypeLinks(where, member, sf, typeLinkViolations)
  457. const raw = rawJsDoc(text, member)
  458. methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) })
  459. if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
  460. if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
  461. const { params, returns } = parseTags(raw)
  462. // Every parameter needs a non-empty @param (`this` receiver exempt),
  463. // and a non-void ANNOTATED result needs a non-empty @returns — the
  464. // shared checkers carry the exact contract.
  465. checkParams(where, 'service', member.parameters, params, sf,
  466. p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
  467. checkReturns(where, member.type, returns, sf, violations)
  468. }
  469. entries.push({
  470. key,
  471. type,
  472. abstract,
  473. doc: clsDoc,
  474. methods,
  475. source: pointer(rel, sf, cls),
  476. })
  477. }
  478. }
  479. reportViolations('gen-cordis-catalog', violations)
  480. reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
  481. return entries.sort((a, b) => a.key.localeCompare(b.key))
  482. }
  483. /**
  484. * The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and
  485. * hand-summarized because (a) it is pinned vendor source that changes only on a
  486. * deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members
  487. * with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would
  488. * wrongly surface as services, and (c) the internal/* events carry no JSDoc to
  489. * render. Source pointers are verified against vendor by `verify-md-links`'
  490. * sibling check is N/A; keep them current on a vendor bump.
  491. */
  492. const INHERITED_EVENTS: InheritedEntry[] = [
  493. { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
  494. { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
  495. { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
  496. { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
  497. { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
  498. { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
  499. { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
  500. { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
  501. { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
  502. { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
  503. { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
  504. { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
  505. { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
  506. { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
  507. { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
  508. ]
  509. export const INHERITED_SERVICES: InheritedEntry[] = [
  510. { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
  511. { 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:34' },
  512. { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
  513. { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
  514. { 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' },
  515. { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
  516. { 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' },
  517. { 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' },
  518. { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
  519. { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
  520. ]
  521. /** Render the cross-link "Types:" line for a signature, or '' if none apply. */
  522. function typeLinks(signature: string): string {
  523. const seen = new Set<string>()
  524. for (const name of Object.keys(LINK_MAP)) {
  525. if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name)
  526. }
  527. if (seen.size === 0) return ''
  528. const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`)
  529. return `Types: ${links.join(' · ')}`
  530. }
  531. /** Render one harness event entry. */
  532. function renderEvent(e: EventEntry): string[] {
  533. const out = [`### \`${e.name}\` — ${e.mode}`, '']
  534. if (e.doc) out.push(e.doc, '')
  535. out.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
  536. const links = typeLinks(e.signature)
  537. if (links) out.push(links, '')
  538. out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
  539. return out
  540. }
  541. /** Render one harness service entry. */
  542. function renderService(s: ServiceEntry): string[] {
  543. const kind = s.abstract ? ' (abstract seam)' : ''
  544. const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, '']
  545. if (s.doc) out.push(s.doc, '')
  546. if (s.methods.length) {
  547. const declarations = s.methods.flatMap((method, index) => [
  548. ...(index > 0 ? [''] : []),
  549. method.jsDoc,
  550. method.signature,
  551. ])
  552. out.push('```' + FENCE, ...declarations, '```', '')
  553. const links = typeLinks(s.methods.map(method => method.signature).join('\n'))
  554. if (links) out.push(links, '')
  555. }
  556. out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
  557. return out
  558. }
  559. /** The shared generated-file banner comment. */
  560. const BANNER = [
  561. '<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
  562. ' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
  563. '',
  564. ]
  565. /** The shared GENERATED + freshness-gate + fence notice paragraph. */
  566. 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 and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.'
  567. /** Render the events catalog (pure, deterministic given sorted inputs). */
  568. export function renderEvents(events: EventEntry[]): string {
  569. const lines: string[] = [
  570. ...BANNER,
  571. '# Cordis Events Catalog',
  572. '',
  573. 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration 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.',
  574. '',
  575. GATE_NOTICE,
  576. '',
  577. '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. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
  578. '',
  579. '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`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer).',
  580. '',
  581. ]
  582. const scopes = [...new Set(events.map(e => e.scope))].sort()
  583. for (const scope of scopes) {
  584. lines.push(`## \`${scope}/*\``, '')
  585. for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
  586. lines.push(...renderEvent(e))
  587. }
  588. }
  589. lines.push(
  590. '## Inherited events (cordis core + loader/hmr/timer)',
  591. '',
  592. '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.',
  593. '',
  594. )
  595. for (const e of INHERITED_EVENTS) {
  596. lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
  597. }
  598. lines.push('')
  599. return lines.join('\n')
  600. }
  601. /** Render the services catalog (pure, deterministic given sorted inputs). */
  602. export function renderServices(services: ServiceEntry[]): string {
  603. const lines: string[] = [
  604. ...BANNER,
  605. '# Cordis Services Catalog',
  606. '',
  607. 'Every `ctx.<key>` service a plugin can call: the exact public interface with original method JSDoc, 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.',
  608. '',
  609. GATE_NOTICE,
  610. '',
  611. '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. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).',
  612. '',
  613. ]
  614. for (const s of services) lines.push(...renderService(s))
  615. lines.push(
  616. '## Inherited `ctx` members (cordis core + loader/hmr/timer)',
  617. '',
  618. '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.',
  619. '',
  620. )
  621. for (const s of INHERITED_SERVICES) {
  622. lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
  623. }
  624. lines.push('')
  625. return lines.join('\n')
  626. }
  627. /** CLI entry: `--write` (default) writes both catalogs, `--check` fails if
  628. * either is stale. Guarded behind an entry-point check so importing this module
  629. * for tests neither regenerates the committed files nor calls process.exit. */
  630. function main(): void {
  631. const outputs: [string, string][] = [
  632. [OUT_EVENTS, renderEvents(collectEvents())],
  633. [OUT_SERVICES, renderServices(collectServices())],
  634. ...renderCordisCoreApiPages(),
  635. ]
  636. if (process.argv.includes('--check')) {
  637. const stale: string[] = []
  638. for (const [out, content] of outputs) {
  639. let committed: string | null = null
  640. try {
  641. committed = readFileSync(resolve(root, out), 'utf8')
  642. } catch {
  643. // Only ENOENT (not yet generated) is expected; a present-but-unreadable
  644. // file is not a state this repo produces. Either way the remedy is the
  645. // same — regenerate — so treat a read failure as "stale".
  646. committed = null
  647. }
  648. if (committed !== content) stale.push(out)
  649. }
  650. if (stale.length === 0) {
  651. console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`)
  652. process.exit(0)
  653. }
  654. console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
  655. process.exit(1)
  656. }
  657. for (const [out, content] of outputs) {
  658. const destination = resolve(root, out)
  659. mkdirSync(dirname(destination), { recursive: true })
  660. writeFileSync(destination, content)
  661. }
  662. console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
  663. }
  664. // Run only when invoked as a script, not when imported by a test.
  665. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  666. main()
  667. }