/** * 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//` plus a * dependency table. * * The file is fully generated — never hand-edit it. Output is deterministic * (packages and edges sorted) so a regenerate-and-diff freshness check is * stable. * * `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md * `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file * is stale (CI / pre-push gate) */ import { dirname, resolve } from 'node:path' import { globSync, readFileSync, writeFileSync } from 'node:fs' 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 /** Package group from `packages//`. */ group: string /** Repo-relative package directory. */ rel: string /** Short names of this package's in-repo peer dependencies, sorted. */ deps: string[] } const GROUP_ORDER = [ 'util', 'llm', 'core', 'bash', 'fs', 'compact', 'subagent', 'web', 'timeout', 'todo', 'cordis', 'hooks', 'session-persistence', 'session-query', 'support', 'ui', ] /** 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() const [, group, leaf] = rel.split('/') if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`) pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), 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(comparePackages) 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 } function comparePackages(a: Pkg, b: Pkg): number { const groupA = GROUP_ORDER.indexOf(a.group) const groupB = GROUP_ORDER.indexOf(b.group) const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short) } function nodeId(prefix: string, value: string): string { return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}` } function escLabel(value: string): string { return value.replace(/"/g, '\\"') } function packageLink(pkg: Pkg): string { return `[\`${pkg.short}\`](../${pkg.rel})` } /** 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(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`) } const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => { const ia = GROUP_ORDER.indexOf(a) const ib = GROUP_ORDER.indexOf(b) const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib return na - nb || a.localeCompare(b) }) const groupBlocks: string[] = [] for (const group of groups) { groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`) for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) { groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`) } groupBlocks.push(' end') } const rows = pkgs.map((p) => { const deps = p.deps.length ? p.deps.map((d) => { const dep = byShort.get(d) return dep ? packageLink(dep) : `\`${d}\`` }).join(', ') : '—' return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |` }) 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) and grouped by the `packages//` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.', '', '```mermaid', 'flowchart TD', ...groupBlocks, ...edges, '```', '', '| Package | Group | 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}.`)