diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbc953e180..749e1dafe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,12 @@ jobs: - name: Doc-sync gates (doc code blocks + event taxonomy) run: pnpm run doc-sync + # Module-graph freshness: regenerate docs/module-graph.md from the + # packages' peerDependencies and fail if it differs from the committed + # file. Only reads source package.json — no build needed. + - name: Module-graph freshness + run: pnpm run verify-module-graph + - name: Tests with coverage gate (per-file 100%) run: pnpm run test:coverage diff --git a/AGENTS.md b/AGENTS.md index 3a5d13c083..c602ff4872 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,9 @@ examples/ Runnable demos (not workspaces). echo-agent = mock model + echo tool + stdio UI + JSONL persistence, wired via cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools (pnpm run demo:coding, needs DEEPSEEK_API_KEY). -docs/ architecture.md — the design doc. adr/ — decision records (the +docs/ architecture.md — the design doc. module-graph.md — generated + inter-package dependency graph (Mermaid; `pnpm run gen-module-graph`). + adr/ — decision records (the why behind vendoring, event-sourcing, the schema DSL, …). rfc/ — proposals for substantial future work. cookbook/ — step-by-step guides: adding a package, a tool, diff --git a/docs/development.md b/docs/development.md index 1252157868..0defe20cec 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,7 +57,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as an early local checkpoint before review: - `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard. -- `pre-push` runs `pnpm run test`, `pnpm run hygiene`, and `pnpm run doc-sync`. +- `pre-push` runs `pnpm run test`, `pnpm run hygiene`, `pnpm run doc-sync`, and `pnpm run verify-module-graph`. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. @@ -72,6 +72,7 @@ The GitHub workflow runs these gates on each pull request: - `pnpm run typecheck` - `pnpm run lint` - `pnpm run doc-sync` +- `pnpm run verify-module-graph` - `pnpm run test:coverage` - `pnpm run build` - `pnpm run knip && pnpm run publint` @@ -93,6 +94,8 @@ pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run verify-event-taxonomy # compare docs/architecture.md event names with source pnpm run doc-sync # doc-typecheck plus event taxonomy verification +pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps +pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # build declarations and JS bundles pnpm run hygiene # knip, publint, and workspace constraints ``` diff --git a/docs/module-graph.md b/docs/module-graph.md new file mode 100644 index 0000000000..36e24a5cc4 --- /dev/null +++ b/docs/module-graph.md @@ -0,0 +1,53 @@ + + +# Module dependency graph + +Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package's `peerDependencies` (the canonical runtime-dependency signal). An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped. + +```mermaid +graph TD + bash-local --> bash + llm-deepseek --> llm + llm-pi-ai --> llm + session --> llm + system-prompt --> llm + agent --> llm + agent --> session + session-persistence --> session + invariants --> agent + invariants --> llm + invariants --> session + session-persistence-jsonl --> session + session-persistence-jsonl --> session-persistence + tools --> agent + tools --> llm + tools --> system-prompt + agent-loop --> agent + agent-loop --> llm + agent-loop --> session + agent-loop --> session-persistence + agent-loop --> system-prompt + agent-loop --> tools + tool-bash --> agent + tool-bash --> bash + tool-bash --> llm + tool-bash --> tools +``` + +| Package | Depends on | +| --- | --- | +| `bash` | — | +| `llm` | — | +| `bash-local` | `bash` | +| `llm-deepseek` | `llm` | +| `llm-pi-ai` | `llm` | +| `session` | `llm` | +| `system-prompt` | `llm` | +| `agent` | `llm`, `session` | +| `session-persistence` | `session` | +| `invariants` | `agent`, `llm`, `session` | +| `session-persistence-jsonl` | `session`, `session-persistence` | +| `tools` | `agent`, `llm`, `system-prompt` | +| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | +| `tool-bash` | `agent`, `bash`, `llm`, `tools` | diff --git a/lefthook.yml b/lefthook.yml index 9789c92029..24600e4985 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -30,3 +30,6 @@ pre-push: - name: doc-sync run: pnpm run doc-sync + + - name: module-graph freshness + run: pnpm run verify-module-graph diff --git a/package.json b/package.json index 9ff3536f9b..b0a7e5ae4d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts", "verify-event-taxonomy": "tsx scripts/verify-event-taxonomy.ts", + "gen-module-graph": "tsx scripts/gen-module-graph.ts", + "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts new file mode 100644 index 0000000000..6314b39399 --- /dev/null +++ b/scripts/gen-module-graph.ts @@ -0,0 +1,126 @@ +/** + * 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 plus a dependency table. + * + * The file is fully generated — never hand-edit it. Output is deterministic + * (packages and edges sorted) so a regenerate-and-diff freshness check is + * stable. + * + * `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md + * `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file + * is stale (CI / pre-push gate) + */ + +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'docs/module-graph.md' +const SCOPE = '@deepseek-ai/dsh-' + +interface Pkg { + /** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */ + short: string + /** Short names of this package's in-repo peer dependencies, sorted. */ + deps: string[] +} + +/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */ +function collect(): Pkg[] { + const pkgs: Pkg[] = [] + for (const rel of globSync('packages/*/package.json', { cwd: root })) { + const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as { + name: string + peerDependencies?: Record + } + if (!json.name.startsWith(SCOPE)) continue + const deps = Object.keys(json.peerDependencies ?? {}) + .filter(d => d.startsWith(SCOPE)) + .map(d => d.slice(SCOPE.length)) + .sort() + pkgs.push({ short: json.name.slice(SCOPE.length), deps }) + } + return topoSort(pkgs) +} + +/** + * Order packages low-level → high-level: a package appears only after every + * package it depends on. Kahn-style layering with an alphabetical tiebreak + * within each layer, so the output stays deterministic (the freshness check + * compares whole-file). The graph is a DAG, so this always terminates; a cycle + * would leave nodes unplaced and throw. + */ +function topoSort(pkgs: Pkg[]): Pkg[] { + const remaining = new Map(pkgs.map(p => [p.short, p])) + const placed = new Set() + const out: Pkg[] = [] + while (remaining.size > 0) { + const ready = [...remaining.values()] + .filter(p => p.deps.every(d => placed.has(d))) + .sort((a, b) => a.short.localeCompare(b.short)) + if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`) + for (const p of ready) { + out.push(p) + placed.add(p.short) + remaining.delete(p.short) + } + } + return out +} + +/** Render the full docs/module-graph.md content (pure, deterministic). */ +function render(pkgs: Pkg[]): string { + const edges: string[] = [] + for (const p of pkgs) { + for (const d of p.deps) edges.push(` ${p.short} --> ${d}`) + } + const rows = pkgs.map(p => `| \`${p.short}\` | ${p.deps.length ? p.deps.map(d => `\`${d}\``).join(', ') : '—'} |`) + return [ + '', + '', + '# Module dependency graph', + '', + 'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal). An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.', + '', + '```mermaid', + 'graph TD', + ...edges, + '```', + '', + '| Package | Depends on |', + '| --- | --- |', + ...rows, + '', + ].join('\n') +} + +const content = render(collect()) + +if (process.argv.includes('--check')) { + let committed: string | null = null + 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. + committed = null + } + if (committed === content) { + console.log(`gen-module-graph: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`) + process.exit(1) +} + +writeFileSync(resolve(root, OUT), content) +console.log(`gen-module-graph: wrote ${OUT}.`)