gen-module-graph.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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 grouped by `packages/<group>/` plus a
  10. * dependency table.
  11. *
  12. * The file is fully generated — never hand-edit it. Output is deterministic
  13. * (packages and edges sorted) so a regenerate-and-diff freshness check is
  14. * stable.
  15. *
  16. * `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md
  17. * `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file
  18. * is stale (CI / pre-push gate)
  19. */
  20. import { dirname, resolve } from 'node:path'
  21. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  22. const root = resolve(import.meta.dirname, '..')
  23. const OUT = 'docs/module-graph.md'
  24. const SCOPE = '@deepseek-ai/dsh-'
  25. interface Pkg {
  26. /** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */
  27. short: string
  28. /** Package group from `packages/<group>/<pkg>`. */
  29. group: string
  30. /** Repo-relative package directory. */
  31. rel: string
  32. /** Short names of this package's in-repo peer dependencies, sorted. */
  33. deps: string[]
  34. }
  35. const GROUP_ORDER = [
  36. 'util',
  37. 'llm',
  38. 'core',
  39. 'bash',
  40. 'fs',
  41. 'skill',
  42. 'compact',
  43. 'subagent',
  44. 'web',
  45. 'timeout',
  46. 'todo',
  47. 'cordis',
  48. 'hooks',
  49. 'session-persistence',
  50. 'session-query',
  51. 'support',
  52. 'ui',
  53. ]
  54. /** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
  55. function collect(): Pkg[] {
  56. const pkgs: Pkg[] = []
  57. for (const rel of globSync('packages/*/*/package.json', { cwd: root })) {
  58. const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
  59. name: string
  60. peerDependencies?: Record<string, string>
  61. }
  62. if (!json.name.startsWith(SCOPE)) continue
  63. const deps = Object.keys(json.peerDependencies ?? {})
  64. .filter(d => d.startsWith(SCOPE))
  65. .map(d => d.slice(SCOPE.length))
  66. .sort()
  67. const [, group, leaf] = rel.split('/')
  68. if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`)
  69. pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), deps })
  70. }
  71. return topoSort(pkgs)
  72. }
  73. /**
  74. * Order packages low-level → high-level: a package appears only after every
  75. * package it depends on. Kahn-style layering with an alphabetical tiebreak
  76. * within each layer, so the output stays deterministic (the freshness check
  77. * compares whole-file). The graph is a DAG, so this always terminates; a cycle
  78. * would leave nodes unplaced and throw.
  79. */
  80. function topoSort(pkgs: Pkg[]): Pkg[] {
  81. const remaining = new Map(pkgs.map(p => [p.short, p]))
  82. const placed = new Set<string>()
  83. const out: Pkg[] = []
  84. while (remaining.size > 0) {
  85. const ready = [...remaining.values()]
  86. .filter(p => p.deps.every(d => placed.has(d)))
  87. .sort(comparePackages)
  88. if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
  89. for (const p of ready) {
  90. out.push(p)
  91. placed.add(p.short)
  92. remaining.delete(p.short)
  93. }
  94. }
  95. return out
  96. }
  97. function comparePackages(a: Pkg, b: Pkg): number {
  98. const groupA = GROUP_ORDER.indexOf(a.group)
  99. const groupB = GROUP_ORDER.indexOf(b.group)
  100. const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
  101. const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
  102. return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
  103. }
  104. function nodeId(prefix: string, value: string): string {
  105. return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
  106. }
  107. function escLabel(value: string): string {
  108. return value.replace(/"/g, '\\"')
  109. }
  110. function packageLink(pkg: Pkg): string {
  111. return `[\`${pkg.short}\`](../${pkg.rel})`
  112. }
  113. /** Render the full docs/module-graph.md content (pure, deterministic). */
  114. function render(pkgs: Pkg[]): string {
  115. const edges: string[] = []
  116. for (const p of pkgs) {
  117. for (const d of p.deps) edges.push(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`)
  118. }
  119. const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
  120. const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => {
  121. const ia = GROUP_ORDER.indexOf(a)
  122. const ib = GROUP_ORDER.indexOf(b)
  123. const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia
  124. const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib
  125. return na - nb || a.localeCompare(b)
  126. })
  127. const groupBlocks: string[] = []
  128. for (const group of groups) {
  129. groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`)
  130. for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) {
  131. groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`)
  132. }
  133. groupBlocks.push(' end')
  134. }
  135. const rows = pkgs.map((p) => {
  136. const deps = p.deps.length ? p.deps.map((d) => {
  137. const dep = byShort.get(d)
  138. return dep ? packageLink(dep) : `\`${d}\``
  139. }).join(', ') : '—'
  140. return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |`
  141. })
  142. return [
  143. '<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.',
  144. ' Run `pnpm run gen-module-graph` to regenerate. -->',
  145. '',
  146. '# Module dependency graph',
  147. '',
  148. '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/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.',
  149. '',
  150. '```mermaid',
  151. 'flowchart TD',
  152. ...groupBlocks,
  153. ...edges,
  154. '```',
  155. '',
  156. '| Package | Group | Depends on |',
  157. '| --- | --- | --- |',
  158. ...rows,
  159. '',
  160. ].join('\n')
  161. }
  162. const content = render(collect())
  163. if (process.argv.includes('--check')) {
  164. let committed: string | null = null
  165. try {
  166. committed = readFileSync(resolve(root, OUT), 'utf8')
  167. } catch {
  168. // Only an ENOENT (file not yet generated) is expected here; readFileSync of
  169. // a present-but-unreadable file is not a state this repo produces. Either
  170. // way the remedy is the same — regenerate — so we treat a read failure as
  171. // "stale" and fall through to the failure branch below.
  172. committed = null
  173. }
  174. if (committed === content) {
  175. console.log(`gen-module-graph: ${OUT} is up to date.`)
  176. process.exit(0)
  177. }
  178. console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`)
  179. process.exit(1)
  180. }
  181. writeFileSync(resolve(root, OUT), content)
  182. console.log(`gen-module-graph: wrote ${OUT}.`)