Merge remote-tracking branch 'upstream/master' into docs/i18n-translation-prompt

# Conflicts:
#	README.i18n.yaml
#	README.zh.md
#	docs/development.i18n.yaml
#	docs/development.zh.md
#	docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
#	docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md
#	scripts/translation-pairing.manifest.json
This commit is contained in:
Ziya
2026-07-12 21:08:45 -07:00
873 changed files with 63362 additions and 12439 deletions

View File

@@ -105,12 +105,25 @@ const dshBinPackageFiles = [
'src',
] as const
const dshWorkerPackageFiles = [
'lib/index.js',
'lib/worker.js',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
}
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
return manifest.bin ? dshBinPackageFiles : dshPackageFiles
if (manifest.bin) return dshBinPackageFiles
// A declared "./worker" subpath export sanctions the one extra runtime
// bundle a worker-thread entry needs (and NodeNext/publint then validate
// that subpath's targets like any other export).
if (manifest.exports?.['./worker']) return dshWorkerPackageFiles
return dshPackageFiles
}
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {

View File

@@ -0,0 +1,29 @@
/**
* Boot the Code Mode demo under the UI named on the command line:
* `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the
* point — the UI is just the surface it happens to wear: each UI boots its
* base example through that example's `code-mode.cordis.yml` overlay
* (include ./cordis.yml, flip `tools.mode` to `code`, insert the
* worker-thread code runtime). Both need DEEPSEEK_API_KEY (repo-root .env
* works). Anything else on the command line is a misconfiguration and
* fails loud with usage.
*/
import { spawn } from 'node:child_process'
// Each UI's node invocation, verbatim what its base demo script runs plus
// the overlay config (the stdio bin keeps --expose-internals for the cordis
// Loader's HMR path).
const UIS = new Map([
['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']],
['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', 'examples/acp-agent/code-mode.cordis.yml']],
])
const ui = process.argv[2] ?? 'repl'
const args = UIS.get(ui)
if (!args || process.argv.length > 3) {
console.error('usage: pnpm run demo:code-mode [repl|acp]')
process.exit(2)
}
const child = spawn(process.execPath, args, { stdio: 'inherit' })
child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) })

View File

@@ -1,10 +1,11 @@
{
"AGENTS.md": 1575,
"AGENTS.md": 1802,
"docs/AGENTS.md": 1315,
"docs/architecture.md": 1890,
"docs/architecture.md": 1750,
"docs/cordis-primer.md": 550,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,
"examples/AGENTS.md": 610,
"examples/AGENTS.md": 705,
"packages/AGENTS.md": 450,
"packages/README.md": 605
"packages/README.md": 710
}

View File

@@ -9,21 +9,24 @@
* 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,
* 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
* doc-typecheck.ts recognizes four more fence variants and skips all four (each
* is a separately-checked category, not an unchecked sketch, so none counts in
* the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that
* `scripts/verify-type-equiv.ts` drift-checks, ` ```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).
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate),
* ` ```ts persistence-catalog ` is a generated log-event payload fragment in the
* persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`),
* and ` ```ts config-catalog ` is a generated verbatim config declaration in the
* plugin config catalog (same reasoning, frozen by `scripts/gen-config-catalog.ts`).
*
* Run: `tsx scripts/doc-typecheck.ts`.
*/
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
@@ -43,8 +46,16 @@ const root = resolve(import.meta.dirname, '..')
* (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.
* - `persistence-catalog` (` ```ts persistence-catalog `) — a generated
* log-event payload fragment in the persistence catalog. Same treatment for
* the same reason; frozen by `scripts/gen-persistence-catalog.ts` + its
* `--check` freshness gate.
* - `config-catalog` (` ```ts config-catalog `) — a generated verbatim config
* declaration in the plugin config catalog (a lone declaration referencing
* imported types does not stand alone). Same treatment for the same reason;
* frozen by `scripts/gen-config-catalog.ts` + its `--check` freshness gate.
*/
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog'
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog'
/** One extracted code block. */
interface Block {
@@ -55,7 +66,8 @@ interface Block {
code: string
}
/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog block from one Markdown file. */
/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog /
* ts persistence-catalog / ts config-catalog block from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const text = readFileSync(absPath, 'utf8')
const lines = text.split('\n')
@@ -82,7 +94,9 @@ function extractBlocks(absPath: string): Block[] {
: info === 'ts ignore-check' ? 'ignore'
: info === 'ts type-equiv' ? 'type-equiv'
: info === 'ts cordis-catalog' ? 'cordis-catalog'
: null
: info === 'ts persistence-catalog' ? 'persistence-catalog'
: info === 'ts config-catalog' ? 'config-catalog'
: null
if (kind) open = { line: i + 1, kind, body: [] }
})
return blocks
@@ -124,18 +138,18 @@ const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages
const files: string[] = []
for (const pattern of markdownGlobs) {
for await (const match of glob(pattern, { cwd: root })) files.push(resolve(root, match))
for (const match of globSync(pattern, { cwd: root })) files.push(resolve(root, match))
}
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` 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.
// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified
// elsewhere (verify-type-equiv.ts and each catalog generator's `--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) {
@@ -171,7 +185,7 @@ try {
const ratio = ignored.length / ratioDenominator
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).`)
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/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.`)

View File

