Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check

This commit is contained in:
imccyu
2026-06-22 00:35:51 +08:00
365 changed files with 13601 additions and 7241 deletions

View File

@@ -5,11 +5,16 @@
* Run: `tsx scripts/check-workspace-constraints.ts`.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const workspaceGlobs = ['vendor', 'packages'] as const
// vendor/* is single-level; packages/<group>/<pkg> nests one level deeper
// (the group dirs — core/llm/bash/… — are pure containers with no manifest).
const workspaceGlobs = [
{ dir: 'vendor', depth: 1 },
{ dir: 'packages', depth: 2 },
] as const
const vendoredPackages = new Set([
'cordis',
'cosmokit',
@@ -51,15 +56,25 @@ function readJson(path: string): PackageManifest {
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
}
/** Repo-relative dirs holding a package.json, walked to the configured depth. */
function packageDirs(base: string, depth: number): string[] {
if (depth === 1) {
return readdirSync(join(root, base), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => join(base, entry.name))
}
return readdirSync(join(root, base), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.flatMap(group => packageDirs(join(base, group.name), depth - 1))
}
function workspaceManifests(): WorkspaceManifest[] {
const manifests: WorkspaceManifest[] = [
{ dir: '.', manifest: readJson(join(root, 'package.json')) },
]
for (const workspaceDir of workspaceGlobs) {
for (const entry of readdirSync(join(root, workspaceDir), { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const dir = join(workspaceDir, entry.name)
for (const { dir: base, depth } of workspaceGlobs) {
for (const dir of packageDirs(base, depth)) {
manifests.push({ dir, manifest: readJson(join(root, dir, 'package.json')) })
}
}
@@ -125,7 +140,37 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
return errors.map(error => `${relative(root, join(root, dir, 'package.json'))}: ${error}`)
}
const errors = workspaceManifests().flatMap(checkWorkspace)
/**
* Enforce the packages/ hierarchy SHAPE: every package lives at exactly
* `packages/<group>/<pkg>`. A group dir is a pure container — it holds packages,
* never sources of its own — so it must NOT carry a package.json, and a package
* must NOT sit directly at the `packages/` root (the old flat layout) nor nest a
* level deeper. The group NAMES are open on purpose: a new group may be added
* without touching this gate, but the depth-2 shape is fixed. This is what keeps
* a stray flat package or an over-nested one from regressing the hierarchy.
*/
function checkHierarchyShape(): string[] {
const errors: string[] = []
const packagesRoot = join(root, 'packages')
for (const group of readdirSync(packagesRoot, { withFileTypes: true })) {
if (!group.isDirectory()) continue
const groupRel = join('packages', group.name)
if (existsSync(join(packagesRoot, group.name, 'package.json'))) {
errors.push(`${groupRel}: a group dir must not contain a package.json — packages live at packages/<group>/<pkg>, not directly under packages/`)
continue
}
for (const pkg of readdirSync(join(packagesRoot, group.name), { withFileTypes: true })) {
if (!pkg.isDirectory()) 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`)
}
}
}
return errors
}
const errors = [...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape()]
if (errors.length > 0) {
console.error(errors.join('\n'))
process.exitCode = 1

View File

@@ -8,7 +8,14 @@
* typecheck. A block that is a deliberate sketch rather than compilable code
* opts out with an explicit ` ```ts ignore-check ` info string — the opt-out
* is visible in the source, and this script reports the ratio so the escape
* hatch can't quietly become the norm.
* 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
* generated event/service signature fragment in the cordis catalog (a bare
* signature is not standalone-compilable; the catalog is generated and frozen by
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate).
*
* Run: `tsx scripts/doc-typecheck.ts`.
*/
@@ -17,33 +24,44 @@ import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
/**
* How a fenced block participates in this gate:
* - `check` (` ```ts `) — compiled.
* - `ignore` (` ```ts ignore-check `) — a deliberate sketch; skipped, and
* counted in the opt-out ratio so the escape hatch can't quietly take over.
* - `type-equiv` (` ```ts type-equiv `) — a verbatim paste of a source type
* definition, drift-checked by `scripts/verify-type-equiv.ts` against the
* source symbol. Skipped HERE (it is not standalone-compilable — no imports)
* and EXCLUDED from the opt-out ratio: it is a separate fully-checked
* category, not an unchecked sketch.
* - `cordis-catalog` (` ```ts cordis-catalog `) — a generated event/service
* signature fragment in the cordis catalog. Skipped HERE for the same reason
* (a bare signature fragment has no imports and does not stand alone) and
* EXCLUDED from the opt-out ratio: the catalog is generated and frozen by
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate.
*/
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog'
/** One extracted code block. */
interface Block {
file: string
/** 1-based line of the opening fence. */
line: number
/** `true` when the fence is ` ```ts ignore-check ` (skip compilation). */
ignored: boolean
kind: BlockKind
code: string
}
/** Strip JSONC comments from checked-in tsconfig files before JSON.parse. */
function stripJsonComments(raw: string): string {
return raw
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1')
}
/** Extract every ```ts / ```ts ignore-check block from one Markdown file. */
/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog block from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const text = readFileSync(absPath, 'utf8')
const lines = text.split('\n')
const file = relative(root, absPath)
const blocks: Block[] = []
let open: { line: number; ignored: boolean; body: string[] } | null = null
let open: { line: number; kind: BlockKind; body: string[] } | null = null
lines.forEach((raw, i) => {
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
@@ -53,23 +71,35 @@ function extractBlocks(absPath: string): Block[] {
}
if (open) {
// closing fence
blocks.push({ file, line: open.line, ignored: open.ignored, code: open.body.join('\n') })
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
open = null
return
}
// opening fence — only care about ts blocks
const info = (fence[2] ?? '').trim()
if (info === 'ts' || info === 'ts ignore-check') {
open = { line: i + 1, ignored: info === 'ts ignore-check', body: [] }
}
const kind: BlockKind | null =
info === 'ts' ? 'check'
: info === 'ts ignore-check' ? 'ignore'
: info === 'ts type-equiv' ? 'type-equiv'
: info === 'ts cordis-catalog' ? 'cordis-catalog'
: null
if (kind) open = { line: i + 1, kind, body: [] }
})
return blocks
}
/** Reuse the repo typecheck graph references from a temp project one directory below root. */
function workspaceReferences(): { path: string }[] {
const raw = readFileSync(join(root, 'tsconfig.json'), 'utf8')
const { references } = JSON.parse(stripJsonComments(raw)) as { references: { path: string }[] }
const file = join(root, 'tsconfig.json')
// Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip:
// a regex strip mistakes the `/*/` in a wildcard path candidate
// (`./packages/core/*/src`) for a block comment and corrupts the map.
const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8'))
if (result.error) {
throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`)
}
// `config` is typed `any` by the TS API; narrow it to the one field we read.
const { references } = result.config as { compilerOptions: { paths: Record<string, string[]> }; references: { path: string }[] }
return references.map(({ path }) => {
const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`
return { path: relativeToTemp }
@@ -90,7 +120,7 @@ function tempTsconfig(): string {
})
}
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/README.md']
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
const files: string[] = []
for (const pattern of markdownGlobs) {
@@ -99,8 +129,14 @@ for (const pattern of markdownGlobs) {
files.sort()
const all = files.flatMap(extractBlocks)
const checked = all.filter(b => !b.ignored)
const ignored = all.filter(b => b.ignored)
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.
const ratioDenominator = checked.length + ignored.length
if (checked.length === 0) {
console.log('doc-typecheck: no ts code blocks to check.')
@@ -133,11 +169,12 @@ try {
process.exit(1)
}
const ratio = ignored.length / all.length
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out).`)
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).`)
// Guard against the escape hatch becoming the norm.
if (all.length >= 4 && ratio > 0.5) {
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${all.length}). Make them compile or delete them.`)
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.`)
process.exit(1)
}
} finally {

View File

@@ -0,0 +1,463 @@
/**
* Generate (and verify) the cordis events + services catalog in
* docs/cordis-catalog/events-and-services.md.
*
* The catalog is the WIRING-axis reference: every cordis event a plugin can
* listen to (exact signature + dispatch mode) and every `ctx.<key>` service it
* can call (exact public interface). It complements the core-data-structures
* catalog (the VOCABULARY axis — the types these signatures move around).
*
* 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`.
*
* `tsx scripts/gen-cordis-catalog.ts` → write the catalog
* `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if the committed file
* is stale (CI / pre-push gate)
*
* The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in
* full from source: signature, the `@mode` badge, and the declaration's JSDoc.
* Every harness event MUST carry an `@mode emit|waterfall|parallel` tag — the
* generator hard-errors on a missing tag, and where the signature shape is
* conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
* it asserts the tag agrees and hard-errors on a contradiction. The INHERITED
* tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
* also sees; it is rendered tersely (name + one-line + source pointer) from a
* curated table in this script, NOT elevated to the harness tier's prominence.
*
* Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck
* recognizes it and skips compilation (the signatures are fragments, not
* standalone-compilable, like the ` ```ts type-equiv ` blocks).
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/cordis-catalog/events-and-services.md'
/** The fenced-block info string for generated signature blocks (skipped by
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
const FENCE = 'ts cordis-catalog'
/** A dispatch mode, rendered as the badge after an event name. */
type Mode = 'emit' | 'waterfall' | 'parallel'
/**
* Cross-link map: a type name that appears in a signature → the
* core-data-structures page that documents it (path relative to OUT's folder).
* Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json —
* that manifest documents the `…Map` symbols (`ContentBlockMap`) while
* signatures reference the derived UNION names (`ContentBlock`), and it lists a
* few symbols on two pages. Here each name resolves to exactly one PRIMARY page.
*/
const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
ContentBlock: 'core.md',
Message: 'core.md',
MessageSource: 'core.md',
GenerateOptions: 'core.md',
SessionEvent: 'core.md',
StreamChunk: 'llm-streaming.md',
TurnEndReason: 'session.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolExecutionResult: 'tools.md',
BashExecRequest: 'bash.md',
BashExecSpec: 'bash.md',
BashRunResult: 'bash.md',
BashTask: 'bash.md',
BashTaskRead: 'bash.md',
}
/** One harness event, extracted from an `interface Events` block. */
interface EventEntry {
/** Scoped name, e.g. `agent/request`. */
name: string
/** The scope prefix, e.g. `agent` (everything before the first `/`). */
scope: string
/** Full signature text (the method-signature member, JSDoc stripped). */
signature: string
/** Dispatch mode from the `@mode` tag. */
mode: Mode
/** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
/** One harness service, extracted from an `interface Context` block. */
interface ServiceEntry {
/** The `ctx.<key>` name, e.g. `llm`. */
key: string
/** The service class/interface name, e.g. `LlmService`. */
type: string
/** Whether the service class is abstract (a seam interface). */
abstract: boolean
/** Class-level JSDoc prose, one line per paragraph. */
doc: string
/** Public method signatures (bodies stripped), in source order. */
methods: string[]
/** Source pointer of the class declaration. */
source: string
}
/** A terse inherited-tier entry (pinned vendor surface). */
interface InheritedEntry {
name: string
summary: string
/** Source pointer `vendor/…:line`. */
source: string
}
/** Repo-relative source pointer `file:line` for a node's first character. */
function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
return `${rel}:${line + 1}`
}
/** The raw `/** … */` JSDoc block immediately preceding a node, or '' if none. */
function rawJsDoc(text: string, node: ts.Node): string {
const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
}
/**
* Parse a raw JSDoc block into description prose + the `@mode` tag (when
* present). Output obeys the repo's markdown conventions so the generated file
* passes verify-md-wrap: each prose paragraph collapses to ONE physical line,
* and a `-` bullet list is preserved with each item on its own single line
* (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines
* other than `@mode` end the current prose run.
*/
function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
const inner = raw
.replace(/^\/\*\*/, '')
.replace(/\*\/$/, '')
.split('\n')
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
let mode: Mode | null = null
const blocks: string[] = []
let para: string[] = []
let list: string[] = []
let item: string[] = []
const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
const flushItem = (): void => {
if (item.length) list.push(join(item))
item = []
}
const flushList = (): void => {
flushItem()
if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
list = []
}
const flushPara = (): void => {
flushList()
if (para.length) blocks.push(join(para))
para = []
}
for (const line of inner) {
const m = /^@mode\s+(emit|waterfall|parallel)\s*$/.exec(line)
if (m) { mode = m[1] as Mode; continue }
if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose
if (line.trim() === '') { flushPara(); continue }
if (/^-\s+/.test(line)) {
// A list item starts: a pending paragraph (e.g. an intro line directly
// above the list, no blank between) flushes FIRST so it renders above.
flushItem()
if (para.length) { blocks.push(join(para)); para = [] }
item.push(line)
continue
}
if (item.length) { item.push(line); continue } // continuation of current item
para.push(line)
}
flushPara()
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
return { doc, mode }
}
/** Find the `declare module 'cordis'` body in a source file, or null. */
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
for (const stmt of sf.statements) {
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
}
}
return null
}
/** The signature text of a method-signature member (everything but a body). */
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
const full = member.getText(sf)
const body = (member as { body?: ts.Node }).body
const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
/** Walk every harness `interface Events` block and extract its events.
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
export function collectEvents(scanRoot: string = root): EventEntry[] {
const entries: EventEntry[] = []
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('interface Events')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!ts.isMethodSignature(member)) continue
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
const signature = memberSignature(member, sf)
const { doc, mode } = parseJsDoc(rawJsDoc(text, member))
const src = pointer(rel, sf, member)
if (!mode) {
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel is not structurally distinguishable, so
// it is trusted from the tag.)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
if (hasNext && mode !== 'waterfall') {
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
}
if (!hasNext && mode === 'waterfall') {
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
}
}
}
return entries
}
/** Walk every harness `interface Context` block + its service class.
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
export function collectServices(scanRoot: string = root): ServiceEntry[] {
const entries: ServiceEntry[] = []
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('interface Context')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
// The ctx key → type mapping(s) declared in this file's interface Context.
const keyToType = new Map<string, string>()
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
for (const member of stmt.members) {
if (!ts.isPropertySignature(member) || !member.type) continue
const key = member.name.getText(sf)
keyToType.set(key, member.type.getText(sf))
}
}
if (keyToType.size === 0) continue
// Find each service class declared in the same file and emit an entry.
for (const [key, type] of keyToType) {
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
)
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
const methods: string[] = []
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
// Only the PUBLIC callable surface a `ctx.<key>` consumer sees. Drop
// private/protected (a protected method like `notifyTaskDone` is a
// subclass hook, not something a plugin calls through `ctx.bash`) and
// static (not reachable through the instance).
const nonPublic = member.modifiers?.some(m =>
m.kind === ts.SyntaxKind.PrivateKeyword
|| m.kind === ts.SyntaxKind.ProtectedKeyword
|| m.kind === ts.SyntaxKind.StaticKeyword)
|| ts.isPrivateIdentifier(member.name)
if (nonPublic) continue
const memberName = member.name.getText(sf)
if (memberName.startsWith('[')) continue // computed/symbol members
methods.push(memberSignature(member, sf))
}
entries.push({
key,
type,
abstract,
doc: parseJsDoc(rawJsDoc(text, cls)).doc,
methods,
source: pointer(rel, sf, cls),
})
}
}
return entries.sort((a, b) => a.key.localeCompare(b.key))
}
/**
* The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and
* hand-summarized because (a) it is pinned vendor source that changes only on a
* deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members
* with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would
* wrongly surface as services, and (c) the internal/* events carry no JSDoc to
* render. Source pointers are verified against vendor by `verify-md-links`'
* sibling check is N/A; keep them current on a vendor bump.
*/
const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
]
const INHERITED_SERVICES: InheritedEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-non-nullish / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
]
/** Render the cross-link "Types:" line for a signature, or '' if none apply. */
function typeLinks(signature: string): string {
const seen = new Set<string>()
for (const name of Object.keys(LINK_MAP)) {
if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name)
}
if (seen.size === 0) return ''
const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`)
return `Types: ${links.join(' · ')}`
}
/** Render one harness event entry. */
function renderEvent(e: EventEntry): string[] {
const out = [`#### \`${e.name}\`${e.mode}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, e.signature, '```', '')
const links = typeLinks(e.signature)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
return out
}
/** Render one harness service entry. */
function renderService(s: ServiceEntry): string[] {
const kind = s.abstract ? ' (abstract seam)' : ''
const out = [`### \`ctx.${s.key}\`\`${s.type}\`${kind}`, '']
if (s.doc) out.push(s.doc, '')
if (s.methods.length) {
out.push('```' + FENCE, ...s.methods, '```', '')
const links = typeLinks(s.methods.join('\n'))
if (links) out.push(links, '')
}
out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
return out
}
/** Render the full catalog (pure, deterministic given sorted inputs). */
function render(events: EventEntry[], services: ServiceEntry[]): string {
const lines: string[] = [
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
'',
'# Cordis Events & Services Catalog',
'',
'An index reference to the **wiring** a plugin author works against: every cordis event you can listen to (exact signature + dispatch mode) and every `ctx.<key>` service you can call (exact public interface). It complements [core-data-structures/](../core-data-structures/core.md), which catalogs the *data structures* these signatures move around — this page is the verbs, that page is the nouns.',
'',
'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.',
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer surface a plugin also sees — pinned vendor source, summarized tersely.',
'',
'## Events',
'',
`Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets \`next()\` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`,
'',
]
const scopes = [...new Set(events.map(e => e.scope))].sort()
for (const scope of scopes) {
lines.push(`### \`${scope}/*\``, '')
for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
lines.push(...renderEvent(e))
}
}
lines.push(
'## Services',
'',
`The ${services.length} \`ctx.<key>\` services the harness provides. An abstract seam (e.g. \`ctx.bash\`) is implemented by a separate package; the interface is what consumers code against.`,
'',
)
for (const s of services) lines.push(...renderService(s))
lines.push(
'## Inherited tier (cordis core + loader/hmr/timer)',
'',
'The framework surface every plugin inherits, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the catalog is a complete picture of what `ctx` and the event bus offer, without elevating framework internals to the harness tier\'s prominence.',
'',
'### Inherited events',
'',
)
for (const e of INHERITED_EVENTS) {
lines.push(`- \`${e.name}\`${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
}
lines.push('', '### Inherited `ctx` members', '')
for (const s of INHERITED_SERVICES) {
lines.push(`- \`${s.name}\`${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
}
lines.push('')
return lines.join('\n')
}
/** CLI entry: `--write` (default) writes the catalog, `--check` fails if stale.
* Guarded behind an entry-point check so importing this module for tests neither
* regenerates the committed file nor calls process.exit. */
function main(): void {
const content = render(collectEvents(), collectServices())
if (process.argv.includes('--check')) {
let committed: string | null = null
try {
committed = readFileSync(resolve(root, OUT), 'utf8')
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
// same — regenerate — so treat a read failure as "stale".
committed = null
}
if (committed === content) {
console.log(`gen-cordis-catalog: ${OUT} is up to date.`)
process.exit(0)
}
console.error(`gen-cordis-catalog: ${OUT} is stale. Run \`pnpm run gen-cordis-catalog\` and commit ${OUT}.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-cordis-catalog: wrote ${OUT}.`)
}
// Run only when invoked as a script, not when imported by a test.
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}

View File

@@ -4,7 +4,7 @@
* The architectural shape of the harness lives implicitly in each package's
* `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror
* these as `workspace:^` plus test-only extras, which would add noise). This
* script reads every `packages/* /package.json`, keeps only the
* 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.
*
@@ -34,7 +34,7 @@ interface Pkg {
/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
function collect(): Pkg[] {
const pkgs: Pkg[] = []
for (const rel of globSync('packages/*/package.json', { cwd: root })) {
for (const rel of globSync('packages/*/*/package.json', { cwd: root })) {
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
name: string
peerDependencies?: Record<string, string>

View File

@@ -1,30 +1,22 @@
import { execFileSync } from 'node:child_process'
import { readdirSync } from 'node:fs'
import { resolve } from 'node:path'
// publint every publishable package (vendor/ is private upstream code and
// examples/ are not packages; both are out of scope).
const packages = [
'packages/llm',
'packages/session',
'packages/session-persistence',
'packages/session-persistence-jsonl',
'packages/session-persistence-sqlite',
'packages/system-prompt',
'packages/tools',
'packages/agent',
'packages/agent-loop',
'packages/bash',
'packages/llm-deepseek',
'packages/llm-pi-ai',
'packages/bash-local',
'packages/tool-bash',
'packages/invariants',
'packages/acp',
'packages/ui-stdio',
'packages/llm-replay',
]
// publint every harness package. Packages live at packages/<group>/<pkg>
// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private
// upstream code and examples/ are not packages, both out of scope. Derived
// from the hierarchy so a new package needs no edit here.
const root = resolve(import.meta.dirname, '..')
const packagesRoot = resolve(root, 'packages')
const packages = readdirSync(packagesRoot, { withFileTypes: true })
.filter(group => group.isDirectory())
.flatMap(group =>
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
.filter(pkg => pkg.isDirectory())
.map(pkg => `packages/${group.name}/${pkg.name}`),
)
for (const path of packages) {
execFileSync('node_modules/.bin/publint', [path], { cwd: root, stdio: 'inherit' })
}

View File

@@ -0,0 +1,40 @@
{
"comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.",
"entries": [
{ "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "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/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/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
{ "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/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }
]
}

View File

@@ -0,0 +1,96 @@
/**
* Doc-sync gate: verify that doc references written in TypeScript COMMENTS
* resolve to a file that exists. Source comments cite docs by root-relative
* prose path — `see docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`,
* `docs/architecture.md § plugin checklist`. `verify-md-links` parses Markdown
* link AST and never sees these, so a doc rename or move could silently orphan
* a `.ts` comment that points at it. The RFC classification reorg
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
* is the motivating case: it moved every RFC under a `{class}/` folder, and
* several `.ts` doc comments cite RFC paths that changed.
*
* Detection is a token scan, NOT an AST walk: doc refs live in free prose inside
* comments, not in a structured form. We match `docs/<path>.md` tokens and
* REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`,
* `docs/architecture.md § plugin checklist` — the section suffix is outside the
* token) is left alone rather than misread as a path. Each token is resolved
* ROOT-RELATIVE (the way the comments are written) and must exist on disk. This
* is checker, not fixer: it reports and never rewrites.
*
* Scope is repo-authored TypeScript under `packages/**` and `examples/**`,
* excluding built output (`lib/`, `*.d.ts`) and `vendor/` (pinned upstream
* source we do not own). The scan is purely textual, so it does not distinguish
* a token in a comment from one in a string literal — a `docs/….md` string in
* code is checked too, which is harmless (such a path should resolve anyway).
*
* Run: `tsx scripts/verify-doc-refs.ts`.
*/
import { existsSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
/** Repo-authored TypeScript that may cite docs in comments. */
const PATTERNS = ['packages/**/*.ts', 'examples/**/*.ts']
/** Paths excluded from the scan: built output and vendored upstream source. */
const isExcluded = (p: string): boolean =>
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
/**
* Match a `docs/…​.md` reference token. The `.md` extension is required so a
* bare `docs/postmortem/0001` (no extension) does not register as a path. The
* character class stops at whitespace, backticks, parens, and the section sign,
* so trailing prose (`… .md § plugin checklist`) is not swallowed into the path.
*/
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
/** A broken doc reference: a root-relative `docs/….md` token with no file. */
interface Violation {
file: string
/** 1-based line where the reference appears. */
line: number
ref: string
}
/** Find every broken `docs/….md` reference in one TypeScript file. */
function findViolations(absPath: string): Violation[] {
const file = relative(root, absPath)
const source = readFileSync(absPath, 'utf8')
const out: Violation[] = []
const lines = source.split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line === undefined) continue
for (const m of line.matchAll(DOC_REF)) {
const ref = m[0]
if (!existsSync(resolve(root, ref))) {
out.push({ file, line: i + 1, ref })
}
}
}
return out
}
const all: Violation[] = []
let checked = 0
for (const pattern of PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) {
if (isExcluded(match)) continue
checked++
all.push(...findViolations(resolve(root, match)))
}
}
if (all.length === 0) {
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
process.exit(0)
}
console.error('verify-doc-refs: broken docs/*.md references found in source comments (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.ref}`)
}
process.exit(1)

View File

@@ -1,114 +0,0 @@
/**
* Doc-sync gate (doc-sync-enforcement RFC, part 2): verify the event-taxonomy table in
* docs/architecture.md against the events actually declared in source.
*
* The table duplicates the `declare module 'cordis' { interface Events }`
* blocks across packages/* /src. This script extracts both sets of event names
* and asserts they match exactly — every declared event appears in the table,
* and the table names no event that isn't declared. Verify, don't generate
* (per the RFC): the table keeps its hand-written Mode/Purpose columns; only
* the set of names is checked.
*
* Run: `tsx scripts/verify-event-taxonomy.ts`.
*/
import { readFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
/**
* Remove `/* */` block comments and `//` line comments from TS source. Used to
* de-risk the brace walk in {@link declaredEvents} — a JSDoc `{@link}` tag would
* otherwise throw off the `{`/`}` depth counter. Good enough for our own source
* (no string literals contain `//` or comment-like brace sequences in an Events
* block); it is not a general tokenizer.
*/
function stripComments(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1')
}
/**
* Event names declared in source: the keys inside every `interface Events`
* block under packages/* /src. A declared event is a quoted `'scope/name'(`
* method signature at the start of a line within such a block.
*/
async function declaredEvents(): Promise<Map<string, string>> {
const found = new Map<string, string>()
for await (const match of glob('packages/*/src/**/*.ts', { cwd: root })) {
const abs = resolve(root, match)
// Strip comments first so a JSDoc `{@link …}` tag (or a `// {` line) inside
// an Events block can't unbalance the brace walk below. Event names live in
// code, never in comments, so this loses nothing.
const text = stripComments(readFileSync(abs, 'utf8'))
// Walk `interface Events {` blocks brace-balanced and pull quoted keys.
const re = /interface\s+Events\s*\{/g
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
let depth = 1
let i = m.index + m[0].length
const start = i
while (i < text.length && depth > 0) {
const ch = text[i]
if (ch === '{') depth++
else if (ch === '}') depth--
i++
}
const body = text.slice(start, i - 1)
// A declaration is a quoted event name followed by `(` (method form).
for (const k of body.matchAll(/['"]([a-z][a-z-]*\/[a-z-]+)['"]\s*\(/g)) {
const name = k[1]
if (name) found.set(name, relative(root, abs))
}
}
}
return found
}
/** Event names referenced in the architecture-doc taxonomy table (in `code`). */
function tableEvents(): Set<string> {
const text = readFileSync(join(root, 'docs/architecture.md'), 'utf8')
const lines = text.split('\n')
const heading = lines.findIndex(l => /^###\s+Event taxonomy/.test(l))
if (heading === -1) throw new Error('verify-event-taxonomy: "### Event taxonomy" heading not found')
const names = new Set<string>()
for (let i = heading + 1; i < lines.length; i++) {
const line = lines[i] ?? ''
if (/^###\s/.test(line)) break // next section ends the table
if (!line.includes('|')) continue
for (const code of line.matchAll(/`([^`]+)`/g)) {
// A cell may read "`a/b` / `c/d` (pkg)" — pull each scoped name.
for (const name of (code[1] ?? '').matchAll(/[a-z][a-z-]*\/[a-z-]+/g)) names.add(name[0])
}
}
return names
}
const declared = await declaredEvents()
const table = tableEvents()
const declaredNames = new Set(declared.keys())
const missingFromTable = [...declaredNames].filter(n => !table.has(n)).sort()
const missingFromSource = [...table].filter(n => !declaredNames.has(n)).sort()
if (missingFromTable.length === 0 && missingFromSource.length === 0) {
console.log(`verify-event-taxonomy: ${declaredNames.size} events match the architecture-doc table.`)
process.exit(0)
}
if (missingFromTable.length > 0) {
console.error('verify-event-taxonomy: declared in source but MISSING from the docs/architecture.md table:')
for (const n of missingFromTable) {
console.error(` ${n} (declared in ${declared.get(n) ?? '?'})`)
}
}
if (missingFromSource.length > 0) {
console.error('verify-event-taxonomy: named in the table but NOT declared in source (stale doc):')
for (const n of missingFromSource) {
console.error(` ${n}`)
}
}
process.exit(1)

View File

@@ -48,7 +48,8 @@ const root = resolve(import.meta.dirname, '..')
const PATTERNS = [
'README.md',
'docs/**/*.md',
'packages/*/README.md',
'packages/*/*.md',
'packages/*/*/*.md',
'AGENTS.md',
'packages/AGENTS.md',
'.agents/skills/**/*.md',

View File

@@ -18,7 +18,7 @@
* A wrapped paragraph inside a list item or blockquote is still a `paragraph`
* node, so those are caught too. Scope mirrors doc-typecheck plus the two
* AGENTS.md files that doc-sync does NOT otherwise cover (the convention itself
* lives there): README.md, docs/** /*.md, packages/* /README.md, AGENTS.md,
* lives there): README.md, docs/** /*.md, packages/* /*.md, AGENTS.md,
* packages/AGENTS.md. The root and packages/ CLAUDE.md are symlinks to the
* AGENTS.md files, so they are deduped by real path.
*
@@ -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/*/README.md', 'AGENTS.md', 'packages/AGENTS.md']
const PATTERNS = ['README.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 {

View File

@@ -0,0 +1,167 @@
/**
* Doc-sync gate: catch DRIFTED `packages/<path>` references — a path to a
* package that has MOVED, written as prose in Markdown or in a TypeScript
* comment/string. Docs and comments cite package locations by root-relative
* path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`);
* `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs`
* only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick
* prose or a code comment goes unchecked. The package-hierarchy reorg is the
* motivating case: it moved every package under a `{group}/` folder, so a stale
* `packages/tools` (now `packages/core/tools`) reads fine to a human but points
* at nothing.
*
* The check is drift-scoped, NOT a blanket existence test: a broken
* `packages/<path>` token is a violation ONLY when one of its path segments is
* the directory name of a package that actually exists on disk — i.e. the
* package is real and the path is merely stale. A token naming a package that
* exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an
* illustrative `packages/<name>/` skeleton) is left alone: this gate reports
* MOVED paths, not hypothetical or future ones, so it applies uniformly to
* proposed/implemented/rejected docs without per-lifecycle exclusions. This is
* checker, not fixer: it reports and never rewrites.
*
* Detection is a token scan, NOT an AST walk: package refs live in free prose,
* backticks, and comments. We match `packages/<path>` tokens whose path is made
* of plain path characters, so a glob, a `<placeholder>`, or a `{brace,expansion}`
* terminates the match before those chars and is never probed.
*
* Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown
* across README/docs/packages/AGENTS, and `.ts` under packages/** and
* examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source).
* A reference to a package's build OUTPUT (`packages/<group>/<pkg>/lib/…`,
* e.g. `packages/ui/acp-agent/lib/bin.js` cited by a built-bin smoke) is also
* skipped — it is emitted only by `pnpm run build`, which CI runs AFTER this
* gate, so flagging it would be a false positive on a path that is correct but
* not yet on disk. That skip is scoped to a REAL package root: a stale
* group-less `packages/acp-agent/lib/bin.js` is still flagged (its root does not
* exist — exactly the moved-package drift this gate catches).
*
* Run: `tsx scripts/verify-package-paths.ts`.
*/
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
/** Markdown + repo-authored TypeScript that may cite package paths. */
const PATTERNS = [
'README.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',
'AGENTS.md',
'packages/AGENTS.md',
'packages/**/*.ts',
'examples/**/*.ts',
]
/** Paths excluded from the scan: built output and vendored upstream source. */
const isExcluded = (p: string): boolean =>
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
/**
* Directory names of every real package, `packages/<group>/<pkg>`. A broken
* reference is only flagged when one of its segments is in this set — that is
* what scopes the gate to DRIFT (a moved real package) rather than typos or
* not-yet-existing packages named in a proposal.
*/
function realPackageNames(): Set<string> {
const names = new Set<string>()
const pkgRoot = resolve(root, 'packages')
for (const group of readdirSync(pkgRoot, { withFileTypes: true })) {
if (!group.isDirectory()) continue
for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) {
if (pkg.isDirectory()) names.add(pkg.name)
}
}
return names
}
const packageNames = realPackageNames()
/**
* Match a `packages/<path>` reference token. The character class is plain path
* characters only, so a glob (`*`), placeholder (`<`, `>`), or brace expansion
* (`{`, `}`, `,`) terminates the match before those chars and is never probed —
* those are patterns, not real paths. A trailing `.`/`/` (e.g. a sentence-ending
* period) is trimmed before the existence check.
*/
const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
/** A broken package reference: a stale root-relative `packages/…` path. */
interface Violation {
file: string
/** 1-based line where the reference appears. */
line: number
ref: string
}
/**
* Find every DRIFTED `packages/…` reference in one file: a token that does not
* resolve on disk AND names a real package in one of its segments (so it is a
* moved path, not a typo or a not-yet-existing package). The same real-package
* test also screens out a bare `packages` (no segment) and illustrative
* skeletons whose segment is not a package.
*/
function findViolations(absPath: string): Violation[] {
const file = relative(root, absPath)
const source = readFileSync(absPath, 'utf8')
const out: Violation[] = []
const lines = source.split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line === undefined) continue
for (const m of line.matchAll(PKG_REF)) {
// Trim a trailing path separator or sentence punctuation that the greedy
// class may have swallowed (`packages/core/tools.` / `…/tools/`).
const ref = m[0].replace(/[./]+$/, '')
if (existsSync(resolve(root, ref))) continue
// A reference INTO a package's built `lib/` is a build OUTPUT, not an
// authored-source location: it does not exist until `pnpm run build` emits
// it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
// the `packages/<group>/<pkg>` ROOT it sits under is real and on disk, so
// `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is
// exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the
// exact moved-package drift this gate exists to catch) still flags. A bare
// `lib` segment is not a blanket escape hatch.
const parts = ref.split('/')
const libAt = parts.indexOf('lib')
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue
// Only a stale path to a REAL (moved) package is a violation; a segment
// matching a live package name is the drift signal.
const segments = ref.split('/').slice(1)
if (segments.some(seg => packageNames.has(seg))) {
out.push({ file, line: i + 1, ref })
}
}
}
return out
}
const all: Violation[] = []
let checked = 0
const seen = new Set<string>()
for (const pattern of PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) {
if (isExcluded(match)) continue
// Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
const real = realpathSync(resolve(root, match))
if (seen.has(real)) continue
seen.add(real)
checked++
all.push(...findViolations(real))
}
}
if (all.length === 0) {
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
process.exit(0)
}
console.error('verify-package-paths: broken packages/* references found (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.ref}`)
}
process.exit(1)

View File

@@ -0,0 +1,160 @@
/**
* Doc-sync gate: enforce the RFC classification scheme
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.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:
*
* 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
* from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a
* lifecycle root (other than the README/AGENTS allowlist) fails; an unknown
* class folder fails; a stray file at an unexpected depth fails. This is what
* makes the set CLOSED: a new class folder can't appear without amending
* CLASSES here (and the README's Classification section, per the RFC).
*
* 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the
* `### {Class}` heading inside the `## {Lifecycle}` section that matches the
* file's path. A missing entry, a duplicate, or an entry under the wrong
* heading fails. This mirrors `verify-event-taxonomy`: a curated doc table
* checked against the on-disk source of truth, so the index can't drift.
*
* The class DESCRIPTIONS in the README prose are not checked (they are
* explanatory text); only the per-class index tables are. This is checker, not
* fixer: it reports and never rewrites.
*
* Run: `tsx scripts/verify-rfc-classification.ts`.
*/
import { readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
const rfcRoot = resolve(root, '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 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`)
}
}
}
// --- Report -----------------------------------------------------------------
if (errors.length === 0) {
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
process.exit(0)
}
console.error('verify-rfc-classification: violations found:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)

View File

@@ -0,0 +1,227 @@
/**
* Doc-sync gate: verify every ` ```ts type-equiv ` block in the docs is a
* VERBATIM copy of the source type definition it documents.
*
* The core-data-structures docs paste real type definitions so a reader sees
* the exact shape. A paste drifts the moment source changes — this script is
* the drift guard. For each block it extracts the documented symbol's
* declaration from source via the TypeScript compiler API, whitespace-
* normalizes both the source text and the block, and asserts they are equal.
*
* Provenance lives in a central manifest (`scripts/type-equiv.manifest.json`),
* NOT in the doc prose: each entry names `{ doc, symbol, source }`. The script
* enforces a 1:1 correspondence — every type-equiv block in the docs has
* exactly one manifest entry (keyed by doc + declared symbol), and every
* manifest entry resolves to exactly one block. An orphan on either side fails,
* so a block can never be silently unchecked and an entry can never rot.
*
* doc-typecheck.ts recognizes the same ` ```ts type-equiv ` fence and skips it
* (it is not standalone-compilable and is not counted in the opt-out ratio);
* the two scripts share the fence, this one owns the verification.
*
* Run: `tsx scripts/verify-type-equiv.ts`.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
/**
* Markdown globs scanned for ` ```ts type-equiv ` blocks — the SAME scope
* doc-typecheck uses. Scanning every doc (not only the docs the manifest names)
* is what makes the 1:1 guarantee real in both directions: a type-equiv block
* added to a doc with NO manifest entry is still discovered here and reported as
* an orphan, instead of being silently skipped.
*/
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
/** One manifest entry: a documented type-equiv block and its source symbol. */
interface ManifestEntry {
/** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */
doc: string
/** The declared symbol the block must match (e.g. `SessionEvent`). */
symbol: string
/** Source file (repo-relative) that exports the symbol. */
source: string
}
/** One extracted ` ```ts type-equiv ` block. */
interface EquivBlock {
doc: string
/** 1-based line of the opening fence (for diagnostics). */
line: number
/** Symbol name parsed from the block's declaration. */
symbol: string
/** Block body (the pasted declaration). */
code: string
}
/** Collapse a declaration to its structural form for comparison: drop comments
* (block + line), then collapse all whitespace runs to single spaces. This lets
* a doc block show a CLEAN definition (without source's verbose inline JSDoc)
* while still guaranteeing the field shapes match — drift in a field name or
* type fails; a reworded inline comment does not. Adequate for our own type
* source (no string literal contains `//` or `/* */`); not a general tokenizer. */
function normalize(code: string): string {
return code
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1')
.replace(/\s+/g, ' ')
.trim()
}
/** Strip a leading `export ` / `export default ` modifier — the doc block shows
* the bare declaration, the source carries the export modifier. */
function stripExport(code: string): string {
return code.replace(/^export\s+(default\s+)?/, '')
}
/** Parse the declared symbol name from a type-equiv block body. */
function blockSymbol(code: string): string | null {
const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code)
return m?.[1] ?? null
}
/** Extract every ` ```ts type-equiv ` block from one Markdown file. */
function extractEquivBlocks(docRel: string): EquivBlock[] {
const text = readFileSync(resolve(root, docRel), 'utf8')
const lines = text.split('\n')
const blocks: EquivBlock[] = []
let open: { line: number; body: string[] } | null = null
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? ''
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
continue
}
if (open) {
const code = open.body.join('\n')
const symbol = blockSymbol(code)
if (!symbol) {
throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
}
blocks.push({ doc: docRel, line: open.line, symbol, code })
open = null
continue
}
if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] }
}
if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
return blocks
}
/** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
* null when the symbol is not declared there. Uses the TS parser so it spans
* interfaces, type aliases (including mapped/generic ones), classes, and enums
* uniformly, and excludes the leading JSDoc (getStart skips leading trivia)
* while keeping inline member comments. */
function sourceDeclaration(sourceRel: string, symbol: string): string | null {
const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8')
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
for (const stmt of sf.statements) {
const named =
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
if (named && stmt.name?.text === symbol) {
return stripExport(stmt.getText(sf))
}
}
return null
}
const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8')
const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] }
const entries = manifest.entries
// Key a block/entry by doc + symbol (a symbol may be documented in more than one
// doc, but at most once per doc).
const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}`
// Collect every type-equiv block across ALL docs in scope — not only the docs
// the manifest names — so a block in an unmanifested doc is found and reported
// as an orphan rather than silently skipped.
const docSet = new Set<string>()
for (const pattern of MARKDOWN_GLOBS) {
for await (const match of glob(pattern, { cwd: root })) docSet.add(match)
}
const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
const errors: string[] = []
// A manifest entry naming a doc that does not exist (or is outside the scanned
// scope, so no block could ever match it) is an error in its own right.
for (const d of [...new Set(entries.map(e => e.doc))]) {
if (!existsSync(resolve(root, d))) errors.push(`manifest references ${d}, which does not exist`)
else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`)
}
// Duplicate-block guard: the same symbol twice in one doc is ambiguous.
const blockByKey = new Map<string, EquivBlock>()
for (const b of blocks) {
const k = keyOf(b)
const prior = blockByKey.get(k)
if (prior) {
errors.push(`duplicate type-equiv block for ${b.symbol} in ${b.doc} (lines ${prior.line} and ${b.line})`)
continue
}
blockByKey.set(k, b)
}
// Duplicate-entry guard in the manifest.
const entryByKey = new Map<string, ManifestEntry>()
for (const e of entries) {
const k = keyOf(e)
if (entryByKey.has(k)) {
errors.push(`duplicate manifest entry for ${e.symbol} in ${e.doc}`)
continue
}
entryByKey.set(k, e)
}
// 1:1 correspondence: orphan blocks (no entry) and orphan entries (no block).
for (const b of blocks) {
if (!entryByKey.has(keyOf(b))) {
errors.push(`type-equiv block ${b.symbol} (${b.doc}:${b.line}) has no manifest entry — add one to scripts/type-equiv.manifest.json`)
}
}
for (const e of entries) {
if (!blockByKey.has(keyOf(e))) {
errors.push(`manifest entry ${e.symbol} (${e.doc}) has no matching type-equiv block — remove it or add the block`)
}
}
// Verbatim check: each matched block must equal its source declaration.
let verified = 0
for (const e of entries) {
const b = blockByKey.get(keyOf(e))
if (!b) continue // already reported as an orphan entry
const decl = sourceDeclaration(e.source, e.symbol)
if (decl === null) {
errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
continue
}
if (normalize(decl) !== normalize(stripExport(b.code))) {
errors.push(
`DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n`
+ ` source: ${normalize(decl)}\n`
+ ` doc: ${normalize(stripExport(b.code))}`,
)
continue
}
verified++
}
if (errors.length === 0) {
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`)
process.exit(0)
}
console.error('verify-type-equiv: type-equiv verification failed:')
for (const e of errors) console.error(` ${e}`)
console.error(`\n(checked ${blocks.length} block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s); manifest at scripts/type-equiv.manifest.json)`)
process.exit(1)