docs: generated cordis events + services catalog

Add scripts/gen-cordis-catalog.ts: a fully-generated docs/cordis-catalog/
events-and-services.md cataloging every cordis event (exact signature + @mode)
and ctx.<key> service (exact interface), modeled on gen-module-graph's
--write/--check freshness gate. The harness tier renders in full from the
interface Events / interface Context declarations and their JSDoc; the inherited
cordis-core/loader/hmr/timer surface renders tersely from a curated table.

The generator hard-errors on a missing @mode tag and on a tag that contradicts
a conclusive signature shape (a trailing next param is structurally a
waterfall). Signature blocks use a ts cordis-catalog fence that doc-typecheck
skips. Type tokens cross-link to the core-data-structures catalog.

This supersedes the hand-maintained event-taxonomy table: verify-event-taxonomy
is deleted and verify-cordis-catalog joins doc-sync. architecture.md keeps the
Event taxonomy heading (TOC anchor) but points at the catalog; the Service-map
role table stays. RFC, AGENTS.md @mode authoring rule, and dependent doc/skill
references updated. Negative gate tests cover the missing-tag and
tag/shape-contradiction paths.
This commit is contained in:
Tianyi Cui
2026-06-20 19:47:09 +08:00
parent ee494969af
commit 4e5c08ef82
15 files changed
+1107 -162

No files matched your search

