/** Generate the paired shared-instance package graph from workspace peer dependencies. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { collectPackageGraph, escapeMermaidLabel as escLabel, graphNodeId as nodeId, type PackageGraphNode, } from './package-graph.ts' import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts' import { renderTranslationPairingRecord, translationPairPaths } from './translation-pairing-record.ts' const root = resolve(import.meta.dirname, '..') const SOURCE = 'docs/module-graph.md' const PATHS = translationPairPaths(SOURCE) type Pkg = PackageGraphNode type Locale = 'en' | 'zh' const GROUP_ORDER = [ 'util', 'llm', 'core', 'goal', 'bash', 'fs', 'skill', 'compact', 'subagent', 'web', 'spill', 'timeout', 'todo', 'plan', 'cordis', 'hooks', 'session-persistence', 'session-query', 'session-title', 'support', 'acp', 'ui', ] function packageLink(pkg: Pkg): string { return `[\`${pkg.short}\`](../${pkg.rel})` } /** * Render one locale of the complete deterministic package graph. * @param pkgs - Dependency-first package nodes. * @param locale - Output document language. * @returns Complete generated Markdown. */ export function renderModuleGraph(pkgs: readonly Pkg[], locale: Locale): string { const edges: string[] = [] for (const pkg of pkgs) { for (const dependency of pkg.deps) edges.push(` ${nodeId('pkg', pkg.short)} --> ${nodeId('pkg', dependency)}`) } const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((left, right) => { const leftIndex = GROUP_ORDER.indexOf(left) const rightIndex = GROUP_ORDER.indexOf(right) const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex return normalizedLeft - normalizedRight || left.localeCompare(right) }) const groupBlocks: string[] = [] for (const group of groups) { groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`) for (const pkg of pkgs.filter(candidate => candidate.group === group) .sort((left, right) => left.short.localeCompare(right.short))) { groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`) } groupBlocks.push(' end') } const rows = pkgs.map((pkg) => { const dependencies = pkg.deps.length > 0 ? pkg.deps.map((dependency) => { const target = byShort.get(dependency) return target ? packageLink(target) : `\`${dependency}\`` }).join(', ') : '—' return `| ${packageLink(pkg)} | \`${pkg.group}\` | ${dependencies} |` }) const chinese = locale === 'zh' return [ chinese ? '' : '', '', chinese ? '# 共享实例依赖关系图' : '# Shared-instance dependency graph', '', ...(chinese ? ['[English](module-graph.md) | 中文', ''] : []), chinese ? '`@deepseek-ai/dsh-*` harness 包之间的 peer 依赖关系。peer 表示消费端需要提供共享实例,不包括普通运行时 dependency 或仅开发期关系。该图按 `packages//` 层级分组;边 `a --> b` 表示包 `a` peer 依赖包 `b`。名称中的 `@deepseek-ai/dsh-` 前缀已移除。' : 'Peer dependencies among the `@deepseek-ai/dsh-*` harness packages. A peer means the consumer requires a shared instance; ordinary runtime dependencies and development-only relationships are not shown. The graph is grouped by the `packages//` hierarchy. An edge `a --> b` means package `a` has package `b` as a peer. Names omit the `@deepseek-ai/dsh-` prefix.', '', '```mermaid', 'flowchart TD', ...groupBlocks, ...edges, '```', '', chinese ? '| 包 | 分组 | Peer 依赖 |' : '| Package | Group | Peer dependencies |', '| --- | --- | --- |', ...rows, '', ].join('\n') } /** * Compute both localized graph documents from the current workspace manifests. * @param scanRoot - Repository root containing packages and documentation. * @returns Repository-relative output paths and exact generated content. */ export function computeModuleGraphOutputs(scanRoot: string = root): ReadonlyMap { const packages = collectPackageGraph(scanRoot, GROUP_ORDER, 'gen-module-graph') return new Map([ [PATHS.source, renderModuleGraph(packages, 'en')], [PATHS.zh, renderModuleGraph(packages, 'zh')], ]) } /** * Write both graph documents and their recovery record. * @param scanRoot - Repository root containing packages and documentation. * @returns Repository-relative paths whose content changed. */ export function writeModuleGraph(scanRoot: string = root): string[] { const outputs = computeModuleGraphOutputs(scanRoot) const changed: string[] = [] for (const [path, content] of outputs) { const destination = resolve(scanRoot, path) if (existsSync(destination) && readFileSync(destination, 'utf8') === content) continue writeFileSync(destination, content) changed.push(path) } const source = Buffer.from(outputs.get(PATHS.source) ?? '') const zh = Buffer.from(outputs.get(PATHS.zh) ?? '') const record = renderTranslationPairingRecord(PATHS, { sourceHash: storeGitBlob(scanRoot, source), zhHash: storeGitBlob(scanRoot, zh), }) const recordPath = resolve(scanRoot, PATHS.meta) if (!existsSync(recordPath) || readFileSync(recordPath, 'utf8') !== record) { writeFileSync(recordPath, record) changed.push(PATHS.meta) } return changed.sort() } /** CLI entry: regenerate by default, or verify all paired outputs with `--check`. @returns Nothing. */ export function main(): void { const outputs = computeModuleGraphOutputs(root) const record = renderTranslationPairingRecord(PATHS, { sourceHash: gitBlobHash(Buffer.from(outputs.get(PATHS.source) ?? '')), zhHash: gitBlobHash(Buffer.from(outputs.get(PATHS.zh) ?? '')), }) const expected = new Map([...outputs, [PATHS.meta, record]]) if (process.argv.includes('--check')) { const stale = [...expected].filter(([path, content]) => ( !existsSync(resolve(root, path)) || readFileSync(resolve(root, path), 'utf8') !== content )).map(([path]) => path) if (stale.length === 0) { console.log(`gen-module-graph: ${expected.size} artifact(s) are up to date.`) return } console.error(`gen-module-graph: stale — ${stale.join(', ')}. Run \`pnpm run gen-module-graph\` and commit the result.`) process.exitCode = 1 return } const changed = writeModuleGraph(root) console.log(`gen-module-graph: ${expected.size} artifact(s) computed, ${String(changed.length)} written.`) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) main()