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.
129 lines
4.7 KiB
TypeScript
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}.`)
|