Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/.vitepress/loaders/rfd-priority.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 45 additions & 20 deletions docs/.vitepress/loaders/rfd-shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]))
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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 = [
Expand All @@ -200,6 +198,33 @@ 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 and must span every id space the
// board can hold — drafts included, since a draft can be abandoned. Ids it
// doesn't know 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.
Expand Down
17 changes: 17 additions & 0 deletions docs/.vitepress/loaders/rfds.data.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import {
checkRequiresOnImplemented,
checkStatusGate,
checkSummaries,
checkTerminalOnBoard,
findCycles,
findDuplicateIds,
findStrayDraftRefs,
loadPriority,
} from './rfd-shared.mjs'

const rfdDir = resolve(import.meta.dirname, '../../rfd')
const draftsDir = resolve(import.meta.dirname, '../../rfd/drafts')
const cachePath = resolve(import.meta.dirname, '../rfd-summaries.json')
const priorityPath = resolve(import.meta.dirname, '../../rfd/.priority.json')

function loadSummaries() {
try {
Expand All @@ -39,6 +43,18 @@ export default {
const summaries = loadSummaries()
const graph = buildGraph(rfdDir, files)

// The board mixes both id spaces, so the terminal check needs statuses
// from both. Drafts are otherwise none of this loader's business: they
// contribute nothing but a status here.
const draftFiles = readdirSync(draftsDir)
.filter(f => /^D\d{2}-.+\.md$/.test(f))
const boardGraph = new Map([
...graph,
...buildGraph(draftsDir, draftFiles),
])

const priority = loadPriority(priorityPath)

// Every validation aborts the published build.
const errors = [
checkSummaries(rfdDir, files, summaries),
Expand All @@ -48,6 +64,7 @@ export default {
checkStatusGate(graph),
checkRequiresOnImplemented(graph),
findCycles(graph),
checkTerminalOnBoard(boardGraph, priority),
]
for (const error of errors) {
if (error) throw new Error(error)
Expand Down
6 changes: 2 additions & 4 deletions docs/.vitepress/theme/RfdBoard.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'

import { normalizePriority } from '../loaders/rfd-priority.mjs'
import { normalizePriority, TERMINAL_STATUSES } from '../loaders/rfd-priority.mjs'
import { createSortable, isDev, loadBoard, saveBoard } from './board.mjs'

const props = defineProps({
Expand All @@ -15,8 +15,6 @@ const props = defineProps({
// bundle never includes SortableJS and the list renders read-only — the
// client-facing view.

const TERMINAL = new Set(['Implemented', 'Superseded', 'Abandoned'])

// Marker rows are synthetic list entries: labeled milestone lines plus the
// unnamed cutoff between the prioritised list and the unsorted backlog. An
// RFD belongs to the nearest milestone marker below it; anything below the
Expand Down Expand Up @@ -75,7 +73,7 @@ function topoSort(list) {
// backlog, and finally any active RFDs the file doesn't mention. Dependencies
// are kept above their dependents; the saved order is the tiebreak.
function buildRows(p) {
const isActive = e => e && !TERMINAL.has(e.status)
const isActive = e => e && !TERMINAL_STATUSES.has(e.status)
const placed = new Set()
const rows = []
const push = num => {
Expand Down
2 changes: 0 additions & 2 deletions docs/rfd/.priority.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"milestone": null,
"ids": [
"065",
"045",
"082",
"088",
"087",
Expand Down Expand Up @@ -93,7 +92,6 @@
"D35",
"D36",
"D37",
"101",
"D39",
"D40",
"D41",
Expand Down
96 changes: 89 additions & 7 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,11 @@ rfd-supersede NNN MMM:
' "$new_file" > "${new_file}.tmp"
mv "${new_file}.tmp" "$new_file"

# The superseded RFD is no longer work to prioritise; the replacement
# carries it. Whether MMM belongs on the board is a call for whoever
# reorders it, so only the old id is dropped.
just _rfd-priority-remove "$old_num"

echo "${old_file}: Superseded by RFD ${new_num}"
echo "${new_file}: Supersedes RFD ${old_num}"

Expand Down Expand Up @@ -1557,6 +1562,10 @@ rfd-promote NNN: _install-jp _install-comfort _install-ticket
done
fi

# The board ranks open work; this RFD is done. Drop it so `rfd-list`
# and the web board stop counting it as planned.
just _rfd-priority-remove "$rfd_id"

echo "${file}: Accepted -> Implemented"
fi

Expand Down Expand Up @@ -1791,18 +1800,88 @@ _rfd-priority-rewrite OLD NEW:
| .backlog = ((.backlog // []) | sub_id)
' "$priority_file" > "${priority_file}.tmp" && mv "${priority_file}.tmp" "$priority_file"

# Internal: drop RFD ids from the priority board.
#
# IDS are canonical ids (`045`, `D12`), space-separated. Each is removed from
# every list that can hold it: the `planned` milestone groups, `backlog`, and
# the legacy flat `order`. Ids the board doesn't mention are ignored, so this is
# safe to call unconditionally. A missing board file is a no-op.
[private]
_rfd-priority-remove +IDS:
#!/usr/bin/env sh
set -eu

priority_file="docs/rfd/.priority.json"
[ -f "$priority_file" ] || exit 0
ids=$(printf '%s\n' {{IDS}})
jq --arg ids "$ids" '
($ids | split("\n") | map(select(. != ""))) as $drop
| def prune: map(select(. as $id | ($drop | index($id)) == null));
(if .planned then .planned |= map(.ids |= prune) else . end)
| (if .order then .order |= prune else . end)
| .backlog = ((.backlog // []) | prune)
' "$priority_file" > "${priority_file}.tmp" && mv "${priority_file}.tmp" "$priority_file"

# Drop terminal RFDs from the priority board.
#
# The board ranks work that is still open, so an Implemented, Superseded, or
# Abandoned RFD has no place on it and the docs build rejects one that lingers.
# `rfd-promote`, `rfd-abandon`, and `rfd-supersede` prune the id as they change
# the status; this recipe is the repair path for a status edited by hand.
[group('rfd')]
rfd-board-prune:
#!/usr/bin/env sh
set -eu

priority_file="docs/rfd/.priority.json"
if [ ! -f "$priority_file" ]; then
echo "No priority board at ${priority_file}; nothing to prune."
exit 0
fi

# Both id spaces: the board ranks published RFDs and drafts alike, and a
# draft can be abandoned.
stale=""
for file in docs/rfd/[0-9][0-9][0-9]-*.md docs/rfd/drafts/D[0-9][0-9]-*.md; do
[ -f "$file" ] || continue
basename_f=$(basename "$file")
case "$basename_f" in 000-*) continue ;; esac

status=$(sed -n 's/^- \*\*Status\*\*: \([A-Za-z]*\).*/\1/p' "$file" | head -1)
case "$status" in
Implemented|Superseded|Abandoned) ;;
*) continue ;;
esac

num=${basename_f%%-*}
if jq -e --arg n "$num" \
'[.planned[]?.ids[]?, .order[]?, .backlog[]?] | index($n) != null' \
"$priority_file" > /dev/null; then
stale="${stale} ${num}"
fi
done

if [ -z "$stale" ]; then
echo "Priority board is clean."
exit 0
fi

just _rfd-priority-remove $stale
echo "Pruned from the priority board:${stale}"

# Mark an RFD as abandoned with the given reason.
#
# Accepts: a permanent number (41, 041) or a draft ID (D01). A draft the author
# has decided not to pursue can be abandoned when the rationale is worth keeping
# as a record, or simply deleted when it isn't (see RFD 001).
[group('rfd')]
rfd-abandon NNN +REASON:
#!/usr/bin/env sh
set -eu

n=$(echo "{{NNN}}" | sed 's/^0*//')
num=$(printf "%03d" "${n:-0}")
file=$(ls docs/rfd/${num}-*.md 2>/dev/null | head -1)
if [ -z "$file" ]; then
echo "No RFD found with number ${num}." >&2; exit 1
fi
out=$(just _rfd-resolve "{{NNN}}") || exit 1
rfd_id="${out%% *}"
file="${out#* }"

current=$(sed -n 's/^- \*\*Status\*\*: \([A-Za-z]*\).*/\1/p' "$file" | head -1)
case "$current" in
Expand All @@ -1820,6 +1899,9 @@ rfd-abandon NNN +REASON:
' "$file" > "${file}.tmp"
mv "${file}.tmp" "$file"

# An abandoned RFD is no longer work to prioritise.
just _rfd-priority-remove "$rfd_id"

# Remind the user to close the tracking issue if one exists.
tracking=$(sed -n 's/^- \*\*Tracking Issue\*\*: \[#\([0-9]*\)\].*/\1/p' "$file" | head -1)
echo "${file}: Abandoned (${current} -> Abandoned)"
Expand All @@ -1838,7 +1920,7 @@ rfd-abandon NNN +REASON:
for r in $(echo "$required_by_line" | grep -oE 'RFD (D[0-9]+|[0-9]{3})' | awk '{print $2}'); do
echo " RFD ${r}" >&2
done
echo "Their dependency on RFD ${num} is now broken — review and update." >&2
echo "Their dependency on RFD ${rfd_id} is now broken — review and update." >&2
fi

# Generate or update AI summaries for RFD documents.
Expand Down
Loading