Merge master into app attribution RFC
This commit is contained in:
+155
-16
@@ -26,7 +26,18 @@
|
||||
* Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag
|
||||
* — the generator hard-errors on a missing tag, and where the signature shape is
|
||||
* conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
|
||||
* it asserts the tag agrees and hard-errors on a contradiction. The INHERITED
|
||||
* it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag,
|
||||
* the walk enforces JSDoc COMPLETENESS on the whole harness surface (the
|
||||
* jsdoc-completeness-gate RFC): every event and public service method carries
|
||||
* description prose; every payload parameter has a non-empty `@param` (`this`
|
||||
* receivers and the trailing waterfall `next` are exempt — next's semantics are
|
||||
* documented once by the mode); a service method with a non-`void`/
|
||||
* `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT
|
||||
* return type annotation (a pure-AST walk cannot classify an inferred return);
|
||||
* a stale `@param` naming no real parameter errors. Violations aggregate into
|
||||
* ONE error listing every offender. The tags are enforcement-only: parseJsDoc
|
||||
* stops prose at the first block tag, so they never change the rendered
|
||||
* catalog. The INHERITED
|
||||
* tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
|
||||
* also sees; it is rendered tersely (name + one-line + source pointer) from a
|
||||
* curated table in this script, NOT elevated to the harness tier's prominence.
|
||||
@@ -147,8 +158,10 @@ function rawJsDoc(text: string, node: ts.Node): string {
|
||||
* present). 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`; `@`-tag lines
|
||||
* other than `@mode` end the current prose run.
|
||||
* (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, so `@param`/`@returns` blocks are
|
||||
* invisible to the rendered catalog.
|
||||
*/
|
||||
function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
const inner = raw
|
||||
@@ -157,6 +170,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
let mode: Mode | null = null
|
||||
let inTags = false
|
||||
const blocks: string[] = []
|
||||
let para: string[] = []
|
||||
let list: string[] = []
|
||||
@@ -178,8 +192,9 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
}
|
||||
for (const line of inner) {
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
|
||||
if (m) { mode = m[1] as Mode; continue }
|
||||
if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose
|
||||
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
|
||||
if (line.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
|
||||
@@ -197,6 +212,60 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
return { doc, mode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the block tags of a raw JSDoc comment for the completeness checks:
|
||||
* every `@param name — description` entry plus the `@returns` description.
|
||||
* Standard JSDoc block-tag semantics — a tag's description runs across
|
||||
* continuation lines until the next tag or a blank line, and the `-`/`—`
|
||||
* separator after a param name is optional. `[name]` optional-brackets unwrap
|
||||
* to `name`. Rendering never sees these: parseJsDoc stops prose at the first
|
||||
* block tag.
|
||||
*/
|
||||
function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
const params = new Map<string, string>()
|
||||
let returns: string | null = null
|
||||
let sink: ((text: string) => void) | null = null
|
||||
for (const line of inner) {
|
||||
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
|
||||
if (param) {
|
||||
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
|
||||
let acc = param[2] ?? ''
|
||||
params.set(name, acc)
|
||||
sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
|
||||
continue
|
||||
}
|
||||
const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
|
||||
if (ret) {
|
||||
let acc = ret[1] ?? ''
|
||||
returns = acc
|
||||
sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
|
||||
sink?.(line.trim())
|
||||
}
|
||||
return { params, returns }
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw one aggregate error for every completeness violation a walk collected.
|
||||
* Aggregation (vs the fail-fast the @mode check used to do) 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-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
|
||||
+ violations.map(v => ` ${v}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
/** Find the `declare module 'cordis'` body in a source file, or null. */
|
||||
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
@@ -215,10 +284,13 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
|
||||
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Walk every harness `interface Events` block and extract its events.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
|
||||
/** Walk every harness `interface Events` block and extract its events, hard-
|
||||
* erroring (aggregated) on any JSDoc-completeness violation: a missing/
|
||||
* contradicted `@mode`, missing description prose, or an undocumented payload
|
||||
* parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */
|
||||
export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const entries: EventEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
@@ -232,33 +304,63 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
const signature = memberSignature(member, sf)
|
||||
const { doc, mode } = parseJsDoc(rawJsDoc(text, member))
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (hasNext && mode !== 'waterfall') {
|
||||
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (!hasNext && mode === 'waterfall') {
|
||||
throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param each. Exempt the `this`
|
||||
// receiver annotation (not payload) and the trailing waterfall `next`
|
||||
// (mode machinery, documented once by @mode semantics). Documenting an
|
||||
// exempt parameter anyway is allowed — only absence is checked.
|
||||
const { params } = parseTags(raw)
|
||||
for (const p of member.parameters) {
|
||||
if (!ts.isIdentifier(p.name)) {
|
||||
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`)
|
||||
continue
|
||||
}
|
||||
const pname = p.name.text
|
||||
if (pname === 'this' || (hasNext && p === last)) continue
|
||||
const desc = params.get(pname)
|
||||
if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
|
||||
else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
|
||||
}
|
||||
for (const tag of params.keys()) {
|
||||
if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
|
||||
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
|
||||
}
|
||||
}
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Walk every harness `interface Context` block + its service class.
|
||||
/** Walk every harness `interface Context` block + its service class, hard-
|
||||
* erroring (aggregated) on any JSDoc-completeness violation: a class or public
|
||||
* method without JSDoc prose, an undocumented parameter, a stale `@param`, a
|
||||
* missing `@returns` on a non-void method, or an inferred (unannotated) return
|
||||
* type the pure-AST walk cannot classify.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir. */
|
||||
export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const entries: ServiceEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
@@ -284,6 +386,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
const methods: string[] = []
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
@@ -300,17 +404,52 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const memberName = member.name.getText(sf)
|
||||
if (memberName.startsWith('[')) continue // computed/symbol members
|
||||
methods.push(memberSignature(member, sf))
|
||||
const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
|
||||
const raw = rawJsDoc(text, member)
|
||||
if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
|
||||
if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
|
||||
const { params, returns } = parseTags(raw)
|
||||
// Every parameter needs a non-empty @param; a `this` receiver
|
||||
// annotation is not payload and is exempt.
|
||||
for (const p of member.parameters) {
|
||||
if (!ts.isIdentifier(p.name)) {
|
||||
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`)
|
||||
continue
|
||||
}
|
||||
const pname = p.name.text
|
||||
if (pname === 'this') continue
|
||||
const desc = params.get(pname)
|
||||
if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
|
||||
else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
|
||||
}
|
||||
for (const tag of params.keys()) {
|
||||
if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
|
||||
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
|
||||
}
|
||||
}
|
||||
// A non-void result needs a non-empty @returns. The return type must be
|
||||
// ANNOTATED: a pure-AST walk cannot classify an inferred return. On a
|
||||
// `void`/`Promise<void>` method @returns stays optional (resolution
|
||||
// timing can be worth documenting), never required.
|
||||
const rt = member.type?.getText(sf).replace(/\s+/g, ' ')
|
||||
if (rt === undefined) {
|
||||
violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
|
||||
} else if (!/^(void|Promise<void>)$/.test(rt)) {
|
||||
if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
|
||||
else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
type,
|
||||
abstract,
|
||||
doc: parseJsDoc(rawJsDoc(text, cls)).doc,
|
||||
doc: clsDoc,
|
||||
methods,
|
||||
source: pointer(rel, sf, cls),
|
||||
})
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
return entries.sort((a, b) => a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Regenerate the RFC index tables in `docs/rfc/README.md` from the RFC tree
|
||||
* (see [rfc-index.ts](./rfc-index.ts) for the layout contract and rendering
|
||||
* rules). Rewrites ONLY the marker-delimited regions; the curated prose is
|
||||
* untouched. Freshness is asserted by `verify-rfc-classification.ts` (a
|
||||
* `doc-sync` member), so a stale committed index fails CI.
|
||||
*
|
||||
* Run: `pnpm run gen-rfc-index`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
|
||||
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
if (errors.length > 0) {
|
||||
console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const readmePath = resolve(rfcRoot, 'README.md')
|
||||
const readme = readFileSync(readmePath, 'utf8')
|
||||
const next = spliceReadme(readme, rfcs)
|
||||
if (next === readme) {
|
||||
console.log(`gen-rfc-index: docs/rfc/README.md is up to date (${rfcs.length} RFCs).`)
|
||||
} else {
|
||||
writeFileSync(readmePath, next)
|
||||
console.log(`gen-rfc-index: docs/rfc/README.md regenerated (${rfcs.length} RFCs).`)
|
||||
}
|
||||
@@ -220,7 +220,6 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
function renderTool(schema: ToolSchema, source: string): string[] {
|
||||
const out = [`### \`${schema.name}\``, '']
|
||||
if (schema.description) out.push(schema.description, '')
|
||||
if (schema.strict !== undefined) out.push(`Strict: \`${String(schema.strict)}\``, '')
|
||||
out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '')
|
||||
out.push(`Source: [\`${source}\`](../../${source})`, '')
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Shared source of truth for the RFC index: the tree walker (structure rules)
|
||||
* and the README table renderer. `gen-rfc-index.ts` writes the generated
|
||||
* regions; `verify-rfc-classification.ts` checks structure and asserts the
|
||||
* committed regions are fresh. Pure module — no side effects on import.
|
||||
*
|
||||
* The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)):
|
||||
* every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the
|
||||
* folder IS the label, and both sets are CLOSED — extending either means
|
||||
* amending this module AND the README's Classification prose.
|
||||
*
|
||||
* The README's per-lifecycle tables are GENERATED between marker comments
|
||||
* (`<!-- gen-rfc-index:begin {lifecycle} -->` … `end`): section headings and
|
||||
* rows are derived from each RFC's path (lifecycle/class), H1 (title, with an
|
||||
* optional `RFC: ` prefix stripped), and filename date, sorted by date then
|
||||
* filename. Prose outside the markers is curated by hand and never touched.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { globSync } from 'node:fs'
|
||||
|
||||
export const rfcRoot = resolve(import.meta.dirname, '../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 a README heading. */
|
||||
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
|
||||
|
||||
/** One RFC file, as discovered by the walker. */
|
||||
export interface Rfc {
|
||||
lifecycle: string
|
||||
cls: string
|
||||
base: string
|
||||
/** Path relative to docs/rfc — the README link target. */
|
||||
rel: string
|
||||
/** H1 text with any `RFC: ` prefix stripped — the README row title. */
|
||||
title: string
|
||||
/** `yyyy-mm-dd` from the filename — the "First proposed" column. */
|
||||
date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the RFC tree, enforcing the structure rules. Returns every valid RFC
|
||||
* plus one error string per violation (unknown lifecycle or class folder, bad
|
||||
* depth, bad filename, missing/malformed H1). Callers treat a non-empty error
|
||||
* list as fatal — the index is only generated from a structurally valid tree.
|
||||
*/
|
||||
export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
|
||||
const rfcs: Rfc[] = []
|
||||
const errors: string[] = []
|
||||
// The lifecycle set is closed too: any directory under docs/rfc/ that is not
|
||||
// a known lifecycle would otherwise hold RFCs invisible to the walk below.
|
||||
for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
|
||||
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
|
||||
}
|
||||
}
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) {
|
||||
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
|
||||
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
|
||||
// indexed via its English filename; the pairing gate owns its consistency.
|
||||
if (match.endsWith('.zh.md')) 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
|
||||
}
|
||||
const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? ''
|
||||
const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine)
|
||||
if (!h1?.[1]) {
|
||||
errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`)
|
||||
continue
|
||||
}
|
||||
rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) })
|
||||
}
|
||||
}
|
||||
return { rfcs, errors }
|
||||
}
|
||||
|
||||
/** The begin/end marker lines that delimit one lifecycle's generated region. */
|
||||
const markers = (lifecycle: string): { begin: string; end: string } => ({
|
||||
begin: `<!-- gen-rfc-index:begin ${lifecycle} -->`,
|
||||
end: `<!-- gen-rfc-index:end ${lifecycle} -->`,
|
||||
})
|
||||
|
||||
/**
|
||||
* Render one lifecycle's generated region body: a `### {Class}` heading plus a
|
||||
* `| Title | First proposed |` table for every non-empty class, in CLASSES
|
||||
* order, rows sorted by date then filename.
|
||||
*/
|
||||
function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
|
||||
const sections: string[] = []
|
||||
for (const cls of CLASSES) {
|
||||
const rows = rfcs
|
||||
.filter(r => r.lifecycle === lifecycle && r.cls === cls)
|
||||
.sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base))
|
||||
if (rows.length === 0) continue
|
||||
const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n')
|
||||
sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`)
|
||||
}
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice freshly rendered regions into the README text. Throws when a marker
|
||||
* pair is missing, duplicated, or out of order, when a region does not sit
|
||||
* under its own `## {Lifecycle}` heading, or when an index-shaped table row
|
||||
* (a `| [title](lifecycle/…)` line) appears OUTSIDE the generated regions —
|
||||
* the markers are part of the curated prose, the heading above each region is
|
||||
* the one its lifecycle names, and index rows live only inside the regions
|
||||
* (prose links to RFCs remain fine anywhere).
|
||||
*/
|
||||
export function spliceReadme(readme: string, rfcs: Rfc[]): string {
|
||||
let out = readme
|
||||
const regions: Array<{ from: number; to: number }> = []
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
const { begin, end } = markers(lifecycle)
|
||||
const beginAt = out.indexOf(begin)
|
||||
const endAt = out.indexOf(end)
|
||||
if (beginAt === -1 || endAt === -1 || endAt < beginAt) {
|
||||
throw new Error(`README.md is missing the ${JSON.stringify(begin)} … ${JSON.stringify(end)} marker pair`)
|
||||
}
|
||||
if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) {
|
||||
throw new Error(`README.md has a duplicated ${lifecycle} index marker`)
|
||||
}
|
||||
// The region must sit directly under its own lifecycle heading: the last
|
||||
// H2 above the begin marker is `## {Heading(lifecycle)}`, or the heading
|
||||
// itself has drifted while the generated table stayed put.
|
||||
const before = out.slice(0, beginAt)
|
||||
const lastH2 = [...before.matchAll(/^##\s+(.+?)\s*$/gm)].at(-1)?.[1]
|
||||
if (lastH2 !== heading(lifecycle)) {
|
||||
throw new Error(`README.md: the ${lifecycle} index region is not under a "## ${heading(lifecycle)}" heading (found "## ${lastH2 ?? '<none>'}")`)
|
||||
}
|
||||
out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}`
|
||||
regions.push({ from: out.indexOf(begin), to: out.indexOf(markers(lifecycle).end) + markers(lifecycle).end.length })
|
||||
}
|
||||
// Index rows are generated state: a table row linking into a lifecycle
|
||||
// folder anywhere OUTSIDE the regions is a hand-added index entry the
|
||||
// generator would never reconcile.
|
||||
let offset = 0
|
||||
for (const line of out.split('\n')) {
|
||||
const inRegion = regions.some(r => offset >= r.from && offset < r.to)
|
||||
if (!inRegion && /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//.test(line)) {
|
||||
throw new Error(`README.md: index-shaped row outside the generated regions: ${JSON.stringify(line.slice(0, 80))}`)
|
||||
}
|
||||
offset += line.length + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -77,7 +77,6 @@
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebCapabilityStatus", "source": "packages/web/web/src/types.ts" }
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" }
|
||||
]
|
||||
}
|
||||
@@ -1,158 +1,50 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the RFC classification scheme
|
||||
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)).
|
||||
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
|
||||
* and the freshness of the generated index tables
|
||||
* ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.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:
|
||||
* Two checks (both against [rfc-index.ts](./rfc-index.ts), the shared walker
|
||||
* and renderer):
|
||||
*
|
||||
* 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).
|
||||
* from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1.
|
||||
* 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 (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.
|
||||
* 2. FRESHNESS — the marker-delimited index regions in `docs/rfc/README.md`
|
||||
* byte-match a fresh render from the tree, so every RFC is listed exactly
|
||||
* once, under the heading matching its path, with its H1 title and filename
|
||||
* date. The fix for a stale index is `pnpm run gen-rfc-index`, never a hand
|
||||
* edit. 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'
|
||||
import { resolve } from 'node:path'
|
||||
import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const rfcRoot = resolve(root, 'docs/rfc')
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
|
||||
/** 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
|
||||
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
|
||||
// indexed via its English filename; the pairing gate owns its consistency.
|
||||
if (match.endsWith('.zh.md')) 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`)
|
||||
if (errors.length === 0) {
|
||||
try {
|
||||
if (spliceReadme(readme, rfcs) !== readme) {
|
||||
errors.push('index: docs/rfc/README.md is stale — run `pnpm run gen-rfc-index` and commit the result')
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`index: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Report -----------------------------------------------------------------
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
|
||||
process.exit(0)
|
||||
|
||||
Reference in New Issue
Block a user