Merge master into app attribution RFC

This commit is contained in:
Tianyi Cui
2026-07-05 00:45:39 +08:00
197 changed files with 2876 additions and 2047 deletions
+155 -16
View File
@@ -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))
}
+30
View File
@@ -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).`)
}
-1
View File
@@ -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
+173
View File
@@ -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
}
+1 -2
View File
@@ -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" }
]
}
+25 -133
View File
@@ -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)