@@ -0,0 +1,935 @@
/**
* Generate (and verify) the plugin config catalog in docs/config-catalog.md.
*
* The page is the DEPLOYMENT-axis reference: for every harness package a
* `cordis.yml` entry can load, the exact config surface its `apply` function or
* service constructor receives — pasted VERBATIM from source (the `export
* interface Config` declaration with its JSDoc), plus resolved links for every
* type the declaration references. It complements the wiring-axis cordis
* catalogs (events + services, what a plugin AUTHOR listens to and calls) the
* same way the tool catalog complements them for the model-facing axis.
*
* The catalog is FULLY GENERATED from source — never hand-edit it. Like the
* cordis catalog (and unlike the tool catalog, which must boot plugins), this
* is a pure-AST pass: every config type is a static declaration and every
* schemastery schema is a static `z.object`/`z.intersect` literal, so
* generation cannot drift and a regenerate-and-diff freshness check (`--check`)
* gates staleness. Because generation enumerates every package under
* `packages/<group>/<pkg>`, a brand-new plugin cannot be silently
* undocumented: it must classify as configurable, config-free, seam, or
* library, and an unclassifiable entry hard-errors the generator.
*
* `tsx scripts/gen-config-catalog.ts` → write the catalog
* `tsx scripts/gen-config-catalog.ts --check` → exit 1 if the committed
* catalog is stale (CI /
* pre-push gate)
*
* What the walk enforces (aggregated into one error, like the sibling
* generators):
*
* - CLASSIFICATION is total. Every package entry resolves, mirroring the
* cordis Loader's `unwrapExports` (`exports.default ?? exports`), to a
* loadable plugin (default class / `apply` function), an abstract seam
* class, or a plain library. Anything else is an error, not a skip.
* - The CONFIG TYPE is the declared type of the plugin's second parameter
* (`apply(ctx, config)` / `constructor(ctx, config)`) — the type cordis
* actually passes — and it must resolve to a declaration inside the owning
* package (entry file or a package-local relative import).
* - Every property of a pasted declaration carries non-empty JSDoc prose: the
* paste IS the documentation, so an undocumented field is a gate failure,
* the same forcing function the events catalog applies via `@mode`.
* - Every type NAME a pasted declaration references resolves: pasted
* transitively when package-local, linked when it is another plugin's
* config type / a core-data-structures entry / a workspace or external
* import. An unresolvable name is an error, and so is a NAME COLLISION —
* two distinct declarations, or a declaration and an import, sharing one
* name across the closure (a verbatim fence has a single flat namespace) —
* never a silent skip.
* - The runtime schemastery schema (`Config` export or `static Config`),
* when present, is walked statically — `z.object` keys, nested object/array
* compositions as key PATHS (`agents[].id`), and `z.intersect` composition
* across packages — and every schema-validated key path must be locatable
* on the declared config type, resolving package-local and
* workspace-imported types, re-export chains, intersections, utility
* wrappers, and indexed access. The paste cannot hide a loader-accepted
* field, top-level or nested. A path that crosses a type the walk cannot
* enumerate (an external package's type) is skipped, never mis-reported,
* and nested keys under dynamic-key shapes (`z.dict`) or union alternatives
* contribute no paths. The reverse direction is deliberately NOT checked: a
* declared field may be a runtime-only seam the schema excludes (e.g. the
* ACP bridge's test-injected `stream`).
*
* Config fences use the ` ```ts config-catalog ` info string: doc-typecheck
* recognizes it and skips compilation (a lone interface referencing imported
* types is not standalone-compilable, like the ` ```ts cordis-catalog `
* signature blocks).
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import ts from 'typescript'
import { LINK_MAP } from './gen-cordis-catalog.ts'
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/config-catalog.md'
/** The fenced-block info string for pasted config declarations (skipped by
* doc-typecheck, since a lone declaration referencing imports is not
* standalone-compilable). */
const FENCE = 'ts config-catalog'
/** TypeScript/Node global type names a config declaration may reference
* without importing; never treated as unresolved. Extend when a new global
* legitimately appears — the generator hard-errors on unknown names, so an
* omission is loud, not silent. */
const GLOBAL_TYPES = new Set([
'Array', 'ReadonlyArray', 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit',
'Promise', 'Map', 'Set', 'Date', 'Error', 'RegExp', 'Exclude', 'Extract', 'NonNullable',
'ReturnType', 'Parameters', 'AbortSignal', 'URL', 'Buffer', 'NodeJS', 'Iterable', 'AsyncIterable',
])
/** How a package classifies for the catalog. */
type Kind = 'config' | 'no-config' | 'seam' | 'library'
/** One name a pasted declaration references but the paste does not contain. */
interface TypeRef {
/** The name as it appears in the pasted text (the local import alias). */
alias: string
/** The name the source module exports it under (pre-alias). */
imported: string
/** The import module specifier (package name or external module). */
specifier: string
}
/** One verbatim declaration paste. */
interface Paste {
/** Full source text: leading JSDoc (when present) through the closing token. */
text: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
/** One package's catalog entry. */
export interface CatalogEntry {
/** npm package name, e.g. `@deepseek-ai/dsh-agent-loop`. */
pkg: string
/** Repo-relative package dir, e.g. `packages/core/agent-loop`. */
dir: string
/** Repo-relative entry file, `<dir>/src/index.ts`. */
entry: string
kind: Kind
/** Service keys the plugin `inject`s (empty when none declared). */
inject: string[]
/** Seam/service class name (kinds `seam` and class-based plugins). */
className?: string
/** Name of the config type (kind `config`). */
configTypeName?: string
/** Verbatim declaration pastes, the config type first (kind `config`). */
pastes?: Paste[]
/** References the pastes leave unresolved locally (kind `config`). */
refs?: TypeRef[]
/** Top-level keys and nested key paths (`agents[].id`) of the runtime
* schema, `null` when no schema exists (kind `config`). */
schemaKeys?: string[] | null
/** Package names whose schemas an intersect composes (kind `config`). */
schemaComposes?: string[]
}
/** A parsed source file plus its import map (local name → origin). */
interface FileCtx {
abs: string
rel: string
text: string
sf: ts.SourceFile
/** Local binding name → `{ imported, specifier }`; default imports record
* `imported: 'default'`. */
imports: Map<string, { imported: string; specifier: string }>
}
/** Throw one aggregate error for every violation the walk collected. */
function report(violations: string[]): void {
if (violations.length === 0) return
throw new Error(
`gen-config-catalog: ${violations.length} violation(s):\n`
+ violations.map(v => ` ${v}`).join('\n'),
)
}
/** Parse a source file and index its import declarations. */
function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCtx {
const cached = cache.get(abs)
if (cached) return cached
const text = readFileSync(abs, 'utf8')
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const imports = new Map<string, { imported: string; specifier: string }>()
for (const stmt of sf.statements) {
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
const specifier = stmt.moduleSpecifier.text
const clause = stmt.importClause
if (!clause) continue
if (clause.name) imports.set(clause.name.text, { imported: 'default', specifier })
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const el of clause.namedBindings.elements) {
imports.set(el.name.text, { imported: (el.propertyName ?? el.name).text, specifier })
}
}
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
imports.set(clause.namedBindings.name.text, { imported: '*', specifier })
}
}
const ctx = { abs, rel, text, sf, imports }
cache.set(abs, ctx)
return ctx
}
/** A type declaration a paste can contain. */
type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration
/** Find an interface/type-alias declaration by name in a file, or null. */
function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null {
for (const stmt of ctx.sf.statements) {
if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt
}
return null
}
/**
* Resolve a type name from a file to its declaration (following package-local
* relative imports transitively) or to the import that brings it in. Returns
* `null` when the name is neither declared, imported, nor a known global.
*/
function resolveTypeName(
ctx: FileCtx,
name: string,
cache: Map<string, FileCtx>,
violations: string[],
): { decl: TypeDecl; ctx: FileCtx } | { ref: TypeRef } | null {
const local = findTypeDecl(ctx, name)
if (local) return { decl: local, ctx }
const imp = ctx.imports.get(name)
if (!imp) return null
if (imp.specifier.startsWith('.')) {
if (!imp.specifier.endsWith('.ts')) {
violations.push(`${ctx.rel}: relative import '${imp.specifier}' lacks the explicit .ts extension the repo convention requires.`)
return null
}
if (imp.imported !== name) {
violations.push(`${ctx.rel}: '${name}' aliases '${imp.imported}' across a package-local import; the catalog pastes declarations verbatim, so keep package-local config types unaliased.`)
return null
}
const abs = resolve(dirname(ctx.abs), imp.specifier)
const rel = ctx.rel.slice(0, ctx.rel.lastIndexOf('/') + 1) + imp.specifier.replace(/^\.\//, '')
const target = loadFile(abs, rel, cache)
return resolveTypeName(target, imp.imported, cache, violations)
}
return { ref: { alias: name, imported: imp.imported, specifier: imp.specifier } }
}
/** Collect every type NAME referenced in type positions under a node. */
function collectTypeNames(node: ts.Node, out: Set<string>): void {
const visit = (n: ts.Node): void => {
if (ts.isTypeReferenceNode(n)) {
let head: ts.EntityName = n.typeName
while (ts.isQualifiedName(head)) head = head.left
out.add(head.text)
} else if (ts.isExpressionWithTypeArguments(n) && ts.isIdentifier(n.expression)) {
out.add(n.expression.text) // heritage clause: `extends X`
}
ts.forEachChild(n, visit)
}
visit(node)
}
/** The verbatim paste text of a declaration: leading JSDoc through the end. */
function pasteText(ctx: FileCtx, decl: TypeDecl): string {
const raw = rawJsDoc(ctx.text, decl)
const start = raw ? ctx.text.indexOf(raw, decl.getFullStart()) : decl.getStart(ctx.sf)
return ctx.text.slice(start, decl.end)
}
/** Enforce non-empty JSDoc prose on every property of a pasted declaration,
* recursing into nested type literals (e.g. an array-of-objects field). */
function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): void {
const walkMembers = (members: ts.NodeArray<ts.TypeElement>, path: string): void => {
for (const member of members) {
if (!ts.isPropertySignature(member)) continue
const name = member.name.getText(ctx.sf)
const where = `config field '${path}.${name}' (${pointer(ctx.rel, ctx.sf, member)})`
if (!parseJsDoc(rawJsDoc(ctx.text, member)).doc) violations.push(`${where} has no JSDoc prose.`)
if (member.type) walkNested(member.type, `${path}.${name}`)
}
}
const walkNested = (type: ts.Node, path: string): void => {
if (ts.isTypeLiteralNode(type)) walkMembers(type.members, path)
else ts.forEachChild(type, (n) => { walkNested(n, path) })
}
if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text)
else walkNested(decl.type, decl.name.text)
}
/** Cross-file resolution context for the schema-path check. */
interface World {
scanRoot: string
cache: Map<string, FileCtx>
/** Workspace package name → repo-relative package dir. */
pkgDirByName: Map<string, string>
}
/** How a schema key path fared against the declared config type: definitely
* present, definitely absent, or crossing a shape the walk cannot enumerate
* (only `missing` is a violation — `unknown` must never mis-report). */
type PathLookup = 'found' | 'missing' | 'unknown'
/** One step of a schema key path: a named member, or an array-element hop. */
type PathStep = { member: string } | { array: true }
/** Parse a schema key path (`agents[].id`) into member/array steps. */
function parsePath(path: string): PathStep[] {
const steps: PathStep[] = []
for (const seg of path.split('.')) {
let name = seg
let arrays = 0
while (name.endsWith('[]')) {
name = name.slice(0, -2)
arrays += 1
}
steps.push({ member: name })
for (let i = 0; i < arrays; i += 1) steps.push({ array: true })
}
return steps
}
/** Load a package-relative import target as a FileCtx. */
function loadRelative(world: World, from: FileCtx, specifier: string): FileCtx {
const abs = resolve(dirname(from.abs), specifier)
const rel = from.rel.slice(0, from.rel.lastIndexOf('/') + 1) + specifier.replace(/^\.\//, '')
return loadFile(abs, rel, world.cache)
}
/** Find a type declaration EXPORTED (directly or via re-export chains) from a
* file, following `export … from './x.ts'` and `export * from './x.ts'`. */
function findExportedTypeDecl(world: World, ctx: FileCtx, name: string, seen = new Set<string>()): { decl: TypeDecl; ctx: FileCtx } | null {
const key = `${ctx.abs}#${name}`
if (seen.has(key)) return null
seen.add(key)
const local = findTypeDecl(ctx, name)
if (local) return { decl: local, ctx }
for (const stmt of ctx.sf.statements) {
if (!ts.isExportDeclaration(stmt) || !stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
const spec = stmt.moduleSpecifier.text
if (!spec.startsWith('.') || !spec.endsWith('.ts')) continue
let lookFor: string | null = null
if (!stmt.exportClause) {
lookFor = name // export * from './x.ts'
} else if (ts.isNamedExports(stmt.exportClause)) {
const el = stmt.exportClause.elements.find(e => e.name.text === name)
if (el) lookFor = (el.propertyName ?? el.name).text
}
if (lookFor === null) continue
const hit = findExportedTypeDecl(world, loadRelative(world, ctx, spec), lookFor, seen)
if (hit) return hit
}
return null
}
/** Resolve a referenced type NAME to its declaration: declared locally, via a
* package-relative import, or via a workspace-package import (entry file +
* re-export chains). `'unknown'` = external or otherwise out of reach. */
function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: TypeDecl; ctx: FileCtx } | 'unknown' {
const local = findTypeDecl(ctx, name)
if (local) return { decl: local, ctx }
const imp = ctx.imports.get(name)
if (!imp) return 'unknown'
if (imp.specifier.startsWith('.')) {
if (!imp.specifier.endsWith('.ts')) return 'unknown'
return findExportedTypeDecl(world, loadRelative(world, ctx, imp.specifier), imp.imported) ?? 'unknown'
}
const dir = world.pkgDirByName.get(imp.specifier)
if (dir === undefined) return 'unknown'
const entryRel = `${dir}/src/index.ts`
let entry: FileCtx
try {
entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
} catch {
// A workspace package without a readable entry is reported by its own
// classification pass; for a lookup it is merely out of reach.
return 'unknown'
}
return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
}
/** Utility wrappers that pass a member lookup through to their type argument. */
const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNullable'])
/**
* Walk a schema key path against a declared type. This is a PRESENCE check,
* not a shape check: it answers "does the declared config type have a member
* here", resolving interfaces (heritage included), type aliases, literals,
* intersections, unions, arrays, indexed access, pass-through utility
* wrappers, and type references across package-local and workspace imports.
* Anything it cannot see through resolves `'unknown'`, never `'missing'`.
*/
function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
if (steps.length === 0) return 'found'
// Guard recursion at NAMED declarations only — the sole way a walk can loop
// (a recursive interface/alias). Structural nodes must not be guarded: a
// first child shares `.pos` with its parent, so a span-keyed guard there
// would mistake ordinary descent for a cycle.
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
const key = `${ctx.abs}:${node.pos}:${steps.length}`
if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop
seen.add(key)
}
const step = steps[0]
if (step === undefined) return 'found'
// Combine branch results: any found wins, else any unknown taints, else missing.
const combine = (results: PathLookup[]): PathLookup => {
if (results.includes('found')) return 'found'
if (results.includes('unknown')) return 'unknown'
return 'missing'
}
const intoMembers = (members: ts.NodeArray<ts.TypeElement>): PathLookup | null => {
if (!('member' in step)) return null
for (const m of members) {
if (!ts.isPropertySignature(m) || m.name.getText(ctx.sf) !== step.member) continue
if (steps.length === 1) return 'found'
return m.type ? lookupPath(world, ctx, m.type, steps.slice(1), seen) : 'unknown'
}
return null // not among these members; caller consults heritage/parts
}
if (ts.isInterfaceDeclaration(node)) {
if (!('member' in step)) return 'unknown' // an array step cannot land on an interface
const direct = intoMembers(node.members)
if (direct !== null) return direct
const bases: PathLookup[] = []
for (const clause of node.heritageClauses ?? []) {
for (const base of clause.types) {
if (!ts.isIdentifier(base.expression)) {
bases.push('unknown')
continue
}
const resolved = declForTypeName(world, ctx, base.expression.text)
bases.push(resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen))
}
}
return bases.length ? combine(bases) : 'missing'
}
if (ts.isTypeAliasDeclaration(node)) return lookupPath(world, ctx, node.type, steps, seen)
if (ts.isTypeLiteralNode(node)) {
if (!('member' in step)) return 'unknown'
return intoMembers(node.members) ?? 'missing'
}
if (ts.isParenthesizedTypeNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
if (ts.isIntersectionTypeNode(node)) {
return combine(node.types.map(t => lookupPath(world, ctx, t, steps, seen)))
}
if (ts.isUnionTypeNode(node)) {
// Presence on a union is only definite when every branch agrees.
const results = node.types.map(t => lookupPath(world, ctx, t, steps, seen))
if (results.every(r => r === 'found')) return 'found'
if (results.every(r => r === 'missing')) return 'missing'
return 'unknown'
}
if (ts.isArrayTypeNode(node)) {
return 'array' in step ? lookupPath(world, ctx, node.elementType, steps.slice(1), seen) : 'unknown'
}
if (ts.isTypeOperatorNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
if (ts.isIndexedAccessTypeNode(node)) {
const index = node.indexType
if (ts.isLiteralTypeNode(index) && ts.isStringLiteral(index.literal)) {
return lookupPath(world, ctx, node.objectType, [{ member: index.literal.text }, ...steps], seen)
}
return 'unknown'
}
if (ts.isTypeReferenceNode(node)) {
let head: ts.EntityName = node.typeName
while (ts.isQualifiedName(head)) head = head.left
const name = head.text
if (PASSTHROUGH_WRAPPERS.has(name) && node.typeArguments?.[0]) {
return lookupPath(world, ctx, node.typeArguments[0], steps, seen)
}
if ((name === 'Array' || name === 'ReadonlyArray') && node.typeArguments?.[0]) {
return 'array' in step ? lookupPath(world, ctx, node.typeArguments[0], steps.slice(1), seen) : 'unknown'
}
if (!ts.isIdentifier(node.typeName)) return 'unknown' // namespace-qualified: out of reach
const resolved = declForTypeName(world, ctx, name)
return resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen)
}
return 'unknown'
}
/** Unwrap `as` / `satisfies` / parenthesized wrappers around an expression. */
function unwrapExpr(expr: ts.Expression): ts.Expression {
let e = expr
while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression
return e
}
/**
* Statically walk a schemastery schema expression to its key paths plus the
* packages whose schemas an intersect composes. A key path is the top-level
* key or a nested path through object/array compositions (`agents[].id`).
* Handles the shapes the repo declares — `z.object({…})` (possibly behind
* chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
* anything else, so a schema the walk cannot see fails the gate instead of
* silently thinning it. Nested values that are neither `object` nor `array`
* compositions (primitives, unions, dynamic-key dicts) contribute no paths.
*/
function walkSchemaExpr(
ctx: FileCtx,
expr: ts.Expression,
where: string,
violations: string[],
): { keys: string[]; composes: string[] } {
const keys: string[] = []
const composes: string[] = []
// Nested paths under one object property's VALUE expression: recurse through
// chained refinements toward the base call, descending into object/array.
const collectValuePaths = (value: ts.Expression, base: string): void => {
const call = unwrapExpr(value)
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return
const method = call.expression.name.text
if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
for (const prop of call.arguments[0].properties) {
if (!ts.isPropertyAssignment(prop)) continue
const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
keys.push(`${base}.${key}`)
collectValuePaths(prop.initializer, `${base}.${key}`)
}
return
}
if (method === 'array' && call.arguments[0]) {
collectValuePaths(call.arguments[0], `${base}[]`)
return
}
const inner = unwrapExpr(call.expression.expression)
if (ts.isCallExpression(inner)) collectValuePaths(inner, base)
}
const visit = (e: ts.Expression): void => {
const call = unwrapExpr(e)
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
violations.push(`${where}: schema expression is not a statically walkable schemastery call.`)
return
}
const method = call.expression.name.text
if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
for (const prop of call.arguments[0].properties) {
if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
keys.push(key)
if (ts.isPropertyAssignment(prop)) collectValuePaths(prop.initializer, key)
} else {
violations.push(`${where}: schema object property '${prop.getText(ctx.sf)}' is not a plain key.`)
}
}
return
}
if (method === 'intersect' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
for (const el of call.arguments[0].elements) {
const part = unwrapExpr(el)
if (ts.isPropertyAccessExpression(part) && part.name.text === 'Config' && ts.isIdentifier(part.expression)) {
const imp = ctx.imports.get(part.expression.text)
if (imp && !imp.specifier.startsWith('.')) { composes.push(imp.specifier); continue }
}
if (ts.isCallExpression(part)) { visit(part); continue }
violations.push(`${where}: intersect element '${part.getText(ctx.sf)}' is neither a workspace plugin's Config nor an inline schema call.`)
}
return
}
// A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
// the call the chain hangs off — keep unwrapping toward it.
const base = unwrapExpr(call.expression.expression)
if (ts.isCallExpression(base)) { visit(base); return }
violations.push(`${where}: schema call '${method}' is not object/intersect and hangs off no walkable base call.`)
}
visit(expr)
return { keys, composes }
}
/** Find a plugin's schemastery schema expression: an exported `const Config`
* in the entry file, else a `static Config` on the plugin class. */
function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): ts.Expression | null {
for (const stmt of ctx.sf.statements) {
if (!ts.isVariableStatement(stmt)) continue
if (!stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) continue
for (const decl of stmt.declarationList.declarations) {
if (ts.isIdentifier(decl.name) && decl.name.text === 'Config' && decl.initializer) return decl.initializer
}
}
for (const member of pluginClass?.members ?? []) {
if (!ts.isPropertyDeclaration(member) || member.name.getText() !== 'Config') continue
if (!member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)) continue
if (member.initializer) return member.initializer
}
return null
}
/** Read an `inject` service-key list: `export const inject = […]` in the entry
* file, else `static inject = […]` on the plugin class. */
function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] {
const fromArray = (expr: ts.Expression, where: string): string[] => {
if (!ts.isArrayLiteralExpression(expr)) {
violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`)
return []
}
return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf))
}
for (const stmt of ctx.sf.statements) {
if (!ts.isVariableStatement(stmt)) continue
for (const decl of stmt.declarationList.declarations) {
if (ts.isIdentifier(decl.name) && decl.name.text === 'inject' && decl.initializer) {
return fromArray(decl.initializer, ctx.rel)
}
}
}
for (const member of pluginClass?.members ?? []) {
if (ts.isPropertyDeclaration(member) && member.name.getText() === 'inject' && member.initializer) {
return fromArray(member.initializer, ctx.rel)
}
}
return []
}
/** Resolve the entry file's default export to its class/function declaration
* (mirroring the Loader's `unwrapExports`), or null when there is none. */
function defaultExport(ctx: FileCtx): ts.ClassDeclaration | ts.FunctionDeclaration | null {
for (const stmt of ctx.sf.statements) {
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
const name = stmt.expression.text
for (const s of ctx.sf.statements) {
if ((ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s)) && s.name?.text === name) return s
}
return null
}
if ((ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt))
&& stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)) return stmt
}
return null
}
/** Find the exported `apply` function declaration in the entry file, or null. */
function applyExport(ctx: FileCtx): ts.FunctionDeclaration | null {
for (const stmt of ctx.sf.statements) {
if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'apply'
&& stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) return stmt
}
return null
}
/**
* Walk every `packages/<group>/<pkg>` entry and build the catalog entries.
* Hard-errors (aggregated) on any violation listed in the module doc.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
const violations: string[] = []
const cache = new Map<string, FileCtx>()
const entries: CatalogEntry[] = []
// Pre-pass: package name → dir, so schema-path lookups can follow
// workspace-package imports while individual packages are still being walked.
const pkgDirByName = new Map<string, string>()
const manifests: { dir: string; pkg: string }[] = []
for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) {
const dir = manifestRel.slice(0, -'/package.json'.length)
const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[] }
const pkg = manifest.name
if (!pkg) {
violations.push(`${manifestRel} has no "name".`)
continue
}
if (manifest.os !== undefined && manifest.cpu !== undefined) {
// A per-platform native-binary package (npm os/cpu selection) ships no
// JavaScript at all — nothing to classify, no Config to catalog.
continue
}
pkgDirByName.set(pkg, dir)
manifests.push({ dir, pkg })
}
const world: World = { scanRoot, cache, pkgDirByName }
for (const { dir, pkg } of manifests) {
const entryRel = `${dir}/src/index.ts`
let ctx: FileCtx
try {
ctx = loadFile(resolve(scanRoot, entryRel), entryRel, cache)
} catch {
// A package without src/index.ts cannot be classified — that is the
// violation itself; nothing else in this loop body can run without it.
violations.push(`${pkg}: entry ${entryRel} is missing or unreadable.`)
continue
}
// Classify, mirroring the Loader's unwrapExports: the default export IS
// the plugin when present; else an exported `apply` makes the module
// namespace the plugin; else the package is a plain library.
const dflt = defaultExport(ctx)
const apply = applyExport(ctx)
let pluginClass: ts.ClassDeclaration | null = null
let configParam: ts.ParameterDeclaration | undefined
let kind: Kind
let className: string | undefined
if (dflt && ts.isClassDeclaration(dflt)) {
className = dflt.name?.text
if (dflt.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword)) {
kind = 'seam'
} else {
pluginClass = dflt
const ctor = dflt.members.find(ts.isConstructorDeclaration)
configParam = ctor?.parameters[1]
kind = configParam ? 'config' : 'no-config'
}
} else if (dflt) {
configParam = dflt.parameters[1]
kind = configParam ? 'config' : 'no-config'
} else if (apply) {
configParam = apply.parameters[1]
kind = configParam ? 'config' : 'no-config'
} else {
kind = 'library'
}
const entry: CatalogEntry = {
pkg,
dir,
entry: entryRel,
kind,
inject: kind === 'library' || kind === 'seam' ? [] : findInject(ctx, pluginClass, violations),
...className !== undefined ? { className } : {},
}
entries.push(entry)
if (kind !== 'config' || !configParam) continue
// Resolve the config type and paste its package-local transitive closure.
if (!configParam.type || !ts.isTypeReferenceNode(configParam.type) || !ts.isIdentifier(configParam.type.typeName)) {
violations.push(`${pkg}: config parameter type (${pointer(entryRel, ctx.sf, configParam)}) is not a plain type-name reference; declare a named config type.`)
continue
}
const typeName = configParam.type.typeName.text
entry.configTypeName = typeName
const pastes: Paste[] = []
const refs = new Map<string, TypeRef>()
// A bare name is the fence's whole namespace: two DIFFERENT declarations
// (or a declaration in one file and an import in another) sharing a name
// cannot both render unambiguously, so every resolution is identity-checked
// by source pointer and a collision is a violation, never a silent skip.
const pastedDeclByName = new Map<string, string>()
const queue: { name: string; from: FileCtx }[] = [{ name: typeName, from: ctx }]
for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
const { name, from } = item
const resolved = resolveTypeName(from, name, cache, violations)
if (resolved === null) {
violations.push(`${pkg}: config declaration references '${name}' (via ${from.rel}), which is neither declared in the package, imported, nor a known global type.`)
continue
}
if ('ref' in resolved) {
if (name === typeName) {
violations.push(`${pkg}: config type '${name}' is imported from '${resolved.ref.specifier}'; a plugin's config type must live in its own package.`)
continue
}
if (pastedDeclByName.has(name)) {
violations.push(`${pkg}: '${name}' resolves to a package-local declaration (${pastedDeclByName.get(name) ?? ''}) in one file and an import from '${resolved.ref.specifier}' in another; rename one so the fence is unambiguous.`)
continue
}
const existing = refs.get(name)
if (existing && (existing.specifier !== resolved.ref.specifier || existing.imported !== resolved.ref.imported)) {
violations.push(`${pkg}: '${name}' is imported from both '${existing.specifier}' (${existing.imported}) and '${resolved.ref.specifier}' (${resolved.ref.imported}) across the pasted closure; disambiguate the aliases.`)
continue
}
refs.set(name, resolved.ref)
continue
}
const declKey = pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl)
const prior = pastedDeclByName.get(name)
if (prior === declKey) continue // same declaration reached again — benign
if (prior !== undefined) {
violations.push(`${pkg}: type name '${name}' resolves to two different declarations (${prior} and ${declKey}) across the pasted closure; rename one — a verbatim fence cannot carry two same-named declarations.`)
continue
}
if (refs.has(name)) {
violations.push(`${pkg}: '${name}' resolves to an import from '${refs.get(name)?.specifier ?? ''}' in one file and a package-local declaration (${declKey}) in another; rename one so the fence is unambiguous.`)
continue
}
pastedDeclByName.set(name, declKey)
pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: declKey })
checkMemberDocs(resolved.ctx, resolved.decl, violations)
const names = new Set<string>()
collectTypeNames(resolved.decl, names)
for (const n of names) {
if (GLOBAL_TYPES.has(n)) continue
queue.push({ name: n, from: resolved.ctx })
}
}
entry.pastes = pastes
entry.refs = [...refs.values()].sort((a, b) => a.alias.localeCompare(b.alias))
// Statically walk the runtime schema (when one exists) for the subset check.
const schemaExpr = findSchemaExpr(ctx, pluginClass)
if (schemaExpr) {
const { keys, composes } = walkSchemaExpr(ctx, unwrapExpr(schemaExpr), `${pkg} (${entryRel})`, violations)
entry.schemaKeys = keys
entry.schemaComposes = composes
} else {
entry.schemaKeys = null
}
}
// Second phase: fold composed schemas' key paths in, then walk every
// schema-validated path against the declared config type. Only a definite
// miss is a violation — a path through a shape the walk cannot enumerate
// stays silent rather than mis-reporting.
const byName = new Map(entries.map(e => [e.pkg, e]))
for (const entry of entries) {
if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
const seen = new Set<string>()
const foldComposed = (e: CatalogEntry): string[] => {
if (seen.has(e.pkg)) return []
seen.add(e.pkg)
const keys = [...e.schemaKeys ?? []]
for (const composed of e.schemaComposes ?? []) {
const target = byName.get(composed)
if (!target) {
violations.push(`${entry.pkg}: schema intersects '${composed}', which is not a workspace package the walk collected.`)
continue
}
keys.push(...foldComposed(target))
}
return keys
}
const allKeys = foldComposed(entry)
const mainPaste = entry.pastes?.[0]
const mainFile = mainPaste?.source.split(':')[0]
const mainCtx = mainFile !== undefined ? cache.get(resolve(scanRoot, mainFile)) : undefined
const mainDecl = mainCtx && entry.configTypeName !== undefined ? findTypeDecl(mainCtx, entry.configTypeName) : null
if (!mainCtx || !mainDecl) {
violations.push(`${entry.pkg}: cannot locate config type '${entry.configTypeName ?? ''}' for the schema-path check.`)
continue
}
for (const keyPath of allKeys) {
if (lookupPath(world, mainCtx, mainDecl, parsePath(keyPath), new Set()) === 'missing') {
violations.push(`${entry.pkg}: schema validates key '${keyPath}' but config type '${entry.configTypeName ?? ''}' declares no such member — the catalog paste would hide a loader-accepted field.`)
}
}
}
report(violations)
return entries.sort((a, b) => a.pkg.localeCompare(b.pkg))
}
/** GitHub-style anchor slug for a `## \`pkg\`` heading. */
function slug(heading: string): string {
return heading.toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/ /g, '-')
}
/** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
function requiresLine(inject: string[]): string {
return inject.length ? `Requires: ${inject.map(k => `\`${k}\``).join(' · ')}` : ''
}
/** Render one reference as a link: another plugin's config type → its section,
* a curated core-data-structures name → its page, any other workspace type →
* its source file, an external type → named with its module, unlinked. */
function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
const target = byName.get(ref.specifier)
if (target?.kind === 'config' && ref.imported === target.configTypeName) {
return `[\`${ref.alias}\`](#${slug(target.pkg)})`
}
const page = LINK_MAP[ref.imported]
if (page) return `[\`${ref.alias}\`](core-data-structures/${page})`
if (target) return `[\`${ref.alias}\`](../${target.entry})`
return `\`${ref.alias}\` (\`${ref.specifier}\`)`
}
/** Render one configurable plugin's section. */
function renderConfigEntry(entry: CatalogEntry, byName: Map<string, CatalogEntry>): string[] {
const out = [`## \`${entry.pkg}\``, '']
const requires = requiresLine(entry.inject)
if (requires) out.push(requires, '')
out.push('```' + FENCE, ...(entry.pastes ?? []).map(p => p.text).join('\n\n').split('\n'), '```', '')
if (entry.refs && entry.refs.length > 0) {
out.push(`Depends on: ${entry.refs.map(r => refLink(r, byName)).join(' · ')}`, '')
}
const source = entry.pastes?.[0]?.source ?? entry.entry
out.push(`Source: [\`${source}\`](../${source.split(':')[0]})`, '')
return out
}
/** Render one terse list line (the no-config / seam / library sections). */
function renderTerse(entry: CatalogEntry, detail: string): string {
const requires = entry.inject.length ? ` — requires ${entry.inject.map(k => `\`${k}\``).join(' · ')}` : ''
return `- \`${entry.pkg}\`${detail}${requires} ([\`${entry.entry}\`](../${entry.entry}))`
}
/** Render the full catalog (pure, deterministic given sorted entries). */
export function render(entries: CatalogEntry[]): string {
const byName = new Map(entries.map(e => [e.pkg, e]))
const lines: string[] = [
'<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-config-catalog` to regenerate. -->',
'',
'# Plugin Config Catalog',
'',
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
'',
'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
'',
'A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.',
'',
]
for (const entry of entries.filter(e => e.kind === 'config')) {
lines.push(...renderConfigEntry(entry, byName))
}
lines.push(
'## Loadable plugins with no config',
'',
'These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.',
'',
...entries.filter(e => e.kind === 'no-config').map(e => renderTerse(e, '')),
'',
'## Seam packages (not directly loadable)',
'',
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).',
'',
...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
'',
'## Library packages (no plugin entry)',
'',
'Imported as libraries by other packages; a `cordis.yml` cannot load them.',
'',
...entries.filter(e => e.kind === 'library').map(e => renderTerse(e, '')),
'',
)
return lines.join('\n')
}
/** CLI entry: default writes the catalog, `--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(collectConfigCatalog())
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-config-catalog: ${OUT} is up to date.`)
process.exit(0)
}
console.error(`gen-config-catalog: ${OUT} is stale. Run \`pnpm run gen-config-catalog\` and commit ${OUT}.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-config-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()
}

247
scripts/gen-cordis-api.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* Generate (and verify) the runtime cordis API catalog the `cordis_inspect`
* tool serves to the model: packages/cordis/tool-cordis/src/api-catalog.ts.
*
* The artifact is the machine-readable sibling of docs/cordis-catalog: it
* reuses `collectServices` / `collectEvents` from `gen-cordis-catalog.ts` (the
* same JSDoc-completeness-enforcing AST walk), so the API the model reads at
* runtime and the API the docs render cannot diverge. Emitted as a typed
* TypeScript data module (not JSON): it compiles under the package tsconfig,
* passes lint and the export-JSDoc gate, and is trivially covered by import.
*
* The data is trimmed for a model-facing text surface: per service the
* `ctx.<key>` name, the first sentence of the class doc, and the raw method
* signatures; per event the name, `@mode`, signature, and first sentence of
* doc; the SHAPES of every exported interface/type-alias the service
* signatures reference (transitively — so a model can see that e.g. a
* `BashRunResult.stdout` is `{ text, truncated }`, not a string); plus the
* curated inherited `ctx` surface shared with the docs catalog. Source
* pointers are dropped (a `file:line` means nothing to the model) and entries
* are sorted deterministically.
*
* `tsx scripts/gen-cordis-api.ts` → write the artifact
* `tsx scripts/gen-cordis-api.ts --check` → exit 1 if the committed file is
* stale (CI / pre-push gate)
*/
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')}'`
}
/**
* Every exported `interface` / `type` declaration under `packages/<group>/<pkg>/src`,
* printed without comments, keyed by name. A name declared in more than one
* package (e.g. each plugin's `Config`) is ambiguous and dropped entirely —
* serving the wrong package's shape is worse than serving none.
*/
function collectTypeDecls(scanRoot: string = root): Map<string, string> {
const printer = ts.createPrinter({ removeComments: true })
const decls = new Map<string, string>()
const ambiguous = new Set<string>()
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
}
/**
* The transitive closure of type names referenced by the seed texts: every
* collected declaration whose name appears (word-bounded) in a seed or in an
* already-included declaration, sorted by name.
*/
function referencedTypes(seeds: string[], decls: Map<string, string>): { name: string; declaration: string }[] {
const included = new Map<string, string>()
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), 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), harness',
' * events (mode + signature), 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 harness `ctx.<key>` service: its one-line summary and public method signatures. */',
'export interface ServiceApiEntry {',
' /** The `ctx.<key>` name, e.g. `tools`. */',
' key: string',
' /** First sentence of the service class JSDoc. */',
' summary: string',
' /** Public method signatures, bodies stripped, in source order. */',
' methods: readonly string[]',
'}',
'',
'/** 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',
' /** 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.<key>` 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(` ${quote(method)},`)
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(` 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()
}

View File

@@ -1,25 +1,28 @@
/**
* Generate (and verify) the cordis events + services catalog in
* docs/cordis-catalog/events-and-services.md.
* Generate (and verify) the cordis events and services catalogs in
* docs/cordis-catalog/events.md and docs/cordis-catalog/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).
* The two pages are the WIRING-axis reference, one axis each: every cordis
* event a plugin can listen to (exact signature + dispatch mode) and every
* `ctx.<key>` service it can call (exact public interface). They complement the
* core-data-structures catalog (the VOCABULARY axis — the types these
* signatures move around).
*
* The catalog 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. Because generation enumerates source rather than checking a
* hand-written subset, a brand-new event cannot be silently undocumented — it
* appears in the next regenerate, and an un-regenerated file fails `--check`.
* The catalogs are FULLY GENERATED from source — never hand-edit them. 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 files
* are build artifacts and a regenerate-and-diff freshness check (`--check`)
* makes drift structurally impossible. Because generation enumerates source
* rather than checking a hand-written subset, a brand-new event cannot be
* silently undocumented — it appears in the next regenerate, and an
* un-regenerated file fails `--check`.
*
* `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)
* `tsx scripts/gen-cordis-catalog.ts` → write both catalogs
* `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if a committed
* catalog 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.
@@ -37,7 +40,9 @@
* a stale `@param` naming no real parameter errors. Violations aggregate into
* ONE error listing every offender. The tags are enforcement-only: parseJsDoc
* stops prose at the first block tag, so they never change the rendered
* catalog. The INHERITED
* catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with
* the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so
* "documented" means the same thing on both surfaces. 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.
@@ -50,45 +55,56 @@
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/cordis-catalog/events-and-services.md'
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
const OUT_SERVICES = 'docs/cordis-catalog/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' | 'serial'
/**
* 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).
* core-data-structures page that documents it (path relative to the catalogs'
* 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.
* Shared with `gen-config-catalog.ts` (each caller prefixes its own relative
* path to `core-data-structures/`), so both catalogs cross-link identically.
* TODO(catalog-type-links): add a verifier or generator for link-map coverage
* so new hook-era decision types like `PromptDecision` / `PreToolDecision` do
* not silently appear in signatures without a "Types:" link.
*/
const LINK_MAP: Record<string, string> = {
export const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
ContentBlock: 'core.md',
Message: 'core.md',
MessageSource: 'core.md',
GenerateOptions: 'core.md',
LlmCallConfig: 'core.md',
SessionEvent: 'core.md',
StreamChunk: 'llm-streaming.md',
TurnEndReason: 'session.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolExecutionResult: 'tools.md',
ApprovalOutcome: 'approval.md',
ApprovalPolicy: 'approval.md',
ApprovalRequest: 'approval.md',
BashExecRequest: 'bash.md',
BashExecSpec: 'bash.md',
BashRunResult: 'bash.md',
BashTask: 'bash.md',
BashTaskRead: 'bash.md',
ConfinedArgv: 'sandbox.md',
SandboxMode: 'sandbox.md',
SandboxPolicy: 'sandbox.md',
CodeRunRequest: 'code-runtime.md',
CodeRunResult: 'code-runtime.md',
FsEditOutcome: 'filesystem.md',
FsEditRequest: 'filesystem.md',
FsInfo: 'filesystem.md',
@@ -140,132 +156,6 @@ interface InheritedEntry {
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`. Description
* prose ends at the FIRST block tag (standard JSDoc semantics): tag lines and
* their continuation lines are never prose, so `@param`/`@returns` blocks are
* invisible to the rendered catalog.
*/
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
let inTags = false
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|serial)\s*$/.exec(line)
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
if (line.startsWith('@')) { flushPara(); inTags = true; continue }
if (inTags) continue // block-tag territory: continuations are never 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 }
}
/**
* Parse the block tags of a raw JSDoc comment for the completeness checks:
* every `@param name — description` entry plus the `@returns` description.
* Standard JSDoc block-tag semantics — a tag's description runs across
* continuation lines until the next tag or a blank line, and the `-`/`—`
* separator after a param name is optional. `[name]` optional-brackets unwrap
* to `name`. Rendering never sees these: parseJsDoc stops prose at the first
* block tag.
*/
function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
const inner = raw
.replace(/^\/\*\*/, '')
.replace(/\*\/$/, '')
.split('\n')
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
const params = new Map<string, string>()
let returns: string | null = null
let sink: ((text: string) => void) | null = null
for (const line of inner) {
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
if (param) {
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
let acc = param[2] ?? ''
params.set(name, acc)
sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
continue
}
const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
if (ret) {
let acc = ret[1] ?? ''
returns = acc
sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
continue
}
if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
sink?.(line.trim())
}
return { params, returns }
}
/**
* Throw one aggregate error for every completeness violation a walk collected.
* Aggregation (vs the fail-fast the @mode check used to do) is deliberate: a
* remediation pass sees the whole list at once instead of replaying the gate
* once per offender.
*/
function reportViolations(violations: string[]): void {
if (violations.length === 0) return
throw new Error(
`gen-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
+ violations.map(v => ` ${v}`).join('\n'),
)
}
/** 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) {
@@ -328,27 +218,13 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
// (mode machinery, documented once by @mode semantics). Documenting an
// exempt parameter anyway is allowed — only absence is checked.
const { params } = parseTags(raw)
for (const p of member.parameters) {
if (!ts.isIdentifier(p.name)) {
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`)
continue
}
const pname = p.name.text
if (pname === 'this' || (hasNext && p === last)) continue
const desc = params.get(pname)
if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
}
for (const tag of params.keys()) {
if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
}
}
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
}
}
}
reportViolations(violations)
reportViolations('gen-cordis-catalog', violations)
return entries
}
@@ -409,35 +285,12 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
const { params, returns } = parseTags(raw)
// Every parameter needs a non-empty @param; a `this` receiver
// annotation is not payload and is exempt.
for (const p of member.parameters) {
if (!ts.isIdentifier(p.name)) {
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`)
continue
}
const pname = p.name.text
if (pname === 'this') continue
const desc = params.get(pname)
if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
}
for (const tag of params.keys()) {
if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
}
}
// A non-void result needs a non-empty @returns. The return type must be
// ANNOTATED: a pure-AST walk cannot classify an inferred return. On a
// `void`/`Promise<void>` method @returns stays optional (resolution
// timing can be worth documenting), never required.
const rt = member.type?.getText(sf).replace(/\s+/g, ' ')
if (rt === undefined) {
violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
} else if (!/^(void|Promise<void>)$/.test(rt)) {
if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
}
// Every parameter needs a non-empty @param (`this` receiver exempt),
// and a non-void ANNOTATED result needs a non-empty @returns — the
// shared checkers carry the exact contract.
checkParams(where, 'service', member.parameters, params, sf,
p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
checkReturns(where, member.type, returns, sf, violations)
}
entries.push({
key,
@@ -449,7 +302,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
})
}
}
reportViolations(violations)
reportViolations('gen-cordis-catalog', violations)
return entries.sort((a, b) => a.key.localeCompare(b.key))
}
@@ -480,7 +333,7 @@ const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
]
const INHERITED_SERVICES: InheritedEntry[] = [
export 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-bail / 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' },
@@ -506,7 +359,7 @@ function typeLinks(signature: string): string {
/** Render one harness event entry. */
function renderEvent(e: EventEntry): string[] {
const out = [`#### \`${e.name}\`${e.mode}`, '']
const out = [`### \`${e.name}\`${e.mode}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, e.signature, '```', '')
const links = typeLinks(e.signature)
@@ -518,7 +371,7 @@ function renderEvent(e: EventEntry): string[] {
/** Render one harness service entry. */
function renderService(s: ServiceEntry): string[] {
const kind = s.abstract ? ' (abstract seam)' : ''
const out = [`### \`ctx.${s.key}\`\`${s.type}\`${kind}`, '']
const out = [`## \`ctx.${s.key}\`\`${s.type}\`${kind}`, '']
if (s.doc) out.push(s.doc, '')
if (s.methods.length) {
out.push('```' + FENCE, ...s.methods, '```', '')
@@ -529,51 +382,71 @@ function renderService(s: ServiceEntry): string[] {
return out
}
/** Render the full catalog (pure, deterministic given sorted inputs). */
function render(events: EventEntry[], services: ServiceEntry[]): string {
/** The shared generated-file banner comment. */
const BANNER = [
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
'',
]
/** The shared GENERATED + freshness-gate + fence notice paragraph. */
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.'
/** Render the events catalog (pure, deterministic given sorted inputs). */
function renderEvents(events: EventEntry[]): string {
const lines: string[] = [
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
...BANNER,
'# Cordis Events Catalog',
'',
'# Cordis Events & Services Catalog',
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
'',
'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.',
GATE_NOTICE,
'',
'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.',
'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 **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; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
'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`).',
'',
]
const scopes = [...new Set(events.map(e => e.scope))].sort()
for (const scope of scopes) {
lines.push(`### \`${scope}/*\``, '')
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',
'## Inherited events (cordis core + loader/hmr/timer)',
'',
'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.',
'',
)
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',
'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.',
'',
)
for (const e of INHERITED_EVENTS) {
lines.push(`- \`${e.name}\`${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
}
lines.push('', '### Inherited `ctx` members', '')
lines.push('')
return lines.join('\n')
}
/** Render the services catalog (pure, deterministic given sorted inputs). */
function renderServices(services: ServiceEntry[]): string {
const lines: string[] = [
...BANNER,
'# Cordis Services Catalog',
'',
'Every `ctx.<key>` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
'',
GATE_NOTICE,
'',
'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.',
'',
]
for (const s of services) lines.push(...renderService(s))
lines.push(
'## Inherited `ctx` members (cordis core + loader/hmr/timer)',
'',
'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.',
'',
)
for (const s of INHERITED_SERVICES) {
lines.push(`- \`${s.name}\`${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
}
@@ -581,31 +454,38 @@ function render(events: EventEntry[], services: ServiceEntry[]): string {
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. */
/** CLI entry: `--write` (default) writes both catalogs, `--check` fails if
* either is stale. Guarded behind an entry-point check so importing this module
* for tests neither regenerates the committed files nor calls process.exit. */
function main(): void {
const content = render(collectEvents(), collectServices())
const outputs: [string, string][] = [
[OUT_EVENTS, renderEvents(collectEvents())],
[OUT_SERVICES, renderServices(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
const stale: string[] = []
for (const [out, content] of outputs) {
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) stale.push(out)
}
if (committed === content) {
console.log(`gen-cordis-catalog: ${OUT} is up to date.`)
if (stale.length === 0) {
console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`)
process.exit(0)
}
console.error(`gen-cordis-catalog: ${OUT} is stale. Run \`pnpm run gen-cordis-catalog\` and commit ${OUT}.`)
console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-cordis-catalog: wrote ${OUT}.`)
for (const [out, content] of outputs) writeFileSync(resolve(root, out), content)
console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`)
}
// Run only when invoked as a script, not when imported by a test.

858
scripts/gen-doc-graphs.ts Normal file
View File

@@ -0,0 +1,858 @@
/**
* Generate (and verify) the relationship-diagram docs.
*
* This is the relationship layer above the existing catalogs:
* - module-graph.md answers "which packages depend on which packages?"
* - cordis-catalog/ answers "which events and services exist?"
* - tool-catalog.md answers "which tools does the model see?"
* - generated relationship diagrams answer "how do those pieces fit together?"
*
* Generated pages discover the enumerable facts from source. Hybrid pages use
* discovered inventory plus small manifests for policy that source cannot infer
* (for example, whether a package is an implementation or consumer in a seam).
* Curated pages are still emitted here so the graph docs are one regenerated unit,
* but their diagrams intentionally explain flow and ownership rather than
* pretending to enumerate every source edge.
*
* `tsx scripts/gen-doc-graphs.ts` -> write generated diagram docs
* `tsx scripts/gen-doc-graphs.ts --check` -> exit 1 if any file is stale
*/
import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import ts from 'typescript'
import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
const root = resolve(import.meta.dirname, '..')
const SCOPE = '@deepseek-ai/dsh-'
interface PkgJson {
name: string
peerDependencies?: Record<string, string>
}
interface Pkg {
short: string
name: string
group: string
rel: string
deps: string[]
}
interface GraphDoc {
rel: string
content: string
}
interface ServiceRole {
key: string
pkg: string
title: string
mode: 'core' | 'seam' | 'bundle'
implementations?: string[]
consumers?: string[]
companions?: string[]
note: string
}
interface ExamplePlugin {
id: string
name: string
}
interface EventRelation {
dispatchers: Map<string, Set<string>>
listeners: Set<string>
}
const GROUP_ORDER = [
'util',
'llm',
'core',
'bash',
'sandbox',
'fs',
'skill',
'compact',
'subagent',
'web',
'todo',
'cordis',
'hooks',
'session-persistence',
'support',
'ui',
]
const SERVICE_ROLES: ServiceRole[] = [
{
key: 'llm',
pkg: 'llm',
title: 'LLM adapter registry',
mode: 'seam',
implementations: ['llm-deepseek', 'llm-pi-ai', 'llm-replay'],
consumers: ['agent-loop', 'compact-basic'],
note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
},
{
key: 'sessions',
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'session-persistence', 'subagent-inprocess', 'invariants'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
key: 'sessionPersistence',
pkg: 'session-persistence',
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
consumers: ['agent-loop', 'acp'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
key: 'systemPrompt',
pkg: 'system-prompt',
title: 'System prompt assembly registry',
mode: 'core',
consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-web'],
note: 'Collects prompt sections and model-facing tool schemas for each step.',
},
{
key: 'tools',
pkg: 'tools',
title: 'Tool registry and execution waterfall',
mode: 'core',
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
},
{
key: 'userInteraction',
pkg: 'user-interaction',
title: 'Human question/answer seam',
mode: 'seam',
implementations: ['stdio-agent', 'acp'],
consumers: ['tool-ask-user', 'stdio-agent', 'acp'],
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'skills',
pkg: 'skill',
title: 'Skill provider registry',
mode: 'seam',
implementations: ['skill-local'],
consumers: ['tool-skill'],
note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
},
{
key: 'agents',
pkg: 'agent',
title: 'Agent registry',
mode: 'core',
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-agent', 'invariants'],
note: 'Owns live Agent handles and the create/resume factory seam.',
},
{
key: 'agentLoop',
pkg: 'agent-loop',
title: 'Concrete loop driver',
mode: 'bundle',
consumers: ['agent-core'],
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
},
{
key: 'bash',
pkg: 'bash',
title: 'Bash executor seam',
mode: 'seam',
implementations: ['bash-local', 'bash-sandbox'],
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
},
{
key: 'sandbox',
pkg: 'sandbox',
title: 'Process-sandbox seam',
mode: 'seam',
implementations: ['sandbox-local'],
consumers: ['bash-sandbox'],
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
},
{
key: 'approval',
pkg: 'approval',
title: 'Approval seam',
mode: 'seam',
implementations: ['acp'],
consumers: ['tools', 'tool-bash'],
note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.',
},
{
key: 'codeRuntime',
pkg: 'code-runtime',
title: 'Code-execution seam',
mode: 'seam',
implementations: ['code-runtime-worker'],
consumers: ['tools'],
note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode).',
},
{
key: 'fs',
pkg: 'fs',
title: 'Filesystem provider seam',
mode: 'seam',
implementations: ['fs-local'],
consumers: ['tool-fs'],
companions: ['fs-policy'],
note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.',
},
{
key: 'compact',
pkg: 'compact',
title: 'Compaction seam',
mode: 'seam',
implementations: ['compact-basic'],
consumers: ['compact-basic'],
note: 'The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred.',
},
{
key: 'subagents',
pkg: 'subagent',
title: 'Subagent provider registry',
mode: 'seam',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-mock'],
consumers: ['tool-subagent'],
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
},
{
key: 'web',
pkg: 'web',
title: 'Web access provider registry',
mode: 'seam',
implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-local'],
consumers: ['tool-web'],
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
},
{
key: 'workflows',
pkg: 'workflow',
title: 'Workflow script engine',
mode: 'seam',
implementations: ['workflow-workerthread'],
consumers: ['tool-workflow'],
note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.',
},
]
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
// Subagent lifecycle events intentionally bypass ctx.emit and call
// ctx.events.dispatch directly so one throwing listener cannot starve later
// listeners or strand an already-started child run.
{ event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' },
{ event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' },
// The workflow/* lifecycle events dispatch the same way, for the same
// per-listener-containment reason (WorkflowService.emitWorkflowEvent).
{ event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' },
]
function generatedHeader(title: string): string[] {
return [
'<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
' Run `pnpm run gen-doc-graphs` to regenerate. -->',
'',
`# ${title}`,
'',
]
}
function maintenanceFooter(source: string): string[] {
return [`Maintenance mode: ${source}.`, '']
}
function graphIndexLink(rel: string): string {
return relative('docs', rel).replaceAll('\\', '/')
}
function linkFromDoc(docRel: string, targetRel: string): string {
return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
}
function collectPackages(): Pkg[] {
const pkgs: Pkg[] = []
for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as PkgJson
if (!json.name.startsWith(SCOPE)) continue
const [, group, leaf] = rel.split('/')
if (group === undefined || leaf === undefined) throw new Error(`gen-doc-graphs: unexpected package path ${rel}`)
const deps = Object.keys(json.peerDependencies ?? {})
.filter(dep => dep.startsWith(SCOPE))
.map(dep => dep.slice(SCOPE.length))
.sort()
pkgs.push({
short: json.name.slice(SCOPE.length),
name: json.name,
group,
rel: dirname(rel),
deps,
})
}
return topoSort(pkgs)
}
function topoSort(pkgs: Pkg[]): Pkg[] {
const remaining = new Map(pkgs.map(p => [p.short, p]))
const placed = new Set<string>()
const out: Pkg[] = []
while (remaining.size > 0) {
const ready = [...remaining.values()]
.filter(pkg => pkg.deps.every(dep => placed.has(dep)))
.sort(comparePackages)
if (ready.length === 0) throw new Error(`gen-doc-graphs: dependency cycle among ${[...remaining.keys()].join(', ')}`)
for (const pkg of ready) {
out.push(pkg)
placed.add(pkg.short)
remaining.delete(pkg.short)
}
}
return out
}
function comparePackages(a: Pkg, b: Pkg): number {
const groupA = GROUP_ORDER.indexOf(a.group)
const groupB = GROUP_ORDER.indexOf(b.group)
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
}
function nodeId(prefix: string, value: string): string {
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
}
function escLabel(value: string): string {
return value.replace(/"/g, '\\"')
}
function mermaidCode(value: string): string {
return `<code>${value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code>`
}
function repoLink(path: string, label: string, up = '..'): string {
return `[${label}](${up}/${path})`
}
function sourceLink(source: string, up = '..'): string {
return repoLink(source.split(':')[0] ?? source, `\`${source}\``, up)
}
function pkgLink(pkg: Pkg | undefined, fallback: string, up = '..'): string {
return pkg ? repoLink(pkg.rel, `\`${pkg.short}\``, up) : `\`${fallback}\``
}
function pkgList(names: string[] | undefined, pkgsByShort: Map<string, Pkg>): string {
if (!names || names.length === 0) return '-'
return names.map(name => pkgLink(pkgsByShort.get(name), name)).join(', ')
}
function tableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\n/g, '<br>')
}
function assertServiceRolesComplete(): void {
const discovered = new Set(collectServices().map(service => service.key))
const classified = new Set(SERVICE_ROLES.map(role => role.key))
const missing = [...discovered].filter(key => !classified.has(key)).sort()
const stale = [...classified].filter(key => !discovered.has(key)).sort()
if (missing.length || stale.length) {
throw new Error([
missing.length ? `missing service role classification: ${missing.join(', ')}` : '',
stale.length ? `stale service role classification: ${stale.join(', ')}` : '',
].filter(Boolean).join('; '))
}
}
function renderCapabilitySeams(pkgs: Pkg[]): string {
assertServiceRolesComplete()
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const maintenance = 'hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard'
const nodes = new Map<string, string>()
const edges = new Set<string>()
const companionEdges = new Set<string>()
const addNode = (id: string, label: string): void => {
if (!nodes.has(id)) nodes.set(id, ` ${id}["${escLabel(label)}"]`)
}
const addEdge = (from: string, to: string): void => { edges.add(` ${from} --> ${to}`) }
const lines = generatedHeader('Capability Seams And Core Services')
lines.push(
'A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.',
'',
'```mermaid',
'flowchart LR',
)
for (const role of SERVICE_ROLES) {
const svc = nodeId('svc', role.key)
const owner = nodeId('pkg', role.pkg)
addNode(owner, role.pkg)
addNode(svc, `ctx.${role.key}<br/>${role.title}`)
addEdge(owner, svc)
for (const impl of role.implementations ?? []) {
addNode(nodeId('pkg', impl), impl)
addEdge(nodeId('pkg', impl), svc)
}
for (const consumer of role.consumers ?? []) {
addNode(nodeId('pkg', consumer), consumer)
addEdge(svc, nodeId('pkg', consumer))
}
for (const companion of role.companions ?? []) {
addNode(nodeId('pkg', companion), companion)
companionEdges.add(` ${svc} -. event gate .-> ${nodeId('pkg', companion)}`)
}
}
lines.push(...nodes.values(), ...[...edges].sort(), ...[...companionEdges].sort())
lines.push('```', '', '| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |', '| --- | --- | --- | --- | --- | --- | --- |')
for (const role of SERVICE_ROLES) {
lines.push(`| \`ctx.${role.key}\` | \`${role.mode}\` | ${pkgLink(pkgsByShort.get(role.pkg), role.pkg)} | ${pkgList(role.implementations, pkgsByShort)} | ${pkgList(role.consumers, pkgsByShort)} | ${pkgList(role.companions, pkgsByShort)} | ${tableCell(role.note)} |`)
}
lines.push('', ...maintenanceFooter(maintenance))
return lines.join('\n')
}
function parseExampleCordis(rel: string): ExamplePlugin[] {
const text = readFileSync(resolve(root, rel), 'utf8')
const plugins: ExamplePlugin[] = []
let current: { id: string; name?: string } | null = null
const flush = (): void => {
if (current?.name) plugins.push({ id: current.id, name: current.name })
}
for (const line of text.split('\n')) {
const id = /^-\s+id:\s+(.+?)\s*$/.exec(line)
if (id?.[1] !== undefined) {
flush()
current = { id: stripYamlScalar(id[1]) }
continue
}
const name = /^\s+name:\s+(.+?)\s*$/.exec(line)
if (name?.[1] !== undefined && current) current.name = stripYamlScalar(name[1])
}
flush()
return plugins
}
function stripYamlScalar(value: string): string {
return value.trim().replace(/^['"]|['"]$/g, '')
}
const APP_EXAMPLES = [
{
id: 'echo',
rel: 'examples/echo-agent/composition.md',
title: 'Echo Agent App Composition',
label: 'examples/echo-agent',
config: 'examples/echo-agent/cordis.yml',
summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.',
},
{
id: 'coding',
rel: 'examples/coding-agent/composition.md',
title: 'Coding Agent App Composition',
label: 'examples/coding-agent',
config: 'examples/coding-agent/cordis.yml',
summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
},
{
id: 'cordis',
rel: 'examples/cordis-agent/composition.md',
title: 'Cordis Agent App Composition',
label: 'examples/cordis-agent',
config: 'examples/cordis-agent/cordis.yml',
summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it.',
},
{
id: 'acp',
rel: 'examples/acp-agent/composition.md',
title: 'ACP Agent App Composition',
label: 'examples/acp-agent',
config: 'examples/acp-agent/cordis.yml',
summary: 'The ACP demo exposes the same agent spine over JSON-RPC stdio, with no stdout logger and no pre-created agent; clients create sessions through the ACP bridge.',
},
]
type AppExample = typeof APP_EXAMPLES[number]
function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void {
const agentCore = nodeId('bundle', 'agent_core')
const jsonl = nodeId('bundle', 'jsonl')
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-core"]`)
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
if (pluginName === '@deepseek-ai/dsh-stdio-agent') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-acp-agent') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`)
}
lines.push(
` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`,
` ${agentCore} --> ${nodeId('spine', 'sessions')}["ctx.sessions"]`,
` ${agentCore} --> ${nodeId('spine', 'tools')}["ctx.tools + tool-bash"]`,
` ${agentCore} --> ${nodeId('spine', 'loop')}["ctx.agents + ctx.agentLoop"]`,
)
}
function renderAppComposition(example: AppExample): string {
const plugins = parseExampleCordis(example.config)
const maintenance = 'hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source'
const lines = generatedHeader(example.title)
lines.push(
example.summary,
'',
'```mermaid',
'flowchart LR',
` cfg["${escLabel(example.label)}<br/>cordis.yml"]`,
)
for (const plugin of plugins) {
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
lines.push(` cfg --> ${pluginNode}`)
if (plugin.name === '@deepseek-ai/dsh-stdio-agent' || plugin.name === '@deepseek-ai/dsh-acp-agent') {
renderAppExpansion(lines, pluginNode, plugin.name)
}
}
lines.push(
'```',
'',
'| Plugin id | Package / module |',
'| --- | --- |',
...plugins.map(plugin => `| \`${plugin.id}\` | \`${plugin.name}\` |`),
'',
`Source config: [\`${example.config}\`](${linkFromDoc(example.rel, example.config)}).`,
)
lines.push('', ...maintenanceFooter(maintenance))
return lines.join('\n')
}
function collectEventRelations(): Map<string, EventRelation> {
const out = new Map<string, EventRelation>()
const ensure = (event: string): EventRelation => {
const existing = out.get(event)
if (existing) return existing
const next = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
out.set(event, next)
return next
}
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root }).sort()) {
const [, , leaf] = rel.split('/')
if (leaf === undefined) continue
const text = readFileSync(resolve(root, rel), 'utf8')
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const method = node.expression.name.text
if (!isCordisContextReceiver(node.expression, sf)) {
ts.forEachChild(node, visit)
return
}
if (method === 'on') {
const event = eventArg(node.arguments, method)
if (event) ensure(event).listeners.add(leaf)
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
const event = eventArg(node.arguments, method)
if (event) {
const relation = ensure(event)
const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
methods.add(method)
relation.dispatchers.set(leaf, methods)
}
}
}
ts.forEachChild(node, visit)
}
visit(sf)
}
for (const entry of DYNAMIC_EVENT_DISPATCHERS) {
const relation = ensure(entry.event)
const methods = relation.dispatchers.get(entry.pkg) ?? new Set<string>()
methods.add(entry.method)
relation.dispatchers.set(entry.pkg, methods)
}
return out
}
function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean {
const target = expr.expression.getText(sf)
return target === 'ctx' || target === 'this.ctx'
}
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
if (method === 'waterfall') {
const arg = args.find(ts.isStringLiteralLike)
return arg?.text
}
const first = args[0]
return first && ts.isStringLiteralLike(first) ? first.text : undefined
}
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
if (map.size === 0) return '-'
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([pkg, methods]) => `${pkgLink(pkgsByShort.get(pkg), pkg)} (${[...methods].sort().map(m => `\`${m}\``).join(', ')})`)
.join(', ')
}
function listenerPackages(listeners: Set<string>, pkgsByShort: Map<string, Pkg>): string {
if (listeners.size === 0) return '-'
return [...listeners].sort().map(pkg => pkgLink(pkgsByShort.get(pkg), pkg)).join(', ')
}
function renderEventRelations(pkgs: Pkg[]): string {
const events = collectEvents()
const relations = collectEventRelations()
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const maintenance = 'hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`'
const lines = generatedHeader('Event Producer And Consumer Matrix')
lines.push(
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
'',
'| Event | Mode | Declared in | Dispatchers | Listeners |',
'| --- | --- | --- | --- | --- |',
)
for (const event of [...events].sort((a, b) => a.name.localeCompare(b.name))) {
const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
}
const declared = new Set(events.map(event => event.name))
const extra = [...relations.keys()].filter(event => !declared.has(event)).sort()
if (extra.length > 0) {
lines.push('', '## Non-harness or undeclared event strings seen in package source', '', '| Event string | Dispatchers | Listeners |', '| --- | --- | --- |')
for (const event of extra) {
const relation = relations.get(event)
if (!relation) continue
lines.push(`| \`${event}\` | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
}
}
lines.push('', ...maintenanceFooter(maintenance))
return lines.join('\n')
}
function renderLifecycle(): string {
const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
return [
...generatedHeader('Agent Turn And Step Lifecycle'),
'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
'',
'```mermaid',
'sequenceDiagram',
' participant User',
' participant Agent',
' participant Driver',
' participant Hooks as hook listeners',
' participant Prompt as ctx.systemPrompt',
' participant LLM as ctx.llm',
' participant Tools as ctx.tools',
' participant Session',
' participant Persistence',
' participant SDK as UI or SDK listener',
' User->>Agent: send(content)',
` Agent-->>SDK: ${mermaidCode('agent/queued')}`,
' Agent->>Driver: queued work wakes driver',
` Driver-->>SDK: ${mermaidCode('agent/status')} running`,
` Driver->>Session: ${mermaidCode('turn/start')}`,
` Driver->>Hooks: ${mermaidCode('agent/prompt-submit')} waterfall`,
' Hooks-->>Driver: allow, block, or add context',
` Driver->>Session: ${mermaidCode('user/message')} or rejected ${mermaidCode('turn/end')}`,
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
` Driver-->>Driver: ${mermaidCode('agent/pre-step')} serial checkpoint`,
` Driver->>Session: ${mermaidCode('step/start')}`,
` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`,
' LLM-->>Driver: StreamChunk*',
` Driver->>Session: ${mermaidCode('assistant/chunk')}*`,
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
` Driver->>Session: ${mermaidCode('assistant/message')}`,
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: execute through pre and post waterfalls',
' Tools-->>Session: tool-owned events when applicable',
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
` Driver->>Session: ${mermaidCode('turn/end')}`,
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
'```',
'',
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
'',
...maintenanceFooter(maintenance),
].join('\n')
}
function renderToolPipeline(): string {
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
return [
...generatedHeader('Tool Execution Pipeline'),
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.',
'',
'```mermaid',
'flowchart TD',
' model["Assistant message contains tool-call block"]',
` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
' presentCall["UI pending card<br/>presentCall(args)"]',
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
' denied["denied<br/>tool body skipped"]',
` approval["${mermaidCode('ctx.approval')} one-shot prompt<br/>absent or unanswerable: deny"]`,
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
' toolBody["Registered tool execute() body"]',
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
' context["Buffered additionalContext<br/>context/message after all tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
' presentResult["UI completed card<br/>presentResult(args, result)"]',
' model --> toolCall',
' toolCall --> presentCall',
' toolCall --> pre',
' pre -->|allow| around',
' around --> toolBody',
' pre -->|deny| denied',
' pre -->|ask| approval',
' approval -->|allowed-once| around',
' approval -->|rejected, cancelled, unavailable| denied',
' denied --> post',
' toolBody --> fsGate',
' fsGate --> toolBody',
' toolBody --> owned',
' toolBody --> around',
' around --> post',
' post --> context',
' post --> toolResult',
' toolResult --> presentResult',
'```',
'',
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and the approval seam\'s permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.',
'',
...maintenanceFooter(maintenance),
].join('\n')
}
function renderSnapshotReplay(): string {
const maintenance = 'curated Mermaid sequence based on the snapshot test harness'
return [
...generatedHeader('ACP Snapshot Replay'),
'This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, ACP stdout is normalized and diffed, and scenario workspaces preserve tool side effects that the UI stream alone cannot prove.',
'',
'```mermaid',
'sequenceDiagram',
' participant Recorder as Real API recording',
' participant Fixture as snapshot fixture',
' participant Workspace',
' participant Replay as llm-replay adapter',
' participant ACP as acp-agent subprocess',
' participant Golden as stdout golden',
' Recorder->>Fixture: session.jsonl + workspace inputs',
' Fixture->>Workspace: seed files and hook configs',
' Fixture->>Replay: recorded StreamChunk script',
` Replay->>ACP: deterministic ${mermaidCode('llm/stream')} chunks`,
' ACP->>Workspace: bash, fs, and hook side effects',
' ACP->>Golden: normalized sessionUpdate stream',
' Golden-->>ACP: diff must be empty',
'```',
'',
'The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.',
'',
...maintenanceFooter(maintenance),
].join('\n')
}
function renderDocs(): GraphDoc[] {
const pkgs = collectPackages()
const docs: GraphDoc[] = [
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
{ rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs) },
{ rel: 'docs/agent-lifecycle.md', content: renderLifecycle() },
{ rel: 'docs/tool-execution-pipeline.md', content: renderToolPipeline() },
{ rel: 'packages/ui/acp/snapshot-replay.md', content: renderSnapshotReplay() },
]
docs.unshift({ rel: 'docs/graph-atlas.md', content: renderIndex(docs) })
return docs
}
function renderIndex(docs: GraphDoc[]): string {
const labels: Record<string, string> = {
'docs/capability-seams.md': 'capability seams and core services',
'examples/echo-agent/composition.md': 'echo-agent app composition',
'examples/coding-agent/composition.md': 'coding-agent app composition',
'examples/cordis-agent/composition.md': 'cordis-agent app composition',
'examples/acp-agent/composition.md': 'acp-agent app composition',
'docs/event-producer-consumer.md': 'event producer/consumer matrix',
'docs/agent-lifecycle.md': 'agent turn and step lifecycle',
'docs/tool-execution-pipeline.md': 'tool execution pipeline',
'packages/ui/acp/snapshot-replay.md': 'ACP snapshot replay',
}
const modes: Record<string, string> = {
'docs/capability-seams.md': 'hybrid generated',
'examples/echo-agent/composition.md': 'hybrid generated',
'examples/coding-agent/composition.md': 'hybrid generated',
'examples/cordis-agent/composition.md': 'hybrid generated',
'examples/acp-agent/composition.md': 'hybrid generated',
'docs/event-producer-consumer.md': 'hybrid generated',
'docs/agent-lifecycle.md': 'curated',
'docs/tool-execution-pipeline.md': 'curated',
'packages/ui/acp/snapshot-replay.md': 'curated',
}
const rows = [
'| [module dependency graph](module-graph.md) | `generated` |',
'| [tool schema catalog and package map](tool-catalog.md) | `generated` |',
...docs.map((doc) => {
const link = graphIndexLink(doc.rel)
return `| [${labels[doc.rel] ?? link}](${link}) | \`${modes[doc.rel] ?? 'generated'}\` |`
}),
]
const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
return [
...generatedHeader('Documentation Graph Index'),
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).',
'',
'The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).',
'',
'| Graph | Mode |',
'| --- | --- |',
...rows,
'',
'Regenerate with `pnpm run gen-doc-graphs`; verify freshness with `pnpm run verify-doc-graphs`.',
'',
...maintenanceFooter(maintenance),
].join('\n')
}
function main(): void {
const docs = renderDocs()
if (process.argv.includes('--check')) {
const stale: string[] = []
for (const doc of docs) {
const abs = resolve(root, doc.rel)
const committed = existsSync(abs) ? readFileSync(abs, 'utf8') : null
if (committed !== doc.content) stale.push(doc.rel)
}
if (stale.length === 0) {
console.log(`gen-doc-graphs: ${docs.length} graph doc(s) are up to date.`)
return
}
console.error(`gen-doc-graphs: stale graph doc(s): ${stale.join(', ')}. Run \`pnpm run gen-doc-graphs\` and commit the result.`)
process.exit(1)
}
for (const doc of docs) {
mkdirSync(dirname(resolve(root, doc.rel)), { recursive: true })
writeFileSync(resolve(root, doc.rel), doc.content)
}
console.log(`gen-doc-graphs: wrote ${docs.length} graph doc(s).`)
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}

