Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
463
scripts/gen-cordis-catalog.ts
Normal file
463
scripts/gen-cordis-catalog.ts
Normal 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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
40
scripts/type-equiv.manifest.json
Normal file
40
scripts/type-equiv.manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
96
scripts/verify-doc-refs.ts
Normal file
96
scripts/verify-doc-refs.ts
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
167
scripts/verify-package-paths.ts
Normal file
167
scripts/verify-package-paths.ts
Normal 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)
|
||||
160
scripts/verify-rfc-classification.ts
Normal file
160
scripts/verify-rfc-classification.ts
Normal 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)
|
||||
227
scripts/verify-type-equiv.ts
Normal file
227
scripts/verify-type-equiv.ts
Normal 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)
|
||||
Reference in New Issue
Block a user