Fix doc cross-links for the hierarchy; add package-path + shape gates

Merge brought in the RFC-classification reorg and two new doc gates;
rewrite every drifted packages/<name> cross-link (Markdown link targets,
moved-README relative depths, and .ts comment paths) to the grouped paths.

Add two doc-sync/hygiene gates so the manual checks this restructure
needed become automated:
- verify-package-paths.ts: flags a packages/<path> reference (in Markdown
  or a .ts comment/string) that does not resolve AND names a real package
  in a segment — i.e. a stale path to a MOVED package. A path naming a
  non-existent package (a forward-looking proposal) is left alone, so it
  applies uniformly across proposed/implemented/rejected.
- check-workspace-constraints: assert the packages/<group>/<pkg> depth-2
  shape (group dirs carry no package.json; no flat or over-nested
  packages). Group names stay open; only the shape is fixed.
This commit is contained in:
Tianyi Cui
2026-06-20 23:12:14 +08:00
parent 08f6f17cc1
commit ca2207e26c
42 changed files with 268 additions and 88 deletions

View File

@@ -0,0 +1,149 @@
/**
* Doc-sync gate: catch DRIFTED `packages/<path>` references — a path to a
* package that has MOVED, written as prose in Markdown or in a TypeScript
* comment/string. Docs and comments cite package locations by root-relative
* path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`);
* `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs`
* only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick
* prose or a code comment goes unchecked. The package-hierarchy reorg is the
* motivating case: it moved every package under a `{group}/` folder, so a stale
* `packages/tools` (now `packages/core/tools`) reads fine to a human but points
* at nothing.
*
* The check is drift-scoped, NOT a blanket existence test: a broken
* `packages/<path>` token is a violation ONLY when one of its path segments is
* the directory name of a package that actually exists on disk — i.e. the
* package is real and the path is merely stale. A token naming a package that
* exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an
* illustrative `packages/<name>/` skeleton) is left alone: this gate reports
* MOVED paths, not hypothetical or future ones, so it applies uniformly to
* proposed/implemented/rejected docs without per-lifecycle exclusions. This is
* checker, not fixer: it reports and never rewrites.
*
* Detection is a token scan, NOT an AST walk: package refs live in free prose,
* backticks, and comments. We match `packages/<path>` tokens whose path is made
* of plain path characters, so a glob, a `<placeholder>`, or a `{brace,expansion}`
* terminates the match before those chars and is never probed.
*
* Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown
* across README/docs/packages/AGENTS, and `.ts` under packages/** and
* examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source).
*
* Run: `tsx scripts/verify-package-paths.ts`.
*/
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
const root = resolve(import.meta.dirname, '..')
/** Markdown + repo-authored TypeScript that may cite package paths. */
const PATTERNS = [
'README.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',
'AGENTS.md',
'packages/AGENTS.md',
'packages/**/*.ts',
'examples/**/*.ts',
]
/** Paths excluded from the scan: built output and vendored upstream source. */
const isExcluded = (p: string): boolean =>
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
/**
* Directory names of every real package, `packages/<group>/<pkg>`. A broken
* reference is only flagged when one of its segments is in this set — that is
* what scopes the gate to DRIFT (a moved real package) rather than typos or
* not-yet-existing packages named in a proposal.
*/
function realPackageNames(): Set<string> {
const names = new Set<string>()
const pkgRoot = resolve(root, 'packages')
for (const group of readdirSync(pkgRoot, { withFileTypes: true })) {
if (!group.isDirectory()) continue
for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) {
if (pkg.isDirectory()) names.add(pkg.name)
}
}
return names
}
const packageNames = realPackageNames()
/**
* Match a `packages/<path>` reference token. The character class is plain path
* characters only, so a glob (`*`), placeholder (`<`, `>`), or brace expansion
* (`{`, `}`, `,`) terminates the match before those chars and is never probed —
* those are patterns, not real paths. A trailing `.`/`/` (e.g. a sentence-ending
* period) is trimmed before the existence check.
*/
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
}
/**
* 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.
*/
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
// 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[] = []
let checked = 0
const seen = new Set<string>()
for (const pattern of PATTERNS) {
for await (const match of glob(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) {
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
process.exit(0)
}
console.error('verify-package-paths: broken packages/* references found (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.ref}`)
}
process.exit(1)