View File

@@ -6,7 +6,8 @@
* these as `workspace:^` plus test-only extras, which would add noise). This
* script reads every `packages/* /* /package.json`, keeps only the
* `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a
* GitHub-viewable Mermaid graph plus a dependency table.
* GitHub-viewable Mermaid graph grouped by `packages/<group>/` plus a
* dependency table.
*
* The file is fully generated — never hand-edit it. Output is deterministic
* (packages and edges sorted) so a regenerate-and-diff freshness check is
@@ -17,8 +18,8 @@
* is stale (CI / pre-push gate)
*/
import { dirname, resolve } from 'node:path'
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/module-graph.md'
@@ -27,10 +28,33 @@ const SCOPE = '@deepseek-ai/dsh-'
interface Pkg {
/** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */
short: string
/** Package group from `packages/<group>/<pkg>`. */
group: string
/** Repo-relative package directory. */
rel: string
/** Short names of this package's in-repo peer dependencies, sorted. */
deps: string[]
}
const GROUP_ORDER = [
'util',
'llm',
'core',
'bash',
'fs',
'skill',
'compact',
'subagent',
'web',
'timeout',
'todo',
'cordis',
'hooks',
'session-persistence',
'support',
'ui',
]
/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
function collect(): Pkg[] {
const pkgs: Pkg[] = []
@@ -44,7 +68,9 @@ function collect(): Pkg[] {
.filter(d => d.startsWith(SCOPE))
.map(d => d.slice(SCOPE.length))
.sort()
pkgs.push({ short: json.name.slice(SCOPE.length), deps })
const [, group, leaf] = rel.split('/')
if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`)
pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), deps })
}
return topoSort(pkgs)
}
@@ -63,7 +89,7 @@ function topoSort(pkgs: Pkg[]): Pkg[] {
while (remaining.size > 0) {
const ready = [...remaining.values()]
.filter(p => p.deps.every(d => placed.has(d)))
.sort((a, b) => a.short.localeCompare(b.short))
.sort(comparePackages)
if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
for (const p of ready) {
out.push(p)
@@ -74,28 +100,71 @@ function topoSort(pkgs: Pkg[]): Pkg[] {
return out
}
function comparePackages(a: Pkg, b: Pkg): number {
const groupA = GROUP_ORDER.indexOf(a.group)
const groupB = GROUP_ORDER.indexOf(b.group)
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
}
function nodeId(prefix: string, value: string): string {
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
}
function escLabel(value: string): string {
return value.replace(/"/g, '\\"')
}
function packageLink(pkg: Pkg): string {
return `[\`${pkg.short}\`](../${pkg.rel})`
}
/** Render the full docs/module-graph.md content (pure, deterministic). */
function render(pkgs: Pkg[]): string {
const edges: string[] = []
for (const p of pkgs) {
for (const d of p.deps) edges.push(` ${p.short} --> ${d}`)
for (const d of p.deps) edges.push(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`)
}
const rows = pkgs.map(p => `| \`${p.short}\` | ${p.deps.length ? p.deps.map(d => `\`${d}\``).join(', ') : '—'} |`)
const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => {
const ia = GROUP_ORDER.indexOf(a)
const ib = GROUP_ORDER.indexOf(b)
const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia
const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib
return na - nb || a.localeCompare(b)
})
const groupBlocks: string[] = []
for (const group of groups) {
groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`)
for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) {
groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`)
}
groupBlocks.push(' end')
}
const rows = pkgs.map((p) => {
const deps = p.deps.length ? p.deps.map((d) => {
const dep = byShort.get(d)
return dep ? packageLink(dep) : `\`${d}\``
}).join(', ') : '—'
return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |`
})
return [
'<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.',
' Run `pnpm run gen-module-graph` to regenerate. -->',
'',
'# Module dependency graph',
'',
'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal). An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.',
'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.',
'',
'```mermaid',
'graph TD',
'flowchart TD',
...groupBlocks,
...edges,
'```',
'',
'| Package | Depends on |',
'| --- | --- |',
'| Package | Group | Depends on |',
'| --- | --- | --- |',
...rows,
'',
].join('\n')

View File

@@ -0,0 +1,456 @@
/**
* Generate (and verify) the persistence log event catalog in
* docs/persistence-catalog.md.
*
* The catalog is the ON-DISK-vocabulary reference: every event type that can
* appear in a session's durable event log — every member of the
* merge-extensible `SessionEventMap`, across the owning declaration in
* `@deepseek-ai/dsh-session` and every plugin declaration merge. It complements
* the cordis events/services catalog (the live bus wiring — a log event is NOT
* a cordis event; it reaches listeners via the single `session/event` emit) and
* the core-data-structures session page (the `SessionEvent` envelope and
* derivation semantics): this page is the RECORDS a persisted log can contain.
*
* `tsx scripts/gen-persistence-catalog.ts` → write the catalog
* `tsx scripts/gen-persistence-catalog.ts --check` → exit 1 if the committed
* file is stale (CI /
* pre-push gate)
*
* Like its AST sibling `gen-cordis-catalog.ts` (and unlike the boot-based
* `gen-tool-catalog.ts`), this is a pure source pass: every log event is a
* string-literal-named property with a static type annotation, so the AST is
* the whole truth and a brand-new event (core or merged) appears in the next
* regenerate — an un-regenerated file fails `--check`. The walk enforces JSDoc
* COMPLETENESS on the whole vocabulary: every member carries description prose
* (it becomes the catalog entry), and an `@mode` tag on a member is a hard
* error — dispatch modes belong to cordis bus events, and a log event has none
* (see docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md).
* Structural holes are hard errors for the same reason: a member that is not a
* property signature with an explicit payload type, an `extends` clause on a
* declaration, a top-level `interface SessionEventMap` that is not the single
* exported declaration in the owning package, and a duplicate declaration of
* one event would each let something join (or impersonate)
* `keyof SessionEventMap` without a truthful catalog row. Violations aggregate
* into ONE error listing every offender.
*
* The surface/log-only badge is parsed from the `SurfaceEventType` union in the
* owning package (never hand-listed here), and every union member must name a
* collected event — a stale union member is a hard error.
*
* Payload fences use the ` ```ts persistence-catalog ` info string:
* doc-typecheck recognizes it and skips compilation (a bare payload fragment is
* not standalone-compilable), excluded from the opt-out ratio.
*/
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/persistence-catalog.md'
/** The fenced-block info string for generated payload blocks (skipped by
* doc-typecheck, since a bare payload fragment is not standalone-compilable). */
const FENCE = 'ts persistence-catalog'
/** The package whose module id plugin merges augment (`declare module '…'`). */
const SESSION_MODULE = '@deepseek-ai/dsh-session'
/**
* Cross-link map: a type name that appears in a payload → the
* core-data-structures page that documents it (path relative to OUT's folder).
* Hand-curated and catalog-owned, same policy as the cordis catalog's map: each
* name resolves to exactly one PRIMARY page. A payload type with no
* core-data-structures home (e.g. `HookDialect`, documented in its package)
* simply gets no link.
*/
const LINK_MAP: Record<string, string> = {
CallId: 'core.md',
ContentBlock: 'core.md',
MessageSource: 'core.md',
StreamChunk: 'llm-streaming.md',
TokenUsage: 'llm-streaming.md',
TodoItem: 'session.md',
TurnTrigger: 'session.md',
TurnEndReason: 'session.md',
}
/** One log event, extracted from a `SessionEventMap` declaration. */
export interface LogEventEntry {
/** Scoped name, e.g. `turn/start`. */
name: string
/** The scope prefix, e.g. `turn` (everything before the first `/`). */
scope: string
/** Payload type text (the member's type annotation, whitespace-collapsed). */
payload: string
/** Description prose (the member's JSDoc), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
/** A {@link LogEventEntry} plus its surface-eligibility badge. */
export interface AnnotatedLogEventEntry extends LogEventEntry {
/** Whether the type is a `SurfaceEventType` member (may carry `surfaceOp`). */
surface: boolean
}
/** 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}`
}
const printer = ts.createPrinter({ removeComments: true })
/**
* One-line payload text for a member's type annotation. Printed through the
* TypeScript printer (not sliced from source text): the printer emits `;`
* member separators regardless of how the source separated them, so a
* multi-line newline-separated type literal still collapses to a VALID
* single-line fragment. The trailing `;` the printer puts before every `}` is
* dropped to match the repo's inline-literal style.
*/
function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
return printer.printNode(ts.EmitHint.Unspecified, type, sf)
.replace(/\s+/g, ' ')
.replace(/;\s*\}/g, ' }')
.trim()
}
/** 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, flagging whether any `@mode`
* tag is present (forbidden on log events). 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`. Description prose ends at the FIRST block tag (standard
* JSDoc semantics): tag lines and their continuation lines are never prose.
*/
function parseJsDoc(raw: string): { doc: string; hasMode: boolean } {
const inner = raw
.replace(/^\/\*\*/, '')
.replace(/\*\/$/, '')
.split('\n')
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
let hasMode = false
let inTags = false
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) {
// Tag detection runs on the trimmed line: the normalization above strips at
// most one post-`*` space, so an extra-indented `* @mode` still reaches
// here with leading whitespace and must not leak into prose.
const tagLine = line.trimStart()
if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
if (inTags) continue // block-tag territory: continuations are never 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, hasMode }
}
/**
* Throw one aggregate error for every completeness violation a walk collected.
* Aggregation is deliberate: a remediation pass sees the whole list at once
* instead of replaying the gate once per offender.
*/
function reportViolations(violations: string[]): void {
if (violations.length === 0) return
throw new Error(
`gen-persistence-catalog: ${violations.length} JSDoc completeness violation(s):\n`
+ violations.map(v => ` ${v}`).join('\n'),
)
}
/**
* Every `interface SessionEventMap` declaration in a source file: the owning
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
* merge inside a `declare module '@deepseek-ai/dsh-session'` block. Both forms
* declare members of the SAME merged interface, so both are catalogued
* uniformly. `topLevel` distinguishes the owning form so the caller can verify
* it actually lives in the owning package — an unrelated local interface that
* happens to share the name must not be catalogued as the on-disk vocabulary.
*/
function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaration; topLevel: boolean }[] {
const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = []
for (const stmt of sf.statements) {
if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true })
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_MODULE
&& stmt.body && ts.isModuleBlock(stmt.body)) {
for (const inner of stmt.body.statements) {
if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false })
}
}
}
return decls
}
/**
* The npm package name owning a `packages/<group>/<pkg>/…` source file, read
* from that package's manifest — or null when the manifest is missing or
* unparseable (the caller treats null as "ownership unverifiable").
*/
function packageNameFor(rel: string, scanRoot: string): string | null {
const dir = rel.split('/').slice(0, 3).join('/')
try {
const manifest = JSON.parse(readFileSync(resolve(scanRoot, dir, 'package.json'), 'utf8')) as { name?: string }
return typeof manifest.name === 'string' ? manifest.name : null
} catch {
// Missing or malformed package.json — every real workspace package has one,
// so this only arises in stripped-down fixture trees; either way ownership
// cannot be verified and the caller reports the declaration.
return null
}
}
/**
* Walk every `SessionEventMap` declaration (the owning interface plus every
* plugin declaration merge) and extract its events, hard-erroring (aggregated)
* on any completeness violation: a member without description prose, an
* `@mode` tag (a category error — log events have no dispatch mode), a member
* that is not a property signature with an explicit payload type, a
* non-literal member name, an `extends` clause (inherited keys would join
* `keyof SessionEventMap` without a catalog row), a top-level declaration that
* is not the single exported one in the owning package, or the same event
* declared twice.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
const entries: LogEventEntry[] = []
const violations: string[] = []
const seen = new Map<string, string>()
let owningDecl: string | null = null
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('SessionEventMap')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const { decl, topLevel } of sessionEventMapDecls(sf)) {
const declSrc = pointer(rel, sf, decl)
if (topLevel) {
// The top-level form is the OWNING vocabulary, and it has exactly one
// home: the single EXPORTED declaration in the owning package. A
// same-named interface anywhere else — another package, a non-exported
// local, a second exported copy — is a different type that must not be
// catalogued as on-disk events.
const pkg = packageNameFor(rel, scanRoot)
if (pkg !== SESSION_MODULE) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`)
continue
}
const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
if (!exported) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is not exported; the owning vocabulary is the single exported declaration — rename a local helper interface.`)
continue
}
if (owningDecl) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is already declared at ${owningDecl}; the owning vocabulary has exactly one home.`)
continue
}
owningDecl = declSrc
}
if (decl.heritageClauses?.length) {
violations.push(`SessionEventMap declaration (${declSrc}) uses extends; inherited keys would join keyof SessionEventMap without a catalog row — declare event members directly.`)
}
for (const member of decl.members) {
const src = pointer(rel, sf, member)
if (!ts.isPropertySignature(member) || !member.type) {
// A method-form or type-less member still joins `keyof SessionEventMap`,
// so skipping it silently would be exactly the undocumented-event hole
// this catalog exists to close.
const label = (member as { name?: ts.Node }).name?.getText(sf) ?? member.getText(sf).replace(/\s+/g, ' ')
violations.push(`SessionEventMap member ${label} (${src}) is not a property signature with an explicit payload type; declare every log event as 'scope/name': <payload>.`)
continue
}
if (!ts.isStringLiteral(member.name)) {
violations.push(`log event at ${src} has a non-literal name; the catalog needs string-literal event names.`)
continue
}
const name = member.name.text
const where = `log event '${name}' (${src})`
const prior = seen.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior}; an event type has exactly one declaration.`)
continue
}
seen.set(name, src)
const payload = payloadText(member.type, sf)
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member))
if (hasMode) {
violations.push(`${where} carries an @mode tag, but a log event has no dispatch mode (it is not a cordis bus event — it rides the 'session/event' emit). Remove the tag.`)
}
if (!doc) {
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
}
entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src })
}
}
}
reportViolations(violations)
return entries
}
/**
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
* types — from source. Hard-errors when the alias is missing, declared more
* than once, or contains a non-string-literal member: the badge derivation
* relies on the union being a closed set of literal event names.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
export function collectSurfaceEventTypes(scanRoot: string = root): string[] {
const found: { names: string[]; source: string }[] = []
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('SurfaceEventType')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || stmt.name.text !== 'SurfaceEventType') continue
const src = pointer(rel, sf, stmt)
const members = ts.isUnionTypeNode(stmt.type) ? [...stmt.type.types] : [stmt.type]
const names: string[] = []
for (const m of members) {
if (ts.isLiteralTypeNode(m) && ts.isStringLiteral(m.literal)) names.push(m.literal.text)
else throw new Error(`gen-persistence-catalog: SurfaceEventType (${src}) has a non-string-literal member; the badge derivation needs a closed literal union.`)
}
found.push({ names, source: src })
}
}
const only = found[0]
if (!only) throw new Error('gen-persistence-catalog: no SurfaceEventType union found under packages/*/*/src.')
if (found.length > 1) throw new Error(`gen-persistence-catalog: SurfaceEventType is declared more than once (${found.map(f => f.source).join(', ')}); the surface subset has exactly one owner.`)
return only.names
}
/**
* Attach the surface/log-only badge to each event. Hard-errors when a
* `SurfaceEventType` union member names no collected event — a stale union
* member would otherwise silently badge nothing.
*/
export function annotateSurface(events: LogEventEntry[], surfaceTypes: string[]): AnnotatedLogEventEntry[] {
const names = new Set(events.map(e => e.name))
const stale = surfaceTypes.filter(t => !names.has(t))
if (stale.length > 0) {
throw new Error(`gen-persistence-catalog: SurfaceEventType member(s) ${stale.map(t => `'${t}'`).join(', ')} name no declared log event (stale union member?).`)
}
const surface = new Set(surfaceTypes)
return events.map(e => ({ ...e, surface: surface.has(e.name) }))
}
/** Render the cross-link "Types:" line for a payload, or '' if none apply. */
function typeLinks(payload: string): string {
const seen = new Set<string>()
for (const name of Object.keys(LINK_MAP)) {
if (new RegExp(`\\b${name}\\b`).test(payload)) 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 log event entry. */
function renderEvent(e: AnnotatedLogEventEntry): string[] {
const out = [`#### \`${e.name}\`${e.surface ? 'surface' : 'log-only'}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '')
const links = typeLinks(e.payload)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
return out
}
/** Render the full catalog (pure, deterministic given the collected inputs). */
export function render(events: AnnotatedLogEventEntry[]): string {
const lines: string[] = [
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
'',
'# Persistence Log Event Catalog',
'',
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
'',
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
'',
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
'',
'## Events',
'',
]
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))
}
}
return lines.join('\n')
}
/** CLI entry: default writes the catalog, `--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(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()))
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-persistence-catalog: ${OUT} is up to date.`)
process.exit(0)
}
console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-persistence-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()
}

36
scripts/gen-rfc-index.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* Regenerate `docs/rfc/INDEX.md` — the fully generated RFC index — from the
* RFC tree (see [rfc-index.ts](./rfc-index.ts) for the layout contract and
* rendering rules). The whole file is generated state; the curated prose lives
* in `docs/rfc/README.md`. Freshness is asserted by
* `verify-rfc-classification.ts` (a `doc-sync` member), so a stale committed
* index fails CI.
*
* Run: `pnpm run gen-rfc-index`.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
const { rfcs, errors } = walkRfcTree()
if (errors.length > 0) {
console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)
}
const indexPath = resolve(rfcRoot, 'INDEX.md')
const next = renderIndex(rfcs)
let current: string | undefined
try {
current = readFileSync(indexPath, 'utf8')
} catch {
// Missing INDEX.md is the fresh-generation case, not an error: fall through and write it.
}
if (next === current) {
console.log(`gen-rfc-index: docs/rfc/INDEX.md is up to date (${rfcs.length} RFCs).`)
} else {
writeFileSync(indexPath, next)
console.log(`gen-rfc-index: docs/rfc/INDEX.md regenerated (${rfcs.length} RFCs).`)
}

View File

@@ -1,5 +1,5 @@
/**
* Generate (and verify) the tool-schema catalog in docs/tool-catalog/tools.md.
* Generate (and verify) the tool-schema catalog in docs/tool-catalog.md.
*
* The catalog is the MODEL-FACING TOOL reference: every tool a shipped plugin
* contributes to `ctx.tools`, with the exact `name` / `description` / JSON-Schema
@@ -38,22 +38,30 @@ import { basename, resolve } from 'node:path'
import { Context } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import WebService from '@deepseek-ai/dsh-web'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/tool-catalog/tools.md'
const OUT = 'docs/tool-catalog.md'
/**
* One tool-plugin package to boot. `mount` is a per-entry recipe (async): it
@@ -75,9 +83,22 @@ interface ToolPackage {
dir: string
/** Repo-relative source path linked from the catalog entry. */
source: string
/** Services or owning runtime surfaces the package requires at execution time. */
requires: string[]
/** Session events or other visible state the tools write or affect. */
writes: string[]
/** Additional model-visible names shipped by example/app config. */
shippedNames?: string[]
/** Plug the injected seams + the tool plugin onto a context that already
* carries `systemPrompt` + `tools`. */
mount: (ctx: Context) => Promise<void>
/**
* Config for the caller's `ToolRegistry` mount. The registry itself ships a
* model-facing tool (`run_code`, registered under a non-native `mode`), so
* ITS catalog entry boots the registry in the mode that surfaces it;
* every other entry uses the default (native) registry.
*/
toolsConfig?: ToolsConfig
/**
* A deployment note rendered after the package's tools, for a fact that
* booting the package alone cannot show. The registered tool NAME can be a
@@ -94,19 +115,64 @@ interface ToolPackage {
* guard proves it is exhaustive against the on-disk glob.
*/
const TOOL_PACKAGES: ToolPackage[] = [
{
pkg: '@deepseek-ai/dsh-tool-ask-user',
dir: 'tool-ask-user',
source: 'packages/ui/tool-ask-user/src/index.ts',
requires: ['ctx.tools', 'ctx.userInteraction'],
writes: ['tool/call', 'tool/result after a UI/provider answers the question'],
async mount(ctx) {
await ctx.plugin(UserInteractionService)
await ctx.plugin(ToolAskUser)
},
note:
'ask_user_question pauses the tool call until the active UI provider returns a human answer.',
},
{
pkg: '@deepseek-ai/dsh-tools',
dir: 'tools',
source: 'packages/core/tools/src/code-mode.ts',
requires: ['ctx.tools', 'ctx.codeRuntime (execution time)', 'ctx.systemPrompt'],
writes: ['tool/call', 'one tool/code-dispatch per bridged sub-call', 'tool/result'],
// The registry's OWN tool: run_code exists only under a non-native mode
// (the registry registers it in its constructor; the code runtime is read
// at assembly/execution time, so the schema harvest needs none mounted).
toolsConfig: { mode: 'code' },
async mount() {},
note:
'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',
source: 'packages/bash/tool-bash/src/index.ts',
requires: ['ctx.tools', 'ctx.bash'],
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
async mount(ctx) {
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolBash)
},
note:
'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
},
{
pkg: '@deepseek-ai/dsh-tool-cordis',
dir: 'tool-cordis',
source: 'packages/cordis/tool-cordis/src/index.ts',
requires: ['ctx.tools'],
writes: ['tool/call', 'tool/result', 'live plugin-tree mutations (mount/unmount)'],
async mount(ctx) {
await ctx.plugin(ToolCordis)
},
note:
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs',
dir: 'tool-fs',
source: 'packages/fs/tool-fs/src/index.ts',
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'],
async mount(ctx) {
// The tool injects `fs`; boot the local backend to satisfy it. The schemas
// do not depend on the policy plugin (an event gate that changes behavior,
@@ -117,10 +183,28 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
},
{
pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill',
source: 'packages/skill/tool-skill/src/index.ts',
requires: ['ctx.tools', 'ctx.skills'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: resolve(root, '.tmp/tool-catalog/.dsh'),
agentsHome: resolve(root, '.tmp/tool-catalog/.agents'),
})
await ctx.plugin(ToolSkill)
},
},
{
pkg: '@deepseek-ai/dsh-tool-subagent',
dir: 'tool-subagent',
source: 'packages/subagent/tool-subagent/src/index.ts',
requires: ['ctx.tools', 'ctx.subagents'],
writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'],
shippedNames: ['subagent', 'subagent_fork'],
async mount(ctx) {
await ctx.plugin(SubagentService)
// Register a scripted provider under the name the tool delegates to.
@@ -134,14 +218,36 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-todo',
dir: 'tool-todo',
source: 'packages/todo/tool-todo/src/index.ts',
requires: ['ctx.tools', 'owning Agent session'],
writes: ['tool/call', 'todo/write', 'tool/result'],
async mount(ctx) {
await ctx.plugin(ToolTodo)
},
note:
'todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan.',
},
{
pkg: '@deepseek-ai/dsh-tool-workflow',
dir: 'tool-workflow',
source: 'packages/workflow/tool-workflow/src/index.ts',
requires: ['ctx.tools', 'ctx.workflows', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents the script children)'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tool injects `workflows`; boot the vm engine over a scripted
// subagent provider to satisfy it. The schema does not depend on which
// provider backs the engine.
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentMock, { name: 'mock' })
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
await ctx.plugin(ToolWorkflow)
},
},
{
pkg: '@deepseek-ai/dsh-tool-web',
dir: 'tool-web',
source: 'packages/web/tool-web/src/index.ts',
requires: ['ctx.tools', 'ctx.web', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tools inject `web`; boot the seam plus one search and one fetch
// provider so both `web_search` and `web_fetch` register. The schemas do
@@ -152,6 +258,8 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(WebFetchLocal)
await ctx.plugin(ToolWeb)
},
note:
'web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps.',
},
]
@@ -159,6 +267,9 @@ const TOOL_PACKAGES: ToolPackage[] = [
interface CatalogPackage {
pkg: string
source: string
requires: string[]
writes: string[]
shippedNames?: string[]
schemas: ToolSchema[]
/** A deployment note (see {@link ToolPackage.note}), rendered after the tools. */
note?: string
@@ -205,10 +316,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
// fiber) — the repo's "dispose must reach quiescence" rule.
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRegistry, entry.toolsConfig ?? {})
await entry.mount(ctx)
const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name))
catalog.push({ pkg: entry.pkg, source: entry.source, schemas, ...entry.note !== undefined ? { note: entry.note } : {} })
catalog.push({
pkg: entry.pkg,
source: entry.source,
requires: entry.requires,
writes: entry.writes,
schemas,
...entry.shippedNames !== undefined ? { shippedNames: entry.shippedNames } : {},
...entry.note !== undefined ? { note: entry.note } : {},
})
} finally {
await ctx.fiber.dispose()
}
@@ -220,12 +339,19 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
function renderTool(schema: ToolSchema, source: string): string[] {
const out = [`### \`${schema.name}\``, '']
if (schema.description) out.push(schema.description, '')
if (schema.strict !== undefined) out.push(`Strict: \`${String(schema.strict)}\``, '')
out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '')
out.push(`Source: [\`${source}\`](../../${source})`, '')
out.push(`Source: [\`${source}\`](../${source})`, '')
return out
}
function codeList(values: string[] | undefined): string {
return values?.length ? values.map(value => `\`${value}\``).join(', ') : '-'
}
function tableCell(value: string | undefined): string {
return value ? value.replace(/\|/g, '\\|').replace(/\n/g, '<br>') : '-'
}
/** Render the full catalog (pure, deterministic given the manifest-ordered input). */
export function render(catalog: ToolCatalog): string {
const lines: string[] = [
@@ -234,12 +360,20 @@ export function render(catalog: ToolCatalog): string {
'',
'# Tool Schema Catalog',
'',
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
'',
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).',
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).',
'',
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
'',
'## Tool Package Map',
'',
'This table connects model-visible tool names to the plugin package and service seams behind them. Exact JSON Schemas follow in the package sections below.',
'',
'| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |',
'| --- | --- | --- | --- | --- | --- |',
...catalog.map(entry => `| \`${entry.pkg}\` | ${codeList(entry.schemas.map(schema => schema.name))} | ${codeList(entry.requires)} | ${codeList(entry.writes)} | ${codeList(entry.shippedNames)} | ${tableCell(entry.note)} |`),
'',
]
for (const entry of catalog) {
lines.push(`## \`${entry.pkg}\``, '')

218
scripts/jsdoc.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* Shared JSDoc parsing and completeness-check helpers for the documentation
* gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
* events + `ctx.<key>` service surface), the plugin config catalog generator
* (`scripts/gen-config-catalog.ts`, which renders the parsed prose), and the
* export-surface gate (`scripts/verify-export-jsdoc.ts` — every module-level
* export). One home for the mechanics so "documented" means the same thing on
* every gated surface: description prose ends at the first block tag; every
* checkable parameter needs a non-empty `@param`; a non-void ANNOTATED return
* needs a non-empty `@returns`; a stale `@param` naming no real parameter
* errors.
*/
import ts from 'typescript'
/** Repo-relative source pointer `file:line` for a node's first character. */
export 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. */
export 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) : ''
}
/** A dispatch mode, rendered as the badge after an event name in the catalog. */
export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
/**
* Parse a raw JSDoc block into description prose + the `@mode` tag (when
* present). Output obeys the repo's markdown conventions so the generated
* catalog 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`.
* Description prose ends at the FIRST block tag (standard JSDoc semantics):
* tag lines and their continuation lines are never prose, so `@param` /
* `@returns` blocks are invisible to the rendered catalog.
* @param raw - the raw comment text including the JSDoc delimiters.
* @returns the collapsed description prose plus the parsed `@mode` (or null).
*/
export 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
let inTags = false
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|serial)\s*$/.exec(line)
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
if (line.startsWith('@')) { flushPara(); inTags = true; continue }
if (inTags) continue // block-tag territory: continuations are never 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 }
}
/**
* Parse the block tags of a raw JSDoc comment for the completeness checks:
* every `@param name — description` entry plus the `@returns` description.
* Standard JSDoc block-tag semantics — a tag's description runs across
* continuation lines until the next tag or a blank line, and the `-`/`—`
* separator after a param name is optional. `[name]` optional-brackets unwrap
* to `name`. Rendering never sees these: parseJsDoc stops prose at the first
* block tag.
* @param raw - the raw comment text including the JSDoc delimiters.
* @returns the `@param` name→description map plus the `@returns` description
* (null when the tag is absent, '' when present but empty).
*/
export function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
const inner = raw
.replace(/^\/\*\*/, '')
.replace(/\*\/$/, '')
.split('\n')
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
const params = new Map<string, string>()
let returns: string | null = null
let sink: ((text: string) => void) | null = null
for (const line of inner) {
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
if (param) {
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
let acc = param[2] ?? ''
params.set(name, acc)
sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
continue
}
const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
if (ret) {
let acc = ret[1] ?? ''
returns = acc
sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
continue
}
if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
sink?.(line.trim())
}
return { params, returns }
}
/**
* Check the `@param` half of the completeness contract for one function-like
* declaration: every checkable parameter carries a non-empty `@param`, and no
* `@param` is stale. A binding-pattern parameter is a violation (it has no name
* for `@param` to match); an exempt parameter may be documented but its absence
* is never checked. Violations append to `violations` in place.
* @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
* @param surface - the surface noun for the binding-pattern message ("event", "service", "export").
* @param parameters - the declaration's parameter list.
* @param tags - the parsed `@param` name→description map from parseTags.
* @param sf - the source file (for rendering a binding pattern's text).
* @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`).
* @param violations - the aggregate list violations append to.
*/
export function checkParams(
where: string,
surface: string,
parameters: readonly ts.ParameterDeclaration[],
tags: Map<string, string>,
sf: ts.SourceFile,
isExempt: (p: ts.ParameterDeclaration) => boolean,
violations: string[],
): void {
for (const p of parameters) {
if (!ts.isIdentifier(p.name)) {
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`)
continue
}
if (isExempt(p)) continue
const desc = tags.get(p.name.text)
if (desc === undefined) violations.push(`${where} is missing @param ${p.name.text}.`)
else if (!desc.trim()) violations.push(`${where}: @param ${p.name.text} has an empty description.`)
}
for (const tag of tags.keys()) {
if (!parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
}
}
}
/**
* Check the `@returns` half of the completeness contract: a non-`void` /
* `Promise<void>` return needs a non-empty `@returns`, and the return type must
* be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void
* declaration `@returns` stays optional (resolution timing can be worth
* documenting), never required. Violations append to `violations` in place.
* @param where - the offender label violations open with.
* @param typeNode - the declared return type annotation, or undefined when inferred.
* @param returns - the parsed `@returns` description from parseTags (null when absent).
* @param sf - the source file (for rendering the annotation's text).
* @param violations - the aggregate list violations append to.
*/
export function checkReturns(
where: string,
typeNode: ts.TypeNode | undefined,
returns: string | null,
sf: ts.SourceFile,
violations: string[],
): void {
if (typeNode === undefined) {
violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
return
}
const rt = typeNode.getText(sf).replace(/\s+/g, ' ')
if (/^(void|Promise<void>)$/.test(rt)) return
if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
}
/**
* Throw one aggregate error for every completeness violation a walk collected.
* Aggregation (vs failing fast) is deliberate: a remediation pass sees the
* whole list at once instead of replaying the gate once per offender.
* @param gate - the reporting gate's name, prefixed to the error message.
* @param violations - the collected violation lines; no-op when empty.
*/
export function reportViolations(gate: string, violations: string[]): void {
if (violations.length === 0) return
throw new Error(
`${gate}: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
+ violations.map(v => ` ${v}`).join('\n'),
)
}

View File

@@ -1,6 +1,11 @@
import { execFileSync } from 'node:child_process'
import { execFile } from 'node:child_process'
import { existsSync, readdirSync } from 'node:fs'
import { availableParallelism } from 'node:os'
import { resolve } from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
// publint every harness package. Packages live at packages/<group>/<pkg>
// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private
@@ -9,15 +14,94 @@ import { resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const packagesRoot = resolve(root, 'packages')
const packages = readdirSync(packagesRoot, { withFileTypes: true })
.filter(group => group.isDirectory())
.flatMap(group =>
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
.filter(pkg => pkg.isDirectory())
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
.map(pkg => `packages/${group.name}/${pkg.name}`),
)
type PublintResult =
| { path: string; status: 'passed'; stdout: string; stderr: string }
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
for (const path of packages) {
execFileSync('node_modules/.bin/publint', [path], { cwd: root, stdio: 'inherit' })
function workspacePackages(): string[] {
return readdirSync(packagesRoot, { withFileTypes: true })
.filter(group => group.isDirectory())
.flatMap(group =>
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
.filter(pkg => pkg.isDirectory())
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
.map(pkg => `packages/${group.name}/${pkg.name}`),
)
}
function publintConcurrency(total: number): number {
if (total === 0) return 0
const raw = process.env[CONCURRENCY_ENV]
if (raw !== undefined) {
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
}
return Math.min(total, parsed)
}
return Math.min(total, availableParallelism())
}
function outputText(value: unknown): string {
if (typeof value === 'string') return value
if (Buffer.isBuffer(value)) return value.toString()
return ''
}
async function runPublint(path: string): Promise<PublintResult> {
try {
const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], {
cwd: root,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
})
return { path, status: 'passed', stdout, stderr }
} catch (error: unknown) {
const failed = error as { stdout?: unknown; stderr?: unknown; message?: string }
return {
path,
status: 'failed',
stdout: outputText(failed.stdout),
stderr: outputText(failed.stderr),
message: failed.message ?? 'publint failed',
}
}
}
async function runAll(paths: string[], concurrency: number): Promise<PublintResult[]> {
let next = 0
const results: Array<PublintResult | undefined> = []
await Promise.all(Array.from({ length: concurrency }, async () => {
for (;;) {
const index = next
next += 1
const path = paths[index]
if (path === undefined) return
results[index] = await runPublint(path)
}
}))
return paths.map((path, index) => {
const result = results[index]
if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`)
return result
})
}
function printResult(result: PublintResult): void {
console.log(`Running publint for ${result.path}...`)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status === 'failed') console.error(result.message)
}
const packages = workspacePackages()
const concurrency = publintConcurrency(packages.length)
console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)
const results = await runAll(packages, concurrency)
for (const result of results) printResult(result)
if (results.some(result => result.status === 'failed')) process.exit(1)

140
scripts/rfc-index.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* Shared source of truth for the RFC index: the tree walker (structure rules)
* and the README table renderer. `gen-rfc-index.ts` writes the generated
* regions; `verify-rfc-classification.ts` checks structure and asserts the
* committed regions are fresh. Pure module — no side effects on import.
*
* The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)):
* every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the
* folder IS the label, and both sets are CLOSED — extending either means
* amending this module AND the README's Classification prose.
*
* The index (`docs/rfc/INDEX.md`) is GENERATED in full: per-lifecycle sections
* whose rows are derived from each RFC's path (lifecycle/class), H1 (title,
* with an optional `RFC: ` prefix stripped), and filename date, sorted by date
* then filename. The curated prose lives in README.md, which carries no index
* rows at all.
*/
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { globSync } from 'node:fs'
export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/**
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
* class is a deliberate act: extend this list AND the README's Classification
* section. The gate rejects any folder not listed here.
*/
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
/** Title-case a class/lifecycle folder name for a README heading. */
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
/** One RFC file, as discovered by the walker. */
export interface Rfc {
lifecycle: string
cls: string
base: string
/** Path relative to docs/rfc — the README link target. */
rel: string
/** H1 text with any `RFC: ` prefix stripped — the README row title. */
title: string
/** `yyyy-mm-dd` from the filename — the "First proposed" column. */
date: string
}
/**
* Walk the RFC tree, enforcing the structure rules. Returns every valid RFC
* plus one error string per violation (unknown lifecycle or class folder, bad
* depth, bad filename, missing/malformed H1). Callers treat a non-empty error
* list as fatal — the index is only generated from a structurally valid tree.
*/
export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
const rfcs: Rfc[] = []
const errors: string[] = []
// The lifecycle set is closed too: any directory under docs/rfc/ that is not
// a known lifecycle would otherwise hold RFCs invisible to the walk below.
for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) {
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
}
}
for (const lifecycle of LIFECYCLES) {
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
// indexed via its English filename; the pairing gate owns its consistency.
if (match.endsWith('.zh.md')) continue
const cls = segs[1]
const base = segs[2]
if (segs.length !== 3 || cls === undefined || base === undefined) {
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
continue
}
if (!(CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
continue
}
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
continue
}
const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? ''
const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine)
if (!h1?.[1]) {
errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`)
continue
}
rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) })
}
}
return { rfcs, errors }
}
/**
* Render one lifecycle's section body: a `### {Class}` heading plus a
* `| Title | First proposed |` table for every non-empty class, in CLASSES
* order, rows sorted by date then filename.
*/
function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
const sections: string[] = []
for (const cls of CLASSES) {
const rows = rfcs
.filter(r => r.lifecycle === lifecycle && r.cls === cls)
.sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base))
if (rows.length === 0) continue
const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n')
sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`)
}
return sections.join('\n\n')
}
/**
* Render the complete `docs/rfc/INDEX.md` content: a generated-file banner
* followed by one `## {Lifecycle}` section per lifecycle in canonical order.
* The whole file is generated state — there is no curated region to preserve.
*/
export function renderIndex(rfcs: Rfc[]): string {
const parts = [
'# RFC index',
'',
'Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md).',
]
for (const lifecycle of LIFECYCLES) {
parts.push('', `## ${heading(lifecycle)}`, '', renderLifecycle(rfcs, lifecycle))
}
return `${parts.join('\n')}\n`
}
/** Matches an index-shaped table row (a `| [title](lifecycle/…) |` line) — generated state that must not appear in curated prose. */
export const INDEX_ROW = /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//

