gen-module-graph.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. /**
  2. * Generate (and verify) the module dependency graph in docs/module-graph.md.
  3. *
  4. * The architectural shape of the harness lives implicitly in each package's
  5. * `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror
  6. * these as `workspace:^` plus test-only extras, which would add noise). This
  7. * script reads every `packages/* /* /package.json`, keeps only the
  8. * `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a
  9. * GitHub-viewable Mermaid graph plus a dependency table.
  10. *
  11. * The file is fully generated — never hand-edit it. Output is deterministic
  12. * (packages and edges sorted) so a regenerate-and-diff freshness check is
  13. * stable.
  14. *
  15. * `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md
  16. * `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file
  17. * is stale (CI / pre-push gate)
  18. */
  19. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  20. import { resolve } from 'node:path'
  21. const root = resolve(import.meta.dirname, '..')
  22. const OUT = 'docs/module-graph.md'
  23. const SCOPE = '@deepseek-ai/dsh-'
  24. interface Pkg {
  25. /** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */
  26. short: string
  27. /** Short names of this package's in-repo peer dependencies, sorted. */
  28. deps: string[]
  29. }
  30. /** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
  31. function collect(): Pkg[] {
  32. const pkgs: Pkg[] = []
  33. for (const rel of globSync('packages/*/*/package.json', { cwd: root })) {
  34. const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
  35. name: string
  36. peerDependencies?: Record<string, string>
  37. }
  38. if (!json.name.startsWith(SCOPE)) continue
  39. const deps = Object.keys(json.peerDependencies ?? {})
  40. .filter(d => d.startsWith(SCOPE))
  41. .map(d => d.slice(SCOPE.length))
  42. .sort()
  43. pkgs.push({ short: json.name.slice(SCOPE.length), deps })
  44. }
  45. return topoSort(pkgs)
  46. }
  47. /**
  48. * Order packages low-level → high-level: a package appears only after every
  49. * package it depends on. Kahn-style layering with an alphabetical tiebreak
  50. * within each layer, so the output stays deterministic (the freshness check
  51. * compares whole-file). The graph is a DAG, so this always terminates; a cycle
  52. * would leave nodes unplaced and throw.
  53. */
  54. function topoSort(pkgs: Pkg[]): Pkg[] {
  55. const remaining = new Map(pkgs.map(p => [p.short, p]))
  56. const placed = new Set<string>()
  57. const out: Pkg[] = []
  58. while (remaining.size > 0) {
  59. const ready = [...remaining.values()]
  60. .filter(p => p.deps.every(d => placed.has(d)))
  61. .sort((a, b) => a.short.localeCompare(b.short))
  62. if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
  63. for (const p of ready) {
  64. out.push(p)
  65. placed.add(p.short)
  66. remaining.delete(p.short)
  67. }
  68. }
  69. return out
  70. }
  71. /** Render the full docs/module-graph.md content (pure, deterministic). */
  72. function render(pkgs: Pkg[]): string {
  73. const edges: string[] = []
  74. for (const p of pkgs) {
  75. for (const d of p.deps) edges.push(` ${p.short} --> ${d}`)
  76. }
  77. const rows = pkgs.map(p => `| \`${p.short}\` | ${p.deps.length ? p.deps.map(d => `\`${d}\``).join(', ') : '—'} |`)
  78. return [
  79. '<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.',
  80. ' Run `pnpm run gen-module-graph` to regenerate. -->',
  81. '',
  82. '# Module dependency graph',
  83. '',
  84. '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.',
  85. '',
  86. '```mermaid',
  87. 'graph TD',
  88. ...edges,
  89. '```',
  90. '',
  91. '| Package | Depends on |',
  92. '| --- | --- |',
  93. ...rows,
  94. '',
  95. ].join('\n')
  96. }
  97. const content = render(collect())
  98. if (process.argv.includes('--check')) {
  99. let committed: string | null = null
  100. try {
  101. committed = readFileSync(resolve(root, OUT), 'utf8')
  102. } catch {
  103. // Only an ENOENT (file not yet generated) is expected here; readFileSync of
  104. // a present-but-unreadable file is not a state this repo produces. Either
  105. // way the remedy is the same — regenerate — so we treat a read failure as
  106. // "stale" and fall through to the failure branch below.
  107. committed = null
  108. }
  109. if (committed === content) {
  110. console.log(`gen-module-graph: ${OUT} is up to date.`)
  111. process.exit(0)
  112. }
  113. console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`)
  114. process.exit(1)
  115. }
  116. writeFileSync(resolve(root, OUT), content)
  117. console.log(`gen-module-graph: wrote ${OUT}.`)