ソースを参照

Merge pull request #281 from deepseek-harness/codex/stricter-duplication-lint

chore: tighten TypeScript duplication lint
Tianyi Cui 2 ヶ月 前
コミット
a0359bc4a9

+ 4 - 3
.jscpd.json

@@ -1,9 +1,10 @@
 {
-  "minTokens": 100,
-  "minLines": 10,
+  "minTokens": 60,
+  "minLines": 6,
   "mode": "mild",
   "format": ["typescript"],
-  "pattern": "**/src/**/*.ts",
+  "pattern": "**/*.ts",
+  "ignore": ["**/tests/**", "**/tsdown.config.ts"],
   "ignorePattern": [
     "(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"
   ],

+ 2 - 0
AGENTS.md

@@ -46,6 +46,7 @@ pnpm run test:snapshot  # keyless ACP replay vs goldens; filter: -t <name>
 pnpm run test:snapshot:record  # re-record goldens (needs key)
 pnpm run typecheck
 pnpm run lint
+pnpm run duplication    # cross-file TypeScript clone detection
 pnpm run build          # tsc emits lib/types, tsdown bundles runtime
 pnpm run hygiene        # knip + publint + workspace constraints + NodeNext consumer check
 pnpm run doc-sync       # all documentation gates; see the doc-sync script in package.json
@@ -63,6 +64,7 @@ During implementation, run the narrowest affected checks; run this full CI-equiv
 set -euo pipefail
 pnpm run typecheck
 pnpm run lint
+pnpm run duplication
 pnpm run test:coverage
 pnpm run test:snapshot
 pnpm run doc-sync

+ 1 - 1
docs/config-catalog.md

@@ -326,7 +326,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts)
+Source: [`packages/hooks/hooks-codex/src/index.ts:47`](../packages/hooks/hooks-codex/src/index.ts)
 
 ## `@deepseek-ai/dsh-jsonrpc`
 

+ 3 - 2
docs/rfc/implemented/process/2026-06-11-quality-gates.md

@@ -11,8 +11,9 @@ This codebase is developed primarily by coding agents. Agents follow enforced ga
 Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts:
 
 - Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary.
-- ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded.
-- Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
+- ESLint strict-type-checked + @stylistic (the house style, enforced), including file-local duplicated logic checks; vendored code excluded.
+- jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations.
+- Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
 - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations.
 - lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus a demo smoke test driving the echo-agent end to end.
 

+ 6 - 0
eslint.config.mjs

@@ -130,6 +130,12 @@ export default tseslint.config(
     plugins: { sonarjs },
     rules: {
       // Cross-file clones are covered separately by jscpd.
+      'sonarjs/duplicates-in-character-class': 'error',
+      'sonarjs/no-all-duplicated-branches': 'error',
+      'sonarjs/no-duplicate-in-composite': 'error',
+      'sonarjs/no-duplicate-test-title': 'error',
+      'sonarjs/no-identical-conditions': 'error',
+      'sonarjs/no-identical-expressions': 'error',
       'sonarjs/no-identical-functions': 'error',
       'sonarjs/no-duplicated-branches': 'error',
     },

+ 1 - 1
package.json

@@ -17,7 +17,7 @@
     "typecheck": "tsc -b tsconfig.json",
     "lint": "eslint .",
     "lint:fix": "eslint . --fix",
-    "duplication": "jscpd --config .jscpd.json packages",
+    "duplication": "jscpd --config .jscpd.json packages scripts",
     "test": "vitest run",
     "test:coverage": "vitest run --coverage",
     "test:e2e": "vitest run --config vitest.e2e.config.ts",

+ 4 - 0
packages/hooks/hooks-codex/src/index.ts

@@ -15,6 +15,9 @@
  * @module @deepseek-ai/dsh-hooks-codex
  */
 
+// Each dialect bridge keeps its complete dependency list visible at the entry
+// point; a cross-package facade for imports alone would add indirection.
+/* jscpd:ignore-start */
 import { readFileSync } from 'node:fs'
 import type { Context } from 'cordis'
 import z from 'schemastery'
@@ -35,6 +38,7 @@ import {
   type MergedHookOutcome,
 } from '@deepseek-ai/dsh-hook-protocol'
 import { parseCodexConfig, type CodexHookConfig } from './config.ts'
+/* jscpd:ignore-end */
 
 export const name = 'hooks-codex'
 export const inject = ['bash']

+ 1 - 1
packages/support/invariants/tests/invariants.spec.ts

@@ -874,7 +874,7 @@ describe('scoped-dispatch invariants', () => {
       ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
     ]
     for (const [event, args] of rows) {
-      const subject = event.startsWith('tools/') ? agent : agent
+      const subject = agent
       expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) },
         `${event} with matching carrier`).not.toThrow()
       expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) },

