feat(#3840): generate docs/FEATURES.md from per-feature fragments (#3845)

* feat(#3840): generate docs/FEATURES.md from per-feature fragments

docs/FEATURES.md was hand-maintained, and every feature PR wrote into two
shared mutable cells: the '### N.' heading whose integer was hand-allocated at
authoring time, and the hand-maintained table of contents. Concurrent PRs all
picked the same next integer, and two PRs adding differently numbered features
still collided on the TOC. #3831 was renumbered 165 -> 166 -> 167 -> 168 across
successive rebases, each collision also costing a full matrix verification run
because the sha-keyed pass marker dies with the rebase.

Mechanism: one fragment per feature at docs/features/<slug>.md carrying
id/title/group (and an optional order) in frontmatter, consolidated by
scripts/gen-features.cjs --write|--check into a marker-delimited region of
docs/FEATURES.md that holds BOTH the TOC and every section body. Group headings
and their order are derived too - a group sorts by its lowest-ordered member -
so there is no shared registry to edit either; optional per-group prose lives in
docs/features/_groups/<slug>.md. A contributor adds exactly one new file.
Wired into regen:derived and lint:generated-sync alongside the eight existing
generators, matching gen-adr-index.cjs's CLI shape and typed-REASON reporting.

Migration froze all 168 existing numbers verbatim: identical section set,
identical order, identical bodies. Two defects found in the tree are fixed
inline rather than carried forward - the '## Related' block had been spliced
into the middle of the document, orphaning §142's Reference line, and four
inbound anchors were already broken on next (FEATURES.md#runtime-identity in
two files, and #143-spec-phase-edge-completeness-probe off by one). Since the
repo has no link checker, --check now validates every inbound
FEATURES.md#anchor by resolved target, so that class cannot ship silently
again; locale FEATURES.md files resolve elsewhere and stay out of scope.

Refs #3840

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3840): carry upstream §69 delta into its fragment and harden the generator

Review found section 69 missing '[--strict]' and REQ-STATE-05/06 versus
origin/next. Root cause was a stale base, not extraction loss: those lines
landed in 394bf384b (#3844) AFTER this branch forked at 63abcface, and
'git diff 63abcface origin/next -- docs/FEATURES.md' is exactly that hunk.
Merging origin/next auto-applied the hunk into the GENERATED region, which
--check immediately reported as stale; the delta is now carried in
docs/features/statemd-consistency-gates.md and regenerated from there.

--write is now fail-closed. It previously rendered the region even with
violations outstanding, warning only on stderr and exiting 0, so a
'--write && git commit' chain could commit a FEATURES.md carrying two
colliding sections. It now refuses and exits 1; --force is the explicit
override and says so in the report. The test that pinned the old behavior now
pins the refusal, plus the --force override and its scoping.

Marker forgery is rejected at two layers. A fragment body containing
'<!-- FEATURES:START' or '<!-- FEATURES:END' is a typed
body_forges_region_marker violation (fragments and group notes alike), and
spliceIntoFeatures anchors the end boundary with lastIndexOf instead of
indexOf, so a marker that reaches the document by any other route can only
make the generated region grow, never shrink. Matching is on marker PREFIXES,
so a decorated variant comment cannot slip past.

Symlinked corpus entries are refused with a typed dirent_not_regular_file
rather than read. A fork PR could otherwise commit docs/features/evil.md as a
symlink to any readable path and have the generator inline those bytes into
the committed docs/FEATURES.md on the next regen.

Equivalence re-verified with a method that cannot cancel out. The first
check extracted both operands with the same body-normalising helper, so
anything that helper dropped was dropped on both sides. The replacement runs
two independent passes: a global content-line multiset diff with no
per-section logic at all (0 gained, 19 lost, all 19 the stale hand-written
mini-TOC links this change deliberately deletes), and a per-section
byte-exact body diff carrying a coverage assertion that fails loudly per file
when the extractor accounts for fewer lines than the file contains. That
assertion caught two blind spots in the checker itself. 168/168 sections
present, order identical, one intended body difference (§142 regains the
Reference line orphaned by the misplaced '## Related' block).

Refs #3840

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3840): backfill changeset PR number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-24 22:49:00 -04:00
committed by GitHub
parent 394bf384be
commit 36375513b9
180 changed files with 5319 additions and 80 deletions

View File

@@ -303,50 +303,76 @@ When a change genuinely has no user-facing documentation impact (infrastructure
When unsure whether a change is user-facing, **update the docs**.
### Adding a section to `docs/FEATURES.md`
### Adding a feature to `docs/FEATURES.md`
`docs/FEATURES.md` numbers each feature with a monotonically increasing integer (`### 164.`). That
integer is the single most common source of merge conflicts in this repo — with several PRs in
flight, everyone picks the same next number and everyone after the first has to renumber.
**`docs/FEATURES.md` is generated. Do not edit it by hand.** Add one fragment
under `docs/features/` and regenerate:
**There are two conflict cells, not one.** The `### N.` heading, and the hand-maintained table of
contents at the top of the file. Two PRs adding *differently numbered* features still collide on
the TOC, so renumbering alone does not make you safe.
```
docs/features/<kebab-slug>.md
```
```markdown
---
id: 3840
title: Runtime Identity
group: v1.7.0 Features
---
**Purpose:** …
```
Then run `npm run regen:derived` (or just `npm run gen:features -- --write`) and
commit both the fragment and the regenerated `docs/FEATURES.md`.
`npm run lint:generated-sync` runs the `--check` twin, so a stale index cannot
merge. `--write` is fail-closed: it refuses to emit `docs/FEATURES.md` while any
fragment violation stands, so a `--write && git commit` chain cannot commit a
corrupt file. Pass `--force` only to see what a broken corpus would render as.
**Why fragments.** The old practice hand-allocated a monotonically increasing
integer at authoring time, and every feature PR wrote into *two* shared mutable
cells: the `### N.` heading and the hand-maintained table of contents. With
several PRs in flight everyone picked the same next integer, and two PRs adding
*differently numbered* features still collided on the TOC. #3831 was renumbered
165 → 166 → 167 → 168 across successive rebases — the last collision landing
*during* a verification run — and because every rebase invalidates the sha-keyed
pass marker, each collision also cost a full remote matrix run. This is the same
fix `.changeset/` already applies to `CHANGELOG.md` and
`tests/emitted-drift-acks/` applies to the ack ledger
([#2914](https://github.com/open-gsd/gsd-core/issues/2914)): one file per
contribution, consolidated by a generator. You add a new file and touch no
shared file, so there is nothing to collide on.
**The rules:**
1. **Allocate the number LAST.** Write the section body first; pick the integer immediately before
you open the PR, or immediately after your final rebase. A number chosen at branch time is
already stale.
2. **Never pre-emptively renumber to dodge a conflict.** When you hit one, rebase, take
`max + 1` of what is now on `next`, and update the TOC in the same commit. Gaps are fine — 58 is
already missing and `#27b-existing-codebase-onboarding` is a live non-integer id, so the
sequence is not an invariant anyone depends on.
3. **Fork contributors: you do not have to fight this.** If your PR conflicts only on the number or
the TOC, say so in a comment and leave it — a maintainer will assign the final number at merge.
Do not rebase repeatedly just to chase the counter.
4. **Do not renumber someone else's section** to make room for yours. Take the next free integer
above the current maximum.
1. **Any unique `id` is legal.** It does not have to be contiguous or maximal —
58, 113 and 131 are already absent, and `6.5`, `27a` and `27b` are live
non-integer ids. **Use your issue number** and you never have to revisit the
choice after a rebase. `--check` rejects a duplicate with an `id_duplicate`
violation naming both fragments, so a collision is a loud one-line fix in
your own file, not a merge conflict.
2. **Never renumber a merged feature.** `id` is frozen once it ships: other docs
link to `FEATURES.md#<id>-<slug>`, and `--check` now verifies every one of
those inbound anchors resolves (`inbound_anchor_unresolved`). Renaming the
*title* moves the anchor too — fix the inbound links in the same commit.
3. **Groups are derived, not registered.** `group` is the `##` heading text.
Groups are ordered by their lowest-ordered member, so adding a release bucket
is just the first fragment that names it. Optional per-group prose lives in
`docs/features/_groups/<slug>.md`, which a feature PR never touches.
4. **`order` is optional.** It defaults to the numeric part of `id`, which is
right for almost everything. Declare it only to place a section somewhere its
number would not put it (`27b` precedes `27a` for historical reasons).
5. **Bodies start at `####`.** A `##` or `###` inside a fragment body would
forge a group or a sibling section with no id and no TOC entry; `--check`
rejects it (`body_heading_too_shallow`).
**If you are an agent**, take an exclusive Fleet lease on the allocation before choosing a number,
and include `docs/FEATURES.md` in your published `touched` set — a `touched` set of symbols alone
leaves you invisible to a peer editing the same index:
**Fork contributors:** there is nothing to coordinate and nothing to chase. Pick
your issue number, add your file, regenerate. If `docs/FEATURES.md` conflicts on
a rebase, discard your side and re-run `--write` — it is a derived artifact.
```
fleet_acquire_lease({ repo_id, agent_id,
scope: ["docs/FEATURES.md::section-number-allocation"], ttl_seconds: 1800 })
… allocate, write, commit …
fleet_release_lease({ lease_id })
```
The lease serializes allocation between coordinating agents. It does not prevent a git conflict
once two branches already carry adjacent text, and it cannot reach a fork PR.
> **Planned durable fix.** This whole class disappears if `FEATURES.md` becomes generated from
> per-feature fragments the way `CHANGELOG.md` is generated from `.changeset/` (three random words
> per fragment, so concurrent PRs cannot collide) and the way `tests/emitted-drift-acks/` works
> ([#2914](https://github.com/open-gsd/gsd-core/issues/2914)). Until that lands, the rules above are
> the practice.
**Agents:** no Fleet allocation lease is needed for a feature number any more.
The lease that used to serialize `docs/FEATURES.md::section-number-allocation`
protected an invariant that no longer exists.
## Testing Standards