/** * Generate the model-facing Cordis API data module from the same event/service * collector as the documentation catalogs. It emits original declaration * JSDoc, first-sentence summaries, raw signatures, transitive public type * shapes, and inherited context entries, without source pointers; output is * deterministic and `--check` verifies it. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import ts from 'typescript' import { collectEvents, collectServices, INHERITED_SERVICES } from './gen-cordis-catalog.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'packages/cordis/tool-cordis/src/api-catalog.ts' /** Declarations longer than this render as a truncated stub — a shape the model cannot skim teaches nothing. */ const MAX_DECL_CHARS = 1500 /** The first sentence of a (possibly multi-line) JSDoc prose block. */ function firstSentence(doc: string): string { const line = doc.split('\n', 1)[0] ?? '' const match = /^(.*?[.!?])(?:\s|$)/.exec(line) return (match?.[1] ?? line).trim() } /** Render a string as a single-quoted, lint-clean TS literal. */ function quote(value: string): string { return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'` } /** * Collect exported interface and type shapes; omit names declared in multiple * packages rather than risk serving the wrong package's shape. */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) const decls = new Map() const ambiguous = new Set() for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) } } for (const name of ambiguous) decls.delete(name) return decls } /** Resolve and sort the word-bounded transitive type closure referenced by seed text. */ function referencedTypes(seeds: string[], decls: Map): { name: string; declaration: string }[] { const included = new Map() let frontier = seeds while (frontier.length > 0) { const next: string[] = [] for (const [name, declaration] of decls) { if (included.has(name)) continue const pattern = new RegExp(`\\b${name}\\b`) if (frontier.some(text => pattern.test(text))) { included.set(name, declaration) next.push(declaration) } } frontier = next } return [...included].map(([name, declaration]) => ({ name, declaration })).sort((a, b) => a.name.localeCompare(b.name)) } /** Render the whole generated module (pure, deterministic given sorted collector output). */ function render(): string { const services = collectServices() const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name)) const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls()) const lines: string[] = [ '/**', ' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run', ' * `pnpm run gen-cordis-api` to regenerate (freshness-gated by', ' * `pnpm run verify-cordis-api` in doc-sync).', ' *', ' * The machine-readable cordis API catalog `cordis_inspect` serves to the', ' * model: harness services (summary + public method signatures/JSDoc),', ' * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by', ' * the same AST walk as docs/cordis-catalog, so this data and the rendered', ' * docs cannot diverge.', ' *', ' * @module @deepseek-ai/dsh-tool-cordis/api-catalog', ' */', '', '/** One public service method and its source-owned contract. */', 'export interface ServiceApiMethod {', ' /** Public method signature with its body stripped. */', ' signature: string', ' /** Original method JSDoc, with only container indentation removed. */', ' jsDoc: string', '}', '', '/** One harness `ctx.` service: its one-line summary and public methods. */', 'export interface ServiceApiEntry {', ' /** The `ctx.` name, e.g. `tools`. */', ' key: string', ' /** First sentence of the service class JSDoc. */', ' summary: string', ' /** Public methods, bodies stripped, in source order. */', ' methods: readonly ServiceApiMethod[]', '}', '', '/** One harness event: its dispatch mode, exact signature, and one-line summary. */', 'export interface EventApiEntry {', ' /** The scoped event name, e.g. `agent/status`. */', ' name: string', ' /** The dispatch mode from the declaration\'s `@mode` tag. */', ' mode: string', ' /** The exact listener signature, whitespace-normalized. */', ' signature: string', ' /** Original event JSDoc, with only container indentation removed. */', ' jsDoc: string', ' /** First sentence of the event JSDoc. */', ' summary: string', '}', '', '/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */', 'export interface InheritedApiEntry {', ' /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */', ' name: string', ' /** One-line summary of what the member does. */', ' summary: string', '}', '', '/** One named type shape the service signatures reference. */', 'export interface TypeApiEntry {', ' /** The exported type/interface name, e.g. `BashRunResult`. */', ' name: string', ' /** The full declaration text, comments stripped. */', ' declaration: string', '}', '', '/** Every harness `ctx.` service, sorted by key. */', 'export const SERVICE_API: readonly ServiceApiEntry[] = [', ] for (const service of services) { lines.push(' {') lines.push(` key: ${quote(service.key)},`) lines.push(` summary: ${quote(firstSentence(service.doc))},`) if (service.methods.length === 0) { lines.push(' methods: [],') } else { lines.push(' methods: [') for (const method of service.methods) { lines.push(' {') lines.push(` signature: ${quote(method.signature)},`) lines.push(` jsDoc: ${quote(method.jsDoc)},`) lines.push(' },') } lines.push(' ],') } lines.push(' },') } lines.push( ']', '', '/** Every harness event, sorted by name. */', 'export const EVENT_API: readonly EventApiEntry[] = [', ) for (const event of events) { lines.push(' {') lines.push(` name: ${quote(event.name)},`) lines.push(` mode: ${quote(event.mode)},`) lines.push(` signature: ${quote(event.signature)},`) lines.push(` jsDoc: ${quote(event.jsDoc)},`) lines.push(` summary: ${quote(firstSentence(event.doc))},`) lines.push(' },') } lines.push( ']', '', '/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */', 'export const TYPE_API: readonly TypeApiEntry[] = [', ) for (const type of types) { lines.push(' {') lines.push(` name: ${quote(type.name)},`) lines.push(` declaration: ${quote(type.declaration)},`) lines.push(' },') } lines.push( ']', '', '/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */', 'export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [', ) for (const inherited of INHERITED_SERVICES) { lines.push(` { name: ${quote(inherited.name)}, summary: ${quote(inherited.summary)} },`) } lines.push(']', '') return lines.join('\n') } /** CLI entry: default writes the artifact, `--check` fails if the committed * copy is stale. Guarded behind an entry-point check so importing this module * for tests neither regenerates the committed file nor calls process.exit. */ function main(): void { const content = render() if (process.argv.includes('--check')) { let committed: string | null = null try { committed = readFileSync(resolve(root, OUT), 'utf8') } catch { // Only ENOENT (not yet generated) is expected; a present-but-unreadable // file is not a state this repo produces. Either way the remedy is the // same — regenerate — so treat a read failure as "stale". committed = null } if (committed === content) { console.log(`gen-cordis-api: ${OUT} is up to date.`) process.exit(0) } console.error(`gen-cordis-api: ${OUT} is stale. Run \`pnpm run gen-cordis-api\` and commit ${OUT}.`) process.exit(1) } writeFileSync(resolve(root, OUT), content) console.log(`gen-cordis-api: wrote ${OUT}.`) } // Run only when invoked as a script, not when imported by a test. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { main() }