+ 4 - 0
packages/ui/acp-agent/src/index.ts

@@ -64,6 +64,9 @@ export interface Config {
   skills?: agentCore.SkillConfig
 }
 
+// Each front door owns a complete, directly readable config schema; extracting
+// the common fields would make two small app contracts depend on a new facade.
+/* jscpd:ignore-start */
 export const Config: z<Config> = z.object({
   model: z.string().required(),
   persona: z.string(),
@@ -77,6 +80,7 @@ export const Config: z<Config> = z.object({
   persistenceRoot: z.string().default('./.sessions'),
   skills: agentCore.SkillConfigSchema,
 })
+/* jscpd:ignore-end */
 
 /**
  * Compose the spine with the ACP front door. The agent-core bundle pre-creates

+ 8 - 0
packages/web/web-search-perplexity/src/provider.ts

@@ -99,12 +99,16 @@ export class PerplexitySearchProvider implements WebSearchProvider {
 
   constructor(private readonly options: PerplexitySearchProviderOptions) {}
 
+  // Availability checks stay beside each provider's distinct config contract;
+  // a shared base class would obscure which fields make this backend usable.
+  /* jscpd:ignore-start */
   status(): WebProviderStatus {
     if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
     if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
     if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
     return { available: true }
   }
+  /* jscpd:ignore-end */
 
   async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
     let response: Response
@@ -159,6 +163,9 @@ export class PerplexitySearchProvider implements WebSearchProvider {
   }
 }
 
+// These two predicates are intentionally local: exporting generic internals
+// from the public web seam would cost more API surface than these pure checks.
+/* jscpd:ignore-start */
 /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
 function isAbortError(error: unknown): boolean {
   return error instanceof DOMException && error.name === 'AbortError'
@@ -168,3 +175,4 @@ function isAbortError(error: unknown): boolean {
 function isPositiveInteger(value: number): boolean {
   return Number.isInteger(value) && value > 0
 }
+/* jscpd:ignore-end */

+ 8 - 71
scripts/gen-doc-graphs.ts

@@ -22,22 +22,15 @@ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'no
 import { dirname, relative, resolve } from 'node:path'
 import ts from 'typescript'
 import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
+import {
+  collectPackageGraph,
+  escapeMermaidLabel as escLabel,
+  graphNodeId as nodeId,
+  type PackageGraphNode,
+} from './package-graph.ts'
 
 const root = resolve(import.meta.dirname, '..')
-const SCOPE = '@deepseek-ai/dsh-'
-
-interface PkgJson {
-  name: string
-  peerDependencies?: Record<string, string>
-}
-
-interface Pkg {
-  short: string
-  name: string
-  group: string
-  rel: string
-  deps: string[]
-}
+type Pkg = PackageGraphNode
 
 interface GraphDoc {
   rel: string
@@ -312,62 +305,6 @@ function linkFromDoc(docRel: string, targetRel: string): string {
   return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
 }
 
-function collectPackages(): Pkg[] {
-  const pkgs: Pkg[] = []
-  for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
-    const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as PkgJson
-    if (!json.name.startsWith(SCOPE)) continue
-    const [, group, leaf] = rel.split('/')
-    if (group === undefined || leaf === undefined) throw new Error(`gen-doc-graphs: unexpected package path ${rel}`)
-    const deps = Object.keys(json.peerDependencies ?? {})
-      .filter(dep => dep.startsWith(SCOPE))
-      .map(dep => dep.slice(SCOPE.length))
-      .sort()
-    pkgs.push({
-      short: json.name.slice(SCOPE.length),
-      name: json.name,
-      group,
-      rel: dirname(rel),
-      deps,
-    })
-  }
-  return topoSort(pkgs)
-}
-
-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(pkg => pkg.deps.every(dep => placed.has(dep)))
-      .sort(comparePackages)
-    if (ready.length === 0) throw new Error(`gen-doc-graphs: dependency cycle among ${[...remaining.keys()].join(', ')}`)
-    for (const pkg of ready) {
-      out.push(pkg)
-      placed.add(pkg.short)
-      remaining.delete(pkg.short)
-    }
-  }
-  return out
-}
-
-function comparePackages(a: Pkg, b: Pkg): number {
-  const groupA = GROUP_ORDER.indexOf(a.group)
-  const groupB = GROUP_ORDER.indexOf(b.group)
-  const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
-  const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
-  return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
-}
-
-function nodeId(prefix: string, value: string): string {
-  return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
-}
-
-function escLabel(value: string): string {
-  return value.replace(/"/g, '\\"')
-}
-
 function mermaidCode(value: string): string {
   return `<code>${value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code>`
 }
@@ -844,7 +781,7 @@ function renderSnapshotReplay(): string {
 }
 
 function renderDocs(): GraphDoc[] {
-  const pkgs = collectPackages()
+  const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs')
   const docs: GraphDoc[] = [
     { rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
     ...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),

+ 10 - 76
scripts/gen-module-graph.ts

@@ -18,23 +18,18 @@
  *                                                is stale (CI / pre-push gate)
  */
 
-import { dirname, resolve } from 'node:path'
-import { globSync, readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { readFileSync, writeFileSync } from 'node:fs'
+import {
+  collectPackageGraph,
+  escapeMermaidLabel as escLabel,
+  graphNodeId as nodeId,
+  type PackageGraphNode,
+} from './package-graph.ts'
 
 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
-  /** Package group from `packages/<group>/<pkg>`. */
-  group: string
-  /** Repo-relative package directory. */
-  rel: string
-  /** Short names of this package's in-repo peer dependencies, sorted. */
-  deps: string[]
-}
+type Pkg = PackageGraphNode
 
 const GROUP_ORDER = [
   'util',
@@ -55,67 +50,6 @@ const GROUP_ORDER = [
   'ui',
 ]
 
-/** 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()
-    const [, group, leaf] = rel.split('/')
-    if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`)
-    pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), 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(comparePackages)
-    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
-}
-
-function comparePackages(a: Pkg, b: Pkg): number {
-  const groupA = GROUP_ORDER.indexOf(a.group)
-  const groupB = GROUP_ORDER.indexOf(b.group)
-  const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
-  const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
-  return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
-}
-
-function nodeId(prefix: string, value: string): string {
-  return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
-}
-
-function escLabel(value: string): string {
-  return value.replace(/"/g, '\\"')
-}
-
 function packageLink(pkg: Pkg): string {
   return `[\`${pkg.short}\`](../${pkg.rel})`
 }
@@ -170,7 +104,7 @@ function render(pkgs: Pkg[]): string {
   ].join('\n')
 }
 
-const content = render(collect())
+const content = render(collectPackageGraph(root, GROUP_ORDER, 'gen-module-graph'))
 
 if (process.argv.includes('--check')) {
   let committed: string | null = null

+ 2 - 88
scripts/gen-persistence-catalog.ts

@@ -45,6 +45,7 @@
 import { globSync, readFileSync, writeFileSync } from 'node:fs'
 import { resolve } from 'node:path'
 import ts from 'typescript'
+import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
 
 const root = resolve(import.meta.dirname, '..')
 const OUT = 'docs/persistence-catalog.md'
@@ -95,12 +96,6 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
   surface: boolean
 }
 
-/** Repo-relative source pointer `file:line` for a node's first character. */
-function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
-  const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
-  return `${rel}:${line + 1}`
-}
-
 const printer = ts.createPrinter({ removeComments: true })
 
 /**
@@ -118,87 +113,6 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
     .trim()
 }
 
-/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */
-function rawJsDoc(text: string, node: ts.Node): string {
-  const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
-  const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
-  return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
-}
-
-/**
- * Parse a raw JSDoc block into description prose, flagging whether any `@mode`
- * tag is present (forbidden on log events). Output obeys the repo's markdown
- * conventions so the generated file passes verify-md-wrap: each prose paragraph
- * collapses to ONE physical line, and a `-` bullet list is preserved with each
- * item on its own single line (continuation lines folded in). `{@link Foo}`
- * unwraps to `Foo`. Description prose ends at the FIRST block tag (standard
- * JSDoc semantics): tag lines and their continuation lines are never prose.
- */
-function parseJsDoc(raw: string): { doc: string; hasMode: boolean } {
-  const inner = raw
-    .replace(/^\/\*\*/, '')
-    .replace(/\*\/$/, '')
-    .split('\n')
-    .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
-  let hasMode = false
-  let inTags = false
-  const blocks: string[] = []
-  let para: string[] = []
-  let list: string[] = []
-  let item: string[] = []
-  const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
-  const flushItem = (): void => {
-    if (item.length) list.push(join(item))
-    item = []
-  }
-  const flushList = (): void => {
-    flushItem()
-    if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
-    list = []
-  }
-  const flushPara = (): void => {
-    flushList()
-    if (para.length) blocks.push(join(para))
-    para = []
-  }
-  for (const line of inner) {
-    // Tag detection runs on the trimmed line: the normalization above strips at
-    // most one post-`*` space, so an extra-indented `*  @mode` still reaches
-    // here with leading whitespace and must not leak into prose.
-    const tagLine = line.trimStart()
-    if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
-    if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
-    if (inTags) continue // block-tag territory: continuations are never prose
-    if (line.trim() === '') { flushPara(); continue }
-    if (/^-\s+/.test(line)) {
-      // A list item starts: a pending paragraph (e.g. an intro line directly
-      // above the list, no blank between) flushes FIRST so it renders above.
-      flushItem()
-      if (para.length) { blocks.push(join(para)); para = [] }
-      item.push(line)
-      continue
-    }
-    if (item.length) { item.push(line); continue } // continuation of current item
-    para.push(line)
-  }
-  flushPara()
-  const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
-  return { doc, hasMode }
-}
-
-/**
- * Throw one aggregate error for every completeness violation a walk collected.
- * Aggregation is deliberate: a remediation pass sees the whole list at once
- * instead of replaying the gate once per offender.
- */
-function reportViolations(violations: string[]): void {
-  if (violations.length === 0) return
-  throw new Error(
-    `gen-persistence-catalog: ${violations.length} JSDoc completeness violation(s):\n`
-    + violations.map(v => `  ${v}`).join('\n'),
-  )
-}
-
 /**
  * Every `interface SessionEventMap` declaration in a source file: the owning
  * top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
@@ -323,7 +237,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
       }
     }
   }
-  reportViolations(violations)
+  reportViolations('gen-persistence-catalog', violations)
   return entries
 }
 

+ 18 - 15
scripts/jsdoc.ts

@@ -1,14 +1,13 @@
 /**
  * Shared JSDoc parsing and completeness-check helpers for the documentation
- * gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
- * events + `ctx.<key>` service surface), the plugin config catalog generator
- * (`scripts/gen-config-catalog.ts`, which renders the parsed prose), and the
- * export-surface gate (`scripts/verify-export-jsdoc.ts` — every module-level
- * export). One home for the mechanics so "documented" means the same thing on
- * every gated surface: description prose ends at the first block tag; every
- * checkable parameter needs a non-empty `@param`; a non-void ANNOTATED return
- * needs a non-empty `@returns`; a stale `@param` naming no real parameter
- * errors.
+ * gates: the cordis and persistence catalog generators
+ * (`scripts/gen-cordis-catalog.ts` / `scripts/gen-persistence-catalog.ts`),
+ * the plugin config catalog generator (`scripts/gen-config-catalog.ts`), and
+ * the export-surface gate (`scripts/verify-export-jsdoc.ts`). One home for the
+ * mechanics so "documented" means the same thing on every gated surface:
+ * description prose ends at the first block tag; every checkable parameter
+ * needs a non-empty `@param`; a non-void ANNOTATED return needs a non-empty
+ * `@returns`; a stale `@param` naming no real parameter errors.
  */
 
 import ts from 'typescript'
@@ -39,15 +38,17 @@ export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
  * tag lines and their continuation lines are never prose, so `@param` /
  * `@returns` blocks are invisible to the rendered catalog.
  * @param raw - the raw comment text including the JSDoc delimiters.
- * @returns the collapsed description prose plus the parsed `@mode` (or null).
+ * @returns the collapsed description prose, parsed valid `@mode` (or null),
+ *   and whether any `@mode` tag was present.
  */
-export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
+export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMode: boolean } {
   const inner = raw
     .replace(/^\/\*\*/, '')
     .replace(/\*\/$/, '')
     .split('\n')
     .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
   let mode: Mode | null = null
+  let hasMode = false
   let inTags = false
   const blocks: string[] = []
   let para: string[] = []
@@ -69,9 +70,11 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
     para = []
   }
   for (const line of inner) {
-    const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
-    if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
-    if (line.startsWith('@')) { flushPara(); inTags = true; continue }
+    const tagLine = line.trimStart()
+    const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(tagLine)
+    if (m) { mode = m[1] as Mode; hasMode = true; flushPara(); inTags = true; continue }
+    if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
+    if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
     if (inTags) continue // block-tag territory: continuations are never prose
     if (line.trim() === '') { flushPara(); continue }
     if (/^-\s+/.test(line)) {
@@ -87,7 +90,7 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
   }
   flushPara()
   const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
-  return { doc, mode }
+  return { doc, mode, hasMode }
 }
 
 /**

+ 23 - 0
scripts/markdown.ts

@@ -0,0 +1,23 @@
+/** Shared Markdown parsing and depth-first traversal for documentation gates. */
+
+import { fromMarkdown } from 'mdast-util-from-markdown'
+import { gfmFromMarkdown } from 'mdast-util-gfm'
+import { gfm } from 'micromark-extension-gfm'
+import type { Nodes } from 'mdast'
+
+/** Parse GitHub-flavored Markdown with the repository's standard extensions. */
+export function parseMarkdown(source: string): Nodes {
+  return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
+}
+
+/**
+ * Visit a Markdown tree depth-first; returning false prunes a node's children.
+ * @param node - current tree node.
+ * @param visitor - callback invoked before each node's children.
+ */
+export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | void): void {
+  if (visitor(node) === false) return
+  if ('children' in node) {
+    for (const child of node.children) visitMarkdown(child, visitor)
+  }
+}

+ 93 - 0
scripts/package-graph.ts

@@ -0,0 +1,93 @@
+/**
+ * Shared workspace-package graph discovery and Mermaid identifier helpers for
+ * the generated module graph and relationship-diagram generators. Each caller
+ * supplies its own group ordering because the documents use different visual
+ * priorities; manifest parsing and dependency-safe ordering have one owner.
+ */
+
+import { globSync, readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+
+const SCOPE = '@deepseek-ai/dsh-'
+
+/** One harness package and its in-repo peer-dependency edges. */
+export interface PackageGraphNode {
+  /** Package name with the `@deepseek-ai/dsh-` prefix removed. */
+  short: string
+  /** Full npm package name. */
+  name: string
+  /** Package group from `packages/<group>/<pkg>`. */
+  group: string
+  /** Repo-relative package directory. */
+  rel: string
+  /** Short names of in-repo peer dependencies, sorted. */
+  deps: string[]
+}
+
+/**
+ * Read every harness package manifest and return dependency-safe graph nodes.
+ * @param root - absolute repository root.
+ * @param groupOrder - caller-specific tiebreak order for packages in the same dependency layer.
+ * @param gate - command name used in structural error messages.
+ * @returns package nodes ordered after all of their in-repo dependencies.
+ */
+export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] {
+  const packages: PackageGraphNode[] = []
+  for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
+    const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
+      name: string
+      peerDependencies?: Record<string, string>
+    }
+    if (!json.name.startsWith(SCOPE)) continue
+    const [, group, leaf] = rel.split('/')
+    if (group === undefined || leaf === undefined) throw new Error(`${gate}: unexpected package path ${rel}`)
+    const deps = Object.keys(json.peerDependencies ?? {})
+      .filter(dep => dep.startsWith(SCOPE))
+      .map(dep => dep.slice(SCOPE.length))
+      .sort()
+    packages.push({
+      short: json.name.slice(SCOPE.length),
+      name: json.name,
+      group,
+      rel: dirname(rel),
+      deps,
+    })
+  }
+  return topoSort(packages, groupOrder, gate)
+}
+
+function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], gate: string): PackageGraphNode[] {
+  const remaining = new Map(packages.map(pkg => [pkg.short, pkg]))
+  const placed = new Set<string>()
+  const out: PackageGraphNode[] = []
+  while (remaining.size > 0) {
+    const ready = [...remaining.values()]
+      .filter(pkg => pkg.deps.every(dep => placed.has(dep)))
+      .sort((a, b) => comparePackages(a, b, groupOrder))
+    if (ready.length === 0) throw new Error(`${gate}: dependency cycle among ${[...remaining.keys()].join(', ')}`)
+    for (const pkg of ready) {
+      out.push(pkg)
+      placed.add(pkg.short)
+      remaining.delete(pkg.short)
+    }
+  }
+  return out
+}
+
+function comparePackages(a: PackageGraphNode, b: PackageGraphNode, groupOrder: readonly string[]): number {
+  const groupA = groupOrder.indexOf(a.group)
+  const groupB = groupOrder.indexOf(b.group)
+  const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
+  const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
+  return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
+}
+
+/** Stable Mermaid id for a graph value. */
+export function graphNodeId(prefix: string, value: string): string {
+  return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
+}
+
+/** Escape a value embedded in a quoted Mermaid label. */
+export function escapeMermaidLabel(value: string): string {
+  return value.replace(/"/g, '\\"')
+}

+ 80 - 0
scripts/repo-files.ts

@@ -0,0 +1,80 @@
+/** Shared repository file discovery and line-oriented reference scanning. */
+
+import { globSync, readFileSync, realpathSync } from 'node:fs'
+import { relative, resolve } from 'node:path'
+
+/** One authored path plus its canonical target for symlink deduplication. */
+export interface RepoFile {
+  /** Absolute path matched by the caller's glob. */
+  abs: string
+  /** Absolute canonical path used only for deduplication. */
+  real: string
+}
+
+/** A rejected line-oriented repository reference. */
+export interface ReferenceViolation {
+  /** Repo-relative file containing the reference. */
+  file: string
+  /** 1-based line containing the reference. */
+  line: number
+  /** Normalized reference text. */
+  ref: string
+}
+
+/**
+ * Expand repository-relative globs and deduplicate symlinked files.
+ * @param root - absolute repository root.
+ * @param patterns - repository-relative glob patterns, processed in order.
+ * @param isExcluded - optional predicate over each matched relative path.
+ * @returns matched files in stable first-seen order.
+ */
+export function uniqueRepoFiles(
+  root: string,
+  patterns: readonly string[],
+  isExcluded: (relativePath: string) => boolean = () => false,
+): RepoFile[] {
+  const seen = new Set<string>()
+  const files: RepoFile[] = []
+  for (const pattern of patterns) {
+    for (const match of globSync(pattern, { cwd: root })) {
+      if (isExcluded(match)) continue
+      const abs = resolve(root, match)
+      const real = realpathSync(abs)
+      if (seen.has(real)) continue
+      seen.add(real)
+      files.push({ abs, real })
+    }
+  }
+  return files
+}
+
+/**
+ * Scan regex matches line by line and return the normalized matches rejected by
+ * a caller predicate.
+ * @param root - absolute repository root used for violation paths.
+ * @param absPath - absolute text-file path to scan.
+ * @param pattern - global regex matched independently against each line.
+ * @param normalize - maps raw regex text to the reference the gate evaluates.
+ * @param isViolation - returns true when the normalized reference is invalid.
+ * @returns every rejected reference in source order.
+ */
+export function findReferenceViolations(
+  root: string,
+  absPath: string,
+  pattern: RegExp,
+  normalize: (raw: string) => string,
+  isViolation: (ref: string) => boolean,
+): ReferenceViolation[] {
+  const file = relative(root, absPath)
+  const out: ReferenceViolation[] = []
+  const lines = readFileSync(absPath, 'utf8').split('\n')
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i]
+    if (line === undefined) continue
+    for (const match of line.matchAll(pattern)) {
+      const ref = normalize(match[0])
+      if (isViolation(ref)) out.push({ file, line: i + 1, ref })
+    }
+  }
+  return out
+}

