Merge remote-tracking branch 'origin/master' into codex/trim-ai-prose
# Conflicts: # docs/config-catalog.md # scripts/gen-persistence-catalog.ts # scripts/jsdoc.ts # scripts/verify-md-wrap.ts # scripts/verify-package-paths.ts
This commit is contained in:
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<string>()
|
||||
const out: Pkg[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(pkg => pkg.deps.every(dep => placed.has(dep)))
|
||||
.sort(comparePackages)
|
||||
if (ready.length === 0) throw new Error(`gen-doc-graphs: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const pkg of ready) {
|
||||
out.push(pkg)
|
||||
placed.add(pkg.short)
|
||||
remaining.delete(pkg.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: Pkg, b: Pkg): number {
|
||||
const groupA = GROUP_ORDER.indexOf(a.group)
|
||||
const groupB = GROUP_ORDER.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
function escLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function mermaidCode(value: string): string {
|
||||
return `<code>${value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`
|
||||
}
|
||||
@@ -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) })),
|
||||
|
||||
@@ -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>/<pkg>`. */
|
||||
group: string
|
||||
/** Repo-relative package directory. */
|
||||
rel: string
|
||||
/** Short names of this package's in-repo peer dependencies, sorted. */
|
||||
deps: string[]
|
||||
}
|
||||
type Pkg = PackageGraphNode
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'util',
|
||||
@@ -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<string, string>
|
||||
}
|
||||
if (!json.name.startsWith(SCOPE)) continue
|
||||
const deps = Object.keys(json.peerDependencies ?? {})
|
||||
.filter(d => d.startsWith(SCOPE))
|
||||
.map(d => d.slice(SCOPE.length))
|
||||
.sort()
|
||||
const [, group, leaf] = rel.split('/')
|
||||
if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`)
|
||||
pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), deps })
|
||||
}
|
||||
return topoSort(pkgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order packages low-level → high-level: a package appears only after every
|
||||
* package it depends on. Kahn-style layering with an alphabetical tiebreak
|
||||
* within each layer, so the output stays deterministic (the freshness check
|
||||
* compares whole-file). The graph is a DAG, so this always terminates; a cycle
|
||||
* would leave nodes unplaced and throw.
|
||||
*/
|
||||
function topoSort(pkgs: Pkg[]): Pkg[] {
|
||||
const remaining = new Map(pkgs.map(p => [p.short, p]))
|
||||
const placed = new Set<string>()
|
||||
const out: Pkg[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(p => p.deps.every(d => placed.has(d)))
|
||||
.sort(comparePackages)
|
||||
if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const p of ready) {
|
||||
out.push(p)
|
||||
placed.add(p.short)
|
||||
remaining.delete(p.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: Pkg, b: Pkg): number {
|
||||
const groupA = GROUP_ORDER.indexOf(a.group)
|
||||
const groupB = GROUP_ORDER.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
function escLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function packageLink(pkg: Pkg): string {
|
||||
return `[\`${pkg.short}\`](../${pkg.rel})`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.<key>` service
|
||||
* surface), the plugin config catalog generator (`scripts/gen-config-catalog.ts`, which
|
||||
* renders the parsed prose), and the export-surface gate (`scripts/verify-export-jsdoc.ts` —
|
||||
* every module-level export). 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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.`)
|
||||
|
||||
@@ -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<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(abs))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
|
||||
|
||||
@@ -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<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(abs))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-wrap: ${checked} file(s) checked, no hard-wrapped prose paragraphs.`)
|
||||
|
||||
@@ -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<string>()
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
// Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
|
||||
const real = realpathSync(resolve(root, match))
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(real))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
|
||||
const all = files.flatMap(file => findViolations(file.real))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
|
||||
|
||||
Reference in New Issue
Block a user