Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/cordis.yml
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	packages/bash/bash-sandbox/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/tool-bash/src/index.ts
#	packages/fs/fs/src/index.ts
#	packages/sandbox/sandbox-policy/src/session-mode.ts
#	packages/ui/permission/src/index.ts
#	packages/ui/permission/tests/permission.spec.ts
#	scripts/doc-budgets.manifest.json
This commit is contained in:
kingwl
2026-07-14 21:25:36 +08:00
608 changed files with 7195 additions and 12508 deletions

View File

@@ -1,49 +1,9 @@
/**
* Build the single-file SDK runtime executables
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
*
* Every settled decision is hardcoded — the PoC judged @yao-pkg/pkg's
* standard mode unusable for this architecture (its ESM→CJS transform breaks
* every runtime `import()`), so the pipeline is fixed on `--sea` mode, plain
* ESM entry, plain-source assets, and a hoisted (symlink-free) staged tree.
*
* Pipeline — every step fails loud with the command it ran:
*
* 1. `pnpm run build` — all packages emit `lib/` (skippable via --skip-build).
* 2. `pnpm --filter dsh-jsonrpc-agent-pkg deploy` — materialize the
* closure-manifest package (python/sdk-runtime/package.json — the single
* source of truth for the exe's plugin set) into the staging dir
* (cleared first; pnpm refuses a non-empty deploy target). Flags, all
* verified against pnpm 11.7: `--legacy` because the workspace does not
* set `inject-workspace-packages=true`; `node-linker=hoisted` for a plain
* file tree with zero symlinks (the safe shape for pkg's VFS, and it
* physically guarantees a single cordis copy); `auto-install-peers=false`
* so transitive `^0.0.x` peers on unpublished packages never hit the
* registry; `link-workspace-packages=true` so the closure resolves to
* workspace/vendor sources.
* 3. Inject the pkg config into the staged package.json: `bin` = the ESM
* `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` (SEA mode
* hands it to Node's default ESM loader — no CJS shim), plus whole-tree
* asset globs. The cordis Loader resolves plugins
* through runtime dynamic `import()` of bare package names, so pkg's
* static analysis discovers none of them — the entire staged tree must be
* globbed in explicitly.
* 4. `pnpm dlx @yao-pkg/pkg@<pinned> <staging> --sea --targets <t> --output
* <out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>` — once per target (SEA mode
* packs a single target per invocation), so each product gets its
* canonical name directly.
* 5. Sync into the Python runtime package
* (python/sdk-runtime/src/deepseek_harness_runtime/runtime/,
* created if missing): each product under its canonical filename (exe
* mode), plus the whole staged closure into runtime/node/ (node mode —
* `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`
* runs it directly; the injected pkg
* fields are harmless to node). dist-exe/ keeps the originals for CI
* artifact upload.
*
* `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` → host-platform exe into dist-exe/
* `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64`
* `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --dry-run` → print the plan without executing
* Build the SDK runtime executables and Python node carrier. The fixed
* `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
* The staged closure is symlink-free, and whole-tree assets cover Cordis's
* runtime imports that pkg cannot discover statically.
*/
import { spawn } from 'node:child_process'
@@ -54,43 +14,27 @@ import { parseArgs } from 'node:util'
const root = resolve(import.meta.dirname, '..')
/**
* The deploy root: the closure-manifest package (python/sdk-runtime) whose
* dependencies define the exe's contents; the runnable entry inside the
* closure is {@link ENTRY_BIN}.
*/
/** The closure manifest whose dependencies define the executable. */
const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
/** The bin entry inside the deployed closure (the dsh-jsonrpc-agent app bin). */
/** The app entry inside the deployed closure. */
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js'
/** Basename of every product; the canonical name appends `-<platform>-<arch>`. */
const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
/** Default exe Node major; SEA mode requires >= node22, the repo tracks node24. */
/** Default Node major; SEA mode requires at least Node 22. */
const DEFAULT_NODE_RANGE = 'node24'
/** Pinned pkg version (the one the PoC and acceptance ran on) for reproducible builds. */
/** Pinned for reproducible builds. */
const PKG_SPEC = '@yao-pkg/pkg@6.21.0'
/** Staging dir for the deployed closure — cleared on every run (gitignored). */
// (No external staging dir: the deploy target IS the Python runtime's
// node-mode carrier — see PYTHON_RUNTIME_DIR/PYTHON_NODE_SUBDIR.)
/** Product output dir (gitignored). */
const OUT_DIR = 'dist-exe'
/**
* Python runtime package dir the products are synced into. A parallel change
* owns the directory and its .gitignore; this script's only contract is the
* destination path, so a missing dir is created, never an error.
*/
/** Python package destination; created when absent. */
const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
/** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */
/** The deployed closure doubles as the node-mode carrier. */
const PYTHON_NODE_SUBDIR = 'node'
/** Deploy-root documentation is not runtime input and violates the generated-directory i18n exclusion if retained. */
/** Documentation excluded from the generated runtime directory. */
const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
/**
* Whole-tree asset globs. The cordis Loader dynamic-imports bare package names
* at runtime, invisible to pkg's static analysis, so every runtime file in the
* closure is listed; SEA mode ships them as plain source in the VFS. Every
* package.json must ride along — bare-name resolution dies without them (the
* json glob would already match, but the manifests are resolution-critical, so
* they get their own explicit entry).
* Whole-tree assets cover Cordis's runtime bare-package imports, which pkg's
* static analysis cannot see. Package manifests are explicit because bare-name
* resolution depends on them.
*/
const ASSET_GLOBS = [
'package.json',
@@ -108,24 +52,20 @@ const ARCHES = ['x64', 'arm64'] as const
type Platform = (typeof PLATFORMS)[number]
type Arch = (typeof ARCHES)[number]
/** True when `value` is a supported pkg platform tag. */
function isPlatform(value: string): value is Platform {
return (PLATFORMS as readonly string[]).includes(value)
}
/** True when `value` is a supported pkg CPU tag. */
function isArch(value: string): value is Arch {
return (ARCHES as readonly string[]).includes(value)
}
/**
* One pkg target triple, e.g. `node24-linux-x64`, as an immutable value.
* Construction goes through {@link Target.parse} (a `--targets` entry) or
* {@link Target.host} (the default), which own all validation.
* A parsed pkg target triple, constructed from `--targets` or the host.
*/
class Target {
private constructor(
/** pkg Node range (`node<major>`); pins the official base binary pkg pulls. */
/** pkg Node range (`node<major>`). */
readonly nodeRange: string,
/**
* pkg platform tag. Windows is a documented non-goal
@@ -142,7 +82,7 @@ class Target {
}
/**
* Parse and validate one target spec; throws on any malformed component.
* Parse one target spec, rejecting malformed triples and unsupported platform or architecture.
* @param spec - the raw triple, e.g. `node24-linux-x64`.
* @returns the parsed target.
*/
@@ -156,7 +96,7 @@ class Target {
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`)
}
if (!isPlatform(platform)) {
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')} (Windows is a docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md non-goal), got ${JSON.stringify(platform)}.`)
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')}, got ${JSON.stringify(platform)}.`)
}
if (!isArch(arch)) {
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
@@ -165,7 +105,7 @@ class Target {
}
/**
* The default target when --targets is omitted: the host platform on node24.
* Resolve the host-platform default on Node 24.
* @returns the host target; throws on an unsupported host platform or arch.
*/
static host(): Target {
@@ -182,9 +122,7 @@ class Target {
}
/**
* Parsed CLI configuration. {@link BuildCli.parse} is the only constructor
* path — it owns flag parsing, target validation, and the --help / bad-flag
* process exits, so an instance always holds a valid plan.
* Validated CLI configuration; construction owns help and parse-error exits.
*/
class BuildCli {
private constructor(
@@ -197,9 +135,8 @@ class BuildCli {
) {}
/**
* Parse argv into a validated configuration. Exits the process for --help
* (code 0, usage) and for unknown/malformed flags (code 1, usage on
* stderr); throws on invalid or colliding targets.
* Parse argv. Help exits 0; malformed flags exit 1; invalid or colliding
* targets throw.
* @param argv - the raw arguments (`process.argv.slice(2)`).
* @returns the parsed, validated configuration.
*/
@@ -231,7 +168,6 @@ class BuildCli {
return new BuildCli(targets, values['skip-build'], values['dry-run'])
}
/** The flag grammar in one place; parseArgs throws on any unknown flag. */
private static parseRaw(argv: string[]) {
return parseArgs({
args: argv,
@@ -244,7 +180,6 @@ class BuildCli {
}).values
}
/** The --help text; also printed under flag-parse errors. */
private static usage(): string {
return [
'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
@@ -255,21 +190,18 @@ class BuildCli {
' --dry-run print every command and config patch without executing.',
' --help print this help.',
'',
'Settled decisions are hardcoded (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md): pkg runs in --sea mode',
`(standard mode breaks runtime import()), pinned to ${PKG_SPEC}; the deploy tree is`,
`hoisted/symlink-free; the closure deploys straight into ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and products land in ${OUT_DIR}/.`,
`Build route: ${PKG_SPEC} --sea; see docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
].join('\n')
}
}
/** The pnpm executable name for the host OS. */
function pnpmBin(): string {
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
}
/**
* Render a command line for logs and error messages, quoting arguments that
* contain spaces.
* Render a command for logs and errors, quoting arguments with spaces.
* @param command - the executable.
* @param args - its arguments.
* @returns the printable command line.
@@ -279,30 +211,25 @@ function formatCommand(command: string, args: string[]): string {
}
/**
* The four-step build pipeline over one parsed CLI. Steps are sequential
* async methods; every subprocess inherits stdio and fails loud with the
* exact command it ran. In --dry-run the command/filesystem layer prints
* what it would do instead of executing.
* Sequential build pipeline. Subprocesses inherit stdio and errors include
* the command; dry runs print commands and filesystem changes.
*/
class SingleExeBuild {
/**
* Absolute staging dir — the Python runtime's node-mode carrier: step 2
* deploys the closure DIRECTLY here (cleared first; it is a pure build
* product, the checked-in default `cordis.yml` lives one level up), step 4
* reads it as the pkg input, and node mode runs it in place.
* The cleared deploy target, pkg input, and Python node-mode carrier. The
* checked-in default `cordis.yml` remains in its parent directory.
*/
readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR)
/** Absolute product output dir. */
private readonly outDir = resolve(root, OUT_DIR)
constructor(private readonly cli: BuildCli) {}
/** Gate the manifest before spending time compiling or packaging it. */
/** Verify the closure before compiling or packaging. */
async verifyClosure(): Promise<void> {
await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure'])
}
/** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */
/** Build all package artifacts unless `--skip-build` was passed. */
async build(): Promise<void> {
if (this.cli.skipBuild) {
console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
@@ -311,7 +238,7 @@ class SingleExeBuild {
await this.run('build', pnpmBin(), ['run', 'build'])
}
/** Step 2: clear the staging dir and deploy the bridge closure into it. */
/** Clear and deploy the runtime closure into the node carrier. */
async deployStaging(): Promise<void> {
if (this.staging === root || root.startsWith(this.staging + sep)) {
throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`)
@@ -336,7 +263,7 @@ class SingleExeBuild {
}
}
/** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */
/** Add the executable entry and pkg assets to the staged manifest. */
async injectPkgConfig(): Promise<void> {
const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
const manifestPath = join(this.staging, 'package.json')
@@ -356,8 +283,7 @@ class SingleExeBuild {
}
/**
* Step 4: run @yao-pkg/pkg over the staged tree for ONE target (SEA mode
* packs a single target per invocation) and return the product path.
* Package one target; SEA mode accepts one target per invocation.
* @param target - the pkg target triple to build.
* @returns the canonical product path `<out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>`.
*/
@@ -381,7 +307,7 @@ class SingleExeBuild {
}
/**
* Print each product path (and size, when it exists on disk).
* Print each product path and, outside dry-run mode, its size.
* @param products - the product paths returned by {@link pack}.
*/
printProducts(products: string[]): void {
@@ -397,10 +323,8 @@ class SingleExeBuild {
}
/**
* Step 5: copy every product into the Python runtime package under its
* canonical filename (exe mode). The node-mode carrier needs no sync — step
* 2 deployed the closure into it directly. dist-exe/ keeps the originals
* for CI artifact upload; the destination dir is created if missing.
* Copy each executable into the Python runtime package. The deployed node
* carrier is already in place, and `dist-exe/` retains upload copies.
* @param products - the product paths returned by {@link pack}.
*/
async syncToPythonRuntime(products: string[]): Promise<void> {
@@ -420,9 +344,8 @@ class SingleExeBuild {
}
/**
* Run one pipeline step as a subprocess with inherited stdio; reject —
* carrying the printable command — on spawn failure and non-zero exit
* alike. In --dry-run, print the command instead of executing.
* Run one subprocess with inherited stdio. Spawn and non-zero-exit errors
* include the command; dry runs only print it.
* @param label - the step name used in logs and error messages.
* @param command - the executable.
* @param args - its arguments.
@@ -451,7 +374,6 @@ class SingleExeBuild {
}
}
/** Entry point: parse the CLI, then await each pipeline step in order. */
async function main(): Promise<void> {
const cli = BuildCli.parse(process.argv.slice(2))
const pipeline = new SingleExeBuild(cli)

View File

@@ -178,13 +178,8 @@ 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.
* Enforce `packages/<group>/<pkg>`: groups are open-named containers without a
* package.json, and packages may be neither flat nor more deeply nested.
*/
function checkHierarchyShape(): string[] {
const errors: string[] = []

View File

@@ -1,12 +1,7 @@
/**
* Boot the Code Mode demo under the UI named on the command line:
* `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the
* point — the UI is just the surface it happens to wear: each UI boots its
* base example through that example's `code-mode.cordis.yml` overlay
* (include ./cordis.yml, flip `tools.mode` to `code`, insert the
* worker-thread code runtime). Both need DEEPSEEK_API_KEY (repo-root .env
* works). Anything else on the command line is a misconfiguration and
* fails loud with usage.
* Boot the REPL or ACP Code Mode overlay, defaulting to REPL. Each overlay
* includes its base example, selects Code Mode, and adds the worker runtime.
* Both require a DeepSeek API key; unsupported arguments fail with usage.
*/
import { spawn } from 'node:child_process'

View File

@@ -1,11 +1,11 @@
{
"AGENTS.md": 1802,
"docs/AGENTS.md": 1315,
"docs/architecture.md": 1800,
"AGENTS.md": 1370,
"docs/AGENTS.md": 1100,
"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": 200,
"packages/AGENTS.md": 290,
"packages/README.md": 710
}

View File

@@ -1,27 +1,7 @@
/**
* Doc-sync gate (doc-sync-enforcement RFC, part 1): typecheck the fenced `ts` code blocks in our
* Markdown so documentation can't drift from the API it documents.
*
* Every ```ts block in README.md, docs/** and packages/* /README.md is
* extracted to a temp typecheck project and compiled against the workspace
* sources through the same project-reference boundaries used by repo
* typecheck. A block that is a deliberate sketch rather than compilable code
* opts out with an explicit ` ```ts ignore-check ` info string — the opt-out
* is visible in the source, and this script reports the ratio so the escape
* hatch can't quietly become the norm. A third info string,
* doc-typecheck.ts recognizes four more fence variants and skips all four (each
* is a separately-checked category, not an unchecked sketch, so none counts in
* the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that
* `scripts/verify-type-equiv.ts` drift-checks, ` ```ts cordis-catalog ` is a
* generated event/service signature fragment in the cordis catalog (a bare
* signature is not standalone-compilable; the catalog is generated and frozen by
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate),
* ` ```ts persistence-catalog ` is a generated log-event payload fragment in the
* persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`),
* and ` ```ts config-catalog ` is a generated verbatim config declaration in the
* plugin config catalog (same reasoning, frozen by `scripts/gen-config-catalog.ts`).
*
* Run: `tsx scripts/doc-typecheck.ts`.
* Typecheck Markdown `ts` fences against workspace sources. `ignore-check`
* fences are reported as opt-outs; generated catalog fragments and
* `type-equiv` blocks are skipped here because their owning gates verify them.
*/
import { execFileSync } from 'node:child_process'
@@ -32,28 +12,9 @@ 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.
* TypeScript-fence ownership. `check` compiles; `ignore` is an unchecked sketch
* counted in the opt-out ratio; the catalog and type-equivalence variants are
* excluded from that ratio because their owning gates verify them.
*/
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog'
@@ -66,8 +27,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 +47,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 +105,8 @@ 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.
// Only compile-eligible fences belong in the opt-out ratio; every other skipped
// kind has an independent verifier named in BlockKind's contract above.
const ratioDenominator = checked.length + ignored.length
if (checked.length === 0) {

View File

@@ -1,68 +1,10 @@
/**
* 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 every enumerable schema path must
* exist on the declared config type. External and dynamic shapes stay unknown;
* declared runtime-only fields need not appear in the schema. `--check` verifies
* the committed artifact.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -353,7 +295,7 @@ function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: Type
entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
} catch {
// A workspace package without a readable entry is reported by its own
// classification pass; for a lookup it is merely out of reach.
// classification pass; for a lookup it is out of reach.
return 'unknown'
}
return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
@@ -372,9 +314,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
// Guard only named declarations, where recursive types can loop. Structural
// children can share a source position with their parent, so guarding them
// would mistake ordinary descent for a cycle.
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
const key = `${ctx.abs}:${node.pos}:${steps.length}`
@@ -775,10 +716,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.
// Fold composed schemas' key paths in, then check each path against the type.
// Only a definite miss fails; shapes the walk cannot enumerate stay unknown.
const byName = new Map(entries.map(e => [e.pkg, e]))
for (const entry of entries) {
if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue

View File

@@ -1,27 +1,8 @@
/**
* 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. It emits first-sentence docs, raw
* signatures, transitive public type shapes, and inherited context entries,
* without source pointers; output is deterministic and `--check` verifies it.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -48,10 +29,8 @@ function quote(value: string): string {
}
/**
* 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 exported interface and type shapes; omit names declared in multiple
* packages rather than risk serving the wrong package's shape.
*/
function collectTypeDecls(scanRoot: string = root): Map<string, string> {
const printer = ts.createPrinter({ removeComments: true })
@@ -78,11 +57,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 and sort the word-bounded transitive type closure referenced by seed text. */
function referencedTypes(seeds: string[], decls: Map<string, string>): { name: string; declaration: string }[] {
const included = new Map<string, string>()
let frontier = seeds

View File

@@ -1,55 +1,8 @@
/**
* Generate (and verify) the cordis events and services catalogs in
* docs/cordis-catalog/events.md and docs/cordis-catalog/services.md.
*
* The two pages are the WIRING-axis reference, one axis each: every cordis
* event a plugin can listen to (exact signature + dispatch mode) and every
* `ctx.<key>` service it can call (exact public interface). They complement the
* core-data-structures catalog (the VOCABULARY axis — the types these
* signatures move around).
*
* The catalogs are FULLY GENERATED from source — never hand-edit them. The
* codebase is disciplined enough that a pure-AST pass captures the whole
* truthful surface: every event/service is a string literal that round-trips
* to a static `interface Events` / `interface Context` declaration (no
* dynamically-named events, no runtime-only services). So the committed files
* are build artifacts and a regenerate-and-diff freshness check (`--check`)
* makes drift structurally impossible. Because generation enumerates source
* rather than checking a hand-written subset, a brand-new event cannot be
* silently undocumented — it appears in the next regenerate, and an
* un-regenerated file fails `--check`.
*
* `tsx scripts/gen-cordis-catalog.ts` → write both catalogs
* `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if a committed
* catalog is stale (CI /
* pre-push gate)
*
* The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in
* full from source: signature, the `@mode` badge, and the declaration's JSDoc.
* Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag
* — the generator hard-errors on a missing tag, and where the signature shape is
* conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
* it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag,
* the walk enforces JSDoc COMPLETENESS on the whole harness surface (the
* jsdoc-completeness-gate RFC): every event and public service method carries
* description prose; every payload parameter has a non-empty `@param` (`this`
* receivers and the trailing waterfall `next` are exempt — next's semantics are
* documented once by the mode); a service method with a non-`void`/
* `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT
* return type annotation (a pure-AST walk cannot classify an inferred return);
* a stale `@param` naming no real parameter errors. Violations aggregate into
* ONE error listing every offender. The tags are enforcement-only: parseJsDoc
* stops prose at the first block tag, so they never change the rendered
* catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with
* the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so
* "documented" means the same thing on both surfaces. The INHERITED
* tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
* also sees; it is rendered tersely (name + one-line + source pointer) from a
* curated table in this script, NOT elevated to the harness tier's prominence.
*
* Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck
* recognizes it and skips compilation (the signatures are fragments, not
* standalone-compilable, like the ` ```ts type-equiv ` blocks).
* Generate the Cordis event and service catalogs from static declarations.
* The walk enforces event modes plus JSDoc parameter/return completeness;
* inherited Cordis services come from the curated table below. `--check`
* verifies both committed artifacts.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -66,19 +19,11 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md'
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.
* One primary core-data-structures page per signature type, shared by the
* Cordis and config catalogs; union names intentionally do not reuse the
* type-equivalence manifest's map-symbol entries.
*/
// TODO(catalog-type-links): verify or generate link-map coverage.
export const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
ContentBlock: 'core.md',
@@ -216,10 +161,8 @@ 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. The `this` receiver is not
// payload, and a waterfall's trailing `next` is covered by its mode.
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
@@ -270,10 +213,8 @@ 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 instance methods callable through `ctx.<key>` are surface;
// private, protected, and static methods are not.
const nonPublic = member.modifiers?.some(m =>
m.kind === ts.SyntaxKind.PrivateKeyword
|| m.kind === ts.SyntaxKind.ProtectedKeyword

View File

@@ -1,21 +1,8 @@
/**
* 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
* Generate the relationship layer above the module, Cordis, and tool catalogs.
* Enumerable facts come from source; hybrid graphs add manifests for policy the
* source cannot infer, while curated graphs explain flow and ownership.
* `--check` verifies the generated set.
*/
import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
@@ -585,13 +572,8 @@ 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 are conventional names. Keep this list in sync
// with renames or the relationship matrix can silently lose an edge.
return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
}
@@ -638,13 +620,8 @@ 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.
// Every declared event needs a dispatcher: zero means dead vocabulary or an
// unrecognized dispatch spelling. Listener-free extension points remain valid.
const undispatched = [...events]
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
.map(event => event.name)
@@ -771,7 +748,7 @@ function renderToolPipeline(): string {
' allResults --> context',
'```',
'',
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.',
'',
...maintenanceFooter(maintenance),
].join('\n')

View File

@@ -1,21 +1,7 @@
/**
* 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)
* Generate `docs/module-graph.md` from in-repo `peerDependencies`, the canonical
* runtime edges. The deterministic output groups packages by directory and
* renders both Mermaid and a dependency table; `--check` verifies freshness.
*/
import { resolve } from 'node:path'
@@ -112,10 +98,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.
// A missing artifact is the expected read failure. Any read failure has the
// same remedy here—regenerate—so it is reported as stale below.
committed = null
}
if (committed === content) {

View File

@@ -1,45 +1,9 @@
/**
* 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. This is the durable-record vocabulary,
* not the live Cordis bus. Event declarations must be unique, explicitly typed,
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
* surface-union member must resolve to one. `--check` verifies the artifact.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -57,14 +21,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',
@@ -99,12 +56,9 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
const printer = ts.createPrinter({ removeComments: true })
/**
* One-line payload text for a member's type annotation. Printed through the
* TypeScript printer (not sliced from source text): the printer emits `;`
* member separators regardless of how the source separated them, so a
* multi-line newline-separated type literal still collapses to a VALID
* single-line fragment. The trailing `;` the printer puts before every `}` is
* dropped to match the repo's inline-literal style.
* Render a member type on one line through the TypeScript printer, which adds
* semicolon separators. Drop its trailing semicolon before `}` to match the
* repository's inline-literal style.
*/
function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
return printer.printNode(ts.EmitHint.Unspecified, type, sf)
@@ -155,16 +109,8 @@ 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 every `SessionEventMap` merge, rejecting inherited, non-literal,
* untyped, undocumented, duplicate, or incorrectly owned members in one report.
*/
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
const entries: LogEventEntry[] = []
@@ -179,11 +125,9 @@ 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 has one home: the single exported declaration in
// the owning package. Same-named interfaces elsewhere are different
// types and must not enter the on-disk catalog.
const pkg = packageNameFor(rel, scanRoot)
if (pkg !== SESSION_MODULE) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`)

View File

@@ -1,36 +1,9 @@
/**
* 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. Rationale and ownership live in
* `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -64,17 +37,9 @@ 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 its hand-maintained boot recipe. The caller mounts the
* prompt and registry; each recipe supplies only package-specific seams and
* config, while `dir` participates in the completeness check.
*/
interface ToolPackage {
/** The npm package name, used as the catalog section heading. */
@@ -174,9 +139,8 @@ 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 needs `fs`; the bare provider is sufficient because policy
// changes behavior, not schema shape.
await ctx.plugin(LocalFileSystem)
await ctx.plugin(ToolFs)
},
@@ -249,10 +213,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.
// Mount search and fetch providers so both tools register. Their schemas
// do not depend on provider identity or availability.
await ctx.plugin(WebService)
await ctx.plugin(WebSearchExa)
await ctx.plugin(WebFetchLocal)

View File

@@ -1,13 +1,6 @@
/**
* Shared JSDoc parsing and completeness-check helpers for the documentation
* gates: the cordis and persistence catalog generators
* (`scripts/gen-cordis-catalog.ts` / `scripts/gen-persistence-catalog.ts`),
* the plugin config catalog generator (`scripts/gen-config-catalog.ts`), and
* the export-surface gate (`scripts/verify-export-jsdoc.ts`). 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 checks for the Cordis, persistence,
* and config catalogs and the export-surface gate.
*/
import ts from 'typescript'
@@ -29,14 +22,9 @@ 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 and an optional `@mode`. Prose
* ends at the first block tag, paragraphs collapse to one line, bullet items
* remain separate lines, and `{@link X}` renders as `X`.
* @param raw - the raw comment text including the JSDoc delimiters.
* @returns the collapsed description prose, parsed valid `@mode` (or null),
* and whether any `@mode` tag was present.
@@ -94,13 +82,8 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMo
}
/**
* 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 `@param` and `@returns` descriptions, including continuation lines.
* Parameter separators are optional and `[optional]` names unwrap.
* @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).
@@ -137,17 +120,15 @@ 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.
* Require a non-empty tag for each non-exempt identifier parameter, reject
* binding-pattern parameters, and reject stale tags. Exempt parameters may
* still be documented.
* @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 binding-pattern 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 whose tag is optional, such as `this` or waterfall `next`.
* @param violations - the aggregate list violations append to.
*/
export function checkParams(
@@ -177,11 +158,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. Void returns may still carry an
* optional tag, for example to document resolution timing.
* @param where - the offender label violations open with.
* @param typeNode - the declared return type annotation, or undefined when inferred.
* @param returns - the parsed `@returns` description from parseTags (null when absent).

View File

@@ -5,6 +5,22 @@ import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import type { Nodes } from 'mdast'
/** One authored Markdown line outside fenced code and rendered-away HTML comments. */
export interface MarkdownProseLine {
/** 1-based source line number. */
index: number
/** Source text without normalization. */
raw: string
}
/** One parsed Markdown heading, retaining its authored first line and rendered text. */
export interface MarkdownHeadingLine extends MarkdownProseLine {
/** Parsed ATX or Setext heading depth. */
depth: 1 | 2 | 3 | 4 | 5 | 6
/** Rendered heading text, excluding raw HTML such as comments. */
text: string
}
/** Parse GitHub-flavored Markdown with the repository's standard extensions. */
export function parseMarkdown(source: string): Nodes {
return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
@@ -21,3 +37,107 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v
for (const child of node.children) visitMarkdown(child, visitor)
}
}
/** Text a reader sees from one Markdown node; raw HTML itself contributes none. */
function renderedText(node: Nodes): string {
if (node.type === 'text' || node.type === 'inlineCode') return node.value
if (node.type === 'image' || node.type === 'imageReference') return node.alt ?? ''
if (node.type === 'break') return ' '
if ('children' in node) return node.children.map(child => renderedText(child)).join('')
return ''
}
/** Return every parsed Markdown heading with its rendered text and source line. */
export function markdownHeadingLines(source: string): MarkdownHeadingLine[] {
const rawLines = source.split('\n')
const headings: MarkdownHeadingLine[] = []
visitMarkdown(parseMarkdown(source), (node) => {
if (node.type !== 'heading' || node.position === undefined) return
headings.push({
depth: node.depth,
index: node.position.start.line,
raw: rawLines[node.position.start.line - 1] ?? '',
text: renderedText(node),
})
})
return headings
}
type ColumnRange = readonly [start: number, end: number]
type OffsetRange = readonly [start: number, end: number]
/** Source-column ranges occupied by parsed HTML comments, keyed by source line. */
function htmlCommentRanges(source: string, rawLines: readonly string[]): Map<number, ColumnRange[]> {
const comments: OffsetRange[] = []
visitMarkdown(parseMarkdown(source), (node) => {
if (node.type !== 'html' || node.position?.start.offset === undefined) return
let cursor = 0
while (true) {
const start = node.value.indexOf('<!--', cursor)
if (start < 0) break
const close = node.value.indexOf('-->', start + '<!--'.length)
const end = close < 0 ? node.value.length : close + '-->'.length
comments.push([node.position.start.offset + start, node.position.start.offset + end])
cursor = end
}
})
const ranges = new Map<number, ColumnRange[]>()
let lineOffset = 0
rawLines.forEach((raw, index) => {
const lineEnd = lineOffset + raw.length
for (const [start, end] of comments) {
const from = Math.max(start, lineOffset)
const to = Math.min(end, lineEnd)
const coversEmptyLine = raw.length === 0 && start <= lineOffset && end > lineOffset
if (from < to || coversEmptyLine) {
const lineRanges = ranges.get(index + 1) ?? []
lineRanges.push([from - lineOffset, to - lineOffset])
ranges.set(index + 1, lineRanges)
}
}
lineOffset = lineEnd + 1
})
return ranges
}
/** Whether a source line retains non-whitespace text after HTML comments disappear. */
function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRange[] | undefined): boolean {
if (ranges === undefined) return true
let cursor = 0
let visible = ''
for (const [start, end] of [...ranges].sort((left, right) => left[0] - right[0])) {
visible += raw.slice(cursor, start)
cursor = Math.max(cursor, end)
}
visible += raw.slice(cursor)
return visible.trim().length > 0
}
/**
* Return source lines outside backtick or tilde fences and HTML comments.
* @param source - Markdown source whose prose should be retained verbatim.
* @returns unfenced lines with their original 1-based locations.
*/
export function markdownProseLines(source: string): MarkdownProseLine[] {
let fence: { marker: '`' | '~'; length: number } | undefined
const kept: MarkdownProseLine[] = []
const rawLines = source.split('\n')
const comments = htmlCommentRanges(source, rawLines)
rawLines.forEach((raw, i) => {
const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1]
if (token !== undefined) {
const marker = token[0] as '`' | '~'
if (fence === undefined) {
fence = { marker, length: token.length }
} else if (marker === fence.marker && token.length >= fence.length) {
fence = undefined
}
return
}
if (fence === undefined && hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
kept.push({ index: i + 1, raw })
}
})
return kept
}

View File

@@ -7,10 +7,8 @@ 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.
// Discover harness packages at packages/<group>/<pkg>; group containers,
// examples, and private vendored sources are not package targets.
const root = resolve(import.meta.dirname, '..')
const packagesRoot = resolve(root, 'packages')

View File

@@ -1,19 +1,9 @@
/**
* 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.
* Lifecycle and class sets are closed under `docs/rfc/README.md`; rows derive
* from path, H1, and filename date and sort deterministically. Import is pure.
*/
import { readFileSync, readdirSync } from 'node:fs'

View File

@@ -278,12 +278,14 @@ function docSyncLeafGates(): Gate[] {
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
pnpmScript('mermaid', 'verify-mermaid'),
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
]
}

View File

@@ -1,4 +1,5 @@
{
"requiredSince": "2026-07-14",
"required": [
"README.md",
"docs/development.md",
@@ -12,12 +13,14 @@
],
"excluded": [
"docs/AGENTS.md",
"docs/module-graph.md",
"docs/config-catalog.md",
"docs/tool-catalog.md",
"docs/persistence-catalog.md",
"docs/cordis-catalog/",
"docs/i18n/style-samples.md",
"docs/i18n/terminology.md",
"docs/i18n/translation-prompt.md",
"docs/module-graph.md",
"docs/persistence-catalog.md",
"docs/tool-catalog.md",
"python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/"
]
}

View File

@@ -1,30 +1,9 @@
/**
* 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.
* Only listed standing docs are budgeted. Ceilings ratchet down with at least
* 5% headroom; raising one requires the justification defined in
* `docs/AGENTS.md`.
*/
import { existsSync, readFileSync } from 'node:fs'

View File

@@ -1,29 +1,7 @@
/**
* 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, checks matching string literals too,
* and excludes built declarations and vendored source.
*/
import { existsSync } from 'node:fs'
@@ -39,12 +17,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
/** Find every broken `docs/….md` reference in one TypeScript file. */

View File

@@ -1,77 +1,10 @@
/**
* 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. Inline callable types, overload
* signatures, namespace members, and public class members are included;
* framework slots, constructors, inherited contracts, augmentations, and source
* re-exports keep their docs at the declaring contract. Unknown forms fail closed.
*/
import { existsSync, globSync } from 'node:fs'
@@ -141,12 +74,8 @@ function unwrapExpression(e: ts.Expression): ts.Expression {
}
/**
* Classify a declarator's type annotation for the function contract: an
* inline function type or a type literal that is EXACTLY one call signature
* is the surface signature itself; a literal mixing call/construct
* signatures with anything else cannot be classified syntactically and is
* refused (fail closed — extract a named type); everything else is a plain
* value shape.
* Classify inline callable annotations. Mixed callable literals fail closed;
* other annotations are ordinary value shapes.
* @param type - the declarator's type annotation.
* @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape.
*/
@@ -162,29 +91,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 +238,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 +263,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 +358,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 +410,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 +464,8 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
}
/**
* Compiler options for the walk's program. The real repo hands over its
* tsconfig.base.json (whose `paths` map resolves cross-package imports to
* source, so heritage-member lookups see seam types); a fixture root without
* one gets `noLib` + no `@types` — fixtures are single-file and
* self-contained, nothing in the walk resolves a lib symbol, and default-lib
* parsing is ~99% of per-program cost (it made the fixture spec time out
* under CI coverage instrumentation). Emit-side options are stripped: the
* walk never emits or asks for diagnostics, it only binds types on demand.
* Compiler options for the walk's program.
*
* @param scanRoot - the root being scanned.
* @returns compiler options for ts.createProgram.
*/

View File

@@ -1,35 +1,8 @@
/**
* 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 resolution against the source file. The checker never rewrites,
* and symlinked instruction files are deduped.
*/
import { existsSync, readFileSync } from 'node:fs'
@@ -40,10 +13,7 @@ import { uniqueRepoFiles } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
/**
* Files to check: doc-typecheck's scope, example Markdown, the AGENTS.md pair,
* and repo-authored agent-skill Markdown.
*/
/** Repo-authored Markdown checked for relative links. */
const PATTERNS = [
'README.md',
'README.zh.md',

View File

@@ -1,30 +1,8 @@
/**
* 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—including those in lists and blockquotes—from
* multiline structural nodes. The checker never rewrites; symlinked instruction
* files are deduped. The owning convention is in `docs/AGENTS.md`.
*/
import { readFileSync } from 'node:fs'
@@ -70,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 false
}
})

View File

@@ -1,15 +1,7 @@
/**
* Doc-sync gate: verify every fenced ```mermaid block parses with Mermaid's
* own parser. Markdown link/type/code gates can say a diagram block exists and
* is linked, but only Mermaid can catch syntax errors that GitHub would fail to
* render.
*
* Scope matches the Markdown link gate so any Mermaid diagram in repo-authored
* docs is checked: README.md, README.zh.md, docs/** /*.md,
* packages/* /*.md, packages/* /* /*.md, examples/** /*.md, AGENTS.md,
* packages/AGENTS.md, and .agents/skills/** /*.md.
*
* Run: `tsx scripts/verify-mermaid.ts`.
* Parse every repo-authored Mermaid fence with Mermaid itself, catching syntax that link and fence
* checks cannot. Scope intentionally matches the Markdown link gate, including standing docs,
* package/example docs, and agent skills. Run with `tsx scripts/verify-mermaid.ts`.
*/
import { globSync, readFileSync, realpathSync } from 'node:fs'

View File

@@ -1,42 +1,8 @@
/**
* Doc-sync gate: catch DRIFTED `packages/<path>` references — a path to a
* package that has MOVED, written as prose in Markdown or in a TypeScript
* comment/string. Docs and comments cite package locations by root-relative
* path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`);
* `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs`
* only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick
* prose or a code comment goes unchecked. The package-hierarchy reorg is the
* motivating case: it moved every package under a `{group}/` folder, so a stale
* `packages/tools` (now `packages/core/tools`) reads fine to a human but points
* at nothing.
*
* The check is drift-scoped, NOT a blanket existence test: a broken
* `packages/<path>` token is a violation ONLY when one of its path segments is
* the directory name of a package that actually exists on disk — i.e. the
* package is real and the path is merely stale. A token naming a package that
* exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an
* illustrative `packages/<name>/` skeleton) is left alone: this gate reports
* MOVED paths, not hypothetical or future ones, so it applies uniformly to
* proposed/implemented/rejected docs without per-lifecycle exclusions. This is
* checker, not fixer: it reports and never rewrites.
*
* Detection is a token scan, NOT an AST walk: package refs live in free prose,
* backticks, and comments. We match `packages/<path>` tokens whose path is made
* of plain path characters, so a glob, a `<placeholder>`, or a `{brace,expansion}`
* terminates the match before those chars and is never probed.
*
* Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown
* across README/docs/packages/AGENTS, and `.ts` under packages/** and
* examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source).
* A reference to a package's build OUTPUT (`packages/<group>/<pkg>/lib/…`,
* e.g. `packages/ui/acp-agent/lib/bin.js` cited by a built-bin smoke) is also
* skipped — it is emitted only by `pnpm run build`, which CI runs AFTER this
* gate, so flagging it would be a false positive on a path that is correct but
* not yet on disk. That skip is scoped to a REAL package root: a stale
* group-less `packages/acp-agent/lib/bin.js` is still flagged (its root does not
* exist — exactly the moved-package drift this gate catches).
*
* Run: `tsx scripts/verify-package-paths.ts`.
* Find stale root-relative `packages/...` references in repo-authored prose and
* TypeScript. A missing path is reported only when it names a real package leaf;
* globs, placeholders, hypothetical packages, and unbuilt `lib/` output are
* outside the check.
*/
import { existsSync, readdirSync } from 'node:fs'
@@ -92,36 +58,22 @@ const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
function isDriftedPackageReference(ref: string): boolean {
if (existsSync(resolve(root, ref))) return false
// 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.
// Ignore unbuilt `lib/` paths only under an existing depth-two package root:
// CI runs this gate before build, while stale group-less paths must still fail.
const parts = ref.split('/')
const libAt = parts.indexOf('lib')
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
// Only a stale path to a REAL (moved) package is a violation; a segment
// matching a live package name is the drift signal.
// A missing reference is drift only when a path segment names a live package.
return ref.split('/').slice(1).some(segment => packageNames.has(segment))
}
/**
* Find every DRIFTED `packages/…` reference in one file: a token that does not
* resolve on disk AND names a real package in one of its segments (so it is a
* moved path, not a typo or a not-yet-existing package). The same real-package
* test also screens out a bare `packages` (no segment) and illustrative
* skeletons whose segment is not a package.
*/
/** Find missing package references whose path names a live package; bare paths, typos, and illustrative skeletons do not count. */
function findViolations(absPath: string): Violation[] {
return findReferenceViolations(
root,
absPath,
PKG_REF,
// Trim a trailing path separator or sentence punctuation that the greedy
// class may have swallowed (`packages/core/tools.` / `…/tools/`).
// Remove trailing separators or sentence punctuation matched greedily.
ref => ref.replace(/[./]+$/, ''),
isDriftedPackageReference,
)

View File

@@ -0,0 +1,93 @@
/**
* Doc-sync gate for the canonical package-README limitations section. It scans
* package manifests, rejects missing or variant sections, and requires one
* top-level bullet; audited packages in {@link NO_LIMITATIONS} must omit it.
* See the [limitations RFC](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md).
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { markdownHeadingLines, markdownProseLines } from './markdown.ts'
const root = resolve(import.meta.dirname, '..')
/** The one canonical section heading, required verbatim as an h2. */
const CANONICAL = '## Known Limitations and Deferred Work'
/** Packages audited as having no limitations section, keyed by repo-relative directory. */
const NO_LIMITATIONS: Readonly<Record<string, string>> = {
'packages/util/brand': 'Type-only nominal-branding primitive with no runtime behavior or deferred work.',
}
/** A heading that reads as a limitations section — canonical or drifted. */
function isLimitationsLike(headingText: string): boolean {
return (
/\blimitations?\b/i.test(headingText)
|| /deferred work/i.test(headingText)
|| /what is not here/i.test(headingText)
|| /^deferred\b/i.test(headingText)
|| /^non-goals?\b/i.test(headingText)
)
}
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort()
const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
const failures: string[] = []
for (const [entry, reason] of Object.entries(NO_LIMITATIONS)) {
if (!scannedPackages.has(entry)) {
failures.push(`whitelist entry ${entry} does not name a scanned package — renamed or removed? update NO_LIMITATIONS in scripts/verify-package-readme-limitations.ts in the same change`)
}
if (reason.trim().length === 0) {
failures.push(`whitelist entry ${entry} has no justification — state why a limitations section would be empty boilerplate`)
}
}
for (const pkg of scannedPackages) {
const readme = `${pkg}/README.md`
if (!existsSync(resolve(root, readme))) {
failures.push(`${readme}: package manifest has no sibling README with the \`${CANONICAL}\` section`)
continue
}
const source = readFileSync(resolve(root, readme), 'utf8')
const lines = markdownProseLines(source)
const headings = markdownHeadingLines(source)
const limitations = headings.filter(heading => isLimitationsLike(heading.text))
if (Object.hasOwn(NO_LIMITATIONS, pkg)) {
for (const heading of limitations) {
failures.push(`${readme}:${heading.index}: whitelisted as having no known limitations, but carries ${JSON.stringify(heading.raw)} — drop the section or remove the package from NO_LIMITATIONS`)
}
continue
}
const heading = limitations.at(0)
if (heading === undefined) {
failures.push(`${readme}: missing the \`${CANONICAL}\` section (a package with genuinely nothing to declare joins NO_LIMITATIONS in scripts/verify-package-readme-limitations.ts instead)`)
continue
}
if (limitations.length > 1) {
failures.push(`${readme}: ${limitations.length} limitations-like headings (lines ${limitations.map(line => line.index).join(', ')}) — keep exactly one \`${CANONICAL}\` section`)
continue
}
if (heading.depth !== 2 || heading.raw.trimEnd() !== CANONICAL) {
failures.push(`${readme}:${heading.index}: non-canonical heading ${JSON.stringify(heading.raw)} — use \`${CANONICAL}\``)
continue
}
const headingAt = lines.findIndex(line => line.index === heading.index)
const body = lines.slice(headingAt + 1)
const headingLines = new Set(headings.map(entry => entry.index))
const end = body.findIndex(line => headingLines.has(line.index))
const section = end === -1 ? body : body.slice(0, end)
if (!section.some(line => /^- /.test(line.raw))) {
failures.push(`${readme}:${heading.index}: the \`${CANONICAL}\` section has no top-level \`- \` bullet — state the limitations, or whitelist the package if there are genuinely none`)
}
}
if (failures.length > 0) {
console.error('verify-package-readme-limitations: violations found:')
for (const failure of failures) console.error(` ${failure}`)
process.exit(1)
}
console.log(`verify-package-readme-limitations: ${scannedPackages.size} package READMEs checked (${Object.keys(NO_LIMITATIONS).length} whitelisted), all conform.`)

View File

@@ -0,0 +1,391 @@
/**
* Doc-sync gate for package README Model Experience sections. It validates
* audited package classifications, context-surface fields, package-owned text
* blocks, generated-catalog links, and final-section order. See the
* [Model Experience RFC](../docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md).
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from './markdown.ts'
const root = resolve(import.meta.dirname, '..')
const HEADING = '## Model Experience'
const LIMITATIONS_HEADING = '## Known Limitations and Deferred Work'
const MODEL_VIEW_LABEL = '**What the model sees**'
const TOKEN_EFFECT_LABEL = '**Token effect**'
type SentenceKind = 'none' | 'indirect'
interface SentenceContract {
kind: SentenceKind
reason: string
}
/**
* Generic packages whose public contract is model-agnostic. Their READMEs omit
* Model Experience entirely; the reason stays here as reviewable audit evidence
* so an absent section cannot be mistaken for forgotten documentation.
*/
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
}
/**
* Packages whose Model Experience is simple enough for one gated sentence.
* Every other package must carry canonical context-surface blocks. A package
* moves on or off this list with the change to its context behavior.
*/
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/core/agent-core': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
'packages/ui/acp-agent': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-core and dsh-acp.' },
'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
'packages/ui/jsonrpc-agent': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
'packages/workflow/workflow': { kind: 'indirect', reason: 'The service delegates parent and child model rendering to its consumer and engine.' },
}
interface Failure {
path: string
message: string
}
type Line = MarkdownProseLine
interface ContextSurface {
heading: Line
modelView: Line
tokenEffect: Line
title: string
verbatimBlocks: number
}
/** Validate H4-plus-markdown literals nested after one context surface's fields. */
function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error?: string } {
let cursor = 0
while (raw[cursor]?.trim().length === 0) cursor += 1
if (cursor === raw.length) return { blocks: 0 }
let blocks = 0
const fragments = new Set<string>()
while (true) {
while (raw[cursor]?.trim().length === 0) cursor += 1
if (cursor === raw.length) break
if (!/^#### \S/.test(raw[cursor] ?? '')) {
return { blocks, error: 'content after Token effect must be a titled H4 verbatim block' }
}
const title = (raw[cursor] as string).slice('#### '.length)
const fragment = headingFragment(title)
if (fragment.length === 0) return { blocks, error: 'verbatim H4 title must be non-empty' }
if (fragments.has(fragment)) {
return { blocks, error: `verbatim H4 title ${JSON.stringify(title)} is duplicated within its context surface` }
}
fragments.add(fragment)
cursor += 1
while (raw[cursor]?.trim().length === 0) cursor += 1
if (raw[cursor] !== '```markdown') {
return { blocks, error: 'each nested verbatim H4 requires an exact ```markdown fence' }
}
cursor += 1
const contentStart = cursor
while (cursor < raw.length && raw[cursor] !== '```') cursor += 1
if (cursor === raw.length) return { blocks, error: 'unterminated nested ```markdown fence' }
if (cursor === contentStart) return { blocks, error: 'nested ```markdown fence must not be empty' }
cursor += 1
blocks += 1
}
return { blocks }
}
/** GitHub-style fragment for the simple ASCII H4 titles allowed by this contract. */
function headingFragment(title: string): string {
return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-')
}
/** A direct stable system-prompt contribution, as named by the README contract. */
function isDirectSystemPromptSurface(title: string): boolean {
return /\bsystem prompt\b/i.test(title)
}
/** Anchored generated-catalog links in one model-view field. */
function toolCatalogLinkFragments(text: string): string[] {
return [...text.matchAll(/\]\(\.\.\/\.\.\/\.\.\/docs\/tool-catalog\.md#([a-z0-9_-]+)\)/g)]
.map(match => match[1] as string)
}
const toolCatalogFragments = new Set<string>()
for (const line of readFileSync(resolve(root, 'docs/tool-catalog.md'), 'utf8').split('\n')) {
const title = /^## (.+)$/.exec(line)?.[1]
if (title !== undefined) toolCatalogFragments.add(headingFragment(title))
}
const failures: Failure[] = []
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort()
const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
let structuredCount = 0
let contextSurfaceCount = 0
let omittedSectionCount = 0
let explainedNoneCount = 0
let indirectCount = 0
let verbatimBlockCount = 0
let systemPromptSurfaceCount = 0
let toolSchemaSurfaceCount = 0
for (const [pkg, reason] of Object.entries(NO_MODEL_EXPERIENCE_SECTION)) {
if (!scannedPackages.has(pkg)) {
failures.push({ path: `${pkg}/README.md`, message: 'no-section allowlist entry does not name a scanned package' })
}
if (reason.trim().length === 0) {
failures.push({ path: `${pkg}/README.md`, message: 'no-section allowlist entry must retain its audit justification' })
}
if (SENTENCE_MODEL_EXPERIENCE[pkg] !== undefined) {
failures.push({ path: `${pkg}/README.md`, message: 'package cannot appear in both Model Experience allowlists' })
}
}
for (const [pkg, contract] of Object.entries(SENTENCE_MODEL_EXPERIENCE)) {
if (!scannedPackages.has(pkg)) {
failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry does not name a scanned package' })
}
if (contract.reason.trim().length === 0) {
failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry must justify why structured context surfaces are unnecessary' })
}
}
for (const packageJson of packageJsons) {
const pkg = packageJson.slice(0, -'/package.json'.length)
const readme = packageJson.replace(/package\.json$/, 'README.md')
const abs = resolve(root, readme)
if (!existsSync(abs)) {
failures.push({ path: readme, message: 'missing package README' })
continue
}
const text = readFileSync(abs, 'utf8')
const rawLines = text.split('\n')
const lines = markdownProseLines(text)
const headings = markdownHeadingLines(text)
const h2Headings = headings.filter(heading => heading.depth === 2)
const modelExperienceHeadings = headings.filter(heading => heading.text
.trim().replaceAll(/\s+/g, ' ').toLowerCase() === 'model experience')
const modelHeadings = modelExperienceHeadings.filter(heading => heading.depth === 2 && heading.raw === HEADING)
if (NO_MODEL_EXPERIENCE_SECTION[pkg] !== undefined) {
if (modelExperienceHeadings.length !== 0) {
for (const heading of modelExperienceHeadings) {
failures.push({ path: readme, message: `line ${heading.index}: audited model-agnostic package must omit every Model Experience heading; found ${JSON.stringify(heading.raw)}` })
}
} else {
omittedSectionCount += 1
}
continue
}
const nonCanonicalModelHeading = modelExperienceHeadings.find(heading => heading.depth !== 2 || heading.raw !== HEADING)
if (nonCanonicalModelHeading !== undefined) {
failures.push({ path: readme, message: `line ${nonCanonicalModelHeading.index}: non-canonical Model Experience heading ${JSON.stringify(nonCanonicalModelHeading.raw)}; use exactly ${JSON.stringify(HEADING)}` })
continue
}
const modelHeading = modelHeadings.at(0)
if (modelHeading === undefined) {
failures.push({
path: readme,
message: `missing ${HEADING}`,
})
continue
}
if (modelHeadings.length !== 1) {
failures.push({ path: readme, message: `contains ${modelHeadings.length} copies of ${HEADING}` })
continue
}
const modelH2Index = h2Headings.indexOf(modelHeading)
const limitationsH2Index = h2Headings.findIndex(heading => heading.depth === 2 && heading.raw === LIMITATIONS_HEADING)
if (limitationsH2Index >= 0) {
if (modelH2Index !== h2Headings.length - 2 || limitationsH2Index !== h2Headings.length - 1) {
failures.push({
path: readme,
message: `${HEADING} and ${LIMITATIONS_HEADING} must be the final two H2 sections, in that order`,
})
continue
}
} else if (modelH2Index !== h2Headings.length - 1) {
failures.push({ path: readme, message: `${HEADING} must be the final H2 when ${LIMITATIONS_HEADING} is absent` })
continue
}
const modelHeadingAt = lines.findIndex(line => line.index === modelHeading.index)
const body = lines.slice(modelHeadingAt + 1)
const h2Lines = new Set(h2Headings.map(heading => heading.index))
const nextH2 = body.findIndex(line => h2Lines.has(line.index))
const section = nextH2 < 0 ? body : body.slice(0, nextH2)
const nextH2Line = nextH2 < 0 ? rawLines.length + 1 : (body[nextH2] as Line).index
const rawSection = rawLines.slice(modelHeading.index, nextH2Line - 1)
const content = section.filter(line => line.raw.trim().length > 0)
const sentenceContract = SENTENCE_MODEL_EXPERIENCE[pkg]
if (sentenceContract !== undefined) {
const pattern = sentenceContract.kind === 'none' ? /^None, as .+\.$/ : /^Indirectly, through .+\.$/
const rawContent = rawSection.filter(line => line.trim().length > 0)
if (content.length !== 1 || rawContent.length !== 1 || !pattern.test(content[0]?.raw ?? '')) {
const prefix = sentenceContract.kind === 'none' ? 'None, as ' : 'Indirectly, through '
failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period` })
continue
}
if (sentenceContract.kind === 'none') explainedNoneCount += 1
else indirectCount += 1
continue
}
const shortSentence = content.find(line => line.raw === 'None.' || /^None, as |^Indirectly, through /.test(line.raw))
if (shortSentence !== undefined) {
failures.push({ path: readme, message: `line ${shortSentence.index}: short Model Experience form requires an audited entry in SENTENCE_MODEL_EXPERIENCE` })
continue
}
const surfaceStarts = content
.map((line, index) => ({ line, index }))
.filter(entry => /^### \S/.test(entry.line.raw))
if (surfaceStarts.length === 0 || surfaceStarts[0]?.index !== 0) {
failures.push({ path: readme, message: 'must contain one or more complete context-surface blocks' })
continue
}
const surfaces: ContextSurface[] = []
const surfaceFragments = new Set<string>()
let surfaceError = false
for (let surfaceIndex = 0; surfaceIndex < surfaceStarts.length; surfaceIndex += 1) {
const start = surfaceStarts[surfaceIndex] as { line: Line; index: number }
const end = surfaceStarts[surfaceIndex + 1]?.index ?? content.length
const entries = content.slice(start.index, end)
const heading = entries[0] as Line
const modelView = entries[1]
const tokenEffect = entries[2]
const title = heading.raw.slice('### '.length)
const fragment = headingFragment(title)
if (fragment.length === 0) {
failures.push({ path: readme, message: `line ${heading.index}: each context surface requires a non-empty H3 heading` })
surfaceError = true
break
}
if (surfaceFragments.has(fragment)) {
failures.push({ path: readme, message: `line ${heading.index}: duplicate context-surface link fragment ${JSON.stringify(fragment)}` })
surfaceError = true
break
}
if (modelView === undefined || !modelView.raw.startsWith(`${MODEL_VIEW_LABEL}: `) || modelView.raw.slice(`${MODEL_VIEW_LABEL}: `.length).trim().length === 0) {
failures.push({ path: readme, message: `line ${modelView?.index ?? heading.index}: context surface requires non-empty ${MODEL_VIEW_LABEL}: text` })
surfaceError = true
break
}
if (tokenEffect === undefined || !tokenEffect.raw.startsWith(`${TOKEN_EFFECT_LABEL}: `) || tokenEffect.raw.slice(`${TOKEN_EFFECT_LABEL}: `.length).trim().length === 0) {
failures.push({ path: readme, message: `line ${tokenEffect?.index ?? heading.index}: context surface requires non-empty ${TOKEN_EFFECT_LABEL}: text` })
surfaceError = true
break
}
if ((surfaceIndex === 0 && heading.index !== modelHeading.index + 2)
|| rawLines[heading.index - 2]?.trim().length !== 0
|| modelView.index !== heading.index + 2
|| tokenEffect.index !== modelView.index + 2) {
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and fields require one blank line between each element` })
surfaceError = true
break
}
const unexpected = entries.slice(3).find(line => !/^#### \S/.test(line.raw))
if (unexpected !== undefined) {
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${TOKEN_EFFECT_LABEL} must be a titled H4 plus \`markdown\` fence inside this context surface` })
surfaceError = true
break
}
const nextHeadingLine = surfaceStarts[surfaceIndex + 1]?.line.index ?? nextH2Line
const verbatim = validateNestedVerbatim(rawLines.slice(tokenEffect.index, nextHeadingLine - 1))
if (verbatim.error !== undefined) {
failures.push({ path: readme, message: `line ${tokenEffect.index}: ${verbatim.error}` })
surfaceError = true
break
}
if (entries.length - 3 !== verbatim.blocks) {
failures.push({ path: readme, message: `line ${tokenEffect.index}: every nested H4 must own exactly one \`markdown\` fence` })
surfaceError = true
break
}
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw)) {
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H4 in its owning H3` })
surfaceError = true
break
}
surfaceFragments.add(fragment)
surfaces.push({ heading, modelView, tokenEffect, title, verbatimBlocks: verbatim.blocks })
}
if (surfaceError) continue
const promptWithoutVerbatim = surfaces.find(surface => isDirectSystemPromptSurface(surface.title)
&& surface.verbatimBlocks === 0)
if (promptWithoutVerbatim !== undefined) {
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H4 plus verbatim \`markdown\` block` })
continue
}
const hasConcreteLiteral = surfaces.some(surface => surface.verbatimBlocks > 0
|| surface.modelView.raw.includes('`')
|| surface.tokenEffect.raw.includes('`')
|| toolCatalogLinkFragments(surface.modelView.raw).length > 0)
if (!hasConcreteLiteral) {
failures.push({ path: readme, message: 'structured Model Experience must ground at least one surface with inline code, a nested `markdown` block, or an anchored tool-catalog link' })
continue
}
let catalogError = false
for (const surface of surfaces) {
if (!/\bschemas?\b/i.test(surface.title)) continue
const fragments = toolCatalogLinkFragments(surface.modelView.raw)
if (fragments.length === 0) {
failures.push({ path: readme, message: `line ${surface.heading.index}: tool-schema surface must link an anchored section of ../../../docs/tool-catalog.md` })
catalogError = true
break
}
const invalid = fragments.find(fragment => !toolCatalogFragments.has(fragment))
if (invalid !== undefined) {
failures.push({ path: readme, message: `line ${surface.modelView.index}: tool-catalog link fragment ${JSON.stringify(invalid)} does not name an H2 section` })
catalogError = true
break
}
}
if (catalogError) continue
verbatimBlockCount += surfaces.reduce((total, surface) => total + surface.verbatimBlocks, 0)
contextSurfaceCount += surfaces.length
systemPromptSurfaceCount += surfaces.filter(surface => isDirectSystemPromptSurface(surface.title)).length
toolSchemaSurfaceCount += surfaces.filter(surface => /\bschemas?\b/i.test(surface.title)).length
structuredCount += 1
}
if (failures.length === 0) {
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
process.exit(0)
}
console.error('verify-package-readme-model-experience failed:')
for (const failure of failures) {
console.error(` ${relative(root, resolve(root, failure.path))}: ${failure.message}`)
}
process.exit(1)

View File

@@ -1,31 +1,8 @@
/**
* 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`; the closed classification
* contract lives in `docs/rfc/README.md`.
*/
import { readFileSync } from 'node:fs'

View File

@@ -1,31 +1,8 @@
/**
* 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. Exact format and
* grandfathering rules live in `docs/rfc/README.md`.
*/
import { readFileSync } from 'node:fs'
@@ -65,9 +42,7 @@ for (const rfc of rfcs) {
errors.push(`format: ${rfc.rel}${msg}`)
}
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
// Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a
// status line, a banned heading, or the grandfather comment inside a fence
// (the README's own format section does), and only real prose counts.
// Format tokens inside fenced examples are not document structure.
let inFence = false
const prose = lines.filter((l) => {
if (l.startsWith('```')) {

View File

@@ -1,10 +1,7 @@
/**
* Verify that the Python single-exe deploy manifest explicitly supplies every
* required workspace peer of every workspace package in its dependency graph.
*
* `pnpm deploy --config.auto-install-peers=false` cannot repair an incomplete
* runtime root. Keeping the peer at the root also prevents a successful build
* from producing an executable that fails only when Cordis loads the plugin.
* Verify that the executable deploy manifest supplies every required workspace
* peer in its dependency graph. With auto peer installation disabled, a missing
* root peer can otherwise fail only when Cordis loads the packaged plugin.
*/
import { readFile, readdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'

View File

@@ -1,19 +1,10 @@
/**
* 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).
* Registry-subject notifications are intentionally unfiltered and belong in neither set.
*/
import { globSync, readFileSync } from 'node:fs'

View File

@@ -1,46 +1,10 @@
/**
* 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. Required files and date-named docs
* at or after `requiredSince` must be paired; excluded docs may have neither a
* counterpart nor sidecar. `--list` reports state and `--write` records both
* sides after human review. Translation quality remains a review responsibility.
* See `docs/i18n/README.md` for the owning contract.
*/
import { createHash } from 'node:crypto'
@@ -62,6 +26,8 @@ const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/*
interface Manifest {
required: string[]
excluded: string[]
/** Date-named documents (yyyy-mm-dd-*.md, i.e. RFCs) dated on/after this day must merge bilingual. */
requiredSince: string
}
const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest
@@ -249,7 +215,22 @@ for (const req of manifest.required) {
}
}
// 2. Every pair that exists at all is complete and consistent. Anchor on the
// 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge
// bilingual: a new RFC lands with its pair or not at all. Deterministic from
// the filename alone — no git history, so it holds on shallow CI checkouts.
const DATED = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
for (const source of sources) {
if (isExcluded(source)) continue
const dated = DATED.exec(source)
if (!dated?.[1] || dated[1] < manifest.requiredSince) continue
const { zh } = pairPaths(source)
if (!existsSync(join(root, zh))) {
errors.push(`${source}: dated ${dated[1]} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
state.set(source, 'missing')
}
}
// 3. Every pair that exists at all is complete and consistent. Anchor on the
// union of .zh.md files and .i18n.yaml records so a half-deleted pair is
// caught from either remnant.
const pairAnchors = new Set<string>()
@@ -316,7 +297,9 @@ if (listMode) {
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
for (const [file, status] of rows) {
const required = manifest.required.includes(file)
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`)
const date = DATED.exec(file)?.[1]
const tag = required ? ' (required)' : date && date >= manifest.requiredSince ? ' (required by date)' : ' (backlog)'
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
}
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
for (const status of state.values()) counts[status]++

View File

@@ -1,25 +1,7 @@
/**
* Doc-sync gate: verify every ` ```ts type-equiv ` block in the docs is a
* VERBATIM copy of the source type definition it documents.
*
* The core-data-structures docs paste real type definitions so a reader sees
* the exact shape. A paste drifts the moment source changes — this script is
* the drift guard. For each block it extracts the documented symbol's
* declaration from source via the TypeScript compiler API, whitespace-
* normalizes both the source text and the block, and asserts they are equal.
*
* Provenance lives in a central manifest (`scripts/type-equiv.manifest.json`),
* NOT in the doc prose: each entry names `{ doc, symbol, source }`. The script
* enforces a 1:1 correspondence — every type-equiv block in the docs has
* exactly one manifest entry (keyed by doc + declared symbol), and every
* manifest entry resolves to exactly one block. An orphan on either side fails,
* so a block can never be silently unchecked and an entry can never rot.
*
* doc-typecheck.ts recognizes the same ` ```ts type-equiv ` fence and skips it
* (it is not standalone-compilable and is not counted in the opt-out ratio);
* the two scripts share the fence, this one owns the verification.
*
* Run: `tsx scripts/verify-type-equiv.ts`.
* Verify every `ts type-equiv` block against the source symbol named by the
* manifest. Blocks and entries have a one-to-one relationship; comparison
* ignores comments and whitespace but preserves declaration structure.
*/
import { globSync, readFileSync, existsSync } from 'node:fs'
@@ -28,13 +10,7 @@ import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
/**
* Markdown globs scanned for ` ```ts type-equiv ` blocks — the SAME scope
* doc-typecheck uses. Scanning every doc (not only the docs the manifest names)
* is what makes the 1:1 guarantee real in both directions: a type-equiv block
* added to a doc with NO manifest entry is still discovered here and reported as
* an orphan, instead of being silently skipped.
*/
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
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,11 @@ 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 so prose-only edits do not drift
* structural copies. This is intentionally not a general tokenizer: repo type
* declarations do not contain comment delimiters inside string literals.
*/
function normalize(code: string): string {
return code
.replace(/\/\*[\s\S]*?\*\//g, '')
@@ -72,8 +47,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+)?/, '')
}