Merge origin/master into skill system branch
Resolve documentation split, tool presentation, and generated catalog changes from master while preserving the skill system integration.
This commit is contained in:
@@ -27,6 +27,8 @@ const vendoredPackages = new Set([
|
||||
'@cordisjs/plugin-logger-console',
|
||||
])
|
||||
|
||||
const localArtifactDirs = new Set(['node_modules'])
|
||||
|
||||
/** The subset of package.json fields this constraint check cares about. */
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
@@ -64,10 +66,13 @@ function packageDirs(base: string, depth: number): string[] {
|
||||
if (depth === 1) {
|
||||
return readdirSync(join(root, base), { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => !localArtifactDirs.has(entry.name))
|
||||
.filter(entry => existsSync(join(root, base, entry.name, 'package.json')))
|
||||
.map(entry => join(base, entry.name))
|
||||
}
|
||||
return readdirSync(join(root, base), { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => !localArtifactDirs.has(entry.name))
|
||||
.flatMap(group => packageDirs(join(base, group.name), depth - 1))
|
||||
}
|
||||
|
||||
@@ -177,6 +182,7 @@ function checkHierarchyShape(): string[] {
|
||||
}
|
||||
for (const pkg of readdirSync(join(packagesRoot, group.name), { withFileTypes: true })) {
|
||||
if (!pkg.isDirectory()) continue
|
||||
if (localArtifactDirs.has(pkg.name)) continue
|
||||
const pkgRel = join(groupRel, pkg.name)
|
||||
if (!existsSync(join(packagesRoot, group.name, pkg.name, 'package.json'))) {
|
||||
errors.push(`${pkgRel}: expected a package here (no package.json found) — the hierarchy is exactly packages/<group>/<pkg>, no deeper nesting`)
|
||||
|
||||
10
scripts/doc-budgets.manifest.json
Normal file
10
scripts/doc-budgets.manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"AGENTS.md": 1575,
|
||||
"docs/AGENTS.md": 1315,
|
||||
"docs/architecture.md": 1890,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 800,
|
||||
"examples/AGENTS.md": 610,
|
||||
"packages/AGENTS.md": 450,
|
||||
"packages/README.md": 605
|
||||
}
|
||||
@@ -9,13 +9,15 @@
|
||||
* 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 three more fence variants and skips all three (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), and
|
||||
* ` ```ts persistence-catalog ` is a generated log-event payload fragment in the
|
||||
* persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`).
|
||||
*
|
||||
* Run: `tsx scripts/doc-typecheck.ts`.
|
||||
*/
|
||||
@@ -43,8 +45,12 @@ 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.
|
||||
*/
|
||||
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog'
|
||||
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog'
|
||||
|
||||
/** One extracted code block. */
|
||||
interface Block {
|
||||
@@ -55,7 +61,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 block from one Markdown file. */
|
||||
function extractBlocks(absPath: string): Block[] {
|
||||
const text = readFileSync(absPath, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
@@ -82,7 +89,8 @@ 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'
|
||||
: null
|
||||
if (kind) open = { line: i + 1, kind, body: [] }
|
||||
})
|
||||
return blocks
|
||||
@@ -131,11 +139,11 @@ files.sort()
|
||||
const all = files.flatMap(extractBlocks)
|
||||
const checked = all.filter(b => b.kind === 'check')
|
||||
const ignored = all.filter(b => b.kind === 'ignore')
|
||||
// `type-equiv` 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 +179,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.`)
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
* Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag
|
||||
* — the generator hard-errors on a missing tag, and where the signature shape is
|
||||
* conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
|
||||
* it asserts the tag agrees and hard-errors on a contradiction. The INHERITED
|
||||
* it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag,
|
||||
* the walk enforces JSDoc COMPLETENESS on the whole harness surface (the
|
||||
* jsdoc-completeness-gate RFC): every event and public service method carries
|
||||
* description prose; every payload parameter has a non-empty `@param` (`this`
|
||||
* receivers and the trailing waterfall `next` are exempt — next's semantics are
|
||||
* documented once by the mode); a service method with a non-`void`/
|
||||
* `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT
|
||||
* return type annotation (a pure-AST walk cannot classify an inferred return);
|
||||
* 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
|
||||
* 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.
|
||||
@@ -41,7 +55,8 @@ import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
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). */
|
||||
@@ -52,11 +67,15 @@ 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.
|
||||
* 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> = {
|
||||
Agent: 'core.md',
|
||||
@@ -144,8 +163,10 @@ function rawJsDoc(text: string, node: ts.Node): string {
|
||||
* present). Output obeys the repo's markdown conventions so the generated file
|
||||
* passes verify-md-wrap: each prose paragraph collapses to ONE physical line,
|
||||
* and a `-` bullet list is preserved with each item on its own single line
|
||||
* (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines
|
||||
* other than `@mode` end the current prose run.
|
||||
* (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
|
||||
@@ -154,6 +175,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
.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[] = []
|
||||
@@ -175,8 +197,9 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
}
|
||||
for (const line of inner) {
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
|
||||
if (m) { mode = m[1] as Mode; continue }
|
||||
if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose
|
||||
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
|
||||
@@ -194,6 +217,60 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
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) {
|
||||
@@ -212,10 +289,13 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
|
||||
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Walk every harness `interface Events` block and extract its events.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
|
||||
/** Walk every harness `interface Events` block and extract its events, hard-
|
||||
* erroring (aggregated) on any JSDoc-completeness violation: a missing/
|
||||
* contradicted `@mode`, missing description prose, or an undocumented payload
|
||||
* parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */
|
||||
export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const entries: EventEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
@@ -229,33 +309,63 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
const signature = memberSignature(member, sf)
|
||||
const { doc, mode } = parseJsDoc(rawJsDoc(text, member))
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (hasNext && mode !== 'waterfall') {
|
||||
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (!hasNext && mode === 'waterfall') {
|
||||
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param each. Exempt the `this`
|
||||
// receiver annotation (not payload) and the trailing waterfall `next`
|
||||
// (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?).`)
|
||||
}
|
||||
}
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Walk every harness `interface Context` block + its service class.
|
||||
/** Walk every harness `interface Context` block + its service class, hard-
|
||||
* erroring (aggregated) on any JSDoc-completeness violation: a class or public
|
||||
* method without JSDoc prose, an undocumented parameter, a stale `@param`, a
|
||||
* missing `@returns` on a non-void method, or an inferred (unannotated) return
|
||||
* type the pure-AST walk cannot classify.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
|
||||
export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const entries: ServiceEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
@@ -281,6 +391,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
const methods: string[] = []
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
@@ -297,17 +409,52 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const memberName = member.name.getText(sf)
|
||||
if (memberName.startsWith('[')) continue // computed/symbol members
|
||||
methods.push(memberSignature(member, sf))
|
||||
const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
|
||||
const raw = rawJsDoc(text, member)
|
||||
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.`)
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
type,
|
||||
abstract,
|
||||
doc: parseJsDoc(rawJsDoc(text, cls)).doc,
|
||||
doc: clsDoc,
|
||||
methods,
|
||||
source: pointer(rel, sf, cls),
|
||||
})
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
return entries.sort((a, b) => a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
@@ -364,7 +511,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)
|
||||
@@ -376,7 +523,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, '```', '')
|
||||
@@ -387,51 +534,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](../architecture.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]}))`)
|
||||
}
|
||||
@@ -439,31 +606,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.
|
||||
|
||||
784
scripts/gen-doc-graphs.ts
Normal file
784
scripts/gen-doc-graphs.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
/**
|
||||
* 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/ 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',
|
||||
'fs',
|
||||
'compact',
|
||||
'subagent',
|
||||
'web',
|
||||
'todo',
|
||||
'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-bash', '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: 'skills',
|
||||
pkg: 'skill',
|
||||
title: 'Skill discovery registry',
|
||||
mode: 'core',
|
||||
consumers: ['agent-core', 'tool-skill'],
|
||||
note: 'Discovers project/user/system skills, injects request-time listings, and serves full skill bodies to the skill tool.',
|
||||
},
|
||||
{
|
||||
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'],
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
]
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')}</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: '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` 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["deny or ask<br/>tool body skipped"]',
|
||||
' 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')}"]`,
|
||||
` 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| toolBody',
|
||||
' pre -->|deny or ask| denied',
|
||||
' denied --> post',
|
||||
' toolBody --> fsGate',
|
||||
' fsGate --> toolBody',
|
||||
' toolBody --> owned',
|
||||
' toolBody --> post',
|
||||
' post --> context',
|
||||
' post --> toolResult',
|
||||
' toolResult --> presentResult',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. 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: 'docs/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/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',
|
||||
'docs/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/acp-agent/composition.md': 'hybrid generated',
|
||||
'docs/event-producer-consumer.md': 'hybrid generated',
|
||||
'docs/agent-lifecycle.md': 'curated',
|
||||
'docs/tool-execution-pipeline.md': 'curated',
|
||||
'docs/acp/snapshot-replay.md': 'curated',
|
||||
}
|
||||
const rows = [
|
||||
'| [module dependency graph](module-graph.md) | `generated` |',
|
||||
'| [tool schema catalog and package map](tool-catalog/tools.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/](tool-catalog/tools.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()
|
||||
}
|
||||
@@ -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,30 @@ 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',
|
||||
'compact',
|
||||
'subagent',
|
||||
'web',
|
||||
'todo',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
|
||||
/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
|
||||
function collect(): Pkg[] {
|
||||
const pkgs: Pkg[] = []
|
||||
@@ -44,7 +65,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 +86,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 +97,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')
|
||||
|
||||
456
scripts/gen-persistence-catalog.ts
Normal file
456
scripts/gen-persistence-catalog.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Generate (and verify) the persistence log event catalog in
|
||||
* docs/persistence-catalog/log-events.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/log-events.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()
|
||||
}
|
||||
30
scripts/gen-rfc-index.ts
Normal file
30
scripts/gen-rfc-index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Regenerate the RFC index tables in `docs/rfc/README.md` from the RFC tree
|
||||
* (see [rfc-index.ts](./rfc-index.ts) for the layout contract and rendering
|
||||
* rules). Rewrites ONLY the marker-delimited regions; the curated prose is
|
||||
* untouched. 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 { rfcRoot, spliceReadme, 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 readmePath = resolve(rfcRoot, 'README.md')
|
||||
const readme = readFileSync(readmePath, 'utf8')
|
||||
const next = spliceReadme(readme, rfcs)
|
||||
if (next === readme) {
|
||||
console.log(`gen-rfc-index: docs/rfc/README.md is up to date (${rfcs.length} RFCs).`)
|
||||
} else {
|
||||
writeFileSync(readmePath, next)
|
||||
console.log(`gen-rfc-index: docs/rfc/README.md regenerated (${rfcs.length} RFCs).`)
|
||||
}
|
||||
@@ -41,6 +41,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
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'
|
||||
@@ -49,6 +52,7 @@ 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'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog/tools.md'
|
||||
@@ -73,6 +77,12 @@ 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>
|
||||
@@ -96,15 +106,21 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
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-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,
|
||||
@@ -119,6 +135,8 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-skill',
|
||||
dir: 'tool-skill',
|
||||
source: 'packages/core/tool-skill/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.skills'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SkillService, {
|
||||
dshHome: resolve(root, '.tmp/tool-catalog/.dsh'),
|
||||
@@ -132,6 +150,9 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
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.
|
||||
@@ -145,9 +166,32 @@ 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-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
|
||||
// not depend on which provider backs the seam (or on it being available),
|
||||
// so any registered provider is enough to harvest them.
|
||||
await ctx.plugin(WebService)
|
||||
await ctx.plugin(WebSearchExa)
|
||||
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.',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -155,6 +199,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
|
||||
@@ -204,7 +251,15 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
await ctx.plugin(ToolRegistry)
|
||||
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()
|
||||
}
|
||||
@@ -216,12 +271,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})`, '')
|
||||
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[] = [
|
||||
@@ -230,12 +292,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).',
|
||||
'',
|
||||
'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}\``, '')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// publint every harness package. Packages live at packages/<group>/<pkg>
|
||||
@@ -14,6 +14,7 @@ const packages = readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.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}`),
|
||||
)
|
||||
|
||||
|
||||
173
scripts/rfc-index.ts
Normal file
173
scripts/rfc-index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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 README's per-lifecycle tables are GENERATED between marker comments
|
||||
* (`<!-- gen-rfc-index:begin {lifecycle} -->` … `end`): section headings and
|
||||
* 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. Prose outside the markers is curated by hand and never touched.
|
||||
*/
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/** The begin/end marker lines that delimit one lifecycle's generated region. */
|
||||
const markers = (lifecycle: string): { begin: string; end: string } => ({
|
||||
begin: `<!-- gen-rfc-index:begin ${lifecycle} -->`,
|
||||
end: `<!-- gen-rfc-index:end ${lifecycle} -->`,
|
||||
})
|
||||
|
||||
/**
|
||||
* Render one lifecycle's generated region 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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice freshly rendered regions into the README text. Throws when a marker
|
||||
* pair is missing, duplicated, or out of order, when a region does not sit
|
||||
* under its own `## {Lifecycle}` heading, or when an index-shaped table row
|
||||
* (a `| [title](lifecycle/…)` line) appears OUTSIDE the generated regions —
|
||||
* the markers are part of the curated prose, the heading above each region is
|
||||
* the one its lifecycle names, and index rows live only inside the regions
|
||||
* (prose links to RFCs remain fine anywhere).
|
||||
*/
|
||||
export function spliceReadme(readme: string, rfcs: Rfc[]): string {
|
||||
let out = readme
|
||||
const regions: Array<{ from: number; to: number }> = []
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
const { begin, end } = markers(lifecycle)
|
||||
const beginAt = out.indexOf(begin)
|
||||
const endAt = out.indexOf(end)
|
||||
if (beginAt === -1 || endAt === -1 || endAt < beginAt) {
|
||||
throw new Error(`README.md is missing the ${JSON.stringify(begin)} … ${JSON.stringify(end)} marker pair`)
|
||||
}
|
||||
if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) {
|
||||
throw new Error(`README.md has a duplicated ${lifecycle} index marker`)
|
||||
}
|
||||
// The region must sit directly under its own lifecycle heading: the last
|
||||
// H2 above the begin marker is `## {Heading(lifecycle)}`, or the heading
|
||||
// itself has drifted while the generated table stayed put.
|
||||
const before = out.slice(0, beginAt)
|
||||
const lastH2 = [...before.matchAll(/^##\s+(.+?)\s*$/gm)].at(-1)?.[1]
|
||||
if (lastH2 !== heading(lifecycle)) {
|
||||
throw new Error(`README.md: the ${lifecycle} index region is not under a "## ${heading(lifecycle)}" heading (found "## ${lastH2 ?? '<none>'}")`)
|
||||
}
|
||||
out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}`
|
||||
regions.push({ from: out.indexOf(begin), to: out.indexOf(markers(lifecycle).end) + markers(lifecycle).end.length })
|
||||
}
|
||||
// Index rows are generated state: a table row linking into a lifecycle
|
||||
// folder anywhere OUTSIDE the regions is a hand-added index entry the
|
||||
// generator would never reconcile.
|
||||
let offset = 0
|
||||
for (const line of out.split('\n')) {
|
||||
const inRegion = regions.some(r => offset >= r.from && offset < r.to)
|
||||
if (!inRegion && /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//.test(line)) {
|
||||
throw new Error(`README.md: index-shaped row outside the generated regions: ${JSON.stringify(line.slice(0, 80))}`)
|
||||
}
|
||||
offset += line.length + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
17
scripts/translation-pairing.manifest.json
Normal file
17
scripts/translation-pairing.manifest.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"required": [
|
||||
"README.md",
|
||||
"docs/development.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md"
|
||||
],
|
||||
"excluded": [
|
||||
"docs/AGENTS.md",
|
||||
"docs/module-graph.md",
|
||||
"docs/cordis-catalog/",
|
||||
"docs/tool-catalog/",
|
||||
"docs/persistence-catalog/",
|
||||
"docs/i18n/terminology.md"
|
||||
]
|
||||
}
|
||||
@@ -10,10 +10,15 @@
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.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" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
|
||||
|
||||
{ "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": "TodoItem", "source": "packages/core/session/src/types.ts" },
|
||||
@@ -34,6 +39,8 @@
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "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/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" },
|
||||
@@ -46,6 +53,7 @@
|
||||
{ "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" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" },
|
||||
@@ -61,6 +69,14 @@
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
|
||||
79
scripts/verify-doc-budgets.ts
Normal file
79
scripts/verify-doc-budgets.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce word-count ceilings on the standing docs that accrete
|
||||
* (docs/AGENTS.md § "Budgets and the ceiling gate"). Instruction files and the
|
||||
* architecture overview grow a paragraph per PR unless something pushes back;
|
||||
* this gate is the pushback — when a ceiling is hit, the fix is to relocate or
|
||||
* condense per the documentation standard, not to raise the ceiling. Raising a
|
||||
* ceiling is allowed but is a deliberate, reviewable manifest diff that the PR
|
||||
* description must justify.
|
||||
*
|
||||
* Scope is deliberately NARROW: only the files listed in
|
||||
* scripts/doc-budgets.manifest.json (path → max words). Reference docs, RFCs,
|
||||
* and package READMEs are unbudgeted — length is legitimate there (a feature
|
||||
* matrix is the right kind of long), and the standard governs them through
|
||||
* review, not a ceiling.
|
||||
*
|
||||
* The manifest is an enforcement frontier, i18n-rollout style: a ceiling sits
|
||||
* at least 5% above the doc's current size (working headroom, so routine
|
||||
* wording edits pass while real growth trips the gate) and ratchets DOWN,
|
||||
* keeping that margin, as the doc is brought to its target budget. A manifest entry whose file is missing
|
||||
* fails the gate, so a rename cannot silently orphan its budget.
|
||||
*
|
||||
* Words are counted `wc -w` style over the whole file (whitespace-delimited
|
||||
* tokens, fenced code included) so a ceiling is reproducible with standard
|
||||
* tools. This is a checker, not a formatter: it reports and never rewrites.
|
||||
*
|
||||
* Run: `tsx scripts/verify-doc-budgets.ts` (or `--list` to print every
|
||||
* budgeted doc's current count vs ceiling without failing).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
const MANIFEST_PATH = resolve(root, 'scripts/doc-budgets.manifest.json')
|
||||
|
||||
/** `wc -w` equivalent: count whitespace-delimited tokens. */
|
||||
function countWords(text: string): number {
|
||||
return text.split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record<string, number>
|
||||
|
||||
const listOnly = process.argv.includes('--list')
|
||||
const failures: string[] = []
|
||||
const rows: string[] = []
|
||||
|
||||
for (const [path, ceiling] of Object.entries(manifest)) {
|
||||
if (!Number.isInteger(ceiling) || ceiling <= 0) {
|
||||
rows.push(`BAD ${'—'.padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`)
|
||||
failures.push(`${path}: ceiling must be a positive integer, got ${ceiling}`)
|
||||
continue
|
||||
}
|
||||
const abs = resolve(root, path)
|
||||
if (!existsSync(abs)) {
|
||||
rows.push(`MISS ${'—'.padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`)
|
||||
failures.push(`${path}: budgeted file does not exist (renamed or deleted? update scripts/doc-budgets.manifest.json in the same change)`)
|
||||
continue
|
||||
}
|
||||
const words = countWords(readFileSync(abs, 'utf8'))
|
||||
rows.push(`${words <= ceiling ? 'ok ' : 'OVER'} ${String(words).padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`)
|
||||
if (words > ceiling) {
|
||||
failures.push(`${path}: ${words} words exceeds the ${ceiling}-word ceiling — relocate or condense per docs/AGENTS.md (raising the ceiling requires justification in the PR)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (listOnly) {
|
||||
console.log(rows.join('\n'))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-doc-budgets failed:\n')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
console.error('\nSee docs/AGENTS.md for the documentation standard and the relocation-first rule.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-doc-budgets: ${Object.keys(manifest).length} budgeted docs within ceiling.`)
|
||||
@@ -48,6 +48,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
*/
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the repo's "Markdown is not hard-wrapped" convention
|
||||
* (AGENTS.md § Type Safety and Documentation) — prose paragraphs are written as
|
||||
* (docs/AGENTS.md § Writing rules) — prose paragraphs are written as
|
||||
* one physical line per paragraph and the editor soft-wraps. A hard-wrapped
|
||||
* paragraph (a one-word edit reflows and re-diffs the whole block) is a defect
|
||||
* this script catches before review.
|
||||
@@ -36,7 +36,7 @@ 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', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md']
|
||||
const PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md']
|
||||
|
||||
/** A located hard-wrap: a prose paragraph spanning more than one source line. */
|
||||
interface Violation {
|
||||
|
||||
108
scripts/verify-mermaid.ts
Normal file
108
scripts/verify-mermaid.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 { readFileSync, realpathSync } from 'node:fs'
|
||||
import { 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'
|
||||
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 await (const match of glob(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)
|
||||
@@ -1,155 +1,50 @@
|
||||
/**
|
||||
* 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 tables
|
||||
* ([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.
|
||||
*
|
||||
* Two checks:
|
||||
* Two checks (both 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).
|
||||
* 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. 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.
|
||||
* 2. FRESHNESS — the marker-delimited index regions in `docs/rfc/README.md`
|
||||
* byte-match 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.
|
||||
*
|
||||
* 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 { rfcRoot, spliceReadme, 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
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
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`)
|
||||
if (errors.length === 0) {
|
||||
try {
|
||||
if (spliceReadme(readme, rfcs) !== readme) {
|
||||
errors.push('index: docs/rfc/README.md is stale — run `pnpm run gen-rfc-index` and commit the result')
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`index: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Report -----------------------------------------------------------------
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
|
||||
process.exit(0)
|
||||
|
||||
335
scripts/verify-translation-pairing.ts
Normal file
335
scripts/verify-translation-pairing.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md).
|
||||
* English and Chinese carry EQUAL authority — either language may be authored
|
||||
* first — so consistency is recorded per pair in a sidecar metadata file,
|
||||
* `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last
|
||||
* time a human confirmed the two say the same thing:
|
||||
*
|
||||
* foo.md: <40-hex blob hash>
|
||||
* foo.zh.md: <40-hex blob hash>
|
||||
*
|
||||
* The gate checks, mechanically, the checkable half of the contract:
|
||||
*
|
||||
* 1. Every file in the manifest's `required` list has a COMPLETE pair
|
||||
* (the enforcement frontier — grows batch by batch).
|
||||
* 2. Every pair that exists at all is complete and consistent: all three
|
||||
* files present (a `.zh.md` or a `.i18n.yaml` without its counterparts
|
||||
* is an error — pairs merge whole, never half), each side's current
|
||||
* blob hash equals the recorded one (an edit to EITHER side without a
|
||||
* re-confirmed counterpart goes red), both sides carry the language
|
||||
* switcher, and the structural signatures match one to one — heading
|
||||
* depths in order, fenced code blocks VERBATIM (info string + content),
|
||||
* table column counts, list kinds, and every link target except the
|
||||
* switcher itself.
|
||||
* 3. `excluded` files (generated docs, agent instructions, the bilingual
|
||||
* terminology table) have no `.zh.md` and no `.i18n.yaml` at all.
|
||||
*
|
||||
* What it deliberately does NOT check is translation quality or which side
|
||||
* is "right": a green gate means the pair was confirmed consistent at these
|
||||
* exact contents, not that the confirmation was sound — accuracy,
|
||||
* terminology, and tone are the human reviewer's half of the contract
|
||||
* (docs/i18n/translation-rules.md).
|
||||
*
|
||||
* Blob hashes, not commit hashes, so a pair edited in the same PR verifies
|
||||
* without any history lookup: consistency is a pure content comparison,
|
||||
* computed here directly (sha1 of `blob <size>\0<content>`) without spawning
|
||||
* git. The recorded hash also recovers the last-confirmed text of either
|
||||
* side (`git cat-file -p <hash>`) for diff-based minimal updates.
|
||||
*
|
||||
* Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to
|
||||
* print the pairing state of every in-scope document as a work list (always
|
||||
* exits 0), or with `--write` to (re)record both hashes for every complete
|
||||
* pair after you have brought the two sides back in line (the resulting
|
||||
* yaml diff is the reviewable act of confirming consistency).
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, 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'
|
||||
import type { Nodes } from 'mdast'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const listMode = process.argv.includes('--list')
|
||||
const writeMode = process.argv.includes('--write')
|
||||
|
||||
/** Scope of the bilingual contract: the root README and the docs tree. */
|
||||
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml']
|
||||
|
||||
/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */
|
||||
interface Manifest {
|
||||
required: string[]
|
||||
excluded: string[]
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest
|
||||
|
||||
/**
|
||||
* An excluded entry ending in `/` excludes the whole directory. The trailing
|
||||
* slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a
|
||||
* sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the
|
||||
* manifest must keep their trailing slash.
|
||||
*/
|
||||
function isExcluded(file: string): boolean {
|
||||
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
|
||||
}
|
||||
|
||||
/** Full git blob hash (what `git hash-object` prints). */
|
||||
function blobHash(content: Buffer): string {
|
||||
const hash = createHash('sha1')
|
||||
hash.update(`blob ${content.byteLength}\0`)
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
/** The three paths of a pair, derived from the English-file path. */
|
||||
function pairPaths(source: string): { zh: string; meta: string } {
|
||||
return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
|
||||
}
|
||||
|
||||
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
|
||||
|
||||
/** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */
|
||||
function parseMeta(content: string): Map<string, string> | undefined {
|
||||
const out = new Map<string, string>()
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '' || line.startsWith('#')) continue
|
||||
const match = META_LINE.exec(line)
|
||||
if (!match?.[1] || !match[2]) return undefined
|
||||
out.set(match[1], match[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Render a `foo.i18n.yaml` consistency record. */
|
||||
function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
|
||||
return [
|
||||
'# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
|
||||
'# side as of the last confirmed-consistent state. Both languages carry equal authority;',
|
||||
'# after editing either side, bring the other along and re-record with:',
|
||||
'# pnpm run verify-translation-pairing --write',
|
||||
`${basename(source)}: ${sourceHash}`,
|
||||
`${basename(zh)}: ${zhHash}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The structural signature the two sides must share, as ordered sequences so
|
||||
* a swap or a level change is caught, not just a count change. Prose is
|
||||
* deliberately absent: the gate checks shape, never wording.
|
||||
*/
|
||||
interface Signature {
|
||||
/** Heading depths in document order (h2 → 2). */
|
||||
headings: number[]
|
||||
/** Fenced code blocks verbatim: info string + content, in order. */
|
||||
code: string[]
|
||||
/** Column count of each table, in order. */
|
||||
tables: number[]
|
||||
/** Each list's kind (ordered vs bullet), in order. */
|
||||
lists: string[]
|
||||
/** Every link target in order, the language switcher's excluded. */
|
||||
links: string[]
|
||||
}
|
||||
|
||||
/** Whether the tree contains a link to exactly `target` (the switcher check). */
|
||||
function linksTo(tree: Nodes, target: string): boolean {
|
||||
let found = false
|
||||
const visit = (node: Nodes): void => {
|
||||
if (node.type === 'link' && node.url === target) found = true
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Collect the structural signature, skipping links to `switcherTarget`. */
|
||||
function signatureOf(tree: Nodes, switcherTarget: string): Signature {
|
||||
const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
||||
const visit = (node: Nodes): void => {
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
sig.headings.push(node.depth)
|
||||
break
|
||||
case 'code':
|
||||
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
|
||||
break
|
||||
case 'table':
|
||||
sig.tables.push(node.children[0]?.children.length ?? 0)
|
||||
break
|
||||
case 'list':
|
||||
sig.lists.push(node.ordered ? 'ordered' : 'bullet')
|
||||
break
|
||||
case 'link':
|
||||
if (node.url !== switcherTarget) sig.links.push(node.url)
|
||||
break
|
||||
default:
|
||||
// Every other node kind is prose or container — not part of the signature.
|
||||
break
|
||||
}
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return sig
|
||||
}
|
||||
|
||||
/** Render a signature element for an error message, truncated for readability. */
|
||||
function show(value: string | number | undefined): string {
|
||||
if (value === undefined) return 'nothing'
|
||||
const text = JSON.stringify(value)
|
||||
return text.length > 72 ? `${text.slice(0, 72)}…` : text
|
||||
}
|
||||
|
||||
/** First divergence between two signatures, as messages; empty when identical. */
|
||||
function signatureDiff(source: Signature, zh: Signature): string[] {
|
||||
const out: string[] = []
|
||||
const fields: [string, (string | number)[], (string | number)[]][] = [
|
||||
['heading (depth)', source.headings, zh.headings],
|
||||
['code block', source.code, zh.code],
|
||||
['table (column count)', source.tables, zh.tables],
|
||||
['list (kind)', source.lists, zh.lists],
|
||||
['link target', source.links, zh.links],
|
||||
]
|
||||
for (const [field, s, z] of fields) {
|
||||
const length = Math.max(s.length, z.length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (s[i] !== z[i]) {
|
||||
out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parse(content: string): Nodes {
|
||||
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
|
||||
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
|
||||
const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
|
||||
|
||||
// --write: (re)record both hashes for every complete pair, creating missing records.
|
||||
if (writeMode) {
|
||||
let written = 0
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
const { zh, meta } = pairPaths(source)
|
||||
if (!existsSync(join(root, zh))) continue
|
||||
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
|
||||
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
|
||||
writeFileSync(join(root, meta), record)
|
||||
console.log(`verify-translation-pairing: recorded ${meta}`)
|
||||
written++
|
||||
}
|
||||
console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
|
||||
|
||||
// 1. Required pairs exist.
|
||||
for (const req of manifest.required) {
|
||||
if (!existsSync(join(root, req))) {
|
||||
errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`)
|
||||
continue
|
||||
}
|
||||
const { zh } = pairPaths(req)
|
||||
if (!existsSync(join(root, zh))) {
|
||||
errors.push(`${req}: required to have a translation, but ${zh} does not exist`)
|
||||
state.set(req, 'missing')
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Every pair that exists at all is complete and consistent. Anchor on the
|
||||
// union of .zh.md files and .i18n.yaml records so a half-deleted pair is
|
||||
// caught from either remnant.
|
||||
const pairAnchors = new Set<string>()
|
||||
for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
|
||||
for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
|
||||
|
||||
for (const source of [...pairAnchors].sort()) {
|
||||
const { zh, meta } = pairPaths(source)
|
||||
const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
|
||||
|
||||
if (isExcluded(source)) {
|
||||
if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
|
||||
if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`)
|
||||
continue
|
||||
}
|
||||
const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta))
|
||||
if (missing.length > 0) {
|
||||
errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`)
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceContent = readFileSync(join(root, source))
|
||||
const zhContent = readFileSync(join(root, zh))
|
||||
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
|
||||
if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
|
||||
errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
|
||||
continue
|
||||
}
|
||||
|
||||
let consistent = true
|
||||
for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
|
||||
const current = blobHash(content)
|
||||
if (record.get(basename(file)) !== current) {
|
||||
errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
|
||||
consistent = false
|
||||
}
|
||||
}
|
||||
if (!consistent) {
|
||||
state.set(source, 'out-of-sync')
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceTree = parse(sourceContent.toString('utf8'))
|
||||
const zhTree = parse(zhContent.toString('utf8'))
|
||||
if (!linksTo(zhTree, basename(source))) {
|
||||
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
|
||||
}
|
||||
if (!linksTo(sourceTree, basename(zh))) {
|
||||
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
|
||||
}
|
||||
for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) {
|
||||
errors.push(`${source} ↔ ${zh}: ${divergence}`)
|
||||
}
|
||||
if (!state.has(source)) state.set(source, 'ok')
|
||||
}
|
||||
|
||||
// Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog.
|
||||
for (const source of sources) {
|
||||
if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing')
|
||||
}
|
||||
|
||||
if (listMode) {
|
||||
const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
|
||||
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
|
||||
for (const [file, status] of rows) {
|
||||
const required = manifest.required.includes(file)
|
||||
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`)
|
||||
}
|
||||
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
|
||||
for (const status of state.values()) counts[status]++
|
||||
console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):')
|
||||
for (const message of errors) console.error(` ${message}`)
|
||||
process.exit(1)
|
||||
Reference in New Issue
Block a user