From 53d9634043333913e175b23504a9ab912885370c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:24:04 +0800 Subject: [PATCH] chore: tighten TypeScript duplication lint --- .jscpd.json | 7 +- AGENTS.md | 2 + docs/config-catalog.md | 2 +- .../process/2026-06-11-quality-gates.md | 5 +- eslint.config.mjs | 6 ++ package.json | 2 +- packages/hooks/hooks-codex/src/index.ts | 4 + .../invariants/tests/invariants.spec.ts | 2 +- packages/ui/acp-agent/src/index.ts | 4 + .../web/web-search-perplexity/src/provider.ts | 8 ++ scripts/gen-doc-graphs.ts | 79 ++-------------- scripts/gen-module-graph.ts | 86 ++--------------- scripts/gen-persistence-catalog.ts | 90 +----------------- scripts/jsdoc.ts | 33 ++++--- scripts/markdown.ts | 23 +++++ scripts/package-graph.ts | 93 +++++++++++++++++++ scripts/repo-files.ts | 80 ++++++++++++++++ scripts/verify-doc-refs.ts | 41 ++------ scripts/verify-md-links.ts | 35 ++----- scripts/verify-md-wrap.ts | 37 ++------ scripts/verify-package-paths.ts | 85 +++++++---------- 21 files changed, 325 insertions(+), 399 deletions(-) create mode 100644 scripts/markdown.ts create mode 100644 scripts/package-graph.ts create mode 100644 scripts/repo-files.ts diff --git a/.jscpd.json b/.jscpd.json index 86b7343d5c..89ec21f0fd 100644 --- a/.jscpd.json +++ b/.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 \\*/" ], diff --git a/AGENTS.md b/AGENTS.md index 84c5827502..4dfbedb97b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ pnpm run test:snapshot # keyless ACP replay vs goldens; filter: -t 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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6172295667..9d4b8038b5 100644 --- a/docs/config-catalog.md +++ b/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` diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 505cea90ff..9f92791f4a 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 955e0fc83e..2434d490d4 100644 --- a/eslint.config.mjs +++ b/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', }, diff --git a/package.json b/package.json index b453bf3791..d97a81096d 100644 --- a/package.json +++ b/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", diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 3347447d86..7c58d397ee 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/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'] diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 5c26b1f4d7..a1e824524b 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/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) }, diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 2de52bda60..43186dfcb0 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/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 = z.object({ model: z.string().required(), persona: z.string(), @@ -77,6 +80,7 @@ export const Config: z = 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 diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index a008438c9a..3f1959549a 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/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 { 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 */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0db3be2f6b..5144e388a2 100644 --- a/scripts/gen-doc-graphs.ts +++ b/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 -} - -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() - 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 `${value.replace(/&/g, '&').replace(//g, '>')}` } @@ -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) })), diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 54dbcf2773..2a687c8f26 100644 --- a/scripts/gen-module-graph.ts +++ b/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: 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 - } - 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() - 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 diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 980ad19606..ca56611424 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/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 } diff --git a/scripts/jsdoc.ts b/scripts/jsdoc.ts index d0e5771882..be6d90adbd 100644 --- a/scripts/jsdoc.ts +++ b/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.` 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 } } /** diff --git a/scripts/markdown.ts b/scripts/markdown.ts new file mode 100644 index 0000000000..8aa0f7d935 --- /dev/null +++ b/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) + } +} diff --git a/scripts/package-graph.ts b/scripts/package-graph.ts new file mode 100644 index 0000000000..5e84e52b86 --- /dev/null +++ b/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: 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 + } + 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() + 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, '\\"') +} diff --git a/scripts/repo-files.ts b/scripts/repo-files.ts new file mode 100644 index 0000000000..e6d50e7627 --- /dev/null +++ b/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() + 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 +} diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts index 56be5140c8..4f07638ab3 100644 --- a/scripts/verify-doc-refs.ts +++ b/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.`) diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index d3e80e285e..9627f92698 100644 --- a/scripts/verify-md-links.ts +++ b/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() -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.`) diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 3143ee5617..7e2f109d6a 100644 --- a/scripts/verify-md-wrap.ts +++ b/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() -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.`) diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index 5d2d91982b..c186c93fd0 100644 --- a/scripts/verify-package-paths.ts +++ b/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//` 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//` 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() -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.`)