2026-06-16 21:00:24 +08:00
/**
* 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 })
}
2026-06-16 21:11:09 +08:00
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
2026-06-16 21:00:24 +08:00
}
/** 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' ,
'' ,
2026-06-16 21:21:21 +08:00
'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.' ,
2026-06-16 21:00:24 +08:00
'' ,
'```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 } .` )