docs: trim generated prose

This commit is contained in:
Tianyi Cui
2026-07-12 03:36:43 +08:00
parent 3dca90261c
commit 75838e10b5
323 changed files with 2857 additions and 11833 deletions

View File

@@ -176,12 +176,7 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
/**
* 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.
* `packages/<group>/<pkg>`.
*/
function checkHierarchyShape(): string[] {
const errors: string[] = []

View File

@@ -1,11 +1,11 @@
{
"AGENTS.md": 1802,
"docs/AGENTS.md": 1315,
"AGENTS.md": 1370,
"docs/AGENTS.md": 1175,
"docs/architecture.md": 1790,
"docs/cordis-primer.md": 550,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,
"examples/AGENTS.md": 705,
"packages/AGENTS.md": 450,
"examples/AGENTS.md": 462,
"packages/AGENTS.md": 200,
"packages/README.md": 710
}

View File

@@ -1,27 +1,7 @@
/**
* Doc-sync gate (doc-sync-enforcement RFC, part 1): typecheck the fenced `ts` code blocks in our
* Markdown so documentation can't drift from the API it documents.
*
* Every ```ts block in README.md, docs/** and packages/* /README.md is
* extracted to a temp typecheck project and compiled against the workspace
* sources through the same project-reference boundaries used by repo
* 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. A third info string,
* doc-typecheck.ts recognizes four more fence variants and skips all four (each
* is a separately-checked category, not an unchecked sketch, so none counts in
* the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that
* `scripts/verify-type-equiv.ts` drift-checks, ` ```ts cordis-catalog ` is a
* generated event/service signature fragment in the cordis catalog (a bare
* signature is not standalone-compilable; the catalog is generated and frozen by
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate),
* ` ```ts persistence-catalog ` is a generated log-event payload fragment in the
* persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`),
* and ` ```ts config-catalog ` is a generated verbatim config declaration in the
* plugin config catalog (same reasoning, frozen by `scripts/gen-config-catalog.ts`).
*
* Run: `tsx scripts/doc-typecheck.ts`.
* Typecheck Markdown `ts` fences against workspace sources. `ignore-check`
* fences are reported as opt-outs; generated catalog fragments and
* `type-equiv` blocks are skipped here because their owning gates verify them.
*/
import { execFileSync } from 'node:child_process'
@@ -31,30 +11,7 @@ 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.
* - `persistence-catalog` (` ```ts persistence-catalog `) — a generated
* log-event payload fragment in the persistence catalog. Same treatment for
* the same reason; frozen by `scripts/gen-persistence-catalog.ts` + its
* `--check` freshness gate.
* - `config-catalog` (` ```ts config-catalog `) — a generated verbatim config
* declaration in the plugin config catalog (a lone declaration referencing
* imported types does not stand alone). Same treatment for the same reason;
* frozen by `scripts/gen-config-catalog.ts` + its `--check` freshness gate.
*/
/** Classification of a TypeScript fence and the gate that owns it. */
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog'
/** One extracted code block. */
@@ -66,8 +23,7 @@ interface Block {
code: string
}
/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog /
* ts persistence-catalog / ts config-catalog block from one Markdown file. */
/** Extract every recognized TypeScript fence from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const text = readFileSync(absPath, 'utf8')
const lines = text.split('\n')
@@ -87,7 +43,7 @@ function extractBlocks(absPath: string): Block[] {
open = null
return
}
// opening fence — only care about ts blocks
// Ignore non-TypeScript fences.
const info = (fence[2] ?? '').trim()
const kind: BlockKind | null =
info === 'ts' ? 'check'
@@ -145,11 +101,10 @@ files.sort()
const all = files.flatMap(extractBlocks)
const checked = all.filter(b => b.kind === 'check')
const ignored = all.filter(b => b.kind === 'ignore')
// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified
// elsewhere (verify-type-equiv.ts and each catalog generator's `--check`
// freshness gate), not here: neither compiled nor counted toward the opt-out
// ratio (each is a separate fully-checked category, not an unchecked sketch).
// The ratio's denominator is therefore the compile-eligible blocks only.
// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified elsewhere
// (verify-type-equiv.ts and each catalog generator's `--check` freshness gate), not here:
// neither compiled nor counted toward the opt-out ratio (each is a separate fully-checked
// category, not an unchecked sketch).
const ratioDenominator = checked.length + ignored.length
if (checked.length === 0) {

View File

@@ -1,68 +1,8 @@
/**
* Generate (and verify) the plugin config catalog in docs/config-catalog.md.
*
* The page is the DEPLOYMENT-axis reference: for every harness package a
* `cordis.yml` entry can load, the exact config surface its `apply` function or
* service constructor receives — pasted VERBATIM from source (the `export
* interface Config` declaration with its JSDoc), plus resolved links for every
* type the declaration references. It complements the wiring-axis cordis
* catalogs (events + services, what a plugin AUTHOR listens to and calls) the
* same way the tool catalog complements them for the model-facing axis.
*
* The catalog is FULLY GENERATED from source — never hand-edit it. Like the
* cordis catalog (and unlike the tool catalog, which must boot plugins), this
* is a pure-AST pass: every config type is a static declaration and every
* schemastery schema is a static `z.object`/`z.intersect` literal, so
* generation cannot drift and a regenerate-and-diff freshness check (`--check`)
* gates staleness. Because generation enumerates every package under
* `packages/<group>/<pkg>`, a brand-new plugin cannot be silently
* undocumented: it must classify as configurable, config-free, seam, or
* library, and an unclassifiable entry hard-errors the generator.
*
* `tsx scripts/gen-config-catalog.ts` → write the catalog
* `tsx scripts/gen-config-catalog.ts --check` → exit 1 if the committed
* catalog is stale (CI /
* pre-push gate)
*
* What the walk enforces (aggregated into one error, like the sibling
* generators):
*
* - CLASSIFICATION is total. Every package entry resolves, mirroring the
* cordis Loader's `unwrapExports` (`exports.default ?? exports`), to a
* loadable plugin (default class / `apply` function), an abstract seam
* class, or a plain library. Anything else is an error, not a skip.
* - The CONFIG TYPE is the declared type of the plugin's second parameter
* (`apply(ctx, config)` / `constructor(ctx, config)`) — the type cordis
* actually passes — and it must resolve to a declaration inside the owning
* package (entry file or a package-local relative import).
* - Every property of a pasted declaration carries non-empty JSDoc prose: the
* paste IS the documentation, so an undocumented field is a gate failure,
* the same forcing function the events catalog applies via `@mode`.
* - Every type NAME a pasted declaration references resolves: pasted
* transitively when package-local, linked when it is another plugin's
* config type / a core-data-structures entry / a workspace or external
* import. An unresolvable name is an error, and so is a NAME COLLISION —
* two distinct declarations, or a declaration and an import, sharing one
* name across the closure (a verbatim fence has a single flat namespace) —
* never a silent skip.
* - The runtime schemastery schema (`Config` export or `static Config`),
* when present, is walked statically — `z.object` keys, nested object/array
* compositions as key PATHS (`agents[].id`), and `z.intersect` composition
* across packages — and every schema-validated key path must be locatable
* on the declared config type, resolving package-local and
* workspace-imported types, re-export chains, intersections, utility
* wrappers, and indexed access. The paste cannot hide a loader-accepted
* field, top-level or nested. A path that crosses a type the walk cannot
* enumerate (an external package's type) is skipped, never mis-reported,
* and nested keys under dynamic-key shapes (`z.dict`) or union alternatives
* contribute no paths. The reverse direction is deliberately NOT checked: a
* declared field may be a runtime-only seam the schema excludes (e.g. the
* ACP bridge's test-injected `stream`).
*
* Config fences use the ` ```ts config-catalog ` info string: doc-typecheck
* recognizes it and skips compilation (a lone interface referencing imported
* types is not standalone-compilable, like the ` ```ts cordis-catalog `
* signature blocks).
* Generate `docs/config-catalog.md` from package entry points, config types,
* JSDoc, and static Schemastery schemas. Every package must classify, referenced
* types must resolve without collisions, and schema paths must exist on the
* declared config type. `--check` verifies the committed artifact.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -372,10 +312,8 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul
*/
function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
if (steps.length === 0) return 'found'
// Guard recursion at NAMED declarations only — the sole way a walk can loop
// (a recursive interface/alias). Structural nodes must not be guarded: a
// first child shares `.pos` with its parent, so a span-keyed guard there
// would mistake ordinary descent for a cycle.
// Guard recursion at NAMED declarations only — the sole way a walk can loop (a recursive
// interface/alias).
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
const key = `${ctx.abs}:${node.pos}:${steps.length}`
if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop
@@ -775,10 +713,8 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
}
}
// Second phase: fold composed schemas' key paths in, then walk every
// schema-validated path against the declared config type. Only a definite
// miss is a violation — a path through a shape the walk cannot enumerate
// stays silent rather than mis-reporting.
// Second phase: fold composed schemas' key paths in, then walk every schema-validated path
// against the declared config type.
const byName = new Map(entries.map(e => [e.pkg, e]))
for (const entry of entries) {
if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue

View File

@@ -1,27 +1,7 @@
/**
* Generate (and verify) the runtime cordis API catalog the `cordis_inspect`
* tool serves to the model: packages/cordis/tool-cordis/src/api-catalog.ts.
*
* The artifact is the machine-readable sibling of docs/cordis-catalog: it
* reuses `collectServices` / `collectEvents` from `gen-cordis-catalog.ts` (the
* same JSDoc-completeness-enforcing AST walk), so the API the model reads at
* runtime and the API the docs render cannot diverge. Emitted as a typed
* TypeScript data module (not JSON): it compiles under the package tsconfig,
* passes lint and the export-JSDoc gate, and is trivially covered by import.
*
* The data is trimmed for a model-facing text surface: per service the
* `ctx.<key>` name, the first sentence of the class doc, and the raw method
* signatures; per event the name, `@mode`, signature, and first sentence of
* doc; the SHAPES of every exported interface/type-alias the service
* signatures reference (transitively — so a model can see that e.g. a
* `BashRunResult.stdout` is `{ text, truncated }`, not a string); plus the
* curated inherited `ctx` surface shared with the docs catalog. Source
* pointers are dropped (a `file:line` means nothing to the model) and entries
* are sorted deterministically.
*
* `tsx scripts/gen-cordis-api.ts` → write the artifact
* `tsx scripts/gen-cordis-api.ts --check` → exit 1 if the committed file is
* stale (CI / pre-push gate)
* Generate the model-facing Cordis API data module from the same event/service
* collector as the documentation catalogs. Output includes concise docs,
* signatures, and referenced public type shapes; `--check` verifies freshness.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -47,12 +27,7 @@ function quote(value: string): string {
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'`
}
/**
* Every exported `interface` / `type` declaration under `packages/<group>/<pkg>/src`,
* printed without comments, keyed by name. A name declared in more than one
* package (e.g. each plugin's `Config`) is ambiguous and dropped entirely —
* serving the wrong package's shape is worse than serving none.
*/
/** Collect uniquely named exported interface and type shapes. */
function collectTypeDecls(scanRoot: string = root): Map<string, string> {
const printer = ts.createPrinter({ removeComments: true })
const decls = new Map<string, string>()
@@ -78,11 +53,7 @@ function collectTypeDecls(scanRoot: string = root): Map<string, string> {
return decls
}
/**
* The transitive closure of type names referenced by the seed texts: every
* collected declaration whose name appears (word-bounded) in a seed or in an
* already-included declaration, sorted by name.
*/
/** Resolve the transitive public type shapes referenced by seed text. */
function referencedTypes(seeds: string[], decls: Map<string, string>): { name: string; declaration: string }[] {
const included = new Map<string, string>()
let frontier = seeds

View File

@@ -1,55 +1,8 @@
/**
* Generate (and verify) the cordis events and services catalogs in
* docs/cordis-catalog/events.md and docs/cordis-catalog/services.md.
*
* The two pages are the WIRING-axis reference, one axis each: every cordis
* event a plugin can listen to (exact signature + dispatch mode) and every
* `ctx.<key>` service it can call (exact public interface). They complement the
* core-data-structures catalog (the VOCABULARY axis — the types these
* signatures move around).
*
* The catalogs are FULLY GENERATED from source — never hand-edit them. The
* codebase is disciplined enough that a pure-AST pass captures the whole
* truthful surface: every event/service is a string literal that round-trips
* to a static `interface Events` / `interface Context` declaration (no
* dynamically-named events, no runtime-only services). So the committed files
* are build artifacts and a regenerate-and-diff freshness check (`--check`)
* makes drift structurally impossible. Because generation enumerates source
* rather than checking a hand-written subset, a brand-new event cannot be
* silently undocumented — it appears in the next regenerate, and an
* un-regenerated file fails `--check`.
*
* `tsx scripts/gen-cordis-catalog.ts` → write both catalogs
* `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if a committed
* catalog is stale (CI /
* pre-push gate)
*
* The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in
* full from source: signature, the `@mode` badge, and the declaration's JSDoc.
* Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag
* — the generator hard-errors on a missing tag, and where the signature shape is
* conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
* it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag,
* the walk enforces JSDoc COMPLETENESS on the whole harness surface (the
* jsdoc-completeness-gate RFC): every event and public service method carries
* description prose; every payload parameter has a non-empty `@param` (`this`
* receivers and the trailing waterfall `next` are exempt — next's semantics are
* documented once by the mode); a service method with a non-`void`/
* `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT
* return type annotation (a pure-AST walk cannot classify an inferred return);
* a stale `@param` naming no real parameter errors. Violations aggregate into
* ONE error listing every offender. The tags are enforcement-only: parseJsDoc
* stops prose at the first block tag, so they never change the rendered
* catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with
* the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so
* "documented" means the same thing on both surfaces. The INHERITED
* tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
* also sees; it is rendered tersely (name + one-line + source pointer) from a
* curated table in this script, NOT elevated to the harness tier's prominence.
*
* 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).
* Generate the Cordis event and service catalogs from static declarations.
* The walk enforces event modes plus JSDoc parameter/return completeness;
* inherited Cordis services come from the curated table below. `--check`
* verifies both committed artifacts.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -65,20 +18,8 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md'
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
const FENCE = 'ts cordis-catalog'
/**
* Cross-link map: a type name that appears in a signature → the
* core-data-structures page that documents it (path relative to the catalogs'
* folder).
* Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json —
* that manifest documents the `…Map` symbols (`ContentBlockMap`) while
* signatures reference the derived UNION names (`ContentBlock`), and it lists a
* few symbols on two pages. Here each name resolves to exactly one PRIMARY page.
* Shared with `gen-config-catalog.ts` (each caller prefixes its own relative
* path to `core-data-structures/`), so both catalogs cross-link identically.
* TODO(catalog-type-links): add a verifier or generator for link-map coverage
* so new hook-era decision types like `PromptDecision` / `PreToolDecision` do
* not silently appear in signatures without a "Types:" link.
*/
/** Primary core-data-structures page for signature types shared by both catalog generators. */
// TODO(catalog-type-links): verify or generate link-map coverage.
export const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
ContentBlock: 'core.md',
@@ -215,10 +156,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
// Payload parameters need a non-empty @param each. Exempt the `this`
// receiver annotation (not payload) and the trailing waterfall `next`
// (mode machinery, documented once by @mode semantics). Documenting an
// exempt parameter anyway is allowed — only absence is checked.
// Payload parameters need a non-empty @param each.
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
@@ -269,10 +207,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
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).
// Only the PUBLIC callable surface a `ctx.<key>` consumer sees.
const nonPublic = member.modifiers?.some(m =>
m.kind === ts.SyntaxKind.PrivateKeyword
|| m.kind === ts.SyntaxKind.ProtectedKeyword

View File

@@ -1,21 +1,5 @@
/**
* Generate (and verify) the relationship-diagram docs.
*
* This is the relationship layer above the existing catalogs:
* - module-graph.md answers "which packages depend on which packages?"
* - cordis-catalog/ answers "which events and services exist?"
* - tool-catalog.md answers "which tools does the model see?"
* - generated relationship diagrams answer "how do those pieces fit together?"
*
* Generated pages discover the enumerable facts from source. Hybrid pages use
* discovered inventory plus small manifests for policy that source cannot infer
* (for example, whether a package is an implementation or consumer in a seam).
* Curated pages are still emitted here so the graph docs are one regenerated unit,
* but their diagrams intentionally explain flow and ownership rather than
* pretending to enumerate every source edge.
*
* `tsx scripts/gen-doc-graphs.ts` -> write generated diagram docs
* `tsx scripts/gen-doc-graphs.ts --check` -> exit 1 if any file is stale
*/
import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
@@ -597,13 +581,10 @@ function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.Sourc
}
const target = expr.expression.getText(sf)
if (target === 'ctx' || target === 'this.ctx') return true
// Scoped-dispatch spellings (the agent-scoping seam): the loop's fused
// dispatcher (`events` from `agentEvents(ctx, agent)`), an agent's setup
// context (`childCtx`), the agent's own context handle (`this.loopCtx`), and
// the session store's captured dispatch context (`emitCtx`). Conventional
// receiver names, pinned by the fused-dispatch convention; a rename here
// must update this list (the producer/consumer matrix silently losing a
// dispatcher or listener is the failure mode this list exists to prevent).
// Scoped-dispatch spellings (the agent-scoping seam): the loop's fused dispatcher (`events`
// from `agentEvents(ctx, agent)`), an agent's setup context (`childCtx`), the agent's own
// context handle (`this.loopCtx`), and the session store's captured dispatch context
// (`emitCtx`).
return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
}
@@ -650,13 +631,9 @@ function renderEventRelations(pkgs: Pkg[]): string {
const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
}
// Completeness guard: every DECLARED event must have at least one dispatcher
// edge — a zero-dispatcher row is either dead vocabulary or (the observed
// failure mode) a dispatch spelling the AST scan does not recognize, silently
// dropping the producer from the matrix. Fail the generation loud instead:
// teach the scan the new spelling, add a DYNAMIC_EVENT_DISPATCHERS override,
// or remove the dead event. Zero LISTENERS is deliberately legal — an event
// dispatched for out-of-repo plugins is an ordinary extension point.
// Completeness guard: every DECLARED event must have at least one dispatcher edge — a
// zero-dispatcher row is either dead vocabulary or (the observed failure mode) a dispatch
// spelling the AST scan does not recognize, silently dropping the producer from the matrix.
const undispatched = [...events]
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
.map(event => event.name)

View File

@@ -1,21 +1,5 @@
/**
* Generate (and verify) the module dependency graph in docs/module-graph.md.
*
* 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
* `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a
* GitHub-viewable Mermaid graph grouped by `packages/<group>/` plus a
* dependency table.
*
* The file is fully generated — never hand-edit it. Output is deterministic
* (packages and edges sorted) so a regenerate-and-diff freshness check is
* stable.
*
* `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md
* `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file
* is stale (CI / pre-push gate)
*/
import { dirname, resolve } from 'node:path'
@@ -177,10 +161,8 @@ if (process.argv.includes('--check')) {
try {
committed = readFileSync(resolve(root, OUT), 'utf8')
} catch {
// Only an ENOENT (file not yet generated) is expected here; readFileSync of
// a present-but-unreadable file is not a state this repo produces. Either
// way the remedy is the same — regenerate — so we treat a read failure as
// "stale" and fall through to the failure branch below.
// Only an ENOENT (file not yet generated) is expected here; readFileSync of a
// present-but-unreadable file is not a state this repo produces.
committed = null
}
if (committed === content) {

View File

@@ -1,45 +1,8 @@
/**
* Generate (and verify) the persistence log event catalog in
* docs/persistence-catalog.md.
*
* The catalog is the ON-DISK-vocabulary reference: every event type that can
* appear in a session's durable event log — every member of the
* merge-extensible `SessionEventMap`, across the owning declaration in
* `@deepseek-ai/dsh-session` and every plugin declaration merge. It complements
* the cordis events/services catalog (the live bus wiring — a log event is NOT
* a cordis event; it reaches listeners via the single `session/event` emit) and
* the core-data-structures session page (the `SessionEvent` envelope and
* derivation semantics): this page is the RECORDS a persisted log can contain.
*
* `tsx scripts/gen-persistence-catalog.ts` → write the catalog
* `tsx scripts/gen-persistence-catalog.ts --check` → exit 1 if the committed
* file is stale (CI /
* pre-push gate)
*
* Like its AST sibling `gen-cordis-catalog.ts` (and unlike the boot-based
* `gen-tool-catalog.ts`), this is a pure source pass: every log event is a
* string-literal-named property with a static type annotation, so the AST is
* the whole truth and a brand-new event (core or merged) appears in the next
* regenerate — an un-regenerated file fails `--check`. The walk enforces JSDoc
* COMPLETENESS on the whole vocabulary: every member carries description prose
* (it becomes the catalog entry), and an `@mode` tag on a member is a hard
* error — dispatch modes belong to cordis bus events, and a log event has none
* (see docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md).
* Structural holes are hard errors for the same reason: a member that is not a
* property signature with an explicit payload type, an `extends` clause on a
* declaration, a top-level `interface SessionEventMap` that is not the single
* exported declaration in the owning package, and a duplicate declaration of
* one event would each let something join (or impersonate)
* `keyof SessionEventMap` without a truthful catalog row. Violations aggregate
* into ONE error listing every offender.
*
* The surface/log-only badge is parsed from the `SurfaceEventType` union in the
* owning package (never hand-listed here), and every union member must name a
* collected event — a stale union member is a hard error.
*
* Payload fences use the ` ```ts persistence-catalog ` info string:
* doc-typecheck recognizes it and skips compilation (a bare payload fragment is
* not standalone-compilable), excluded from the opt-out ratio.
* Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
* the owning `SurfaceEventType` union. Event declarations must be unique,
* explicitly typed, documented, and free of Cordis-only `@mode` tags. `--check`
* verifies the committed artifact.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -56,14 +19,7 @@ const FENCE = 'ts persistence-catalog'
/** The package whose module id plugin merges augment (`declare module '…'`). */
const SESSION_MODULE = '@deepseek-ai/dsh-session'
/**
* Cross-link map: a type name that appears in a payload → the
* core-data-structures page that documents it (path relative to OUT's folder).
* Hand-curated and catalog-owned, same policy as the cordis catalog's map: each
* name resolves to exactly one PRIMARY page. A payload type with no
* core-data-structures home (e.g. `HookDialect`, documented in its package)
* simply gets no link.
*/
/** Primary core-data-structures page for linked payload types. */
const LINK_MAP: Record<string, string> = {
CallId: 'core.md',
ContentBlock: 'core.md',
@@ -240,18 +196,7 @@ function packageNameFor(rel: string, scanRoot: string): string | null {
}
}
/**
* Walk every `SessionEventMap` declaration (the owning interface plus every
* plugin declaration merge) and extract its events, hard-erroring (aggregated)
* on any completeness violation: a member without description prose, an
* `@mode` tag (a category error — log events have no dispatch mode), a member
* that is not a property signature with an explicit payload type, a
* non-literal member name, an `extends` clause (inherited keys would join
* `keyof SessionEventMap` without a catalog row), a top-level declaration that
* is not the single exported one in the owning package, or the same event
* declared twice.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
/** Collect and validate every `SessionEventMap` declaration merge. */
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
const entries: LogEventEntry[] = []
const violations: string[] = []
@@ -265,11 +210,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
for (const { decl, topLevel } of sessionEventMapDecls(sf)) {
const declSrc = pointer(rel, sf, decl)
if (topLevel) {
// The top-level form is the OWNING vocabulary, and it has exactly one
// home: the single EXPORTED declaration in the owning package. A
// same-named interface anywhere else — another package, a non-exported
// local, a second exported copy — is a different type that must not be
// catalogued as on-disk events.
// The top-level form is the OWNING vocabulary, and it has exactly one home: the single
// EXPORTED declaration in the owning package.
const pkg = packageNameFor(rel, scanRoot)
if (pkg !== SESSION_MODULE) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`)

View File

@@ -1,36 +1,8 @@
/**
* Generate (and verify) the tool-schema catalog in docs/tool-catalog.md.
*
* The catalog is the MODEL-FACING TOOL reference: every tool a shipped plugin
* contributes to `ctx.tools`, with the exact `name` / `description` / JSON-Schema
* `parameters` the model receives via the system-prompt assembly. It complements
* the cordis events/services catalog (the wiring a plugin author works against)
* and the core-data-structures catalog (the vocabulary those signatures move):
* this page is the TOOLS the agent is offered.
*
* `tsx scripts/gen-tool-catalog.ts` → write the catalog
* `tsx scripts/gen-tool-catalog.ts --check` → exit 1 if the committed file
* is stale (CI / pre-push gate)
*
* Why this generator BOOTS PLUGINS instead of parsing source (unlike its AST
* sibling `gen-cordis-catalog.ts`): a tool's schema is not statically knowable.
* `tool-todo` writes `enum: [...STATUSES]` (a runtime spread), descriptions are
* built by string concatenation, `tool-subagent`'s tool name is `config.toolName`,
* and an MCP plugin can register RAW JSON Schema without `defineTool` at all. The
* faithful source of truth is therefore the SHIPPED schema: mount each tool
* plugin on a real cordis Context and read `ctx.tools.schemas()` — exactly the
* `ToolSchema[]` the model is sent. See
* docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md.
*
* Booting sacrifices the AST pass's structural "nothing can be silently omitted"
* property (there is no source declaration to enumerate), so a COMPLETENESS GUARD
* restores it: the generator globs every `tool-*` package under `packages/` and
* hard-errors if any such package is absent from the boot manifest below. A new
* tool package fails the generator — and thus the freshness gate — until it is
* registered here, mirroring how a new event appears in the cordis regenerate.
*
* Schema blocks use a plain ` ```json ` fence: doc-typecheck only extracts `ts*`
* fences, so no BlockKind wiring is needed there.
* Generate `docs/tool-catalog.md` from schemas collected by booting each tool
* plugin. Runtime registration is the source of truth for computed schemas;
* the manifest is checked against every on-disk `tool-*` package. `--check`
* verifies the committed artifact.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -63,19 +35,7 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/tool-catalog.md'
/**
* One tool-plugin package to boot. `mount` is a per-entry recipe (async): it
* plugs the injected seams the plugin's `apply` reads (an executor for
* `ctx.bash`, a provider for `ctx.subagents`) BEFORE the tool plugin itself.
* `SystemPrompt` + `ToolRegistry` are mounted for every entry by the caller
* (`ToolRegistry` injects `systemPrompt`), so `mount` only handles the extras.
*
* The recipe is irreducible policy — WHICH seams a given tool needs and with
* WHAT config is not derivable from the package layout — so it stays a hand-
* maintained closure. The `dir` field is what the completeness guard matches
* against the on-disk `tool-*` package glob, so a NEW tool package cannot be
* silently omitted (see the module doc).
*/
/** Tool package plus the non-default dependencies needed to boot it. */
interface ToolPackage {
/** The npm package name, used as the catalog section heading. */
pkg: string
@@ -174,9 +134,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'],
async mount(ctx) {
// The tool injects `fs`; boot the local backend to satisfy it. The schemas
// do not depend on the policy plugin (an event gate that changes behavior,
// not tool shape), so the bare provider is enough to harvest them.
// The tool injects `fs`; boot the local backend to satisfy it.
await ctx.plugin(LocalFileSystem)
await ctx.plugin(ToolFs)
},
@@ -249,10 +207,8 @@ const TOOL_PACKAGES: ToolPackage[] = [
requires: ['ctx.tools', 'ctx.web', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tools inject `web`; boot the seam plus one search and one fetch
// provider so both `web_search` and `web_fetch` register. The schemas do
// not depend on which provider backs the seam (or on it being available),
// so any registered provider is enough to harvest them.
// The tools inject `web`; boot the seam plus one search and one fetch provider so both
// `web_search` and `web_fetch` register.
await ctx.plugin(WebService)
await ctx.plugin(WebSearchExa)
await ctx.plugin(WebFetchLocal)

View File

@@ -1,14 +1,9 @@
/**
* Shared JSDoc parsing and completeness-check helpers for the documentation
* gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
* events + `ctx.<key>` service surface), the plugin config catalog generator
* (`scripts/gen-config-catalog.ts`, which renders the parsed prose), and the
* export-surface gate (`scripts/verify-export-jsdoc.ts` — every module-level
* export). One home for the mechanics so "documented" means the same thing on
* every gated surface: description prose ends at the first block tag; every
* checkable parameter needs a non-empty `@param`; a non-void ANNOTATED return
* needs a non-empty `@returns`; a stale `@param` naming no real parameter
* errors.
* Shared JSDoc parsing and completeness-check helpers for the documentation gates: the cordis
* catalog generator (`scripts/gen-cordis-catalog.ts` — the events + `ctx.<key>` service
* surface), the plugin config catalog generator (`scripts/gen-config-catalog.ts`, which
* renders the parsed prose), and the export-surface gate (`scripts/verify-export-jsdoc.ts` —
* every module-level export).
*/
import ts from 'typescript'
@@ -30,14 +25,8 @@ export function rawJsDoc(text: string, node: ts.Node): string {
export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
/**
* Parse a raw JSDoc block into description prose + the `@mode` tag (when
* present). Output obeys the repo's markdown conventions so the generated
* catalog passes verify-md-wrap: each prose paragraph collapses to ONE physical
* line, and a `-` bullet list is preserved with each item on its own single
* line (continuation lines folded in). `{@link Foo}` unwraps to `Foo`.
* Description prose ends at the FIRST block tag (standard JSDoc semantics):
* tag lines and their continuation lines are never prose, so `@param` /
* `@returns` blocks are invisible to the rendered catalog.
* Parse a raw JSDoc block into description prose + the `@mode` tag (when present).
*
* @param raw - the raw comment text including the JSDoc delimiters.
* @returns the collapsed description prose plus the parsed `@mode` (or null).
*/
@@ -91,13 +80,9 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
}
/**
* Parse the block tags of a raw JSDoc comment for the completeness checks:
* every `@param name — description` entry plus the `@returns` description.
* Standard JSDoc block-tag semantics — a tag's description runs across
* continuation lines until the next tag or a blank line, and the `-`/`—`
* separator after a param name is optional. `[name]` optional-brackets unwrap
* to `name`. Rendering never sees these: parseJsDoc stops prose at the first
* block tag.
* Parse the block tags of a raw JSDoc comment for the completeness checks: every `@param name
* — description` entry plus the `@returns` description.
*
* @param raw - the raw comment text including the JSDoc delimiters.
* @returns the `@param` name→description map plus the `@returns` description
* (null when the tag is absent, '' when present but empty).
@@ -134,17 +119,13 @@ export function parseTags(raw: string): { params: Map<string, string>; returns:
}
/**
* Check the `@param` half of the completeness contract for one function-like
* declaration: every checkable parameter carries a non-empty `@param`, and no
* `@param` is stale. A binding-pattern parameter is a violation (it has no name
* for `@param` to match); an exempt parameter may be documented but its absence
* is never checked. Violations append to `violations` in place.
* Check that required parameter tags exist and no stale tag remains.
* @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
* @param surface - the surface noun for the binding-pattern message ("event", "service", "export").
* @param surface - surface noun used in diagnostics.
* @param parameters - the declaration's parameter list.
* @param tags - the parsed `@param` name→description map from parseTags.
* @param sf - the source file (for rendering a binding pattern's text).
* @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`).
* @param sf - source file used to render binding patterns.
* @param isExempt - parameters that need no tag.
* @param violations - the aggregate list violations append to.
*/
export function checkParams(
@@ -174,11 +155,10 @@ export function checkParams(
}
/**
* Check the `@returns` half of the completeness contract: a non-`void` /
* `Promise<void>` return needs a non-empty `@returns`, and the return type must
* be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void
* declaration `@returns` stays optional (resolution timing can be worth
* documenting), never required. Violations append to `violations` in place.
* Check the `@returns` half of the completeness contract: a non-`void` / `Promise<void>`
* return needs a non-empty `@returns`, and the return type must be ANNOTATED — a pure-AST
* walk cannot classify an inferred return.
*
* @param where - the offender label violations open with.
* @param typeNode - the declared return type annotation, or undefined when inferred.
* @param returns - the parsed `@returns` description from parseTags (null when absent).

View File

@@ -7,10 +7,7 @@ import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
// publint every harness package. Packages live at packages/<group>/<pkg>
// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private
// upstream code and examples/ are not packages, both out of scope. Derived
// from the hierarchy so a new package needs no edit here.
// publint every harness package.
const root = resolve(import.meta.dirname, '..')
const packagesRoot = resolve(root, 'packages')

View File

@@ -1,19 +1,8 @@
/**
* Shared source of truth for the RFC index: the tree walker (structure rules)
* and the README table renderer. `gen-rfc-index.ts` writes the generated
* regions; `verify-rfc-classification.ts` checks structure and asserts the
* committed regions are fresh. Pure module — no side effects on import.
*
* The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)):
* every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the
* folder IS the label, and both sets are CLOSED — extending either means
* amending this module AND the README's Classification prose.
*
* The index (`docs/rfc/INDEX.md`) is GENERATED in full: per-lifecycle sections
* whose rows are derived from each RFC's path (lifecycle/class), H1 (title,
* with an optional `RFC: ` prefix stripped), and filename date, sorted by date
* then filename. The curated prose lives in README.md, which carries no index
* rows at all.
* Shared source of truth for the RFC index: the tree walker (structure rules) and the README
* table renderer. `gen-rfc-index.ts` writes the generated regions;
* `verify-rfc-classification.ts` checks structure and asserts the committed regions are fresh.
* Pure module — no side effects on import.
*/
import { readFileSync, readdirSync } from 'node:fs'

View File

@@ -1,30 +1,7 @@
/**
* Doc-sync gate: enforce word-count ceilings on the standing docs that accrete
* (docs/AGENTS.md § "Budgets and the ceiling gate"). Instruction files and the
* architecture overview grow a paragraph per PR unless something pushes back;
* this gate is the pushback — when a ceiling is hit, the fix is to relocate or
* condense per the documentation standard, not to raise the ceiling. Raising a
* ceiling is allowed but is a deliberate, reviewable manifest diff that the PR
* description must justify.
*
* Scope is deliberately NARROW: only the files listed in
* scripts/doc-budgets.manifest.json (path → max words). Reference docs, RFCs,
* and package READMEs are unbudgeted — length is legitimate there (a feature
* matrix is the right kind of long), and the standard governs them through
* review, not a ceiling.
*
* The manifest is an enforcement frontier, i18n-rollout style: a ceiling sits
* at least 5% above the doc's current size (working headroom, so routine
* wording edits pass while real growth trips the gate) and ratchets DOWN,
* keeping that margin, as the doc is brought to its target budget. A manifest entry whose file is missing
* fails the gate, so a rename cannot silently orphan its budget.
*
* Words are counted `wc -w` style over the whole file (whitespace-delimited
* tokens, fenced code included) so a ceiling is reproducible with standard
* tools. This is a checker, not a formatter: it reports and never rewrites.
*
* Run: `tsx scripts/verify-doc-budgets.ts` (or `--list` to print every
* budgeted doc's current count vs ceiling without failing).
* Enforce `wc -w`-style ceilings from `scripts/doc-budgets.manifest.json`.
* Missing files and invalid ceilings fail; `--list` reports current usage.
* Ceiling changes remain reviewable manifest edits.
*/
import { existsSync, readFileSync } from 'node:fs'

View File

@@ -1,29 +1,6 @@
/**
* 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 § Where New Behavior Goes`. `verify-md-links` parses Markdown
* link AST and never sees these, so a doc rename or move could silently orphan
* a `.ts` comment that points at it. The RFC classification reorg
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
* 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 § Where New Behavior Goes` — the section suffix is outside the
* token) is left alone rather than misread as a path. Each token is resolved
* ROOT-RELATIVE (the way the comments are written) and must exist on disk. This
* is checker, not fixer: it reports and never rewrites.
*
* 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`.
* Verify root-relative `docs/*.md` tokens in repo-authored TypeScript. The
* textual scan requires the extension and excludes built and vendored source.
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
@@ -38,12 +15,7 @@ const PATTERNS = ['packages/**/*.ts', 'examples/**/*.ts']
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 § Where New Behavior Goes`) is not swallowed into the path.
*/
/** Root-relative Markdown path token, excluding trailing prose. */
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
/** A broken doc reference: a root-relative `docs/….md` token with no file. */

View File

@@ -1,77 +1,9 @@
/**
* Verify JSDoc completeness for EVERY module-level exported name of every
* non-vendored package (each `packages/<group>/<pkg>/src/` tree). This is the
* mechanical form of the AGENTS.md rule "every export has a JSDoc explaining
* semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`,
* which owns `interface Events` members and `ctx.<key>` service classes) to
* the whole export surface; the parsing + check helpers are shared via
* `scripts/jsdoc.ts` so "documented" means the same thing on both.
*
* `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender
*
* The contract, per exported declaration kind:
*
* - Every exported name needs JSDoc with non-empty description prose (prose
* ends at the first block tag, standard JSDoc semantics).
* - A function-like export (function declaration, a const with a function
* initializer or an INLINE callable annotation, or a non-identifier
* function default export) additionally needs a non-empty `@param` per
* parameter (`this` receiver annotations exempt; a stale `@param` errors)
* and a non-empty `@returns` unless the return type is `void` /
* `Promise<void>`. Wrapper expressions (parentheses, `as` / `satisfies`
* casts, non-null assertions) are peeled before classifying. The walk
* classifies returns syntactically, so the return type must be ANNOTATED —
* except a const whose declarator is annotated with a NAMED type (e.g.
* `export const f: Handler = …`), where that type's own declaration owns
* the signature contract and `@returns` stays optional; an inline
* `(x: T) => U` annotation or single-call-signature literal is the surface
* signature itself and gets the full contract, and a literal mixing
* call/construct signatures with anything else is refused (extract a named
* type).
* - An exported class needs class-level JSDoc; its public methods (static
* included — they are reachable on the exported name) follow the function
* contract, and public properties and accessors need description prose (on
* a get/set pair the getter's doc covers both). A member declared by an
* `extends`/`implements` heritage type is EXEMPT — the seam declaration is
* the doc's one home, the IDE inherits it, and re-documenting every
* implementation invites drift — UNLESS the override grows surface the
* base never documented: a protected-only base member does not exempt a
* public override, parameters the base never names keep their `@param`
* duty, and a concrete result above a void base return keeps its
* `@returns` duty. Heritage members (and classifying an unannotated
* override's inferred return above a void base) are the questions the walk
* asks the TYPE CHECKER; everything else is pure AST.
* Constructors are exempt like the cordis gate's: plugin classes are
* framework-constructed, and the class doc owns the story.
* - Exported interfaces, type aliases, enums: description prose on the
* declaration (member-level docs stay review's job; the highest-value
* member surface — seam service classes — is already under the cordis
* gate).
* - An exported namespace recurses (its exported members are package
* surface; in an ambient `declare` namespace every member exports
* implicitly); the namespace itself needs prose only when it does not
* merge with an already-documented same-name declaration (the
* Config-namespace idiom documents the class/function once, not twice).
* - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
* / `reusable` / `Config` consts and the `apply` entry, plus the same
* slots as statics on a plugin class. Their shape is fixed by the
* framework, so a doc would restate the protocol — the module doc comment
* and the `interface Config` carry the plugin's real semantics. (These
* names are reserved by cordis convention; documenting one anyway is
* allowed, only absence goes unchecked.)
* - Overload groups: each overload signature carries its own docs; the
* implementation signature is exempt (callers never see it).
* - Skipped: `declare module` / `declare global` augmentation bodies (the
* cordis gate's turf; an augmentation is not an export of the package) and
* re-export statements with a module specifier (`export … from`) — the
* defining module is walked on its own, and external definitions are not
* ours to document. An `export import X = N.member` alias documents
* ITSELF, and only prose-only target kinds are gate-supported: a callable,
* class, or namespace target carries signature/member contracts the alias
* cannot hold and is refused (export the declaration directly).
* - Everything else fails CLOSED: `export =` is refused outright, and an
* exported statement kind the dispatch does not recognize is itself a
* violation, so no export form can pass unchecked by omission.
* Enforce JSDoc on every non-vendored package export. Functions and public
* class methods require parameter and non-void return documentation; exported
* declarations require description prose. Framework protocol slots,
* constructors, inherited members, augmentations, and source re-exports keep
* their documentation at the declaring contract. Unknown export forms fail.
*/
import { existsSync, globSync } from 'node:fs'
@@ -162,29 +94,12 @@ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'r
}
/**
* The heritage-member exemption for one class member. When the member's name
* is declared by an `extends`/`implements` heritage type, the seam declaration
* is the doc's one home (the IDE inherits it on hover) and the member needs no
* doc of its own — EXCEPT where the override grows public surface the base
* never documented: a base member that is protected on every declaration does
* not exempt a public override (consumers could not call it before);
* parameters the base never names keep their own `@param` duty (the caller
* reads the seam doc, which cannot describe them; an underscore-prefixed
* rename of a base parameter — the deliberately-unused marker — is the same
* parameter, not new surface); and a void base return carried no `@returns`
* duty, so an override returning a concrete result documents it itself.
* Static members are looked up on the base CONSTRUCTOR type (only an
* `extends` expression has one; an unresolvable or interface expression
* yields no property and therefore no exemption).
* Find inherited documentation for a class member without exempting newly public surface.
* @param cls - the class whose heritage to search.
* @param name - the member name to look up.
* @param staticSide - whether to search the constructor side instead of the instance side.
* @param checker - the program's type checker.
* @returns null when no exemption applies; otherwise the parameter names the
* base declarations carry (`baseParams: null` when not syntactically
* recoverable — a complex heritage type — exempting all parameters) plus
* whether every recoverable base return annotation is `void`-like
* (`baseVoidReturn: null` when none is recoverable, exempting the result).
* @returns inherited parameter and return coverage, or `null` when none applies.
*/
function heritageExemption(
cls: ts.ClassDeclaration,
@@ -326,11 +241,8 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
checkParams(where, 'export', m.parameters, parseTags(raw).params, w.sf,
p => thisReceiver(p) || inBase(p), w.violations)
}
// A void base return carried no @returns duty, so an override growing
// a concrete result documents it itself. An annotated override runs
// the standard check; an inferred one is classified by the checker
// (this branch is already the checker's domain), so a faithful void
// override stays exempt without a boilerplate annotation.
// A void base return carried no @returns duty, so an override growing a concrete result
// documents it itself.
if (exemption.baseVoidReturn === true) {
if (m.type !== undefined) {
checkReturns(where, m.type, parseTags(raw).returns, w.sf, w.violations)
@@ -354,20 +266,14 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
}
/**
* Check one exported declaration statement, dispatching on its kind. Any
* exported statement kind the dispatch does not recognize is a violation
* (fail closed), so no export form can pass unchecked by omission.
* @param stmt - the exported statement (export modifier or export-list target).
* @param prefix - the namespace qualification for surface names ('' at top level).
* @param overloadSigs - names in this scope declared as bodyless function overload signatures.
* @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
* @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly.
* @param w - the walk state violations append to.
* @param only - for a multi-declarator variable statement reached through an
* export list (or a default-export identifier), the declarator names that
* are actually exported; `null` means the whole statement is surface
* (direct `export` modifier or ambient scope). Non-variable statements
* declare exactly one name, so the filter never applies to them.
* Check one exported declaration.
* @param stmt - exported statement.
* @param prefix - namespace qualifier.
* @param overloadSigs - bodyless overload names.
* @param byName - declarations keyed by name.
* @param ambient - whether exports are implicit.
* @param w - walk state.
* @param only - selected declarators, or all.
*/
function checkDecl(
stmt: ts.Statement,
@@ -455,13 +361,9 @@ function checkDecl(
}
if (ts.isImportEqualsDeclaration(stmt)) {
const where = `exported alias '${prefix}${stmt.name.text}'${at(stmt)}`
// An alias is a distinct exported name whose target may be a non-exported
// namespace member no walk ever visits, so it documents ITSELF — which
// matches the gate's strength only for prose-only target kinds. A
// callable, class, or namespace target carries signature or member
// contracts the alias prose cannot hold: refuse those (fail closed) and
// demand the declaration be exported directly. An unresolvable target is
// refused for the same reason.
// An alias is a distinct exported name whose target may be a non-exported namespace member
// no walk ever visits, so it documents ITSELF — which matches the gate's strength only for
// prose-only target kinds.
const sym = w.checker.getSymbolAtLocation(stmt.name)
const target = sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0 ? w.checker.getAliasedSymbol(sym) : sym
const RICH_TARGETS = ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule
@@ -511,17 +413,7 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
}
}
}
// Two-phase dispatch. Phase one accumulates WHICH statements are surface
// and, for a variable statement reached by name (an export list or a
// default-export identifier), which of its declarators the exports actually
// name — `null` marks the whole statement as surface (a direct `export`
// modifier, or an ambient scope). Requests for the same statement merge:
// `null` absorbs any name set, and name sets union, so
// `export { a }; export { b }` over one `const a = …, b = …` checks both
// declarators while a never-exported sibling stays out of the surface.
// Phase two runs each surfaced statement exactly once. (Checking a
// statement eagerly per request would either re-check on the second list or
// — deduplicated — silently drop the second list's declarators.)
// Two-phase dispatch.
const requested = new Map<ts.Statement, Set<string> | null>()
const request = (stmt: ts.Statement, name: string | null): void => {
const prior = requested.get(stmt)
@@ -575,14 +467,8 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
}
/**
* Compiler options for the walk's program. The real repo hands over its
* tsconfig.base.json (whose `paths` map resolves cross-package imports to
* source, so heritage-member lookups see seam types); a fixture root without
* one gets `noLib` + no `@types` — fixtures are single-file and
* self-contained, nothing in the walk resolves a lib symbol, and default-lib
* parsing is ~99% of per-program cost (it made the fixture spec time out
* under CI coverage instrumentation). Emit-side options are stripped: the
* walk never emits or asks for diagnostics, it only binds types on demand.
* Compiler options for the walk's program.
*
* @param scanRoot - the root being scanned.
* @returns compiler options for ts.createProgram.
*/

View File

@@ -1,35 +1,7 @@
/**
* Doc-sync gate: verify that every relative Markdown cross-link resolves to a
* file that exists. Docs in this repo link to each other by relative path
* (`[topic](../implemented/2026-…-….md)`, `[the cookbook](adding-a-tool.md)`);
* a rename or a move silently breaks those links, and nothing caught it before
* review. The RFC tree reorganization (one `docs/rfc/` with proposed/
* implemented/ rejected/ subfolders, every file renamed to a dated slug) is the
* motivating case: ~40 inter-doc links were rewritten by hand, and a single
* fat-fingered path would have shipped a dead link.
*
* Detection is AST-based, mirroring verify-md-wrap: parse each file with
* mdast-util-from-markdown + GFM, then walk every `link`, `image`, and
* `definition` node. A target is checked when it is a RELATIVE path; these are
* skipped because they are not ours to verify:
* - absolute URLs with a scheme (`https:`, `http:`, `mailto:`, …),
* - protocol-relative URLs (`//host/path`),
* - root-absolute paths (`/foo` — no stable base in a repo checkout),
* - pure in-page anchors (`#section`).
* For a relative target the `#fragment` and `?query` are stripped, the path is
* resolved against the linking file's directory, and the result must exist on
* disk. This is checker, not fixer: it reports and never rewrites.
*
* Scope is the other doc-sync gates' set plus example Markdown, AGENTS.md
* files in those checked trees, AND the repo-authored agent-skill Markdown under
* `.agents/skills/` — those skill files cross-link into the docs tree (e.g. the
* dsh-code-review skill cites the RFC index), so a rename must not silently
* break them either: README.md, docs/** /*.md, packages/* /README.md,
* examples/** /*.md, AGENTS.md, packages/AGENTS.md, .agents/skills/** /*.md.
* The root, packages/, and examples/ CLAUDE.md files are symlinks to the
* AGENTS.md files, so they are deduped by real path.
*
* Run: `tsx scripts/verify-md-links.ts`.
* Verify that relative Markdown links, images, and definitions resolve. URL,
* root-absolute, and in-page targets are excluded; query strings and fragments
* do not affect the filesystem check. Symlinked instruction files are deduped.
*/
import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
@@ -41,10 +13,7 @@ import type { Nodes } from 'mdast'
const root = resolve(import.meta.dirname, '..')
/**
* Files to check: doc-typecheck's scope, example Markdown, the AGENTS.md pair,
* and repo-authored agent-skill Markdown.
*/
/** Repo-authored Markdown checked for relative links. */
const PATTERNS = [
'README.md',
'README.zh.md',

View File

@@ -1,30 +1,7 @@
/**
* Doc-sync gate: enforce the repo's "Markdown is not hard-wrapped" convention
* (docs/AGENTS.md § Writing rules) — prose paragraphs are written as
* one physical line per paragraph and the editor soft-wraps. A hard-wrapped
* paragraph (a one-word edit reflows and re-diffs the whole block) is a defect
* this script catches before review.
*
* Detection is AST-based: we parse each file with mdast-util-from-markdown (the
* CommonMark parser behind remark) plus the GFM extension, then flag any
* `paragraph` node whose source span covers more than one line. The parser owns
* all the structure that legitimately occupies multiple lines — fenced code
* (any fence length), tables, list items, blockquotes, HTML blocks, headings,
* thematic breaks, link-reference definitions — so a hard wrap is simply "a
* paragraph node that starts and ends on different lines." This is checker, not
* formatter: it reports and never rewrites, so it introduces zero cosmetic
* churn (no emphasis-marker or table-delimiter normalization).
*
* 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), plus generated system-prompt Markdown goldens: README.md,
* docs/** /*.md, packages/* /*.md, examples/** /system-prompt.golden.md,
* packages/** /system-prompt.golden.md, AGENTS.md, packages/AGENTS.md. The root
* and packages/ CLAUDE.md are symlinks to the AGENTS.md files, so they are
* deduped by real path.
*
* Run: `tsx scripts/verify-md-wrap.ts`.
* Reject Markdown prose paragraphs spanning multiple physical lines. The GFM
* AST distinguishes paragraphs from multiline structural nodes; symlinked
* instruction files are deduped.
*/
import { globSync, readFileSync, realpathSync } from 'node:fs'
@@ -71,8 +48,7 @@ function findViolations(absPath: string): Violation[] {
const firstLine = source.split('\n')[start.line - 1] ?? ''
out.push({ file, line: start.line, text: firstLine.trim() })
}
// A paragraph's children are inline (text/emphasis/…); no nested
// paragraphs to find, so don't descend.
// Paragraph children are inline, so no further paragraph can be nested.
return
}
if ('children' in node) {

View File

@@ -1,42 +1,8 @@
/**
* 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`.
* Find stale root-relative `packages/...` references in repo-authored prose and
* TypeScript. A missing path is reported only when it names a real package leaf;
* globs, placeholders, hypothetical packages, and unbuilt `lib/` output are
* outside the check.
*/
import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
@@ -117,14 +83,9 @@ function findViolations(absPath: string): Violation[] {
// 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.
// 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.
const parts = ref.split('/')
const libAt = parts.indexOf('lib')
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue

View File

@@ -1,31 +1,7 @@
/**
* Doc-sync gate: enforce the RFC classification scheme
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
* and the freshness of the generated index
* ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)).
* Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
* folder IS the label. This gate is the machine source of truth for the closed
* class set and keeps the generated index honest.
*
* Three checks (all against [rfc-index.ts](./rfc-index.ts), the shared walker
* and renderer):
*
* 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
* from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1.
* A loose `.md` directly under a lifecycle root (other than the
* README/AGENTS allowlist) fails; an unknown class folder fails; a stray
* file at an unexpected depth fails. This is what makes the set CLOSED: a
* new class folder can't appear without amending CLASSES (and the README's
* Classification section, per the RFC).
* 2. FRESHNESS — the committed `docs/rfc/INDEX.md` byte-matches a fresh render
* from the tree, so every RFC is listed exactly once, under the heading
* matching its path, with its H1 title and filename date. The fix for a
* stale index is `pnpm run gen-rfc-index`, never a hand edit. This is
* checker, not fixer: it reports and never rewrites.
* 3. NO STRAY ROWS — `docs/rfc/README.md` (the curated front door) carries no
* index-shaped table rows; the list lives only in the generated INDEX.md.
*
* Run: `tsx scripts/verify-rfc-classification.ts`.
* Enforce RFC lifecycle/class paths, dated filenames, and titles; verify the
* generated index and reject index rows in the curated README. Structural rules
* and rendering are shared with `rfc-index.ts`.
*/
import { readFileSync } from 'node:fs'

View File

@@ -1,31 +1,7 @@
/**
* Doc-sync gate: enforce the RFC in-file format
* ([README.md § The file format](../docs/rfc/README.md), the contract; rationale in
* [the uniform-format RFC](../docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md)).
* The classification gate owns WHERE a file sits and how it is named; this gate
* owns what is INSIDE: the header block, the per-lifecycle body skeleton, and
* the Alternatives-considered mandate.
*
* Per English RFC (`.zh.md` counterparts are the pairing gate's concern):
*
* 1. HEADER — line 1 is `# RFC: <title>`, line 2 blank, line 3 the one
* `Status:` line in the file, line 4 blank. The status is the dateless enum
* matching the lifecycle folder: `Status: proposed`, `Status: implemented`,
* or `Status: rejected — <reason>`.
* 2. SKELETON — the first `##` section is `## Problem`; the lifecycle's
* required sections are present under their canonical names (`proposed/`:
* Proposal, Acceptance criteria, Risks; `implemented/`: Decision,
* Consequences; `rejected/`: Proposal); `implemented/` must not carry the
* proposal-era headings (Proposal, Plan, Migration plan, Acceptance
* criteria) that the docs standard's slop checklist outlaws there.
* 3. ALTERNATIVES — `## Alternatives considered` is present, or the file is a
* pre-format RFC (dated before the format landed) carrying the exact
* grandfather comment instead. Carrying both, or grandfathering a
* post-format RFC, fails.
* 4. DEBT MARKER — the retired legacy-format debt comment may not reappear.
*
* Checker, not fixer: it reports and never rewrites.
* Run: `tsx scripts/verify-rfc-format.ts`.
* Enforce RFC headers, lifecycle-specific sections, alternatives, and retired
* marker rules. Classification and filenames belong to the sibling tree gate;
* translation structure belongs to the pairing gate.
*/
import { readFileSync } from 'node:fs'
@@ -65,9 +41,7 @@ for (const rfc of rfcs) {
errors.push(`format: ${rfc.rel}${msg}`)
}
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
// Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a
// status line, a banned heading, or the grandfather comment inside a fence
// (the README's own format section does), and only real prose counts.
// Format tokens inside fenced examples are not document structure.
let inFence = false
const prose = lines.filter((l) => {
if (l.startsWith('```')) {

View File

@@ -1,19 +1,9 @@
/**
* Scoped-dispatch drift gate: the set of scope-filtered events is declared in
* TWO places that must never diverge — the dev-invariants runtime table (the
* `scopedSubject` map in `packages/support/invariants/src/index.ts`, which
* enforces carriers at dispatch time) and the event declarations' JSDoc (the
* "Scope-filtered dispatch" sentence rendered into the events catalog, which
* tells plugin authors what a scoped listener will and won't hear). An event
* added to one side without the other either silently escapes runtime
* enforcement or documents filtering that never happens; this gate fails the
* build instead.
*
* Sources of truth: the invariant table is parsed from the invariants source;
* the documented set is parsed from every `declare module 'cordis'` Events
* JSDoc in packages/*\/*\/src carrying the marker sentence. Registry-subject
* notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`)
* are deliberately unfiltered and must appear in NEITHER set.
* Scoped-dispatch drift gate: the set of scope-filtered events is declared in TWO places that
* must never diverge — the dev-invariants runtime table (the `scopedSubject` map in
* `packages/support/invariants/src/index.ts`, which enforces carriers at dispatch time) and
* the event declarations' JSDoc (the "Scope-filtered dispatch" sentence rendered into the
* events catalog, which tells plugin authors what a scoped listener will and won't hear).
*/
import { globSync, readFileSync } from 'node:fs'

View File

@@ -1,46 +1,8 @@
/**
* Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md).
* English and Chinese carry EQUAL authority — either language may be authored
* first — so consistency is recorded per pair in a sidecar metadata file,
* `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last
* time a human confirmed the two say the same thing:
*
* foo.md: <40-hex blob hash>
* foo.zh.md: <40-hex blob hash>
*
* The gate checks, mechanically, the checkable half of the contract:
*
* 1. Every file in the manifest's `required` list has a COMPLETE pair
* (the enforcement frontier — grows batch by batch).
* 2. Every pair that exists at all is complete and consistent: all three
* files present (a `.zh.md` or a `.i18n.yaml` without its counterparts
* is an error — pairs merge whole, never half), each side's current
* blob hash equals the recorded one (an edit to EITHER side without a
* re-confirmed counterpart goes red), both sides carry the language
* switcher, and the structural signatures match one to one — heading
* depths in order, fenced code blocks VERBATIM (info string + content),
* table column counts, list kinds, and every link target except the
* switcher itself.
* 3. `excluded` files (generated docs, agent instructions, the bilingual
* terminology table) have no `.zh.md` and no `.i18n.yaml` at all.
*
* What it deliberately does NOT check is translation quality or which side
* is "right": a green gate means the pair was confirmed consistent at these
* exact contents, not that the confirmation was sound — accuracy,
* terminology, and tone are the human reviewer's half of the contract
* (docs/i18n/translation-rules.md).
*
* Blob hashes, not commit hashes, so a pair edited in the same PR verifies
* without any history lookup: consistency is a pure content comparison,
* computed here directly (sha1 of `blob <size>\0<content>`) without spawning
* git. The recorded hash also recovers the last-confirmed text of either
* side (`git cat-file -p <hash>`) for diff-based minimal updates.
*
* Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to
* print the pairing state of every in-scope document as a work list (always
* exits 0), or with `--write` to (re)record both hashes for every complete
* pair after you have brought the two sides back in line (the resulting
* yaml diff is the reviewable act of confirming consistency).
* Enforce complete English/Chinese pairs, matching structure, and recorded git
* blob hashes under the bilingual manifest. `--list` reports state; `--write`
* records both sides after human review. Translation quality remains a review
* responsibility.
*/
import { createHash } from 'node:crypto'

View File

@@ -1,25 +1,7 @@
/**
* 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`.
* Verify every `ts type-equiv` block against the source symbol named by the
* manifest. Blocks and entries have a one-to-one relationship; comparison
* ignores comments and whitespace but preserves declaration structure.
*/
import { globSync, readFileSync, existsSync } from 'node:fs'
@@ -28,13 +10,7 @@ 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.
*/
/** Markdown scope shared with doc-typecheck. */
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
/** One manifest entry: a documented type-equiv block and its source symbol. */
@@ -58,12 +34,7 @@ interface EquivBlock {
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. */
/** Remove comments and normalize whitespace for structural comparison. */
function normalize(code: string): string {
return code
.replace(/\/\*[\s\S]*?\*\//g, '')
@@ -72,8 +43,7 @@ function normalize(code: string): string {
.trim()
}
/** Strip a leading `export ` / `export default ` modifier — the doc block shows
* the bare declaration, the source carries the export modifier. */
/** Strip source-only export modifiers. */
function stripExport(code: string): string {
return code.replace(/^export\s+(default\s+)?/, '')
}