Merge remote-tracking branch 'origin/master' into unify-acp-example

This commit is contained in:
Tianyi Cui
2026-07-14 01:11:37 +08:00
21 changed files with 325 additions and 399 deletions

View File

@@ -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 \\*/"
],

View File

@@ -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

View File

@@ -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`

View File

@@ -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.

View File

@@ -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',
},

View File

@@ -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",

View File

@@ -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']

View File

@@ -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) },

View File

@@ -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

View File

@@ -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 */

View File

@@ -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
@@ -321,62 +314,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>`
}
@@ -853,7 +790,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) })),

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
scripts/markdown.ts Normal file
View File

@@ -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
scripts/package-graph.ts Normal file
View File

@@ -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
scripts/repo-files.ts Normal file
View File

@@ -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
}

View File

@@ -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.`)

View File

@@ -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.`)

View File

@@ -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.`)

View File

@@ -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.`)