448
scripts/run-gates.ts Normal file
View File

@@ -0,0 +1,448 @@
/**
* Run local and CI quality gates with bounded in-process scheduling.
*
* The gate vocabulary stays in package.json; this runner only decides which
* independent commands can overlap and which commands wait for built artifacts.
*/
import { spawn } from 'node:child_process'
import { readdir, rm } from 'node:fs/promises'
import { availableParallelism } from 'node:os'
import { join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
type Mode =
| 'ci-primary'
| 'ci-static'
| 'ci-lint'
| 'ci-coverage'
| 'ci-snapshot'
| 'ci-artifacts'
| 'node-compat'
| 'pre-push'
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
interface Gate {
id: string
label: string
command: string
args: string[]
needs?: string[]
env?: Record<string, string | undefined>
input?: string
verify?: (result: GateResult) => Promise<void>
}
interface GateResult {
gate: Gate
status: GateStatus
durationMs: number
stdout: string
stderr: string
exitCode: number | null
error?: string
}
interface RunningGate {
gate: Gate
promise: Promise<GateResult>
}
const root = resolve(import.meta.dirname, '..')
const mode = parseMode(process.argv[2])
const gates = gatesForMode(mode)
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length))
const startedAt = performance.now()
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`)
const results = await runGates(gates, maxConcurrency)
printSummary(results, performance.now() - startedAt)
if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1)
function parseMode(raw: string | undefined): Mode {
switch (raw) {
case 'ci-primary':
case 'ci-static':
case 'ci-lint':
case 'ci-coverage':
case 'ci-snapshot':
case 'ci-artifacts':
case 'node-compat':
case 'pre-push':
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`,
)
}
}
function defaultConcurrency(total: number): number {
return Math.min(total, Math.max(4, availableParallelism()))
}
function concurrencyFromEnv(name: string, fallback: number): number {
const raw = process.env[name]
if (raw === undefined || raw === '') return fallback
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error(`run-gates: ${name} must be a positive integer, got ${JSON.stringify(raw)}.`)
}
return parsed
}
function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Gate {
return {
id,
label: options.label ?? script,
command: pnpmBin(),
args: ['run', script],
...options,
}
}
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
return {
id,
label: options.label ?? `pnpm exec ${args.join(' ')}`,
command: pnpmBin(),
args: ['exec', ...args],
...options,
}
}
function pnpmBin(): string {
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
}
function nodeOptions(...options: string[]): string {
return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ')
}
function gatesForMode(selected: Mode): Gate[] {
switch (selected) {
case 'ci-primary':
return ciPrimaryGates()
case 'ci-static':
return ciStaticGates()
case 'ci-lint':
return [
lintGate(),
]
case 'ci-coverage':
return [
coverageGate(),
]
case 'ci-snapshot':
return [
pnpmScript('snapshot', 'test:snapshot'),
]
case 'ci-artifacts':
return ciArtifactGates()
case 'node-compat':
return [
pnpmScript('typecheck', 'typecheck'),
]
case 'pre-push':
return [
pnpmScript('test', 'test'),
pnpmScript('snapshot', 'test:snapshot'),
pnpmScript('build', 'build'),
...hygieneLeafGates({ artifactNeeds: ['build'] }),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
]
}
}
function ciPrimaryGates(): Gate[] {
return [
pnpmScript('constraints', 'constraints'),
pnpmScript('typecheck', 'typecheck'),
lintGate(),
coverageGate(),
pnpmScript('snapshot', 'test:snapshot'),
demoSmokeGate({ needs: ['lint'] }),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
pnpmScript('build', 'build', { needs: ['typecheck'] }),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
needs: ['build'],
}),
builtBinSmokeGate(),
]
}
function ciStaticGates(): Gate[] {
return [
pnpmScript('constraints', 'constraints'),
demoSmokeGate(),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
]
}
function ciArtifactGates(): Gate[] {
return [
pnpmScript('build', 'build'),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
needs: ['build'],
}),
builtBinSmokeGate(),
]
}
function lintGate(): Gate {
if (process.env.DSH_ESLINT_CACHE === '1') {
return pnpmExec('lint', [
'eslint',
'.',
'--cache',
'--cache-location',
'.cache/eslint/',
'--cache-strategy',
'content',
], {
label: 'lint',
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
return pnpmScript('lint', 'lint', {
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
function coverageGate(): Gate {
return pnpmExec('coverage', [
'vitest',
'run',
'--coverage',
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
], {
label: 'test:coverage',
})
}
function positiveIntArg(envName: string, flag: string): string[] {
const raw = process.env[envName]
if (raw === undefined || raw === '') return []
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`run-gates: ${envName} must be a positive integer, got ${JSON.stringify(raw)}.`)
}
return [`${flag}=${raw}`]
}
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
return [
pnpmScript('knip', 'knip'),
pnpmScript('publint', 'publint', artifactOptions),
pnpmScript('constraints', 'constraints'),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
...artifactOptions,
}),
]
}
function docSyncLeafGates(): Gate[] {
return [
pnpmScript('doc-typecheck', 'doc-typecheck'),
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
pnpmScript('mermaid', 'verify-mermaid'),
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
]
}
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
return {
id: 'demo-smoke',
label: 'demo smoke',
command: pnpmBin(),
args: ['run', 'demo:echo'],
input: 'echo ci smoke\n',
...dependencyOptions,
verify: async (result) => {
const output = result.stdout + result.stderr
const sessionsRoot = join(root, '.sessions')
try {
if (!output.includes('[tool call] echo({"text":"ci smoke"})')) {
throw new Error('demo smoke did not show the echo tool call.')
}
if (!output.includes('[tool result] ECHO: CI SMOKE')) {
throw new Error('demo smoke did not show the echo tool result.')
}
const buckets = await readdir(sessionsRoot, { withFileTypes: true })
let found = false
for (const bucket of buckets) {
if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue
const entries = await readdir(join(sessionsRoot, bucket.name))
if (entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
found = true
break
}
}
if (!found) throw new Error('demo smoke did not create a main-session JSONL log in a cwd bucket.')
} finally {
await rm(sessionsRoot, { recursive: true, force: true })
}
},
}
}
function builtBinSmokeGate(): Gate {
return pnpmExec('built-bin-smoke', [
'vitest',
'run',
'--config',
'vitest.e2e.config.ts',
'packages/ui/stdio-agent/tests/built-bin.e2e.ts',
'packages/ui/acp-agent/tests/built-bin.e2e.ts',
// The worker-entry packages' built bundles: the only automated proof
// that lib/index.js resolves its sibling lib/worker.js under plain node
// (the e2e lane runs unbuilt, so these files self-skip there).
'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts',
'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts',
], {
label: 'built-bin smoke',
needs: ['build'],
})
}
async function runGates(allGates: Gate[], maxActive: number): Promise<GateResult[]> {
const states = new Map<string, GateStatus>(allGates.map(gate => [gate.id, 'pending']))
const results = new Map<string, GateResult>()
const running: RunningGate[] = []
for (;;) {
let madeProgress = false
while (running.length < maxActive) {
const ready = allGates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states))
if (ready === undefined) break
states.set(ready.id, 'running')
running.push({ gate: ready, promise: runGate(ready) })
console.log(`run-gates: start ${ready.label}`)
madeProgress = true
}
if (running.length === 0) {
const pending = allGates.filter(gate => states.get(gate.id) === 'pending')
for (const gate of pending) {
const failedDeps = (gate.needs ?? []).filter(id => states.get(id) !== 'passed')
const result: GateResult = {
gate,
status: 'skipped',
durationMs: 0,
stdout: '',
stderr: '',
exitCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
}
states.set(gate.id, 'skipped')
results.set(gate.id, result)
printResult(result)
}
break
}
if (!madeProgress) {
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
running.splice(running.indexOf(settled.item), 1)
states.set(settled.item.gate.id, settled.result.status)
results.set(settled.item.gate.id, settled.result)
printResult(settled.result)
}
}
return allGates.map((gate) => {
const result = results.get(gate.id)
if (result === undefined) throw new Error(`run-gates: missing result for ${gate.id}.`)
return result
})
}
function dependenciesPassed(gate: Gate, states: Map<string, GateStatus>): boolean {
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
}
async function runGate(gate: Gate): Promise<GateResult> {
const started = performance.now()
let stdout = ''
let stderr = ''
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
const child = spawn(gate.command, gate.args, {
cwd: root,
env: { ...process.env, ...gate.env },
stdio: ['pipe', 'pipe', 'pipe'],
})
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.on('data', (chunk: string) => { stderr += chunk })
child.on('error', reject)
child.on('close', resolveExit)
if (gate.input !== undefined) child.stdin.end(gate.input)
else child.stdin.end()
})
let status: GateStatus = exitCode === 0 ? 'passed' : 'failed'
let error: string | undefined
if (status === 'passed' && gate.verify !== undefined) {
try {
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode })
} catch (verifyError: unknown) {
status = 'failed'
error = verifyError instanceof Error ? verifyError.message : String(verifyError)
}
}
const result: GateResult = {
gate,
status,
durationMs: performance.now() - started,
stdout,
stderr,
exitCode,
}
if (error !== undefined) result.error = error
return result
}
function printResult(result: GateResult): void {
const seconds = (result.durationMs / 1000).toFixed(2)
console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.error !== undefined) console.error(result.error)
}
function printSummary(results: GateResult[], durationMs: number): void {
const passed = results.filter(result => result.status === 'passed').length
const failed = results.filter(result => result.status === 'failed').length
const skipped = results.filter(result => result.status === 'skipped').length
const seconds = (durationMs / 1000).toFixed(2)
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
}

