docs: trim generated prose
This commit is contained in:
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}'.`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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('```')) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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+)?/, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user