From 4f3b2a30de8b4891978df40d33596c0da44cf424 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Tue, 18 Aug 2026 20:12:49 +0200 Subject: [PATCH 1/2] chore(rfd): Prune terminal RFDs from board The priority board contains only open RFDs, so completed and retired work cannot retain a planned position. Status workflows remove affected entries, and the docs build rejects stale board ids. `just rfd-board-prune` repairs boards after manual status edits. Signed-off-by: Jean Mertz --- docs/.vitepress/loaders/rfd-priority.mjs | 9 +++ docs/.vitepress/loaders/rfd-shared.mjs | 64 +++++++++++++------ docs/.vitepress/loaders/rfds.data.js | 8 +++ docs/.vitepress/theme/RfdBoard.vue | 6 +- docs/rfd/.priority.json | 2 - justfile | 79 ++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 26 deletions(-) diff --git a/docs/.vitepress/loaders/rfd-priority.mjs b/docs/.vitepress/loaders/rfd-priority.mjs index 36911a0e9..53b4ef835 100644 --- a/docs/.vitepress/loaders/rfd-priority.mjs +++ b/docs/.vitepress/loaders/rfd-priority.mjs @@ -3,6 +3,15 @@ // (`rfd-shared.mjs`) and the browser-side board (`RfdBoard.vue`) both consume // this module, so it must run in either environment. +// Statuses that take an RFD off the priority board. The board ranks the work +// that is still open (Draft, Discussion, Accepted); everything else is done or +// dead and holds no position. +export const TERMINAL_STATUSES = new Set([ + 'Implemented', + 'Superseded', + 'Abandoned', +]) + // Normalize a raw priority record into a fixed shape: // // - `planned`: milestone groups in board order. Each group's `ids` are the diff --git a/docs/.vitepress/loaders/rfd-shared.mjs b/docs/.vitepress/loaders/rfd-shared.mjs index f6a3009b9..ab1058eec 100644 --- a/docs/.vitepress/loaders/rfd-shared.mjs +++ b/docs/.vitepress/loaders/rfd-shared.mjs @@ -3,7 +3,11 @@ import { readdirSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { field, unescapeTitle } from './metadata.mjs' -import { checkMilestones, normalizePriority } from './rfd-priority.mjs' +import { + checkMilestones, + normalizePriority, + TERMINAL_STATUSES, +} from './rfd-priority.mjs' import { inDevelopmentRfds, loadTickets } from './ticket-shared.mjs' // Shared parsing and validation for the RFD data loaders. @@ -129,20 +133,14 @@ export function loadPriority(path) { return normalizePriority(raw) } -// Statuses that take an RFD off the priority board. The board is the active -// backlog the client prioritizes (Discussion, Accepted); everything else is -// done or dead. -export const TERMINAL_STATUSES = new Set([ - 'Implemented', - 'Superseded', - 'Abandoned', -]) - // Annotate each entry with its board position and milestone. `priority` is the // index in the combined `order` + `backlog` list (lower = higher priority) or -// `null` when the RFD hasn't been placed yet. `milestone` is the name of the -// planned group the RFD sits in, or `null` (unassigned, backlogged, or -// unplaced). Status is left untouched so the view can drop terminal RFDs itself. +// `null` when the RFD holds no position. `milestone` is the name of the planned +// group the RFD sits in, or `null` (unassigned, backlogged, or unplaced). +// +// A terminal RFD holds no position whatever the board file says. The file goes +// stale as soon as a status changes, so membership is settled here, once, for +// every surface. export function mergePriority(entries, priority) { const combined = [...priority.order, ...(priority.backlog ?? [])] const rank = new Map(combined.map((num, i) => [num, i])) @@ -151,8 +149,9 @@ export function mergePriority(entries, priority) { for (const num of group.ids) milestoneOf.set(num, group.milestone) } for (const entry of entries) { - entry.priority = rank.has(entry.num) ? rank.get(entry.num) : null - entry.milestone = milestoneOf.get(entry.num) ?? null + const placed = !TERMINAL_STATUSES.has(entry.status) && rank.has(entry.num) + entry.priority = placed ? rank.get(entry.num) : null + entry.milestone = placed ? (milestoneOf.get(entry.num) ?? null) : null } } @@ -180,11 +179,10 @@ export function mergeDependencies(entries, graph) { } } -// Reject board entries that don't match a known published RFD. Numbers are -// never reused, so an unknown id is real corruption (a hand-edit typo, or a -// stale id), not the expected churn of an RFD becoming Implemented. Terminal -// ids lingering in `order` are tolerated: they fall off on the next save and -// the view filters them out meanwhile. +// Reject board entries that don't match a known RFD. Numbers are never reused, +// so an unknown id is real corruption: a hand-edit typo, or an id whose file +// went away. A terminal RFD still listed on the board is a separate, milder +// problem — see `checkTerminalOnBoard`. export function checkPriority(entries, priority) { const known = new Set(entries.map(e => e.num)) const unknown = [ @@ -200,6 +198,32 @@ export function checkPriority(entries, priority) { `ids or remove them (the board UI rewrites this file on save).` } +// Reject terminal RFDs listed on the priority board. +// +// The board ranks open work, so an Implemented, Superseded, or Abandoned RFD has +// no place on it. `graph` supplies the statuses; ids it doesn't know (drafts, +// when the graph is published-only) are left to `checkPriority`. +// +// This is an error rather than a warning because the board file is what a human +// reads and reorders: nothing renders a stale id, so nothing else would ever +// point it out. +export function checkTerminalOnBoard(graph, priority) { + const placed = [...priority.order, ...(priority.backlog ?? [])] + const terminal = [...new Set(placed)] + .filter(num => TERMINAL_STATUSES.has(graph.get(num)?.status)) + .sort() + + if (terminal.length === 0) return null + + const report = terminal + .map(num => ` ${num} (${graph.get(num).status})`) + .join('\n') + return `Terminal RFDs on the priority board:\n${report}\n\n` + + `The board ranks work that is still open. Run ` + + `\`just rfd-board-prune\` to drop these from ` + + `\`docs/rfd/.priority.json\`.` +} + // Each id (`NNN` or `DNN`) must map to exactly one file. Once drafts left the // website's validation pipeline it became possible to land two files sharing a // draft id; this guards both id spaces. diff --git a/docs/.vitepress/loaders/rfds.data.js b/docs/.vitepress/loaders/rfds.data.js index f1715b41f..26fcf20b1 100644 --- a/docs/.vitepress/loaders/rfds.data.js +++ b/docs/.vitepress/loaders/rfds.data.js @@ -8,13 +8,16 @@ import { checkRequiresOnImplemented, checkStatusGate, checkSummaries, + checkTerminalOnBoard, findCycles, findDuplicateIds, findStrayDraftRefs, + loadPriority, } from './rfd-shared.mjs' const rfdDir = resolve(import.meta.dirname, '../../rfd') const cachePath = resolve(import.meta.dirname, '../rfd-summaries.json') +const priorityPath = resolve(import.meta.dirname, '../../rfd/.priority.json') function loadSummaries() { try { @@ -39,6 +42,10 @@ export default { const summaries = loadSummaries() const graph = buildGraph(rfdDir, files) + // Only published RFDs reach a terminal status, so the published graph + // carries every status the board check needs. + const priority = loadPriority(priorityPath) + // Every validation aborts the published build. const errors = [ checkSummaries(rfdDir, files, summaries), @@ -48,6 +55,7 @@ export default { checkStatusGate(graph), checkRequiresOnImplemented(graph), findCycles(graph), + checkTerminalOnBoard(graph, priority), ] for (const error of errors) { if (error) throw new Error(error) diff --git a/docs/.vitepress/theme/RfdBoard.vue b/docs/.vitepress/theme/RfdBoard.vue index 0fae1bdb4..9322cd595 100644 --- a/docs/.vitepress/theme/RfdBoard.vue +++ b/docs/.vitepress/theme/RfdBoard.vue @@ -1,7 +1,7 @@