From 4c8c1da8b37cd35191b00888285c2d2965db4056 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:00:24 +0800 Subject: [PATCH 1/3] feat: generate module dependency graph with freshness gate Add scripts/gen-module-graph.ts, which derives the inter-package dependency graph from each package's @deepseek-ai/dsh-* peerDependencies and renders docs/module-graph.md (a GitHub-native Mermaid graph plus a dependency table). Output is deterministic so a regenerate-and-diff check is stable. Wire a freshness gate the same way doc-sync is wired (ADR 0007: hooks and CI run the same package.json scripts): verify-module-graph runs in pre-push (lefthook) and as a CI step. It fails if the committed file drifts from what the generator would produce. --- .github/workflows/ci.yml | 6 +++ AGENTS.md | 4 +- docs/module-graph.md | 49 +++++++++++++++++ lefthook.yml | 3 ++ package.json | 2 + scripts/gen-module-graph.ts | 103 ++++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 docs/module-graph.md create mode 100644 scripts/gen-module-graph.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3d378fa4a..f0293d1ddb 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/module-graph.md b/docs/module-graph.md new file mode 100644 index 0000000000..4caee085f4 --- /dev/null +++ b/docs/module-graph.md @@ -0,0 +1,49 @@ + + +# 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 + agent --> llm + agent --> session + agent-loop --> agent + agent-loop --> llm + agent-loop --> session + agent-loop --> system-prompt + agent-loop --> tools + bash-local --> bash + invariants --> agent + invariants --> llm + invariants --> session + llm-deepseek --> llm + llm-pi-ai --> llm + session --> llm + system-prompt --> llm + tool-bash --> agent + tool-bash --> bash + tool-bash --> llm + tool-bash --> tools + tools --> agent + tools --> llm + tools --> system-prompt +``` + +| Package | Depends on | +| --- | --- | +| `agent` | `llm`, `session` | +| `agent-loop` | `agent`, `llm`, `session`, `system-prompt`, `tools` | +| `bash` | — | +| `bash-local` | `bash` | +| `invariants` | `agent`, `llm`, `session` | +| `llm` | — | +| `llm-deepseek` | `llm` | +| `llm-pi-ai` | `llm` | +| `session` | `llm` | +| `system-prompt` | `llm` | +| `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `tools` | `agent`, `llm`, `system-prompt` | 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..58e8bd0a36 --- /dev/null +++ b/scripts/gen-module-graph.ts @@ -0,0 +1,103 @@ +/** + * 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 pkgs.sort((a, b) => a.short.localeCompare(b.short)) +} + +/** 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}.`) From b33668ef0586cc66416a12a1e383ad620afa1742 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:11:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20order=20module-graph=20table=20?= =?UTF-8?q?topologically=20(low=E2=86=92high=20level)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the alphabetical package ordering with a Kahn-style topological sort (alphabetical tiebreak within each layer to stay deterministic), so the table and graph list leaf interfaces first and dependents last. --- docs/module-graph.md | 32 ++++++++++++++++---------------- scripts/gen-module-graph.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 4caee085f4..e488f61ea1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,41 +9,41 @@ package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix st ```mermaid graph TD + bash-local --> bash + llm-deepseek --> llm + llm-pi-ai --> llm + session --> llm + system-prompt --> llm agent --> llm agent --> session + invariants --> agent + invariants --> llm + invariants --> session + tools --> agent + tools --> llm + tools --> system-prompt agent-loop --> agent agent-loop --> llm agent-loop --> session agent-loop --> system-prompt agent-loop --> tools - bash-local --> bash - invariants --> agent - invariants --> llm - invariants --> session - llm-deepseek --> llm - llm-pi-ai --> llm - session --> llm - system-prompt --> llm tool-bash --> agent tool-bash --> bash tool-bash --> llm tool-bash --> tools - tools --> agent - tools --> llm - tools --> system-prompt ``` | Package | Depends on | | --- | --- | -| `agent` | `llm`, `session` | -| `agent-loop` | `agent`, `llm`, `session`, `system-prompt`, `tools` | | `bash` | — | -| `bash-local` | `bash` | -| `invariants` | `agent`, `llm`, `session` | | `llm` | — | +| `bash-local` | `bash` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | | `session` | `llm` | | `system-prompt` | `llm` | -| `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `agent` | `llm`, `session` | +| `invariants` | `agent`, `llm`, `session` | | `tools` | `agent`, `llm`, `system-prompt` | +| `agent-loop` | `agent`, `llm`, `session`, `system-prompt`, `tools` | +| `tool-bash` | `agent`, `bash`, `llm`, `tools` | diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 58e8bd0a36..865211ccde 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -46,7 +46,32 @@ function collect(): Pkg[] { .sort() pkgs.push({ short: json.name.slice(SCOPE.length), deps }) } - return pkgs.sort((a, b) => a.short.localeCompare(b.short)) + 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). */ From 8e6fd91e15fc9d8380cf535988bfef00dcef48ef Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:21:21 +0800 Subject: [PATCH 3/3] docs: document module-graph gate; unwrap generated prose Codex review follow-up: - Update docs/development.md: add verify-module-graph to the pre-push and CI gate lists and gen/verify-module-graph to the daily commands, so the contributor guide matches the new freshness gate. - Emit the module-graph.md intro paragraph as a single line (repo Markdown convention: one line per paragraph), since regenerating would otherwise reintroduce hard wrapping. --- docs/development.md | 5 ++++- docs/module-graph.md | 4 +--- scripts/gen-module-graph.ts | 4 +--- 3 files changed, 6 insertions(+), 7 deletions(-) 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 index e488f61ea1..4e01fdd7d3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -3,9 +3,7 @@ # 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. +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 diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 865211ccde..6314b39399 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -87,9 +87,7 @@ function render(pkgs: Pkg[]): string { '', '# 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.', + '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',