Files
deepseek-harness/scripts/gen-module-graph.ts
Tianyi Cui b33668ef05 refactor: order module-graph table topologically (low→high level)
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.
2026-06-16 21:11:09 +08:00

129 lines
4.7 KiB
TypeScript

/**
* 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<string, string>
}
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<string>()
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 [
'<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.',
' Run `pnpm run gen-module-graph` to regenerate. -->',
'',
'# 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}.`)