+ 7 - 34
scripts/verify-doc-refs.ts

@@ -26,8 +26,9 @@
  * Run: `tsx scripts/verify-doc-refs.ts`.
  */
 
-import { existsSync, globSync, readFileSync } from 'node:fs'
-import { relative, resolve } from 'node:path'
+import { existsSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -46,42 +47,14 @@ const isExcluded = (p: string): boolean =>
  */
 const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
 
-/** A broken doc reference: a root-relative `docs/….md` token with no file. */
-interface Violation {
-  file: string
-  /** 1-based line where the reference appears. */
-  line: number
-  ref: string
-}
-
 /** Find every broken `docs/….md` reference in one TypeScript file. */
 function findViolations(absPath: string): Violation[] {
-  const file = relative(root, absPath)
-  const source = readFileSync(absPath, 'utf8')
-  const out: Violation[] = []
-  const lines = source.split('\n')
-  for (let i = 0; i < lines.length; i++) {
-    const line = lines[i]
-    if (line === undefined) continue
-    for (const m of line.matchAll(DOC_REF)) {
-      const ref = m[0]
-      if (!existsSync(resolve(root, ref))) {
-        out.push({ file, line: i + 1, ref })
-      }
-    }
-  }
-  return out
+  return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref)))
 }
 
-const all: Violation[] = []
-let checked = 0
-for (const pattern of PATTERNS) {
-  for (const match of globSync(pattern, { cwd: root })) {
-    if (isExcluded(match)) continue
-    checked++
-    all.push(...findViolations(resolve(root, match)))
-  }
-}
+const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
+const all = files.flatMap(file => findViolations(file.abs))
+const checked = files.length
 
 if (all.length === 0) {
   console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)

