|
|
@@ -0,0 +1,126 @@
|
|
|
+/**
|
|
|
+ * Generate (and verify) the module dependency graph in docs/module-graph.md.
|
|
|
+ *
|
|
|
+ * The architectural shape of the harness lives implicitly in each package's
|
|
|
+ * `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror
|
|
|
+ * these as `workspace:^` plus test-only extras, which would add noise). This
|
|
|
+ * script reads every `packages/* /package.json`, keeps only the
|
|
|
+ * `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a
|
|
|
+ * GitHub-viewable Mermaid graph plus a dependency table.
|
|
|
+ *
|
|
|
+ * The file is fully generated — never hand-edit it. Output is deterministic
|
|
|
+ * (packages and edges sorted) so a regenerate-and-diff freshness check is
|
|
|
+ * stable.
|
|
|
+ *
|
|
|
+ * `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md
|
|
|
+ * `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file
|
|
|
+ * is stale (CI / pre-push gate)
|
|
|
+ */
|
|
|
+
|
|
|
+import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
+import { resolve } from 'node:path'
|
|
|
+
|
|
|
+const root = resolve(import.meta.dirname, '..')
|
|
|
+const OUT = 'docs/module-graph.md'
|
|
|
+const SCOPE = '@deepseek-ai/dsh-'
|
|
|
+
|
|
|
+interface Pkg {
|
|
|
+ /** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */
|
|
|
+ short: string
|
|
|
+ /** Short names of this package's in-repo peer dependencies, sorted. */
|
|
|
+ deps: string[]
|
|
|
+}
|
|
|
+
|
|
|
+/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
|
|
|
+function collect(): Pkg[] {
|
|
|
+ const pkgs: Pkg[] = []
|
|
|
+ for (const rel of globSync('packages/*/package.json', { cwd: root })) {
|
|
|
+ const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
|
|
|
+ name: string
|
|
|
+ peerDependencies?: Record<string, string>
|
|
|
+ }
|
|
|
+ if (!json.name.startsWith(SCOPE)) continue
|
|
|
+ const deps = Object.keys(json.peerDependencies ?? {})
|
|
|
+ .filter(d => d.startsWith(SCOPE))
|
|
|
+ .map(d => d.slice(SCOPE.length))
|
|
|
+ .sort()
|
|
|
+ pkgs.push({ short: json.name.slice(SCOPE.length), deps })
|
|
|
+ }
|
|
|
+ return topoSort(pkgs)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Order packages low-level → high-level: a package appears only after every
|
|
|
+ * package it depends on. Kahn-style layering with an alphabetical tiebreak
|
|
|
+ * within each layer, so the output stays deterministic (the freshness check
|
|
|
+ * compares whole-file). The graph is a DAG, so this always terminates; a cycle
|
|
|
+ * would leave nodes unplaced and throw.
|
|
|
+ */
|
|
|
+function topoSort(pkgs: Pkg[]): Pkg[] {
|
|
|
+ const remaining = new Map(pkgs.map(p => [p.short, p]))
|
|
|
+ const placed = new Set<string>()
|
|
|
+ const out: Pkg[] = []
|
|
|
+ while (remaining.size > 0) {
|
|
|
+ const ready = [...remaining.values()]
|
|
|
+ .filter(p => p.deps.every(d => placed.has(d)))
|
|
|
+ .sort((a, b) => a.short.localeCompare(b.short))
|
|
|
+ if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
|
|
+ for (const p of ready) {
|
|
|
+ out.push(p)
|
|
|
+ placed.add(p.short)
|
|
|
+ remaining.delete(p.short)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+/** Render the full docs/module-graph.md content (pure, deterministic). */
|
|
|
+function render(pkgs: Pkg[]): string {
|
|
|
+ const edges: string[] = []
|
|
|
+ for (const p of pkgs) {
|
|
|
+ for (const d of p.deps) edges.push(` ${p.short} --> ${d}`)
|
|
|
+ }
|
|
|
+ const rows = pkgs.map(p => `| \`${p.short}\` | ${p.deps.length ? p.deps.map(d => `\`${d}\``).join(', ') : '—'} |`)
|
|
|
+ return [
|
|
|
+ '<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.',
|
|
|
+ ' Run `pnpm run gen-module-graph` to regenerate. -->',
|
|
|
+ '',
|
|
|
+ '# Module dependency graph',
|
|
|
+ '',
|
|
|
+ '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.',
|
|
|
+ '',
|
|
|
+ '```mermaid',
|
|
|
+ 'graph TD',
|
|
|
+ ...edges,
|
|
|
+ '```',
|
|
|
+ '',
|
|
|
+ '| Package | Depends on |',
|
|
|
+ '| --- | --- |',
|
|
|
+ ...rows,
|
|
|
+ '',
|
|
|
+ ].join('\n')
|
|
|
+}
|
|
|
+
|
|
|
+const content = render(collect())
|
|
|
+
|
|
|
+if (process.argv.includes('--check')) {
|
|
|
+ let committed: string | null = null
|
|
|
+ try {
|
|
|
+ committed = readFileSync(resolve(root, OUT), 'utf8')
|
|
|
+ } catch {
|
|
|
+ // Only an ENOENT (file not yet generated) is expected here; readFileSync of
|
|
|
+ // a present-but-unreadable file is not a state this repo produces. Either
|
|
|
+ // way the remedy is the same — regenerate — so we treat a read failure as
|
|
|
+ // "stale" and fall through to the failure branch below.
|
|
|
+ committed = null
|
|
|
+ }
|
|
|
+ if (committed === content) {
|
|
|
+ console.log(`gen-module-graph: ${OUT} is up to date.`)
|
|
|
+ process.exit(0)
|
|
|
+ }
|
|
|
+ console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`)
|
|
|
+ process.exit(1)
|
|
|
+}
|
|
|
+
|
|
|
+writeFileSync(resolve(root, OUT), content)
|
|
|
+console.log(`gen-module-graph: wrote ${OUT}.`)
|