package-graph.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. /**
  2. * Shared workspace-package graph discovery and Mermaid identifier helpers for
  3. * the generated module graph and relationship-diagram generators. Each caller
  4. * supplies its own group ordering because the documents use different visual
  5. * priorities; manifest parsing and dependency-safe ordering have one owner.
  6. */
  7. import { globSync, readFileSync } from 'node:fs'
  8. import { dirname, resolve, sep } from 'node:path'
  9. const SCOPE = '@deepseek-ai/dsh-'
  10. /** One harness package and its in-repo peer-dependency edges. */
  11. export interface PackageGraphNode {
  12. /** Package name with the `@deepseek-ai/dsh-` prefix removed. */
  13. short: string
  14. /** Full npm package name. */
  15. name: string
  16. /** Package group from `packages/<group>/<pkg>`. */
  17. group: string
  18. /** Repo-relative package directory. */
  19. rel: string
  20. /** Short names of in-repo peer dependencies, sorted. */
  21. deps: string[]
  22. }
  23. /**
  24. * Read every harness package manifest and return dependency-safe graph nodes.
  25. * @param root - absolute repository root.
  26. * @param groupOrder - caller-specific tiebreak order for packages in the same dependency layer.
  27. * @param gate - command name used in structural error messages.
  28. * @returns package nodes ordered after all of their in-repo dependencies.
  29. */
  30. export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] {
  31. const packages: PackageGraphNode[] = []
  32. for (const rel of globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()) {
  33. const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
  34. name: string
  35. peerDependencies?: Record<string, string>
  36. }
  37. if (!json.name.startsWith(SCOPE)) continue
  38. const [, group, leaf] = rel.split('/')
  39. if (group === undefined || leaf === undefined) throw new Error(`${gate}: unexpected package path ${rel}`)
  40. const deps = Object.keys(json.peerDependencies ?? {})
  41. .filter(dep => dep.startsWith(SCOPE))
  42. .map(dep => dep.slice(SCOPE.length))
  43. .sort()
  44. packages.push({
  45. short: json.name.slice(SCOPE.length),
  46. name: json.name,
  47. group,
  48. rel: dirname(rel),
  49. deps,
  50. })
  51. }
  52. return topoSort(packages, groupOrder, gate)
  53. }
  54. function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], gate: string): PackageGraphNode[] {
  55. const remaining = new Map(packages.map(pkg => [pkg.short, pkg]))
  56. const placed = new Set<string>()
  57. const out: PackageGraphNode[] = []
  58. while (remaining.size > 0) {
  59. const ready = [...remaining.values()]
  60. .filter(pkg => pkg.deps.every(dep => placed.has(dep)))
  61. .sort((a, b) => comparePackages(a, b, groupOrder))
  62. if (ready.length === 0) throw new Error(`${gate}: dependency cycle among ${[...remaining.keys()].join(', ')}`)
  63. for (const pkg of ready) {
  64. out.push(pkg)
  65. placed.add(pkg.short)
  66. remaining.delete(pkg.short)
  67. }
  68. }
  69. return out
  70. }
  71. function comparePackages(a: PackageGraphNode, b: PackageGraphNode, groupOrder: readonly string[]): number {
  72. const groupA = groupOrder.indexOf(a.group)
  73. const groupB = groupOrder.indexOf(b.group)
  74. const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
  75. const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
  76. return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
  77. }
  78. /** Stable Mermaid id for a graph value. */
  79. export function graphNodeId(prefix: string, value: string): string {
  80. return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
  81. }
  82. /** Escape a value embedded in a quoted Mermaid label. */
  83. export function escapeMermaidLabel(value: string): string {
  84. return value.replace(/"/g, '\\"')
  85. }