+ 9 - 26
scripts/verify-md-links.ts

@@ -32,12 +32,11 @@
  * Run: `tsx scripts/verify-md-links.ts`.
  */
 
-import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
 import { dirname, relative, resolve } from 'node:path'
-import { fromMarkdown } from 'mdast-util-from-markdown'
-import { gfmFromMarkdown } from 'mdast-util-gfm'
-import { gfm } from 'micromark-extension-gfm'
 import type { Nodes } from 'mdast'
+import { parseMarkdown, visitMarkdown } from './markdown.ts'
+import { uniqueRepoFiles } from './repo-files.ts'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -103,7 +102,7 @@ function findViolations(absPath: string): Violation[] {
   const file = relative(root, absPath)
   const dir = dirname(absPath)
   const source = readFileSync(absPath, 'utf8')
-  const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
+  const tree = parseMarkdown(source)
   const out: Violation[] = []
 
   const check = (url: string, node: Nodes): void => {
@@ -117,33 +116,17 @@ function findViolations(absPath: string): Violation[] {
     }
   }
 
-  const visit = (node: Nodes): void => {
+  visitMarkdown(tree, (node: Nodes): void => {
     if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) {
       check(node.url, node)
     }
-    if ('children' in node) {
-      for (const child of node.children) visit(child)
-    }
-  }
-  visit(tree)
+  })
   return out
 }
 