View File

@@ -10,8 +10,10 @@
"excluded": [
"docs/AGENTS.md",
"docs/module-graph.md",
"docs/config-catalog.md",
"docs/tool-catalog.md",
"docs/persistence-catalog.md",
"docs/cordis-catalog/",
"docs/tool-catalog/",
"docs/i18n/terminology.md",
"docs/i18n/style-samples.md",
"docs/i18n/translation-prompt.md"

View File

@@ -8,6 +8,7 @@
{ "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
@@ -18,8 +19,10 @@
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" },
@@ -40,14 +43,45 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswer", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequestId", "source": "packages/ui/user-approval/src/index.ts" },
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalOutcome", "source": "packages/ui/user-approval/src/index.ts" },
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" },
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxEnforcement", "source": "packages/sandbox/sandbox/src/index.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxPolicy", "source": "packages/sandbox/sandbox/src/index.ts" },
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", "source": "packages/sandbox/sandbox/src/index.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
@@ -61,6 +95,16 @@
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSource", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillResourceBase", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSummary", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillCandidate", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillDefinition", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillRegistration", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillProvider", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/skills.md", "symbol": "Config", "source": "packages/skill/skill/src/index.ts" },
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" },
@@ -77,6 +121,10 @@
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebCapabilityStatus", "source": "packages/web/web/src/types.ts" }
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" }
]
}

