1
0

package-graph.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  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-first 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 their in-repo dependencies, except for
  29. * stable back edges inside a dependency cycle.
  30. */
  31. export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] {
  32. const packages: PackageGraphNode[] = []
  33. for (const rel of globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()) {
  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 [, group, leaf] = rel.split('/')
  40. if (group === undefined || leaf === undefined) throw new Error(`${gate}: unexpected package path ${rel}`)
  41. const deps = Object.keys(json.peerDependencies ?? {})
  42. .filter(dep => dep.startsWith(SCOPE))
  43. .map(dep => dep.slice(SCOPE.length))
  44. .sort()
  45. packages.push({
  46. short: json.name.slice(SCOPE.length),
  47. name: json.name,
  48. group,
  49. rel: dirname(rel),
  50. deps,
  51. })
  52. }
  53. return topoSort(packages, groupOrder, gate)
  54. }
  55. function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], gate: string): PackageGraphNode[] {
  56. const byName = new Map(packages.map(pkg => [pkg.short, pkg]))
  57. for (const pkg of packages) {
  58. for (const dependency of pkg.deps) {
  59. if (!byName.has(dependency)) {
  60. throw new Error(`${gate}: ${pkg.name} references missing in-repo peer ${SCOPE}${dependency}`)
  61. }
  62. }
  63. }
  64. const remaining = new Map(byName)
  65. const placed = new Set<string>()
  66. const out: PackageGraphNode[] = []
  67. while (remaining.size > 0) {
  68. let ready = [...remaining.values()]
  69. .filter(pkg => pkg.deps.every(dep => placed.has(dep)))
  70. .sort((a, b) => comparePackages(a, b, groupOrder))
  71. if (ready.length === 0) {
  72. const cycle = sinkCycles(remaining)
  73. .map(component => component.sort((a, b) => comparePackages(a, b, groupOrder)))
  74. .sort((a, b) => comparePackages(a[0], b[0], groupOrder))[0]
  75. if (cycle === undefined) throw new Error(`${gate}: could not order package dependency graph`)
  76. ready = cycle
  77. }
  78. for (const pkg of ready) {
  79. out.push(pkg)
  80. placed.add(pkg.short)
  81. remaining.delete(pkg.short)
  82. }
  83. }
  84. return out
  85. }
  86. type PackageGraphComponent = [PackageGraphNode, ...PackageGraphNode[]]
  87. function sinkCycles(remaining: ReadonlyMap<string, PackageGraphNode>): PackageGraphComponent[] {
  88. let nextIndex = 0
  89. const indices = new Map<string, number>()
  90. const lowLinks = new Map<string, number>()
  91. const stack: PackageGraphNode[] = []
  92. const stacked = new Set<string>()
  93. const components: PackageGraphComponent[] = []
  94. const visit = (pkg: PackageGraphNode): void => {
  95. const index = nextIndex
  96. nextIndex += 1
  97. indices.set(pkg.short, index)
  98. lowLinks.set(pkg.short, index)
  99. stack.push(pkg)
  100. stacked.add(pkg.short)
  101. for (const dependency of pkg.deps) {
  102. const target = remaining.get(dependency)
  103. if (target === undefined) continue
  104. if (!indices.has(target.short)) {
  105. visit(target)
  106. lowLinks.set(pkg.short, Math.min(requiredValue(lowLinks, pkg.short), requiredValue(lowLinks, target.short)))
  107. } else if (stacked.has(target.short)) {
  108. lowLinks.set(pkg.short, Math.min(requiredValue(lowLinks, pkg.short), requiredValue(indices, target.short)))
  109. }
  110. }
  111. if (lowLinks.get(pkg.short) !== indices.get(pkg.short)) return
  112. const first = stack.pop()
  113. if (first === undefined) throw new Error('package graph traversal lost its active component')
  114. stacked.delete(first.short)
  115. const component: PackageGraphComponent = [first]
  116. let member = first
  117. while (member !== pkg) {
  118. const next = stack.pop()
  119. if (next === undefined) throw new Error('package graph traversal lost its active component')
  120. stacked.delete(next.short)
  121. component.push(next)
  122. member = next
  123. }
  124. components.push(component)
  125. }
  126. for (const pkg of remaining.values()) {
  127. if (!indices.has(pkg.short)) visit(pkg)
  128. }
  129. return components.filter((component) => {
  130. const names = new Set(component.map(pkg => pkg.short))
  131. const first = component[0]
  132. const cyclic = component.length > 1 || first.deps.includes(first.short)
  133. return cyclic && component.every(pkg => pkg.deps.every(dep => !remaining.has(dep) || names.has(dep)))
  134. })
  135. }
  136. function requiredValue<K, V>(values: ReadonlyMap<K, V>, key: K): V {
  137. const value = values.get(key)
  138. if (value === undefined) throw new Error('package graph traversal lost an indexed node')
  139. return value
  140. }
  141. function comparePackages(a: PackageGraphNode, b: PackageGraphNode, groupOrder: readonly string[]): number {
  142. const groupA = groupOrder.indexOf(a.group)
  143. const groupB = groupOrder.indexOf(b.group)
  144. const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
  145. const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
  146. return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
  147. }
  148. /** Stable Mermaid id for a graph value. */
  149. export function graphNodeId(prefix: string, value: string): string {
  150. return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
  151. }
  152. /** Escape a value embedded in a quoted Mermaid label. */
  153. export function escapeMermaidLabel(value: string): string {
  154. return value.replace(/"/g, '\\"')
  155. }