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 ba285eb862..622076ed42 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 @@ Run narrow checks during implementation and this CI-equivalent sequence before m set -euo pipefail pnpm run typecheck pnpm run lint +pnpm run duplication pnpm run test:coverage pnpm run test:snapshot pnpm run doc-sync @@ -117,7 +119,7 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class. -Comments and docs preserve complete contracts and non-obvious orientation, not the author's reasoning process. Do not narrate control flow, walk through tests, preserve review history, or restate code. Keep every factual clause that affects behavior, failure, timing, ownership, or safe use; link aggressively to the owning rationale instead of duplicating it. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified escape hatches rather than disabling a rule globally. +Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified exceptions rather than disabling a rule globally. Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). diff --git a/docs/config-catalog.md b/docs/config-catalog.md index aee768e71a..45af111253 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:34`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:38`](../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 ff1978c68c..3f0e8ce820 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -112,6 +112,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 7ceed90d0f..8d3ac3415b 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -6,6 +6,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' @@ -26,6 +29,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 24046fa8dd..e53b0b8e40 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -860,7 +860,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 4c28309255..eeed80ab0e 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -43,6 +43,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(), @@ -56,6 +59,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 d5a58bf656..f0502786b4 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -92,12 +92,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 @@ -152,6 +156,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' @@ -161,3 +168,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 18ab5c50be..1f67a0b3b4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -9,22 +9,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 @@ -299,62 +292,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, '>')}` } @@ -821,7 +758,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 5361af39c8..18d75ac706 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -4,23 +4,18 @@ * renders both Mermaid and a dependency table; `--check` verifies freshness. */ -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', @@ -41,67 +36,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})` } @@ -156,7 +90,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 293c7c75dc..5c93d8875e 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -9,6 +9,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' @@ -52,21 +53,12 @@ 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 }) /** - * One-line payload text for a member's type annotation. Printed through the - * TypeScript printer (not sliced from source text): the printer emits `;` - * member separators regardless of how the source separated them, so a - * multi-line newline-separated type literal still collapses to a VALID - * single-line fragment. The trailing `;` the printer puts before every `}` is - * dropped to match the repo's inline-literal style. + * Render a member type on one line through the TypeScript printer, which adds + * semicolon separators. Drop its trailing semicolon before `}` to match the + * repository's inline-literal style. */ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string { return printer.printNode(ts.EmitHint.Unspecified, type, sf) @@ -75,82 +67,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 pre-tag JSDoc prose into one-line paragraphs and bullets, unwrap - * `{@link ...}`, and report whether the forbidden `@mode` tag appears. - */ -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 @@ -265,7 +181,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 3b8250a89b..7ac38d3807 100644 --- a/scripts/jsdoc.ts +++ b/scripts/jsdoc.ts @@ -1,10 +1,6 @@ /** - * 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). This is the single definition of description, - * parameter, return, and stale-tag completeness across those surfaces. + * Shared JSDoc parsing and completeness checks for the Cordis, persistence, + * and config catalogs and the export-surface gate. */ import ts from 'typescript' @@ -30,15 +26,17 @@ export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' * ends at the first block tag, paragraphs collapse to one line, bullet items * remain separate lines, and `{@link X}` renders as `X`. * @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[] = [] @@ -60,9 +58,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)) { @@ -78,7 +78,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 0f0d02b1fd..e1885f7b6e 100644 --- a/scripts/verify-doc-refs.ts +++ b/scripts/verify-doc-refs.ts @@ -4,8 +4,9 @@ * and excludes built declarations and vendored source. */ -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, '..') @@ -19,42 +20,14 @@ const isExcluded = (p: string): boolean => /** Root-relative Markdown path token, excluding trailing prose. */ 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 4a3e96f10e..f0d61feb6a 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -5,12 +5,11 @@ * and symlinked instruction files are deduped. */ -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, '..') @@ -73,7 +72,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 => { @@ -87,33 +86,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 6eafaebace..20586ec777 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -5,12 +5,11 @@ * files are deduped. The owning convention is in `docs/AGENTS.md`. */ -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, '..') @@ -39,10 +38,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) { @@ -50,31 +49,15 @@ function findViolations(absPath: string): Violation[] { out.push({ file, line: start.line, text: firstLine.trim() }) } // Paragraph children are inline, so no further paragraph can be nested. - 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 eeb922f32d..bf03da4247 100644 --- a/scripts/verify-package-paths.ts +++ b/scripts/verify-package-paths.ts @@ -5,8 +5,9 @@ * outside the check. */ -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, '..') @@ -55,64 +56,32 @@ 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 + // Ignore unbuilt `lib/` paths only under an existing depth-two package root: + // CI runs this gate before build, while stale group-less paths must still fail. + const parts = ref.split('/') + const libAt = parts.indexOf('lib') + if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false + // A missing reference is drift only when a path segment names a live package. + return ref.split('/').slice(1).some(segment => packageNames.has(segment)) } -/** - * Find every DRIFTED `packages/…` reference in one file: a token that does not - * resolve on disk AND names a real package in one of its segments (so it is a - * moved path, not a typo or a not-yet-existing package). The same real-package - * test also screens out a bare `packages` (no segment) and illustrative - * skeletons whose segment is not a package. - */ +/** Find missing package references whose path names a live package; bare paths, typos, and illustrative skeletons do not count. */ 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 - // Skip unbuilt `lib/` only below a real depth-two package root. A stale - // group-less path still fails; `lib` 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, + // Remove trailing separators or sentence punctuation matched greedily. + 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.`)