View File

@@ -2,7 +2,7 @@
* Doc-sync gate: verify that doc references written in TypeScript COMMENTS
* resolve to a file that exists. Source comments cite docs by root-relative
* prose path — `see docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`,
* `docs/architecture.md § plugin checklist`. `verify-md-links` parses Markdown
* `docs/architecture.md § Where New Behavior Goes`. `verify-md-links` parses Markdown
* link AST and never sees these, so a doc rename or move could silently orphan
* a `.ts` comment that points at it. The RFC classification reorg
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
@@ -12,7 +12,7 @@
* Detection is a token scan, NOT an AST walk: doc refs live in free prose inside
* comments, not in a structured form. We match `docs/<path>.md` tokens and
* REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`,
* `docs/architecture.md § plugin checklist` — the section suffix is outside the
* `docs/architecture.md § Where New Behavior Goes` — the section suffix is outside the
* token) is left alone rather than misread as a path. Each token is resolved
* ROOT-RELATIVE (the way the comments are written) and must exist on disk. This
* is checker, not fixer: it reports and never rewrites.
@@ -26,9 +26,8 @@
* Run: `tsx scripts/verify-doc-refs.ts`.
*/
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, globSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
@@ -43,7 +42,7 @@ const isExcluded = (p: string): boolean =>
* Match a `docs/…​.md` reference token. The `.md` extension is required so a
* bare `docs/postmortem/0001` (no extension) does not register as a path. The
* character class stops at whitespace, backticks, parens, and the section sign,
* so trailing prose (`… .md § plugin checklist`) is not swallowed into the path.
* so trailing prose (`… .md § Where New Behavior Goes`) is not swallowed into the path.
*/
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
@@ -77,7 +76,7 @@ function findViolations(absPath: string): Violation[] {
const all: Violation[] = []
let checked = 0
for (const pattern of PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) {
for (const match of globSync(pattern, { cwd: root })) {
if (isExcluded(match)) continue
checked++
all.push(...findViolations(resolve(root, match)))

View File

@@ -0,0 +1,643 @@
/**
* Verify JSDoc completeness for EVERY module-level exported name of every
* non-vendored package (each `packages/<group>/<pkg>/src/` tree). This is the
* mechanical form of the AGENTS.md rule "every export has a JSDoc explaining
* semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`,
* which owns `interface Events` members and `ctx.<key>` service classes) to
* the whole export surface; the parsing + check helpers are shared via
* `scripts/jsdoc.ts` so "documented" means the same thing on both.
*
* `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender
*
* The contract, per exported declaration kind:
*
* - Every exported name needs JSDoc with non-empty description prose (prose
* ends at the first block tag, standard JSDoc semantics).
* - A function-like export (function declaration, a const with a function
* initializer or an INLINE callable annotation, or a non-identifier
* function default export) additionally needs a non-empty `@param` per
* parameter (`this` receiver annotations exempt; a stale `@param` errors)
* and a non-empty `@returns` unless the return type is `void` /
* `Promise<void>`. Wrapper expressions (parentheses, `as` / `satisfies`
* casts, non-null assertions) are peeled before classifying. The walk
* classifies returns syntactically, so the return type must be ANNOTATED —
* except a const whose declarator is annotated with a NAMED type (e.g.
* `export const f: Handler = …`), where that type's own declaration owns
* the signature contract and `@returns` stays optional; an inline
* `(x: T) => U` annotation or single-call-signature literal is the surface
* signature itself and gets the full contract, and a literal mixing
* call/construct signatures with anything else is refused (extract a named
* type).
* - An exported class needs class-level JSDoc; its public methods (static
* included — they are reachable on the exported name) follow the function
* contract, and public properties and accessors need description prose (on
* a get/set pair the getter's doc covers both). A member declared by an
* `extends`/`implements` heritage type is EXEMPT — the seam declaration is
* the doc's one home, the IDE inherits it, and re-documenting every
* implementation invites drift — UNLESS the override grows surface the
* base never documented: a protected-only base member does not exempt a
* public override, parameters the base never names keep their `@param`
* duty, and a concrete result above a void base return keeps its
* `@returns` duty. Heritage members (and classifying an unannotated
* override's inferred return above a void base) are the questions the walk
* asks the TYPE CHECKER; everything else is pure AST.
* Constructors are exempt like the cordis gate's: plugin classes are
* framework-constructed, and the class doc owns the story.
* - Exported interfaces, type aliases, enums: description prose on the
* declaration (member-level docs stay review's job; the highest-value
* member surface — seam service classes — is already under the cordis
* gate).
* - An exported namespace recurses (its exported members are package
* surface; in an ambient `declare` namespace every member exports
* implicitly); the namespace itself needs prose only when it does not
* merge with an already-documented same-name declaration (the
* Config-namespace idiom documents the class/function once, not twice).
* - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
* / `reusable` / `Config` consts and the `apply` entry, plus the same
* slots as statics on a plugin class. Their shape is fixed by the
* framework, so a doc would restate the protocol — the module doc comment
* and the `interface Config` carry the plugin's real semantics. (These
* names are reserved by cordis convention; documenting one anyway is
* allowed, only absence goes unchecked.)
* - Overload groups: each overload signature carries its own docs; the
* implementation signature is exempt (callers never see it).
* - Skipped: `declare module` / `declare global` augmentation bodies (the
* cordis gate's turf; an augmentation is not an export of the package) and
* re-export statements with a module specifier (`export … from`) — the
* defining module is walked on its own, and external definitions are not
* ours to document. An `export import X = N.member` alias documents
* ITSELF, and only prose-only target kinds are gate-supported: a callable,
* class, or namespace target carries signature/member contracts the alias
* cannot hold and is refused (export the declaration directly).
* - Everything else fails CLOSED: `export =` is refused outright, and an
* exported statement kind the dispatch does not recognize is itself a
* violation, so no export form can pass unchecked by omission.
*/
import { existsSync, globSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
/** Plugin-protocol slot names exempt as statics on an exported class. */
const PROTOCOL_STATICS = new Set(['Config', 'inject', 'name', 'reusable'])
/** Plugin-protocol slot names exempt as top-level exports (const or function). */
const PROTOCOL_EXPORTS = new Set(['Config', 'inject', 'name', 'reusable', 'apply'])
/** Per-file walk state threaded through the scope recursion. */
interface Walk {
/** Repo-relative path of the file being walked. */
rel: string
/** The parsed source file. */
sf: ts.SourceFile
/** Raw file text (rawJsDoc reads comment ranges out of it). */
text: string
/** The program's checker, consulted only for heritage-member lookups. */
checker: ts.TypeChecker
/** The aggregate violation list, appended in place. */
violations: string[]
}
/** True when a statement carries the `export` modifier. */
function isExported(stmt: ts.Statement): boolean {
return ts.canHaveModifiers(stmt) && (ts.getModifiers(stmt)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)
}
/** True for a class member a consumer cannot reach: `private`/`protected`/`#name`. */
function isNonPublic(member: ts.ClassElement): boolean {
const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
return (mods?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false)
|| ('name' in member && ts.isPrivateIdentifier(member.name))
}
/** True when a class member carries the `static` modifier. */
function isStatic(member: ts.ClassElement): boolean {
const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
return mods?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false
}
/** The `this`-receiver exemption every function-like check shares. */
function thisReceiver(p: ts.ParameterDeclaration): boolean {
return ts.isIdentifier(p.name) && p.name.text === 'this'
}
/**
* Peel wrapper expressions that carry no surface of their own — parentheses,
* `as` / `satisfies` / angle-bracket casts, non-null assertions — so a
* wrapped function expression is still classified as function-like.
* @param e - the expression to unwrap.
* @returns the innermost non-wrapper expression.
*/
function unwrapExpression(e: ts.Expression): ts.Expression {
let inner = e
while (
ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner) || ts.isSatisfiesExpression(inner)
|| ts.isNonNullExpression(inner) || ts.isTypeAssertionExpression(inner)
) inner = inner.expression
return inner
}
/**
* Classify a declarator's type annotation for the function contract: an
* inline function type or a type literal that is EXACTLY one call signature
* is the surface signature itself; a literal mixing call/construct
* signatures with anything else cannot be classified syntactically and is
* refused (fail closed — extract a named type); everything else is a plain
* value shape.
* @param type - the declarator's type annotation.
* @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape.
*/
function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null {
if (ts.isFunctionTypeNode(type)) return type
if (!ts.isTypeLiteralNode(type)) return null
const signatures = type.members.filter(m => ts.isCallSignatureDeclaration(m) || ts.isConstructSignatureDeclaration(m))
if (signatures.length === 0) return null
if (signatures.length === 1 && type.members.length === 1 && signatures[0] !== undefined && ts.isCallSignatureDeclaration(signatures[0])) {
return signatures[0]
}
return 'refuse'
}
/**
* The heritage-member exemption for one class member. When the member's name
* is declared by an `extends`/`implements` heritage type, the seam declaration
* is the doc's one home (the IDE inherits it on hover) and the member needs no
* doc of its own — EXCEPT where the override grows public surface the base
* never documented: a base member that is protected on every declaration does
* not exempt a public override (consumers could not call it before);
* parameters the base never names keep their own `@param` duty (the caller
* reads the seam doc, which cannot describe them; an underscore-prefixed
* rename of a base parameter — the deliberately-unused marker — is the same
* parameter, not new surface); and a void base return carried no `@returns`
* duty, so an override returning a concrete result documents it itself.
* Static members are looked up on the base CONSTRUCTOR type (only an
* `extends` expression has one; an unresolvable or interface expression
* yields no property and therefore no exemption).
* @param cls - the class whose heritage to search.
* @param name - the member name to look up.
* @param staticSide - whether to search the constructor side instead of the instance side.
* @param checker - the program's type checker.
* @returns null when no exemption applies; otherwise the parameter names the
* base declarations carry (`baseParams: null` when not syntactically
* recoverable — a complex heritage type — exempting all parameters) plus
* whether every recoverable base return annotation is `void`-like
* (`baseVoidReturn: null` when none is recoverable, exempting the result).
*/
function heritageExemption(
cls: ts.ClassDeclaration,
name: string,
staticSide: boolean,
checker: ts.TypeChecker,
): { baseParams: Set<string> | null; baseVoidReturn: boolean | null } | null {
const isProtected = (d: ts.Declaration): boolean =>
(ts.canHaveModifiers(d) ? ts.getModifiers(d) : undefined)?.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false
for (const clause of cls.heritageClauses ?? []) {
for (const t of clause.types) {
const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t)
const prop = type.getProperty(name)
if (prop === undefined) continue
const decls = prop.declarations ?? []
if (decls.length > 0 && decls.every(isProtected)) continue // public override of a protected base: new surface
let baseParams: Set<string> | null = null
let baseVoidReturn: boolean | null = null
for (const d of decls) {
let params: readonly ts.ParameterDeclaration[] | undefined
let returnType: ts.TypeNode | undefined
if (ts.isMethodDeclaration(d) || ts.isMethodSignature(d)) {
params = d.parameters
returnType = d.type
} else if ((ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) && d.type !== undefined && ts.isFunctionTypeNode(d.type)) {
params = d.type.parameters
returnType = d.type.type
} else continue
baseParams ??= new Set()
// Leading underscores are the deliberately-unused marker (eslint
// argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the
// same parameter, so compare underscore-stripped on both sides.
for (const p of params) if (ts.isIdentifier(p.name)) baseParams.add(p.name.text.replace(/^_+/, ''))
if (returnType !== undefined) {
const voidish = /^(void|Promise<void>)$/.test(returnType.getText(d.getSourceFile()).replace(/\s+/g, ' '))
baseVoidReturn = (baseVoidReturn ?? true) && voidish
}
}
return { baseParams, baseVoidReturn }
}
}
return null
}
/**
* True when a method's INFERRED return type is void-like (void, undefined,
* never, or a promise of one) — the one return the walk asks the checker to
* classify: an unannotated override above a void heritage member, where
* demanding an annotation just to prove faithfulness would be boilerplate.
* @param m - a method declaration with no return type annotation.
* @param checker - the program's type checker.
* @returns true when the inferred result carries nothing to document.
*/
function inferredReturnIsVoidish(m: ts.MethodDeclaration, checker: ts.TypeChecker): boolean {
const sig = checker.getSignatureFromDeclaration(m)
if (sig === undefined) return true // no callable signature: nothing classifiable to document
const returned = checker.getReturnTypeOfSignature(sig)
const awaited = checker.getAwaitedType(returned) ?? returned
return (awaited.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0
}
/**
* Check description-prose presence for one labeled declaration: JSDoc must
* exist and carry prose above its block tags.
* @param where - the offender label violations open with.
* @param raw - the declaration's raw JSDoc block ('' if none).
* @param w - the walk state violations append to.
*/
function checkDescribed(where: string, raw: string, w: Walk): void {
if (!raw) w.violations.push(`${where} has no JSDoc.`)
else if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
}
/**
* Check the full function contract for one labeled function-like declaration:
* description prose, `@param` per parameter, `@returns` on a non-void result.
* @param where - the offender label violations open with.
* @param raw - the declaration's raw JSDoc block ('' if none).
* @param parameters - the declaration's parameter list.
* @param returnType - the return type annotation, or undefined when inferred.
* @param returnsWaived - suppress the `@returns`/annotation requirement (a
* declarator-annotated const defers its return contract to the named type).
* @param w - the walk state violations append to.
*/
function checkFunctionLike(
where: string,
raw: string,
parameters: readonly ts.ParameterDeclaration[],
returnType: ts.TypeNode | undefined,
returnsWaived: boolean,
w: Walk,
): void {
if (!raw) { w.violations.push(`${where} has no JSDoc.`); return }
if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
const { params, returns } = parseTags(raw)
checkParams(where, 'export', parameters, params, w.sf, thisReceiver, w.violations)
if (!returnsWaived) checkReturns(where, returnType, returns, w.sf, w.violations)
}
/**
* Check one exported class: class-level prose, the function contract on every
* public method (overload implementations exempt), and description prose on
* public properties and accessors (a get/set pair is covered by the getter's
* doc). Heritage-declared members are exempt per heritageExemption (an
* override's extra parameters keep their @param duty); plugin-protocol
* statics are exempt; constructors are not checked (framework-constructed
* plugins, and the class doc owns the story).
* @param cls - the exported class declaration.
* @param name - the class's surface name (namespace-qualified).
* @param w - the walk state violations append to.
*/
function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
checkDescribed(`exported class '${name}' (${pointer(w.rel, w.sf, cls)})`, rawJsDoc(w.text, cls), w)
const overloadSigs = new Set<string>()
const documentedGetters = new Set<string>()
for (const m of cls.members) {
if ('name' in m && ts.isComputedPropertyName(m.name)) continue
if (ts.isMethodDeclaration(m) && !m.body) overloadSigs.add(m.name.getText(w.sf))
if (ts.isGetAccessorDeclaration(m)) documentedGetters.add(m.name.getText(w.sf))
}
for (const m of cls.members) {
if (isNonPublic(m) || ts.isConstructorDeclaration(m)) continue
if (!('name' in m) || ts.isComputedPropertyName(m.name)) continue // computed/symbol members
const mname = m.name.getText(w.sf)
if (isStatic(m) && PROTOCOL_STATICS.has(mname)) continue // cordis plugin-protocol slot
const exemption = heritageExemption(cls, mname, isStatic(m), w.checker)
if (ts.isMethodDeclaration(m)) {
if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs
const where = `exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`
if (exemption !== null) {
const raw = rawJsDoc(w.text, m)
// The heritage declaration owns the prose; parameters the base never
// names — including binding patterns, which no base declaration can
// name — are new surface and keep their @param duty.
const base = exemption.baseParams
const inBase = (p: ts.ParameterDeclaration): boolean =>
base !== null && ts.isIdentifier(p.name) && base.has(p.name.text.replace(/^_+/, ''))
if (base !== null && m.parameters.some(p => !thisReceiver(p) && !inBase(p))) {
checkParams(where, 'export', m.parameters, parseTags(raw).params, w.sf,
p => thisReceiver(p) || inBase(p), w.violations)
}
// A void base return carried no @returns duty, so an override growing
// a concrete result documents it itself. An annotated override runs
// the standard check; an inferred one is classified by the checker
// (this branch is already the checker's domain), so a faithful void
// override stays exempt without a boilerplate annotation.
if (exemption.baseVoidReturn === true) {
if (m.type !== undefined) {
checkReturns(where, m.type, parseTags(raw).returns, w.sf, w.violations)
} else if (!inferredReturnIsVoidish(m, w.checker)) {
w.violations.push(`${where} returns a non-void result its heritage declaration does not document; annotate the return type and add @returns.`)
}
}
continue
}
checkFunctionLike(where, rawJsDoc(w.text, m), m.parameters, m.type, false, w)
} else if (exemption !== null) {
continue // the heritage declaration owns the doc (properties/accessors carry no own parameters)
} else if (ts.isGetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) {
const kind = ts.isPropertyDeclaration(m) ? 'property' : 'accessor'
checkDescribed(`exported class ${kind} '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
} else if (ts.isSetAccessorDeclaration(m) && !documentedGetters.has(mname)) {
checkDescribed(`exported class accessor '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
}
// index signatures / static blocks: not named surface
}
}
/**
* Check one exported declaration statement, dispatching on its kind. Any
* exported statement kind the dispatch does not recognize is a violation
* (fail closed), so no export form can pass unchecked by omission.
* @param stmt - the exported statement (export modifier or export-list target).
* @param prefix - the namespace qualification for surface names ('' at top level).
* @param overloadSigs - names in this scope declared as bodyless function overload signatures.
* @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
* @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly.
* @param w - the walk state violations append to.
* @param only - for a multi-declarator variable statement reached through an
* export list (or a default-export identifier), the declarator names that
* are actually exported; `null` means the whole statement is surface
* (direct `export` modifier or ambient scope). Non-variable statements
* declare exactly one name, so the filter never applies to them.
*/
function checkDecl(
stmt: ts.Statement,
prefix: string,
overloadSigs: Set<string>,
byName: Map<string, ts.Statement[]>,
ambient: boolean,
w: Walk,
only: ReadonlySet<string> | null = null,
): void {
const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})`
if (ts.isFunctionDeclaration(stmt)) {
const name = stmt.name?.text ?? 'default'
if (prefix === '' && PROTOCOL_EXPORTS.has(name)) return // cordis plugin-protocol slot
if (stmt.body && overloadSigs.has(name)) return // overload implementation: the signatures carry the docs
checkFunctionLike(`exported function '${prefix}${name}'${at(stmt)}`, rawJsDoc(w.text, stmt),
stmt.parameters, stmt.type, false, w)
return
}
if (ts.isClassDeclaration(stmt)) {
checkClass(stmt, `${prefix}${stmt.name?.text ?? 'default'}`, w)
return
}
if (ts.isInterfaceDeclaration(stmt)) {
checkDescribed(`exported interface '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
return
}
if (ts.isTypeAliasDeclaration(stmt)) {
checkDescribed(`exported type '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
return
}
if (ts.isEnumDeclaration(stmt)) {
checkDescribed(`exported enum '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
return
}
if (ts.isVariableStatement(stmt)) {
const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator
for (const d of stmt.declarationList.declarations) {
const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf)
if (only !== null && !only.has(name)) continue // sibling declarator the export list never named: not surface
if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot
const where = `exported const '${prefix}${name}'${at(d)}`
const annotation = d.type !== undefined ? callableAnnotation(d.type) : null
const init = d.initializer !== undefined ? unwrapExpression(d.initializer) : undefined
if (annotation === 'refuse') {
// A literal mixing call/construct signatures with other members (or
// overloading them) has no single signature the walk can hold the
// tags against — fail closed rather than silently narrow the check.
w.violations.push(`${where}: its callable type literal is not gate-classifiable; extract a named type and document it there.`)
} else if (annotation !== null) {
// An INLINE callable annotation is the surface signature itself: its
// parameters and result need docs right here. (A NAMED reference
// type carries its docs at the type's own declaration instead.)
checkFunctionLike(where, raw, annotation.parameters, annotation.type, false, w)
} else if (init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
// A named declarator type annotation (`const f: Handler = …`) hands
// the return contract to the named type; the arrow's own annotation is
// still checked when it is the only signature the reader has.
checkFunctionLike(where, raw, init.parameters, init.type, init.type === undefined && d.type !== undefined, w)
} else {
checkDescribed(where, raw, w)
}
}
return
}
if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
// A namespace merging with a documented same-name sibling (the
// Config-namespace idiom) needs no second doc block of its own.
const siblings = (byName.get(stmt.name.text) ?? []).filter(s => s !== stmt)
const merged = siblings.some(s => parseJsDoc(rawJsDoc(w.text, s)).doc !== '')
if (!merged) checkDescribed(`exported namespace '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
let body = stmt.body
let nsPrefix = `${prefix}${stmt.name.text}.`
while (body !== undefined && ts.isModuleDeclaration(body)) { // dotted `namespace A.B`
nsPrefix += `${body.name.getText(w.sf)}.`
body = body.body
}
// In an ambient (`declare`) namespace body, members are implicitly
// exported — no `export` modifier required — so the recursion must treat
// every statement as surface.
const declared = ambient
|| ((ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w, declared)
return
}
if (ts.isImportEqualsDeclaration(stmt)) {
const where = `exported alias '${prefix}${stmt.name.text}'${at(stmt)}`
// An alias is a distinct exported name whose target may be a non-exported
// namespace member no walk ever visits, so it documents ITSELF — which
// matches the gate's strength only for prose-only target kinds. A
// callable, class, or namespace target carries signature or member
// contracts the alias prose cannot hold: refuse those (fail closed) and
// demand the declaration be exported directly. An unresolvable target is
// refused for the same reason.
const sym = w.checker.getSymbolAtLocation(stmt.name)
const target = sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0 ? w.checker.getAliasedSymbol(sym) : sym
const RICH_TARGETS = ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule
const rich = target === undefined
|| (target.flags & RICH_TARGETS) !== 0
|| w.checker.getTypeOfSymbol(target).getCallSignatures().length > 0
if (rich) {
w.violations.push(`${where} aliases a callable, class, or namespace target whose signature/member contract the alias cannot carry; export the declaration directly instead.`)
return
}
checkDescribed(where, rawJsDoc(w.text, stmt), w)
return
}
// Fail CLOSED: an exported statement kind this dispatch does not recognize
// must never pass silently — the gate's whole promise is that unchecked
// surface cannot exist. New TypeScript export forms extend the gate here.
w.violations.push(`exported statement${at(stmt)} uses an export form verify-export-jsdoc does not handle; extend the gate.`)
}
/**
* Walk one lexical scope (file top level or a namespace body): check every
* exported declaration, resolving `export { … }` lists (no module specifier)
* to their local declarations.
* @param statements - the scope's statements.
* @param prefix - the namespace qualification for surface names ('' at top level).
* @param w - the walk state violations append to.
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
*/
function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): void {
const byName = new Map<string, ts.Statement[]>()
const overloadSigs = new Set<string>()
const add = (name: string, stmt: ts.Statement): void => {
byName.set(name, [...(byName.get(name) ?? []), stmt])
}
for (const stmt of statements) {
if (ts.isFunctionDeclaration(stmt)) {
if (stmt.name) add(stmt.name.text, stmt)
if (!stmt.body && stmt.name) overloadSigs.add(stmt.name.text)
} else if (ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)
|| ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) {
if (stmt.name) add(stmt.name.text, stmt)
} else if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
add(stmt.name.text, stmt)
} else if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (ts.isIdentifier(d.name)) add(d.name.text, stmt)
}
}
}
// Two-phase dispatch. Phase one accumulates WHICH statements are surface
// and, for a variable statement reached by name (an export list or a
// default-export identifier), which of its declarators the exports actually
// name — `null` marks the whole statement as surface (a direct `export`
// modifier, or an ambient scope). Requests for the same statement merge:
// `null` absorbs any name set, and name sets union, so
// `export { a }; export { b }` over one `const a = …, b = …` checks both
// declarators while a never-exported sibling stays out of the surface.
// Phase two runs each surfaced statement exactly once. (Checking a
// statement eagerly per request would either re-check on the second list or
// — deduplicated — silently drop the second list's declarators.)
const requested = new Map<ts.Statement, Set<string> | null>()
const request = (stmt: ts.Statement, name: string | null): void => {
const prior = requested.get(stmt)
if (name === null || prior === null) {
requested.set(stmt, null)
return
}
requested.set(stmt, prior === undefined ? new Set([name]) : prior.add(name))
}
for (const stmt of statements) {
if (ts.isModuleDeclaration(stmt)
&& (ts.isStringLiteral(stmt.name) || (stmt.flags & ts.NodeFlags.GlobalAugmentation) !== 0)) {
continue // `declare module '…'` / `declare global` augmentation: not an export of this package
}
if (ts.isExportDeclaration(stmt)) {
if (stmt.moduleSpecifier) continue // re-export: the defining module is walked on its own
if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
for (const el of stmt.exportClause.elements) {
const local = (el.propertyName ?? el.name).text
for (const decl of byName.get(local) ?? []) request(decl, local)
// a name with no local declaration is an imported binding re-exported
// without a specifier — its defining module is walked on its own
}
}
continue
}
if (ts.isExportAssignment(stmt)) {
if (stmt.isExportEquals) {
// `export =` has no ESM consumer surface in this repo and the walk
// cannot classify its operand's shape; refuse rather than fail open.
w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`)
continue
}
const where = `default export (${pointer(w.rel, w.sf, stmt)})`
const expr = unwrapExpression(stmt.expression)
if (ts.isIdentifier(expr)) {
for (const decl of byName.get(expr.text) ?? []) request(decl, expr.text)
} else if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
checkFunctionLike(where, rawJsDoc(w.text, stmt), expr.parameters, expr.type, false, w)
} else {
checkDescribed(where, rawJsDoc(w.text, stmt), w)
}
continue
}
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) request(stmt, null)
}
for (const stmt of statements) {
const only = requested.get(stmt)
if (only !== undefined) checkDecl(stmt, prefix, overloadSigs, byName, ambient, w, only)
}
}
/**
* Compiler options for the walk's program. The real repo hands over its
* tsconfig.base.json (whose `paths` map resolves cross-package imports to
* source, so heritage-member lookups see seam types); a fixture root without
* one gets `noLib` + no `@types` — fixtures are single-file and
* self-contained, nothing in the walk resolves a lib symbol, and default-lib
* parsing is ~99% of per-program cost (it made the fixture spec time out
* under CI coverage instrumentation). Emit-side options are stripped: the
* walk never emits or asks for diagnostics, it only binds types on demand.
* @param scanRoot - the root being scanned.
* @returns compiler options for ts.createProgram.
*/
function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
const cfgPath = resolve(scanRoot, 'tsconfig.base.json')
if (!existsSync(cfgPath)) return { skipLibCheck: true, noLib: true, types: [] }
const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile.bind(ts.sys)) as { config?: unknown }
const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, scanRoot)
return {
...parsed.options,
noEmit: true,
composite: false,
declaration: false,
declarationMap: false,
sourceMap: false,
incremental: false,
}
}
/**
* Walk every non-vendored package source file and collect JSDoc-completeness
* violations for its module-level exports. Returns findings instead of
* throwing so tests assert on the list; the CLI entry turns a non-empty list
* into exit 1.
* @param scanRoot - the repo root to scan; tests pass a fixture dir.
* @returns every violation, in file order, one human-readable line each.
*/
export function collectExportJsdocViolations(scanRoot: string = root): string[] {
const violations: string[] = []
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
const checker = program.getTypeChecker()
for (const rel of rels) {
const sf = program.getSourceFile(resolve(scanRoot, rel))
if (!sf) continue // program root files always resolve; guard for narrowing
// A script-style declaration file (no imports/exports) is one big ambient
// scope; a module-style .d.ts still honors explicit export modifiers.
checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf))
}
return violations
}
/** CLI entry: list every violation and exit 1, or confirm a clean surface. */
function main(): void {
const violations = collectExportJsdocViolations()
if (violations.length === 0) {
console.log('verify-export-jsdoc: every exported name on the package surface is documented.')
return
}
console.error(`verify-export-jsdoc: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):`)
for (const v of violations) console.error(` ${v}`)
process.exit(1)
}
// 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()
}