+23 -12
View File
@@ -9,10 +9,13 @@
* compilable code opts out with an explicit ` ```ts ignore-check ` info string
* — the opt-out is visible in the source, and this script reports the ratio so
* the escape hatch can't quietly become the norm. A third info string,
* ` ```ts type-equiv `, marks a verbatim paste of a source type definition that
* `scripts/verify-type-equiv.ts` drift-checks against the source symbol; it is
* skipped here and EXCLUDED from the opt-out ratio (a separately-checked
* category, not an unchecked sketch).
* doc-typecheck.ts recognizes two more fence variants and skips both (each is a
* separately-checked category, not an unchecked sketch, so neither counts in the
* opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that
* `scripts/verify-type-equiv.ts` drift-checks, and ` ```ts cordis-catalog ` is a
* generated event/service signature fragment in the cordis catalog (a bare
* signature is not standalone-compilable; the catalog is generated and frozen by
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate).
*
* Run: `tsx scripts/doc-typecheck.ts`.
*/
@@ -34,8 +37,13 @@ const root = resolve(import.meta.dirname, '..')
* source symbol. Skipped HERE (it is not standalone-compilable — no imports)
* and EXCLUDED from the opt-out ratio: it is a separate fully-checked
* category, not an unchecked sketch.
* - `cordis-catalog` (` ```ts cordis-catalog `) — a generated event/service
* signature fragment in the cordis catalog. Skipped HERE for the same reason
* (a bare signature fragment has no imports and does not stand alone) and
* EXCLUDED from the opt-out ratio: the catalog is generated and frozen by
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate.
*/
type BlockKind = 'check' | 'ignore' | 'type-equiv'
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog'
/** One extracted code block. */
interface Block {
@@ -46,7 +54,7 @@ interface Block {
code: string
}
/** Extract every ```ts / ```ts ignore-check / ```ts type-equiv block from one Markdown file. */
/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog block from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const text = readFileSync(absPath, 'utf8')
const lines = text.split('\n')
@@ -72,7 +80,8 @@ function extractBlocks(absPath: string): Block[] {
info === 'ts' ? 'check'
: info === 'ts ignore-check' ? 'ignore'
: info === 'ts type-equiv' ? 'type-equiv'
: null
: info === 'ts cordis-catalog' ? 'cordis-catalog'
: null
if (kind) open = { line: i + 1, kind, body: [] }
})
return blocks
@@ -127,10 +136,11 @@ files.sort()
const all = files.flatMap(extractBlocks)
const checked = all.filter(b => b.kind === 'check')
const ignored = all.filter(b => b.kind === 'ignore')
// `type-equiv` blocks are verified by verify-type-equiv.ts, not here: neither
// compiled nor counted toward the opt-out ratio (they are a separate
// fully-checked category, not an unchecked sketch). The ratio's denominator is
// therefore the compile-eligible blocks only.
// `type-equiv` and `cordis-catalog` blocks are verified elsewhere
// (verify-type-equiv.ts and the gen-cordis-catalog `--check` freshness gate),
// not here: neither compiled nor counted toward the opt-out ratio (each is a
// separate fully-checked category, not an unchecked sketch). The ratio's
// denominator is therefore the compile-eligible blocks only.
const ratioDenominator = checked.length + ignored.length
if (checked.length === 0) {
@@ -164,7 +174,8 @@ try {
}
const ratio = ignored.length / ratioDenominator
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${all.length - ratioDenominator} type-equiv (checked by verify-type-equiv).`)
const skipped = all.length - ratioDenominator
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/cordis-catalog (checked elsewhere).`)
// Guard against the escape hatch becoming the norm.
if (ratioDenominator >= 4 && ratio > 0.5) {
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
+458
View File
@@ -0,0 +1,458 @@
/**
* Generate (and verify) the cordis events + services catalog in
* docs/cordis-catalog/events-and-services.md.
*
* The catalog is the WIRING-axis reference: every cordis event a plugin can
* listen to (exact signature + dispatch mode) and every `ctx.<key>` service it
* can call (exact public interface). It complements the core-data-structures
* catalog (the VOCABULARY axis — the types these signatures move around).
*
* Unlike the core-data-structures docs (a hand-paste drift-checked by
* verify-type-equiv), this file is FULLY GENERATED from source — never
* hand-edit it. The codebase is disciplined enough that a pure-AST pass
* captures the whole truthful surface: every event/service is a string literal
* that round-trips to a static `interface Events` / `interface Context`
* declaration (no dynamically-named events, no runtime-only services). So the
* committed file is a build artifact and a regenerate-and-diff freshness check
* (`--check`) makes drift structurally impossible — which also closes the gap a
* name-set verifier could not: a brand-new UNDOCUMENTED event cannot slip
* through, because generation enumerates source rather than checking a subset.
*
* `tsx scripts/gen-cordis-catalog.ts` → write the catalog
* `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if the committed file
* is stale (CI / pre-push gate)
*
* The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in
* full from source: signature, the `@mode` badge, and the declaration's JSDoc.
* Every harness event MUST carry an `@mode emit|waterfall|parallel` tag — the
* generator hard-errors on a missing tag, and where the signature shape is
* conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
* it asserts the tag agrees and hard-errors on a contradiction. The INHERITED
* tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
* also sees; it is rendered tersely (name + one-line + source pointer) from a
* curated table in this script, NOT elevated to the harness tier's prominence.
*
* Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck
* recognizes it and skips compilation (the signatures are fragments, not
* standalone-compilable, like the ` ```ts type-equiv ` blocks).
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/cordis-catalog/events-and-services.md'
/** The fenced-block info string for generated signature blocks (skipped by
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
const FENCE = 'ts cordis-catalog'
/** A dispatch mode, rendered as the badge after an event name. */
type Mode = 'emit' | 'waterfall' | 'parallel'
/**
* Cross-link map: a type name that appears in a signature → the
* core-data-structures page that documents it (path relative to OUT's folder).
* Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json —
* that manifest documents the `…Map` symbols (`ContentBlockMap`) while
* signatures reference the derived UNION names (`ContentBlock`), and it lists a
* few symbols on two pages. Here each name resolves to exactly one PRIMARY page.
*/
const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
ContentBlock: 'core.md',
Message: 'core.md',
MessageSource: 'core.md',
GenerateOptions: 'core.md',
GenerateResult: 'core.md',
SessionEvent: 'core.md',
StreamChunk: 'llm-streaming.md',
TurnEndReason: 'session.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolExecutionResult: 'tools.md',
BashExecRequest: 'bash.md',
BashExecSpec: 'bash.md',
BashRunResult: 'bash.md',
BashTask: 'bash.md',
}
/** One harness event, extracted from an `interface Events` block. */
interface EventEntry {
/** Scoped name, e.g. `agent/request`. */
name: string
/** The scope prefix, e.g. `agent` (everything before the first `/`). */
scope: string
/** Full signature text (the method-signature member, JSDoc stripped). */
signature: string
/** Dispatch mode from the `@mode` tag. */
mode: Mode
/** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
/** One harness service, extracted from an `interface Context` block. */
interface ServiceEntry {
/** The `ctx.<key>` name, e.g. `llm`. */
key: string
/** The service class/interface name, e.g. `LlmService`. */
type: string
/** Whether the service class is abstract (a seam interface). */
abstract: boolean
/** Class-level JSDoc prose, one line per paragraph. */
doc: string
/** Public method signatures (bodies stripped), in source order. */
methods: string[]
/** Source pointer of the class declaration. */
source: string
}
/** A terse inherited-tier entry (pinned vendor surface). */
interface InheritedEntry {
name: string
summary: string
/** Source pointer `vendor/…:line`. */
source: string
}
/** Repo-relative source pointer `file:line` for a node's first character. */
function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
return `${rel}:${line + 1}`
}
/** The raw `/** … */` JSDoc block immediately preceding a node, or '' if none. */
function rawJsDoc(text: string, node: ts.Node): string {
const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
}
/**
* Parse a raw JSDoc block into description prose + the `@mode` tag (when
* present). Output obeys the repo's markdown conventions so the generated file
* passes verify-md-wrap: each prose paragraph collapses to ONE physical line,
* and a `-` bullet list is preserved with each item on its own single line
* (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines
* other than `@mode` end the current prose run.
*/
function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
const inner = raw
.replace(/^\/\*\*/, '')
.replace(/\*\/$/, '')
.split('\n')
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
let mode: Mode | null = null
const blocks: string[] = []
let para: string[] = []
let list: string[] = []
let item: string[] = []
const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
const flushItem = (): void => {
if (item.length) list.push(join(item))
item = []
}
const flushList = (): void => {
flushItem()
if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
list = []
}
const flushPara = (): void => {
flushList()
if (para.length) blocks.push(join(para))
para = []
}
for (const line of inner) {
const m = /^@mode\s+(emit|waterfall|parallel)\s*$/.exec(line)
if (m) { mode = m[1] as Mode; continue }
if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose
if (line.trim() === '') { flushPara(); continue }
if (/^-\s+/.test(line)) {
// A list item starts: a pending paragraph (e.g. an intro line directly
// above the list, no blank between) flushes FIRST so it renders above.
flushItem()
if (para.length) { blocks.push(join(para)); para = [] }
item.push(line)
continue
}
if (item.length) { item.push(line); continue } // continuation of current item
para.push(line)
}
flushPara()
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
return { doc, mode }
}
/** Find the `declare module 'cordis'` body in a source file, or null. */
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
for (const stmt of sf.statements) {
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
}
}
return null
}
/** The signature text of a method-signature member (everything but a body). */
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
const full = member.getText(sf)
const body = (member as { body?: ts.Node }).body
const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
/** Walk every harness `interface Events` block and extract its events.
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
export function collectEvents(scanRoot: string = root): EventEntry[] {
const entries: EventEntry[] = []
for (const rel of globSync('packages/*/src/*.ts', { cwd: scanRoot }).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('interface Events')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!ts.isMethodSignature(member)) continue
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
const signature = memberSignature(member, sf)
const { doc, mode } = parseJsDoc(rawJsDoc(text, member))
const src = pointer(rel, sf, member)
if (!mode) {
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel is not structurally distinguishable, so
// it is trusted from the tag.)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
if (hasNext && mode !== 'waterfall') {
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.`)
}
if (!hasNext && mode === 'waterfall') {
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
}
}
}
return entries
}
/** Walk every harness `interface Context` block + its service class.
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
export function collectServices(scanRoot: string = root): ServiceEntry[] {
const entries: ServiceEntry[] = []
for (const rel of globSync('packages/*/src/index.ts', { cwd: scanRoot }).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('interface Context')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
// The ctx key → type mapping(s) declared in this file's interface Context.
const keyToType = new Map<string, string>()
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
for (const member of stmt.members) {
if (!ts.isPropertySignature(member) || !member.type) continue
const key = member.name.getText(sf)
keyToType.set(key, member.type.getText(sf))
}
}
if (keyToType.size === 0) continue
// Find each service class declared in the same file and emit an entry.
for (const [key, type] of keyToType) {
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
)
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
const methods: string[] = []
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
const isPrivate = member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword)
|| ts.isPrivateIdentifier(member.name)
const isStatic = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
if (isPrivate || isStatic) continue
const memberName = member.name.getText(sf)
if (memberName.startsWith('[')) continue // computed/symbol members
methods.push(memberSignature(member, sf))
}
entries.push({
key,
type,
abstract,
doc: parseJsDoc(rawJsDoc(text, cls)).doc,
methods,
source: pointer(rel, sf, cls),
})
}
}
return entries.sort((a, b) => a.key.localeCompare(b.key))
}
/**
* The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and
* hand-summarized because (a) it is pinned vendor source that changes only on a
* deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members
* with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would
* wrongly surface as services, and (c) the internal/* events carry no JSDoc to
* render. Source pointers are verified against vendor by `verify-md-links`'
* sibling check is N/A; keep them current on a vendor bump.
*/
const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
]
const INHERITED_SERVICES: InheritedEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-non-nullish / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ 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' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
{ 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' },
{ 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' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
]
/** Render the cross-link "Types:" line for a signature, or '' if none apply. */
function typeLinks(signature: string): string {
const seen = new Set<string>()
for (const name of Object.keys(LINK_MAP)) {
if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name)
}
if (seen.size === 0) return ''
const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`)
return `Types: ${links.join(' · ')}`
}
/** Render one harness event entry. */
function renderEvent(e: EventEntry): string[] {
const out = [`#### \`${e.name}\`${e.mode}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, e.signature, '```', '')
const links = typeLinks(e.signature)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
return out
}
/** Render one harness service entry. */
function renderService(s: ServiceEntry): string[] {
const kind = s.abstract ? ' (abstract seam)' : ''
const out = [`### \`ctx.${s.key}\`\`${s.type}\`${kind}`, '']
if (s.doc) out.push(s.doc, '')
if (s.methods.length) {
out.push('```' + FENCE, ...s.methods, '```', '')
const links = typeLinks(s.methods.join('\n'))
if (links) out.push(links, '')
}
out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
return out
}
/** Render the full catalog (pure, deterministic given sorted inputs). */
function render(events: EventEntry[], services: ServiceEntry[]): string {
const lines: string[] = [
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
'',
'# Cordis Events & Services Catalog',
'',
'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.',
'',
'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.',
'',
'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.',
'',
'## Events',
'',
`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, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`,
'',
]
const scopes = [...new Set(events.map(e => e.scope))].sort()
for (const scope of scopes) {
lines.push(`### \`${scope}/*\``, '')
for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
lines.push(...renderEvent(e))
}
}
lines.push(
'## Services',
'',
`The ${services.length} \`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.`,
'',
)
for (const s of services) lines.push(...renderService(s))
lines.push(
'## Inherited tier (cordis core + loader/hmr/timer)',
'',
'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.',
'',
'### Inherited events',
'',
)
for (const e of INHERITED_EVENTS) {
lines.push(`- \`${e.name}\`${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
}
lines.push('', '### Inherited `ctx` members', '')
for (const s of INHERITED_SERVICES) {
lines.push(`- \`${s.name}\`${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
}
lines.push('')
return lines.join('\n')
}
/** CLI entry: `--write` (default) writes the catalog, `--check` fails if 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(collectEvents(), collectServices())
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-catalog: ${OUT} is up to date.`)
process.exit(0)
}
console.error(`gen-cordis-catalog: ${OUT} is stale. Run \`pnpm run gen-cordis-catalog\` and commit ${OUT}.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-cordis-catalog: 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()
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Doc-sync gate (doc-sync-enforcement RFC, part 2): verify the event-taxonomy table in
* docs/architecture.md against the events actually declared in source.
*
* The table duplicates the `declare module 'cordis' { interface Events }`
* blocks across packages/* /src. This script extracts both sets of event names
* and asserts they match exactly — every declared event appears in the table,
* and the table names no event that isn't declared. Verify, don't generate
* (per the RFC): the table keeps its hand-written Mode/Purpose columns; only
* the set of names is checked.
*
* Run: `tsx scripts/verify-event-taxonomy.ts`.
*/
import { readFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
/**
* Remove `/* */` block comments and `//` line comments from TS source. Used to
* de-risk the brace walk in {@link declaredEvents} — a JSDoc `{@link}` tag would
* otherwise throw off the `{`/`}` depth counter. Good enough for our own source
* (no string literals contain `//` or comment-like brace sequences in an Events
* block); it is not a general tokenizer.
*/
function stripComments(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1')
}
/**
* Event names declared in source: the keys inside every `interface Events`
* block under packages/* /src. A declared event is a quoted `'scope/name'(`
* method signature at the start of a line within such a block.
*/
async function declaredEvents(): Promise<Map<string, string>> {
const found = new Map<string, string>()
for await (const match of glob('packages/*/src/**/*.ts', { cwd: root })) {
const abs = resolve(root, match)
// Strip comments first so a JSDoc `{@link …}` tag (or a `// {` line) inside
// an Events block can't unbalance the brace walk below. Event names live in
// code, never in comments, so this loses nothing.
const text = stripComments(readFileSync(abs, 'utf8'))
// Walk `interface Events {` blocks brace-balanced and pull quoted keys.
const re = /interface\s+Events\s*\{/g
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
let depth = 1
let i = m.index + m[0].length
const start = i
while (i < text.length && depth > 0) {
const ch = text[i]
if (ch === '{') depth++
else if (ch === '}') depth--
i++
}
const body = text.slice(start, i - 1)
// A declaration is a quoted event name followed by `(` (method form).
for (const k of body.matchAll(/['"]([a-z][a-z-]*\/[a-z-]+)['"]\s*\(/g)) {
const name = k[1]
if (name) found.set(name, relative(root, abs))
}
}
}
return found
}
/** Event names referenced in the architecture-doc taxonomy table (in `code`). */
function tableEvents(): Set<string> {
const text = readFileSync(join(root, 'docs/architecture.md'), 'utf8')
const lines = text.split('\n')
const heading = lines.findIndex(l => /^###\s+Event taxonomy/.test(l))
if (heading === -1) throw new Error('verify-event-taxonomy: "### Event taxonomy" heading not found')
const names = new Set<string>()
for (let i = heading + 1; i < lines.length; i++) {
const line = lines[i] ?? ''
if (/^###\s/.test(line)) break // next section ends the table
if (!line.includes('|')) continue
for (const code of line.matchAll(/`([^`]+)`/g)) {
// A cell may read "`a/b` / `c/d` (pkg)" — pull each scoped name.
for (const name of (code[1] ?? '').matchAll(/[a-z][a-z-]*\/[a-z-]+/g)) names.add(name[0])
}
}
return names
}
const declared = await declaredEvents()
const table = tableEvents()
const declaredNames = new Set(declared.keys())
const missingFromTable = [...declaredNames].filter(n => !table.has(n)).sort()
const missingFromSource = [...table].filter(n => !declaredNames.has(n)).sort()
if (missingFromTable.length === 0 && missingFromSource.length === 0) {
console.log(`verify-event-taxonomy: ${declaredNames.size} events match the architecture-doc table.`)
process.exit(0)
}
if (missingFromTable.length > 0) {
console.error('verify-event-taxonomy: declared in source but MISSING from the docs/architecture.md table:')
for (const n of missingFromTable) {
console.error(` ${n} (declared in ${declared.get(n) ?? '?'})`)
}
}
if (missingFromSource.length > 0) {
console.error('verify-event-taxonomy: named in the table but NOT declared in source (stale doc):')
for (const n of missingFromSource) {
console.error(` ${n}`)
}
}
process.exit(1)