-const seen = new Set<string>()
-const all: Violation[] = []
-let checked = 0
-for (const pattern of PATTERNS) {
-  for (const match of globSync(pattern, { cwd: root })) {
-    const abs = resolve(root, match)
-    // CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
-    // matched twice (or via symlink) is checked once.
-    const real = realpathSync(abs)
-    if (seen.has(real)) continue
-    seen.add(real)
-    checked++
-    all.push(...findViolations(abs))
-  }
-}
+const files = uniqueRepoFiles(root, PATTERNS)
+const all = files.flatMap(file => findViolations(file.abs))
+const checked = files.length
 
 if (all.length === 0) {
   console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)

+ 10 - 27
scripts/verify-md-wrap.ts

@@ -27,12 +27,11 @@
  * Run: `tsx scripts/verify-md-wrap.ts`.
  */
 
-import { globSync, readFileSync, realpathSync } from 'node:fs'
+import { readFileSync } from 'node:fs'
 import { relative, resolve } from 'node:path'
-import { fromMarkdown } from 'mdast-util-from-markdown'
-import { gfmFromMarkdown } from 'mdast-util-gfm'
-import { gfm } from 'micromark-extension-gfm'
 import type { Nodes } from 'mdast'
+import { parseMarkdown, visitMarkdown } from './markdown.ts'
+import { uniqueRepoFiles } from './repo-files.ts'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -61,10 +60,10 @@ interface Violation {
 function findViolations(absPath: string): Violation[] {
   const file = relative(root, absPath)
   const source = readFileSync(absPath, 'utf8')
-  const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
+  const tree = parseMarkdown(source)
   const out: Violation[] = []
 
-  const visit = (node: Nodes): void => {
+  visitMarkdown(tree, (node: Nodes): boolean | void => {
     if (node.type === 'paragraph' && node.position) {
       const { start, end } = node.position
       if (end.line > start.line) {
@@ -73,31 +72,15 @@ function findViolations(absPath: string): Violation[] {
       }
       // A paragraph's children are inline (text/emphasis/…); no nested
       // paragraphs to find, so don't descend.
-      return
+      return false
     }
-    if ('children' in node) {
-      for (const child of node.children) visit(child)
-    }
-  }
-  visit(tree)
+  })
   return out
 }
 
-const seen = new Set<string>()
-const all: Violation[] = []
-let checked = 0
-for (const pattern of PATTERNS) {
-  for (const match of globSync(pattern, { cwd: root })) {
-    const abs = resolve(root, match)
-    // CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
-    // matched twice (or via symlink) is checked once.
-    const real = realpathSync(abs)
-    if (seen.has(real)) continue
-    seen.add(real)
-    checked++
-    all.push(...findViolations(abs))
-  }
-}
+const files = uniqueRepoFiles(root, PATTERNS)
+const all = files.flatMap(file => findViolations(file.abs))
+const checked = files.length
 
 if (all.length === 0) {
   console.log(`verify-md-wrap: ${checked} file(s) checked, no hard-wrapped prose paragraphs.`)

+ 31 - 54
scripts/verify-package-paths.ts

@@ -39,8 +39,9 @@
  * Run: `tsx scripts/verify-package-paths.ts`.
  */
 
-import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
-import { relative, resolve } from 'node:path'
+import { existsSync, readdirSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -89,12 +90,22 @@ const packageNames = realPackageNames()
  */
 const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
 
-/** A broken package reference: a stale root-relative `packages/…` path. */
-interface Violation {
-  file: string
-  /** 1-based line where the reference appears. */
-  line: number
-  ref: string
+function isDriftedPackageReference(ref: string): boolean {
+  if (existsSync(resolve(root, ref))) return false
+  // A reference INTO a package's built `lib/` is a build OUTPUT, not an
+  // authored-source location: it does not exist until `pnpm run build` emits
+  // it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
+  // the `packages/<group>/<pkg>` ROOT it sits under is real and on disk, so
+  // `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is
+  // exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the
+  // exact moved-package drift this gate exists to catch) still flags. A bare
+  // `lib` segment is not a blanket escape hatch.
+  const parts = ref.split('/')
+  const libAt = parts.indexOf('lib')
+  if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
+  // Only a stale path to a REAL (moved) package is a violation; a segment
+  // matching a live package name is the drift signal.
+  return ref.split('/').slice(1).some(segment => packageNames.has(segment))
 }
 
 /**
@@ -105,54 +116,20 @@ interface Violation {
  * skeletons whose segment is not a package.
  */
 function findViolations(absPath: string): Violation[] {
-  const file = relative(root, absPath)
-  const source = readFileSync(absPath, 'utf8')
-  const out: Violation[] = []
-  const lines = source.split('\n')
-  for (let i = 0; i < lines.length; i++) {
-    const line = lines[i]
-    if (line === undefined) continue
-    for (const m of line.matchAll(PKG_REF)) {
-      // Trim a trailing path separator or sentence punctuation that the greedy
-      // class may have swallowed (`packages/core/tools.` / `…/tools/`).
-      const ref = m[0].replace(/[./]+$/, '')
-      if (existsSync(resolve(root, ref))) continue
-      // A reference INTO a package's built `lib/` is a build OUTPUT, not an
-      // authored-source location: it does not exist until `pnpm run build` emits
-      // it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
-      // the `packages/<group>/<pkg>` ROOT it sits under is real and on disk, so
-      // `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is
-      // exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the
-      // exact moved-package drift this gate exists to catch) still flags. A bare
-      // `lib` segment is not a blanket escape hatch.
-      const parts = ref.split('/')
-      const libAt = parts.indexOf('lib')
-      if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue
-      // Only a stale path to a REAL (moved) package is a violation; a segment
-      // matching a live package name is the drift signal.
-      const segments = ref.split('/').slice(1)
-      if (segments.some(seg => packageNames.has(seg))) {
-        out.push({ file, line: i + 1, ref })
-      }
-    }
-  }
-  return out
+  return findReferenceViolations(
+    root,
+    absPath,
+    PKG_REF,
+    // Trim a trailing path separator or sentence punctuation that the greedy
+    // class may have swallowed (`packages/core/tools.` / `…/tools/`).
+    ref => ref.replace(/[./]+$/, ''),
+    isDriftedPackageReference,
+  )
 }
 
-const all: Violation[] = []
-let checked = 0
-const seen = new Set<string>()
-for (const pattern of PATTERNS) {
-  for (const match of globSync(pattern, { cwd: root })) {
-    if (isExcluded(match)) continue
-    // Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
-    const real = realpathSync(resolve(root, match))
-    if (seen.has(real)) continue
-    seen.add(real)
-    checked++
-    all.push(...findViolations(real))
-  }
-}
+const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
+const all = files.flatMap(file => findViolations(file.real))
+const checked = files.length
 
 if (all.length === 0) {
   console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)