View File

@@ -32,9 +32,8 @@
* Run: `tsx scripts/verify-md-links.ts`.
*/
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
@@ -134,7 +133,7 @@ const seen = new Set<string>()
const all: Violation[] = []
let checked = 0
for (const pattern of PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) {
for (const match of globSync(pattern, { cwd: root })) {
const abs = resolve(root, match)
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
// matched twice (or via symlink) is checked once.

View File

@@ -18,16 +18,17 @@
* A wrapped paragraph inside a list item or blockquote is still a `paragraph`
* node, so those are caught too. Scope mirrors doc-typecheck plus the two
* AGENTS.md files that doc-sync does NOT otherwise cover (the convention itself
* lives there): README.md, docs/** /*.md, packages/* /*.md, AGENTS.md,
* packages/AGENTS.md. The root and packages/ CLAUDE.md are symlinks to the
* AGENTS.md files, so they are deduped by real path.
* lives there), plus generated system-prompt Markdown goldens: README.md,
* docs/** /*.md, packages/* /*.md, examples/** /system-prompt.golden.md,
* packages/** /system-prompt.golden.md, AGENTS.md, packages/AGENTS.md. The root
* and packages/ CLAUDE.md are symlinks to the AGENTS.md files, so they are
* deduped by real path.
*
* Run: `tsx scripts/verify-md-wrap.ts`.
*/
import { readFileSync, realpathSync } from 'node:fs'
import { globSync, readFileSync, realpathSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
@@ -35,8 +36,18 @@ import type { Nodes } from 'mdast'
const root = resolve(import.meta.dirname, '..')
/** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */
const PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md']
/** Files to check: doc-typecheck's scope, prompt goldens, and the AGENTS.md pair. */
const PATTERNS = [
'README.md',
'README.zh.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',
'examples/**/system-prompt.golden.md',
'packages/**/system-prompt.golden.md',
'AGENTS.md',
'packages/AGENTS.md',
]
/** A located hard-wrap: a prose paragraph spanning more than one source line. */
interface Violation {
@@ -76,7 +87,7 @@ const seen = new Set<string>()
const all: Violation[] = []
let checked = 0
for (const pattern of PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) {
for (const match of globSync(pattern, { cwd: root })) {
const abs = resolve(root, match)
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
// matched twice (or via symlink) is checked once.

107
scripts/verify-mermaid.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Doc-sync gate: verify every fenced ```mermaid block parses with Mermaid's
* own parser. Markdown link/type/code gates can say a diagram block exists and
* is linked, but only Mermaid can catch syntax errors that GitHub would fail to
* render.
*
* Scope matches the Markdown link gate so any Mermaid diagram in repo-authored
* docs is checked: README.md, README.zh.md, docs/** /*.md,
* packages/* /*.md, packages/* /* /*.md, examples/** /*.md, AGENTS.md,
* packages/AGENTS.md, and .agents/skills/** /*.md.
*
* Run: `tsx scripts/verify-mermaid.ts`.
*/
import { globSync, readFileSync, realpathSync } from 'node:fs'
import { resolve } from 'node:path'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import { JSDOM } from 'jsdom'
import type { Nodes } from 'mdast'
const root = resolve(import.meta.dirname, '..')
const PATTERNS = [
'README.md',
'README.zh.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',
'examples/**/*.md',
'AGENTS.md',
'packages/AGENTS.md',
'.agents/skills/**/*.md',
]
interface Block {
file: string
line: number
source: string
}
interface Violation {
file: string
line: number
message: string
}
function extractMermaidBlocks(file: string): Block[] {
const source = readFileSync(resolve(root, file), 'utf8')
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
const out: Block[] = []
const visit = (node: Nodes): void => {
if (node.type === 'code' && node.lang === 'mermaid') {
out.push({ file, line: node.position?.start.line ?? 0, source: node.value })
}
if ('children' in node) {
for (const child of node.children) visit(child)
}
}
visit(tree)
return out
}
function formatError(error: unknown): string {
if (error instanceof Error) return error.message.replace(/\s+/g, ' ').trim()
return String(error).replace(/\s+/g, ' ').trim()
}
const blocks: Block[] = []
const seen = new Set<string>()
let checkedFiles = 0
for (const pattern of PATTERNS) {
for (const match of globSync(pattern, { cwd: root })) {
const real = realpathSync(resolve(root, match))
if (seen.has(real)) continue
seen.add(real)
checkedFiles++
blocks.push(...extractMermaidBlocks(match))
}
}
const violations: Violation[] = []
const { window } = new JSDOM('')
Object.defineProperty(globalThis, 'window', { value: window })
Object.defineProperty(globalThis, 'document', { value: window.document })
Object.defineProperty(globalThis, 'navigator', { value: window.navigator })
const mermaid = (await import('mermaid')).default
mermaid.initialize({ startOnLoad: false })
for (const block of blocks) {
try {
await mermaid.parse(block.source, { suppressErrors: false })
} catch (error: unknown) {
violations.push({ file: block.file, line: block.line, message: formatError(error) })
}
}
if (violations.length === 0) {
console.log(`verify-mermaid: ${blocks.length} mermaid block(s) parsed across ${checkedFiles} file(s).`)
process.exit(0)
}
console.error('verify-mermaid: Mermaid syntax errors found:')
for (const violation of violations) {
console.error(` ${violation.file}:${violation.line} ${violation.message}`)
}
process.exit(1)

View File

@@ -39,9 +39,8 @@
* Run: `tsx scripts/verify-package-paths.ts`.
*/
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
@@ -144,7 +143,7 @@ const all: Violation[] = []
let checked = 0
const seen = new Set<string>()
for (const pattern of PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) {
for (const match of globSync(pattern, { cwd: root })) {
if (isExcluded(match)) continue
// Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
const real = realpathSync(resolve(root, match))

View File

@@ -1,158 +1,57 @@
/**
* Doc-sync gate: enforce the RFC classification scheme
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)).
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
* and the freshness of the generated index
* ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)).
* Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
* folder IS the label. This gate is the machine source of truth for the closed
* class set and keeps the README index honest.
* class set and keeps the generated index honest.
*
* Two checks:
* Three checks (all against [rfc-index.ts](./rfc-index.ts), the shared walker
* and renderer):
*
* 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
* from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a
* lifecycle root (other than the README/AGENTS allowlist) fails; an unknown
* class folder fails; a stray file at an unexpected depth fails. This is what
* makes the set CLOSED: a new class folder can't appear without amending
* CLASSES here (and the README's Classification section, per the RFC).
*
* 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the
* `### {Class}` heading inside the `## {Lifecycle}` section that matches the
* file's path. A missing entry, a duplicate, or an entry under the wrong
* heading fails. This mirrors `verify-event-taxonomy`: a curated doc table
* checked against the on-disk source of truth, so the index can't drift.
*
* The class DESCRIPTIONS in the README prose are not checked (they are
* explanatory text); only the per-class index tables are. This is checker, not
* fixer: it reports and never rewrites.
* from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1.
* A loose `.md` directly under a lifecycle root (other than the
* README/AGENTS allowlist) fails; an unknown class folder fails; a stray
* file at an unexpected depth fails. This is what makes the set CLOSED: a
* new class folder can't appear without amending CLASSES (and the README's
* Classification section, per the RFC).
* 2. FRESHNESS — the committed `docs/rfc/INDEX.md` byte-matches a fresh render
* from the tree, so every RFC is listed exactly once, under the heading
* matching its path, with its H1 title and filename date. The fix for a
* stale index is `pnpm run gen-rfc-index`, never a hand edit. This is
* checker, not fixer: it reports and never rewrites.
* 3. NO STRAY ROWS — `docs/rfc/README.md` (the curated front door) carries no
* index-shaped table rows; the list lives only in the generated INDEX.md.
*
* Run: `tsx scripts/verify-rfc-classification.ts`.
*/
import { readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import { resolve } from 'node:path'
import { INDEX_ROW, renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
const root = resolve(import.meta.dirname, '..')
const rfcRoot = resolve(root, 'docs/rfc')
const { rfcs, errors } = walkRfcTree()
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/**
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
* class is a deliberate act: extend this list AND the README's Classification
* section. The gate rejects any folder not listed here.
*/
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
/** Title-case a class/lifecycle folder name for README heading comparison. */
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
const errors: string[] = []
// --- Check 1: structure -----------------------------------------------------
// Every Markdown file anywhere under a lifecycle folder, at any depth.
interface Rfc {
lifecycle: string
cls: string
base: string
/** Path relative to docs/rfc, for the README link check. */
rel: string
}
const rfcs: Rfc[] = []
for (const lifecycle of LIFECYCLES) {
for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
// indexed via its English filename; the pairing gate owns its consistency.
if (match.endsWith('.zh.md')) continue
const cls = segs[1]
const base = segs[2]
if (segs.length !== 3 || cls === undefined || base === undefined) {
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
continue
}
if (!(CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
continue
}
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
continue
}
rfcs.push({ lifecycle, cls, base, rel: match })
if (errors.length === 0) {
let index: string | undefined
try {
index = readFileSync(resolve(rfcRoot, 'INDEX.md'), 'utf8')
} catch {
// A missing INDEX.md is reported below as staleness, exactly like a drifted one.
}
}
// --- Check 2: README completeness -------------------------------------------
// Parse the index into (lifecycle, class) -> set of linked rel paths, by
// tracking the current `## {Lifecycle}` and `### {Class}` headings and reading
// every `](path)` link target underneath. A link target is normalized to its
// path relative to docs/rfc.
const readmePath = resolve(rfcRoot, 'README.md')
const readme = readFileSync(readmePath, 'utf8')
const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l]))
const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c]))
/** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */
const listed = new Map<string, Set<string>>()
let curLifecycle: string | null = null
let curClass: string | null = null
for (const line of readme.split('\n')) {
const h2 = /^##\s+(.+?)\s*$/.exec(line)
if (h2?.[1] !== undefined) {
curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null
curClass = null
continue
if (renderIndex(rfcs) !== index) {
errors.push('index: docs/rfc/INDEX.md is stale or missing — run `pnpm run gen-rfc-index` and commit the result')
}
const h3 = /^###\s+(.+?)\s*$/.exec(line)
if (h3?.[1] !== undefined) {
curClass = classByHeading.get(h3[1].trim()) ?? null
continue
}
if (!curLifecycle || !curClass) continue
// Collect every relative .md link target on this line.
for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) {
const target = m[1]
if (target === undefined) continue
// README links are relative to docs/rfc; normalize and key by location.
const rel = relative(rfcRoot, resolve(rfcRoot, target))
const key = `${curLifecycle}/${curClass}`
const set = listed.get(key) ?? new Set<string>()
set.add(rel)
listed.set(key, set)
}
}
// Every on-disk RFC must be listed under the heading matching its path.
const seenOnDisk = new Set<string>()
for (const rfc of rfcs) {
seenOnDisk.add(rfc.rel)
const key = `${rfc.lifecycle}/${rfc.cls}`
if (!listed.get(key)?.has(rfc.rel)) {
errors.push(
`index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`,
)
}
}
// Every README entry must point at a real RFC under that same heading (catches a
// misfiled or stale row).
for (const [key, targets] of listed) {
for (const rel of targets) {
if (!seenOnDisk.has(rel)) {
errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`)
const readme = readFileSync(resolve(rfcRoot, 'README.md'), 'utf8')
for (const line of readme.split('\n')) {
if (INDEX_ROW.test(line)) {
errors.push(`readme: index-shaped row in the curated README (the list lives in INDEX.md): ${JSON.stringify(line.slice(0, 80))}`)
}
}
}
// --- Report -----------------------------------------------------------------
if (errors.length === 0) {
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
process.exit(0)

View File

@@ -0,0 +1,119 @@
/**
* Doc-sync gate: enforce the RFC in-file format
* ([README.md § The file format](../docs/rfc/README.md), the contract; rationale in
* [the uniform-format RFC](../docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md)).
* The classification gate owns WHERE a file sits and how it is named; this gate
* owns what is INSIDE: the header block, the per-lifecycle body skeleton, and
* the Alternatives-considered mandate.
*
* Per English RFC (`.zh.md` counterparts are the pairing gate's concern):
*
* 1. HEADER — line 1 is `# RFC: <title>`, line 2 blank, line 3 the one
* `Status:` line in the file, line 4 blank. The status is the dateless enum
* matching the lifecycle folder: `Status: proposed`, `Status: implemented`,
* or `Status: rejected — <reason>`.
* 2. SKELETON — the first `##` section is `## Problem`; the lifecycle's
* required sections are present under their canonical names (`proposed/`:
* Proposal, Acceptance criteria, Risks; `implemented/`: Decision,
* Consequences; `rejected/`: Proposal); `implemented/` must not carry the
* proposal-era headings (Proposal, Plan, Migration plan, Acceptance
* criteria) that the docs standard's slop checklist outlaws there.
* 3. ALTERNATIVES — `## Alternatives considered` is present, or the file is a
* pre-format RFC (dated before the format landed) carrying the exact
* grandfather comment instead. Carrying both, or grandfathering a
* post-format RFC, fails.
* 4. DEBT MARKER — the retired legacy-format debt comment may not reappear.
*
* Checker, not fixer: it reports and never rewrites.
* Run: `tsx scripts/verify-rfc-format.ts`.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { rfcRoot, walkRfcTree } from './rfc-index.ts'
/** The date the format contract landed; the grandfather comment is valid only before it. */
const FORMAT_ADOPTED = '2026-07-05'
/** The exact comment a pre-format RFC carries in place of `## Alternatives considered`. */
const GRANDFATHER = '<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->'
/** The retired debt marker that flagged pre-format bodies; banned so it cannot creep back. */
const LEGACY_MARKER = 'XXX: legacy ADR/RFC body format'
/** Status-line grammar per lifecycle folder. */
const STATUS: Record<string, RegExp> = {
proposed: /^Status: proposed$/,
implemented: /^Status: implemented$/,
rejected: /^Status: rejected — .+$/,
}
/** Required `##` headings per lifecycle, beyond the universal `## Problem` opener. */
const REQUIRED: Record<string, string[]> = {
proposed: ['## Proposal', '## Acceptance criteria', '## Risks'],
implemented: ['## Decision', '## Consequences'],
rejected: ['## Proposal'],
}
/** Headings banned in `implemented/` — proposal-era spec-speak per the slop checklist. */
const BANNED_IMPLEMENTED = /^## (?:Proposal\b|Plan\b|Migration plan\b|Acceptance criteria\b)/i
const { rfcs, errors } = walkRfcTree()
for (const rfc of rfcs) {
const fail = (msg: string): void => {
errors.push(`format: ${rfc.rel}${msg}`)
}
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
// Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a
// status line, a banned heading, or the grandfather comment inside a fence
// (the README's own format section does), and only real prose counts.
let inFence = false
const prose = lines.filter((l) => {
if (l.startsWith('```')) {
inFence = !inFence
return false
}
return !inFence
})
if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: <title>`')
if (lines[1] !== '') fail('line 2 must be blank')
const status = STATUS[rfc.lifecycle]
if (status !== undefined && !status.test(lines[2] ?? '')) {
fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`)
}
if (lines[3] !== '') fail('line 4 must be blank')
const statusLines = prose.filter(l => l.startsWith('Status:') && l !== lines[2])
if (statusLines.length > 0 || prose.filter(l => l === lines[2]).length > 1) {
fail('the line-3 `Status:` line must be the only one in the file')
}
const h2s = prose.filter(l => l.startsWith('## ')).map(l => l.trimEnd())
if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '<none>')})`)
for (const required of REQUIRED[rfc.lifecycle] ?? []) {
if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`)
}
if (rfc.lifecycle === 'implemented') {
for (const h2 of h2s.filter(h => BANNED_IMPLEMENTED.test(h))) {
fail(`\`${h2}\` is a proposal-era heading; an implemented RFC states what is (fold it into Decision/Consequences/Testing)`)
}
}
const hasSection = h2s.includes('## Alternatives considered')
const hasGrandfather = prose.includes(GRANDFATHER)
if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment')
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)')
if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`)
if (prose.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker')
}
if (errors.length === 0) {
console.log(`verify-rfc-format: ${rfcs.length} RFC(s) checked, all conform to docs/rfc/README.md § The file format.`)
process.exit(0)
}
console.error('verify-rfc-format: violations found:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)

View File

@@ -47,9 +47,8 @@
*/
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
@@ -216,7 +215,7 @@ function parse(content: string): Nodes {
// Enumerate the scope once.
const files = new Set<string>()
for (const pattern of SCOPE_PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) files.add(match)
for (const match of globSync(pattern, { cwd: root })) files.add(match)
}
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()

View File

@@ -22,9 +22,8 @@
* Run: `tsx scripts/verify-type-equiv.ts`.
*/
import { readFileSync, existsSync } from 'node:fs'
import { globSync, readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
@@ -148,7 +147,7 @@ const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.sym
// as an orphan rather than silently skipped.
const docSet = new Set<string>()
for (const pattern of MARKDOWN_GLOBS) {
for await (const match of glob(pattern, { cwd: root })) docSet.add(match)
for (const match of globSync(pattern, { cwd: root })) docSet.add(match)
}
const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)