mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
docs(rfc): classify RFCs by kind via path-encoded subdirectories
Add a second axis to every RFC — its class (feature, bug-fix,
simplification, architecture, process, testing) — encoded in the path
as docs/rfc/{lifecycle}/{class}/file.md. The folder is the label, so
the closed set is enforced by structure rather than a parsed field.
Two new doc-sync gates back it:
- verify-rfc-classification: every RFC sits in a valid class folder and
the README index lists it under the matching lifecycle→class heading.
- verify-doc-refs: every docs/*.md path cited in a packages|examples TS
comment resolves — closes a drift class verify-md-links can't see, and
catches the four comment refs this reorg moved.
The README gains a Classification section explaining the taxonomy and
per-class index sub-sections. A self-referential process RFC records why
the scheme is path-encoded and gated.
This commit is contained in:
96
scripts/verify-doc-refs.ts
Normal file
96
scripts/verify-doc-refs.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Doc-sync gate: verify that doc references written in TypeScript COMMENTS
|
||||
* resolve to a file that exists. Source comments cite docs by root-relative
|
||||
* prose path — `see docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`,
|
||||
* `docs/architecture.md § plugin checklist`. `verify-md-links` parses Markdown
|
||||
* link AST and never sees these, so a doc rename or move could silently orphan
|
||||
* a `.ts` comment that points at it. The RFC classification reorg
|
||||
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
|
||||
* is the motivating case: it moved every RFC under a `{class}/` folder, and
|
||||
* several `.ts` doc comments cite RFC paths that changed.
|
||||
*
|
||||
* Detection is a token scan, NOT an AST walk: doc refs live in free prose inside
|
||||
* comments, not in a structured form. We match `docs/<path>.md` tokens and
|
||||
* REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`,
|
||||
* `docs/architecture.md § plugin checklist` — the section suffix is outside the
|
||||
* token) is left alone rather than misread as a path. Each token is resolved
|
||||
* ROOT-RELATIVE (the way the comments are written) and must exist on disk. This
|
||||
* is checker, not fixer: it reports and never rewrites.
|
||||
*
|
||||
* Scope is repo-authored TypeScript under `packages/**` and `examples/**`,
|
||||
* excluding built output (`lib/`, `*.d.ts`) and `vendor/` (pinned upstream
|
||||
* source we do not own). The scan is purely textual, so it does not distinguish
|
||||
* a token in a comment from one in a string literal — a `docs/….md` string in
|
||||
* code is checked too, which is harmless (such a path should resolve anyway).
|
||||
*
|
||||
* Run: `tsx scripts/verify-doc-refs.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Repo-authored TypeScript that may cite docs in comments. */
|
||||
const PATTERNS = ['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/')
|
||||
|
||||
/**
|
||||
* Match a `docs/….md` reference token. The `.md` extension is required so a
|
||||
* bare `docs/postmortem/0001` (no extension) does not register as a path. The
|
||||
* character class stops at whitespace, backticks, parens, and the section sign,
|
||||
* so trailing prose (`… .md § plugin checklist`) is not swallowed into the path.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
checked++
|
||||
all.push(...findViolations(resolve(root, match)))
|
||||
}
|
||||
}
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-doc-refs: broken docs/*.md references found in source comments (target does not exist):')
|
||||
for (const v of all) {
|
||||
console.error(` ${v.file}:${v.line} ${v.ref}`)
|
||||
}
|
||||
process.exit(1)
|
||||
160
scripts/verify-rfc-classification.ts
Normal file
160
scripts/verify-rfc-classification.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the RFC classification scheme
|
||||
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)).
|
||||
* Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
|
||||
* folder IS the label. This gate is the machine source of truth for the closed
|
||||
* class set and keeps the README index honest.
|
||||
*
|
||||
* Two checks:
|
||||
*
|
||||
* 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
|
||||
* from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a
|
||||
* lifecycle root (other than the README/AGENTS allowlist) fails; an unknown
|
||||
* class folder fails; a stray file at an unexpected depth fails. This is what
|
||||
* makes the set CLOSED: a new class folder can't appear without amending
|
||||
* CLASSES here (and the README's Classification section, per the RFC).
|
||||
*
|
||||
* 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the
|
||||
* `### {Class}` heading inside the `## {Lifecycle}` section that matches the
|
||||
* file's path. A missing entry, a duplicate, or an entry under the wrong
|
||||
* heading fails. This mirrors `verify-event-taxonomy`: a curated doc table
|
||||
* checked against the on-disk source of truth, so the index can't drift.
|
||||
*
|
||||
* The class DESCRIPTIONS in the README prose are not checked (they are
|
||||
* explanatory text); only the per-class index tables are. This is checker, not
|
||||
* fixer: it reports and never rewrites.
|
||||
*
|
||||
* Run: `tsx scripts/verify-rfc-classification.ts`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const rfcRoot = resolve(root, 'docs/rfc')
|
||||
|
||||
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
|
||||
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
|
||||
|
||||
/**
|
||||
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
|
||||
* class is a deliberate act: extend this list AND the README's Classification
|
||||
* section. The gate rejects any folder not listed here.
|
||||
*/
|
||||
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
|
||||
|
||||
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
|
||||
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
|
||||
|
||||
/** Title-case a class/lifecycle folder name for README heading comparison. */
|
||||
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
// --- Check 1: structure -----------------------------------------------------
|
||||
// Every Markdown file anywhere under a lifecycle folder, at any depth.
|
||||
interface Rfc {
|
||||
lifecycle: string
|
||||
cls: string
|
||||
base: string
|
||||
/** Path relative to docs/rfc, for the README link check. */
|
||||
rel: string
|
||||
}
|
||||
const rfcs: Rfc[] = []
|
||||
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) {
|
||||
const segs = match.split('/')
|
||||
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
|
||||
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
|
||||
const cls = segs[1]
|
||||
const base = segs[2]
|
||||
if (segs.length !== 3 || cls === undefined || base === undefined) {
|
||||
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
|
||||
continue
|
||||
}
|
||||
if (!(CLASSES as readonly string[]).includes(cls)) {
|
||||
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
|
||||
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
|
||||
continue
|
||||
}
|
||||
rfcs.push({ lifecycle, cls, base, rel: match })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Check 2: README completeness -------------------------------------------
|
||||
// Parse the index into (lifecycle, class) -> set of linked rel paths, by
|
||||
// tracking the current `## {Lifecycle}` and `### {Class}` headings and reading
|
||||
// every `](path)` link target underneath. A link target is normalized to its
|
||||
// path relative to docs/rfc.
|
||||
const readmePath = resolve(rfcRoot, 'README.md')
|
||||
const readme = readFileSync(readmePath, 'utf8')
|
||||
const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l]))
|
||||
const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c]))
|
||||
|
||||
/** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */
|
||||
const listed = new Map<string, Set<string>>()
|
||||
let curLifecycle: string | null = null
|
||||
let curClass: string | null = null
|
||||
|
||||
for (const line of readme.split('\n')) {
|
||||
const h2 = /^##\s+(.+?)\s*$/.exec(line)
|
||||
if (h2?.[1] !== undefined) {
|
||||
curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null
|
||||
curClass = null
|
||||
continue
|
||||
}
|
||||
const h3 = /^###\s+(.+?)\s*$/.exec(line)
|
||||
if (h3?.[1] !== undefined) {
|
||||
curClass = classByHeading.get(h3[1].trim()) ?? null
|
||||
continue
|
||||
}
|
||||
if (!curLifecycle || !curClass) continue
|
||||
// Collect every relative .md link target on this line.
|
||||
for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) {
|
||||
const target = m[1]
|
||||
if (target === undefined) continue
|
||||
// README links are relative to docs/rfc; normalize and key by location.
|
||||
const rel = relative(rfcRoot, resolve(rfcRoot, target))
|
||||
const key = `${curLifecycle}/${curClass}`
|
||||
const set = listed.get(key) ?? new Set<string>()
|
||||
set.add(rel)
|
||||
listed.set(key, set)
|
||||
}
|
||||
}
|
||||
|
||||
// Every on-disk RFC must be listed under the heading matching its path.
|
||||
const seenOnDisk = new Set<string>()
|
||||
for (const rfc of rfcs) {
|
||||
seenOnDisk.add(rfc.rel)
|
||||
const key = `${rfc.lifecycle}/${rfc.cls}`
|
||||
if (!listed.get(key)?.has(rfc.rel)) {
|
||||
errors.push(
|
||||
`index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Every README entry must point at a real RFC under that same heading (catches a
|
||||
// misfiled or stale row).
|
||||
for (const [key, targets] of listed) {
|
||||
for (const rel of targets) {
|
||||
if (!seenOnDisk.has(rel)) {
|
||||
errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Report -----------------------------------------------------------------
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-rfc-classification: violations found:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
Reference in New Issue
Block a user