Merge remote-tracking branch 'origin/master' into unify-acp-example
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"minTokens": 100,
|
"minTokens": 60,
|
||||||
"minLines": 10,
|
"minLines": 6,
|
||||||
"mode": "mild",
|
"mode": "mild",
|
||||||
"format": ["typescript"],
|
"format": ["typescript"],
|
||||||
"pattern": "**/src/**/*.ts",
|
"pattern": "**/*.ts",
|
||||||
|
"ignore": ["**/tests/**", "**/tsdown.config.ts"],
|
||||||
"ignorePattern": [
|
"ignorePattern": [
|
||||||
"(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"
|
"(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 test:snapshot:record # re-record goldens (needs key)
|
||||||
pnpm run typecheck
|
pnpm run typecheck
|
||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
pnpm run duplication # cross-file TypeScript clone detection
|
||||||
pnpm run build # tsc emits lib/types, tsdown bundles runtime
|
pnpm run build # tsc emits lib/types, tsdown bundles runtime
|
||||||
pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check
|
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
|
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
|
set -euo pipefail
|
||||||
pnpm run typecheck
|
pnpm run typecheck
|
||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
pnpm run duplication
|
||||||
pnpm run test:coverage
|
pnpm run test:coverage
|
||||||
pnpm run test:snapshot
|
pnpm run test:snapshot
|
||||||
pnpm run doc-sync
|
pnpm run doc-sync
|
||||||
|
|||||||
@@ -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`
|
## `@deepseek-ai/dsh-jsonrpc`
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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.
|
- 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.
|
- ESLint strict-type-checked + @stylistic (the house style, enforced), including file-local duplicated logic checks; vendored code excluded.
|
||||||
- Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ export default tseslint.config(
|
|||||||
plugins: { sonarjs },
|
plugins: { sonarjs },
|
||||||
rules: {
|
rules: {
|
||||||
// Cross-file clones are covered separately by jscpd.
|
// 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-identical-functions': 'error',
|
||||||
'sonarjs/no-duplicated-branches': 'error',
|
'sonarjs/no-duplicated-branches': 'error',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"typecheck": "tsc -b tsconfig.json",
|
"typecheck": "tsc -b tsconfig.json",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"duplication": "jscpd --config .jscpd.json packages",
|
"duplication": "jscpd --config .jscpd.json packages scripts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
* @module @deepseek-ai/dsh-hooks-codex
|
* @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 { readFileSync } from 'node:fs'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
@@ -35,6 +38,7 @@ import {
|
|||||||
type MergedHookOutcome,
|
type MergedHookOutcome,
|
||||||
} from '@deepseek-ai/dsh-hook-protocol'
|
} from '@deepseek-ai/dsh-hook-protocol'
|
||||||
import { parseCodexConfig, type CodexHookConfig } from './config.ts'
|
import { parseCodexConfig, type CodexHookConfig } from './config.ts'
|
||||||
|
/* jscpd:ignore-end */
|
||||||
|
|
||||||
export const name = 'hooks-codex'
|
export const name = 'hooks-codex'
|
||||||
export const inject = ['bash']
|
export const inject = ['bash']
|
||||||
|
|||||||
@@ -874,7 +874,7 @@ describe('scoped-dispatch invariants', () => {
|
|||||||
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
|
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
|
||||||
]
|
]
|
||||||
for (const [event, args] of rows) {
|
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) },
|
expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) },
|
||||||
`${event} with matching carrier`).not.toThrow()
|
`${event} with matching carrier`).not.toThrow()
|
||||||
expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) },
|
expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) },
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export interface Config {
|
|||||||
skills?: agentCore.SkillConfig
|
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({
|
export const Config: z<Config> = z.object({
|
||||||
model: z.string().required(),
|
model: z.string().required(),
|
||||||
persona: z.string(),
|
persona: z.string(),
|
||||||
@@ -77,6 +80,7 @@ export const Config: z<Config> = z.object({
|
|||||||
persistenceRoot: z.string().default('./.sessions'),
|
persistenceRoot: z.string().default('./.sessions'),
|
||||||
skills: agentCore.SkillConfigSchema,
|
skills: agentCore.SkillConfigSchema,
|
||||||
})
|
})
|
||||||
|
/* jscpd:ignore-end */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose the spine with the ACP front door. The agent-core bundle pre-creates
|
* Compose the spine with the ACP front door. The agent-core bundle pre-creates
|
||||||
|
|||||||
@@ -99,12 +99,16 @@ export class PerplexitySearchProvider implements WebSearchProvider {
|
|||||||
|
|
||||||
constructor(private readonly options: PerplexitySearchProviderOptions) {}
|
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 {
|
status(): WebProviderStatus {
|
||||||
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
|
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
|
||||||
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
|
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
|
||||||
if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
|
if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
|
||||||
return { available: true }
|
return { available: true }
|
||||||
}
|
}
|
||||||
|
/* jscpd:ignore-end */
|
||||||
|
|
||||||
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
|
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
|
||||||
let response: Response
|
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`. */
|
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
||||||
function isAbortError(error: unknown): boolean {
|
function isAbortError(error: unknown): boolean {
|
||||||
return error instanceof DOMException && error.name === 'AbortError'
|
return error instanceof DOMException && error.name === 'AbortError'
|
||||||
@@ -168,3 +175,4 @@ function isAbortError(error: unknown): boolean {
|
|||||||
function isPositiveInteger(value: number): boolean {
|
function isPositiveInteger(value: number): boolean {
|
||||||
return Number.isInteger(value) && value > 0
|
return Number.isInteger(value) && value > 0
|
||||||
}
|
}
|
||||||
|
/* jscpd:ignore-end */
|
||||||
|
|||||||
@@ -22,22 +22,15 @@ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'no
|
|||||||
import { dirname, relative, resolve } from 'node:path'
|
import { dirname, relative, resolve } from 'node:path'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
|
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 root = resolve(import.meta.dirname, '..')
|
||||||
const SCOPE = '@deepseek-ai/dsh-'
|
type Pkg = PackageGraphNode
|
||||||
|
|
||||||
interface PkgJson {
|
|
||||||
name: string
|
|
||||||
peerDependencies?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Pkg {
|
|
||||||
short: string
|
|
||||||
name: string
|
|
||||||
group: string
|
|
||||||
rel: string
|
|
||||||
deps: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphDoc {
|
interface GraphDoc {
|
||||||
rel: string
|
rel: string
|
||||||
@@ -321,62 +314,6 @@ function linkFromDoc(docRel: string, targetRel: string): string {
|
|||||||
return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
|
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 {
|
function mermaidCode(value: string): string {
|
||||||
return `<code>${value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`
|
return `<code>${value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`
|
||||||
}
|
}
|
||||||
@@ -853,7 +790,7 @@ function renderSnapshotReplay(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDocs(): GraphDoc[] {
|
function renderDocs(): GraphDoc[] {
|
||||||
const pkgs = collectPackages()
|
const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs')
|
||||||
const docs: GraphDoc[] = [
|
const docs: GraphDoc[] = [
|
||||||
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
|
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
|
||||||
...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
|
...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
|
||||||
|
|||||||
@@ -18,23 +18,18 @@
|
|||||||
* is stale (CI / pre-push gate)
|
* is stale (CI / pre-push gate)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { dirname, resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
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 root = resolve(import.meta.dirname, '..')
|
||||||
const OUT = 'docs/module-graph.md'
|
const OUT = 'docs/module-graph.md'
|
||||||
const SCOPE = '@deepseek-ai/dsh-'
|
type Pkg = PackageGraphNode
|
||||||
|
|
||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const GROUP_ORDER = [
|
const GROUP_ORDER = [
|
||||||
'util',
|
'util',
|
||||||
@@ -55,67 +50,6 @@ const GROUP_ORDER = [
|
|||||||
'ui',
|
'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 {
|
function packageLink(pkg: Pkg): string {
|
||||||
return `[\`${pkg.short}\`](../${pkg.rel})`
|
return `[\`${pkg.short}\`](../${pkg.rel})`
|
||||||
}
|
}
|
||||||
@@ -170,7 +104,7 @@ function render(pkgs: Pkg[]): string {
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = render(collect())
|
const content = render(collectPackageGraph(root, GROUP_ORDER, 'gen-module-graph'))
|
||||||
|
|
||||||
if (process.argv.includes('--check')) {
|
if (process.argv.includes('--check')) {
|
||||||
let committed: string | null = null
|
let committed: string | null = null
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
|
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
const root = resolve(import.meta.dirname, '..')
|
||||||
const OUT = 'docs/persistence-catalog.md'
|
const OUT = 'docs/persistence-catalog.md'
|
||||||
@@ -95,12 +96,6 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
|
|||||||
surface: boolean
|
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 })
|
const printer = ts.createPrinter({ removeComments: true })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,87 +113,6 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
|||||||
.trim()
|
.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
|
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
* 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
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Shared JSDoc parsing and completeness-check helpers for the documentation
|
* Shared JSDoc parsing and completeness-check helpers for the documentation
|
||||||
* gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
|
* gates: the cordis and persistence catalog generators
|
||||||
* events + `ctx.<key>` service surface), the plugin config catalog generator
|
* (`scripts/gen-cordis-catalog.ts` / `scripts/gen-persistence-catalog.ts`),
|
||||||
* (`scripts/gen-config-catalog.ts`, which renders the parsed prose), and the
|
* the plugin config catalog generator (`scripts/gen-config-catalog.ts`), and
|
||||||
* export-surface gate (`scripts/verify-export-jsdoc.ts` — every module-level
|
* the export-surface gate (`scripts/verify-export-jsdoc.ts`). One home for the
|
||||||
* export). One home for the mechanics so "documented" means the same thing on
|
* mechanics so "documented" means the same thing on every gated surface:
|
||||||
* every gated surface: description prose ends at the first block tag; every
|
* description prose ends at the first block tag; every checkable parameter
|
||||||
* checkable parameter needs a non-empty `@param`; a non-void ANNOTATED return
|
* needs a non-empty `@param`; a non-void ANNOTATED return needs a non-empty
|
||||||
* needs a non-empty `@returns`; a stale `@param` naming no real parameter
|
* `@returns`; a stale `@param` naming no real parameter errors.
|
||||||
* errors.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import ts from 'typescript'
|
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` /
|
* tag lines and their continuation lines are never prose, so `@param` /
|
||||||
* `@returns` blocks are invisible to the rendered catalog.
|
* `@returns` blocks are invisible to the rendered catalog.
|
||||||
* @param raw - the raw comment text including the JSDoc delimiters.
|
* @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
|
const inner = raw
|
||||||
.replace(/^\/\*\*/, '')
|
.replace(/^\/\*\*/, '')
|
||||||
.replace(/\*\/$/, '')
|
.replace(/\*\/$/, '')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||||
let mode: Mode | null = null
|
let mode: Mode | null = null
|
||||||
|
let hasMode = false
|
||||||
let inTags = false
|
let inTags = false
|
||||||
const blocks: string[] = []
|
const blocks: string[] = []
|
||||||
let para: string[] = []
|
let para: string[] = []
|
||||||
@@ -69,9 +70,11 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
|||||||
para = []
|
para = []
|
||||||
}
|
}
|
||||||
for (const line of inner) {
|
for (const line of inner) {
|
||||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
|
const tagLine = line.trimStart()
|
||||||
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
|
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(tagLine)
|
||||||
if (line.startsWith('@')) { flushPara(); inTags = true; continue }
|
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 (inTags) continue // block-tag territory: continuations are never prose
|
||||||
if (line.trim() === '') { flushPara(); continue }
|
if (line.trim() === '') { flushPara(); continue }
|
||||||
if (/^-\s+/.test(line)) {
|
if (/^-\s+/.test(line)) {
|
||||||
@@ -87,7 +90,7 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
|||||||
}
|
}
|
||||||
flushPara()
|
flushPara()
|
||||||
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
|
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
23
scripts/markdown.ts
Normal 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
93
scripts/package-graph.ts
Normal 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
80
scripts/repo-files.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -26,8 +26,9 @@
|
|||||||
* Run: `tsx scripts/verify-doc-refs.ts`.
|
* Run: `tsx scripts/verify-doc-refs.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { relative, resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
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
|
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. */
|
/** Find every broken `docs/….md` reference in one TypeScript file. */
|
||||||
function findViolations(absPath: string): Violation[] {
|
function findViolations(absPath: string): Violation[] {
|
||||||
const file = relative(root, absPath)
|
return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref)))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const all: Violation[] = []
|
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
|
||||||
let checked = 0
|
const all = files.flatMap(file => findViolations(file.abs))
|
||||||
for (const pattern of PATTERNS) {
|
const checked = files.length
|
||||||
for (const match of globSync(pattern, { cwd: root })) {
|
|
||||||
if (isExcluded(match)) continue
|
|
||||||
checked++
|
|
||||||
all.push(...findViolations(resolve(root, match)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (all.length === 0) {
|
if (all.length === 0) {
|
||||||
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
|
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
|
||||||
|
|||||||
@@ -32,12 +32,11 @@
|
|||||||
* Run: `tsx scripts/verify-md-links.ts`.
|
* 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 { 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 type { Nodes } from 'mdast'
|
||||||
|
import { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||||
|
import { uniqueRepoFiles } from './repo-files.ts'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
const root = resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
@@ -103,7 +102,7 @@ function findViolations(absPath: string): Violation[] {
|
|||||||
const file = relative(root, absPath)
|
const file = relative(root, absPath)
|
||||||
const dir = dirname(absPath)
|
const dir = dirname(absPath)
|
||||||
const source = readFileSync(absPath, 'utf8')
|
const source = readFileSync(absPath, 'utf8')
|
||||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
const tree = parseMarkdown(source)
|
||||||
const out: Violation[] = []
|
const out: Violation[] = []
|
||||||
|
|
||||||
const check = (url: string, node: Nodes): void => {
|
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) {
|
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) {
|
||||||
check(node.url, node)
|
check(node.url, node)
|
||||||
}
|
}
|
||||||
if ('children' in node) {
|
})
|
||||||
for (const child of node.children) visit(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visit(tree)
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Set<string>()
|
const files = uniqueRepoFiles(root, PATTERNS)
|
||||||
const all: Violation[] = []
|
const all = files.flatMap(file => findViolations(file.abs))
|
||||||
let checked = 0
|
const checked = files.length
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (all.length === 0) {
|
if (all.length === 0) {
|
||||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
|
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
|
||||||
|
|||||||
@@ -27,12 +27,11 @@
|
|||||||
* Run: `tsx scripts/verify-md-wrap.ts`.
|
* 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 { 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 type { Nodes } from 'mdast'
|
||||||
|
import { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||||
|
import { uniqueRepoFiles } from './repo-files.ts'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
const root = resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
@@ -61,10 +60,10 @@ interface Violation {
|
|||||||
function findViolations(absPath: string): Violation[] {
|
function findViolations(absPath: string): Violation[] {
|
||||||
const file = relative(root, absPath)
|
const file = relative(root, absPath)
|
||||||
const source = readFileSync(absPath, 'utf8')
|
const source = readFileSync(absPath, 'utf8')
|
||||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
const tree = parseMarkdown(source)
|
||||||
const out: Violation[] = []
|
const out: Violation[] = []
|
||||||
|
|
||||||
const visit = (node: Nodes): void => {
|
visitMarkdown(tree, (node: Nodes): boolean | void => {
|
||||||
if (node.type === 'paragraph' && node.position) {
|
if (node.type === 'paragraph' && node.position) {
|
||||||
const { start, end } = node.position
|
const { start, end } = node.position
|
||||||
if (end.line > start.line) {
|
if (end.line > start.line) {
|
||||||
@@ -73,31 +72,15 @@ function findViolations(absPath: string): Violation[] {
|
|||||||
}
|
}
|
||||||
// A paragraph's children are inline (text/emphasis/…); no nested
|
// A paragraph's children are inline (text/emphasis/…); no nested
|
||||||
// paragraphs to find, so don't descend.
|
// 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Set<string>()
|
const files = uniqueRepoFiles(root, PATTERNS)
|
||||||
const all: Violation[] = []
|
const all = files.flatMap(file => findViolations(file.abs))
|
||||||
let checked = 0
|
const checked = files.length
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (all.length === 0) {
|
if (all.length === 0) {
|
||||||
console.log(`verify-md-wrap: ${checked} file(s) checked, no hard-wrapped prose paragraphs.`)
|
console.log(`verify-md-wrap: ${checked} file(s) checked, no hard-wrapped prose paragraphs.`)
|
||||||
|
|||||||
@@ -39,8 +39,9 @@
|
|||||||
* Run: `tsx scripts/verify-package-paths.ts`.
|
* Run: `tsx scripts/verify-package-paths.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
import { existsSync, readdirSync } from 'node:fs'
|
||||||
import { relative, resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
const root = resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
@@ -89,12 +90,22 @@ const packageNames = realPackageNames()
|
|||||||
*/
|
*/
|
||||||
const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
|
const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
|
||||||
|
|
||||||
/** A broken package reference: a stale root-relative `packages/…` path. */
|
function isDriftedPackageReference(ref: string): boolean {
|
||||||
interface Violation {
|
if (existsSync(resolve(root, ref))) return false
|
||||||
file: string
|
// A reference INTO a package's built `lib/` is a build OUTPUT, not an
|
||||||
/** 1-based line where the reference appears. */
|
// authored-source location: it does not exist until `pnpm run build` emits
|
||||||
line: number
|
// it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
|
||||||
ref: string
|
// 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.
|
* skeletons whose segment is not a package.
|
||||||
*/
|
*/
|
||||||
function findViolations(absPath: string): Violation[] {
|
function findViolations(absPath: string): Violation[] {
|
||||||
const file = relative(root, absPath)
|
return findReferenceViolations(
|
||||||
const source = readFileSync(absPath, 'utf8')
|
root,
|
||||||
const out: Violation[] = []
|
absPath,
|
||||||
const lines = source.split('\n')
|
PKG_REF,
|
||||||
for (let i = 0; i < lines.length; i++) {
|
// Trim a trailing path separator or sentence punctuation that the greedy
|
||||||
const line = lines[i]
|
// class may have swallowed (`packages/core/tools.` / `…/tools/`).
|
||||||
if (line === undefined) continue
|
ref => ref.replace(/[./]+$/, ''),
|
||||||
for (const m of line.matchAll(PKG_REF)) {
|
isDriftedPackageReference,
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const all: Violation[] = []
|
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
|
||||||
let checked = 0
|
const all = files.flatMap(file => findViolations(file.real))
|
||||||
const seen = new Set<string>()
|
const checked = files.length
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (all.length === 0) {
|
if (all.length === 0) {
|
||||||
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
|
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
|
||||||
|
|||||||
Reference in New Issue
Block a user