fix(521): de-dup 6 overlapped records; guard duplicate metadata blocks; exclude retrieval-eval; complete eval bank [decisions-edit]

- Exclude docs/decisions/retrieval-eval.md from active decision parsing
  (_NON_DECISION_FILES); its `## N.` eval-question headings were being
  miscounted as 7 legacy-unmigrated records.
- Add decisions_lib.metadata_line_count() + a decisions_validate guard
  that fails a record with more than one `key:` metadata line, so a
  stacked-metadata-block migration bug (which the parser silently
  tolerated by reading only the first block) can't recur unnoticed.
  TDD: test_duplicate_metadata_block_fails / test_single_metadata_block_passes.
- De-duplicate the 6 docs/decisions.md records left with two stacked
  metadata blocks (scan.getoraddfolder-db-lookup #488,
  scan.musicvideo-reconciliation #494, scan.jellyfin-mixed-content-library
  #489, iptv.logo-drives-bug-preset #67, ffmpeg.qsv-decode-encode-split
  #498, ci.small-lane-git-only server-management#639), merging the union
  of Signals/paths/issues/Mechanics from both blocks and keeping the
  richer Rule wording; rationale prose untouched.
- Fill in the deferred Q6b row in docs/decisions/retrieval-eval.md now
  that startup.parallel-orientation is active in docs/decisions.md,
  scoring it as a real active-vs-superseded question against the
  archived docs.queue-state-gitea-tracker.
- Regenerate docs/decisions/README.md via build_decisions_catalog.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 11:54:52 +02:00
co-authored by Claude Opus 4.8
parent 4ac18e06bc
commit fb6720ea27
6 changed files with 79 additions and 56 deletions
+13 -43
View File
@@ -2522,13 +2522,8 @@ for its whole slot. This closes that gap.
## 2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488)
`key: scan.getoraddfolder-db-lookup` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers.
**Signals:** GetOrAddFolder, LibraryFolders navigation, ArgumentNullException · paths: `ILibraryRepository.GetOrAddFolder`, `JellyfinMusicVideoLibraryScanner` · issues: #488
**Mechanics:** `LibraryRepositoryTests` (`LibraryPath.LibraryFolders == null` case)
`key: scan.getoraddfolder-db-lookup` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `GetOrAddFolder` looks the existing folder up with a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` navigation collection (which is null on the remote/Jellyfin path).
**Signals:** folder lookup contract, remote scanner crash, ArgumentNullException, eager-load assumption · paths: `ILibraryRepository.GetOrAddFolder`, `LibraryRepositoryTests`, `JellyfinMusicVideoLibraryScanner` · issues: #488
**Mechanics:** `ILibraryRepository.GetOrAddFolder`; test coverage in `LibraryRepositoryTests`
**Signals:** GetOrAddFolder, LibraryFolders navigation, ArgumentNullException, folder lookup contract, remote scanner crash, eager-load assumption · paths: `ILibraryRepository.GetOrAddFolder`, `LibraryRepositoryTests`, `JellyfinMusicVideoLibraryScanner` · issues: #488
**Mechanics:** `LibraryRepositoryTests` (`LibraryPath.LibraryFolders == null` case); test coverage in `LibraryRepositoryTests`
`GetOrAddFolder` looked the existing folder up by reading `libraryPath.LibraryFolders` in memory. That
navigation collection is only eager-loaded on the **local** scan path — `LibraryRepository.GetLibrary`
@@ -2563,13 +2558,8 @@ remote scanner tripped it, and the feature had never run in prod, CI, or locally
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
`key: scan.musicvideo-reconciliation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
**Signals:** music-video trash sweep, path-based identity, cross-kind safety · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath` · issues: #494, #477, #488, #496
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`
`key: scan.musicvideo-reconciliation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `JellyfinMusicVideoLibraryScanner` reconciles by a library-scoped local-path diff and hard-deletes music videos gone upstream, since it has no server itemId/etag identity to diff against.
**Signals:** music-video trash sweep, path-keyed identity, cross-kind safety, empty-fetch guard reuse · paths: `JellyfinMusicVideoLibraryScanner`, `TrashMissingMusicVideos`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496
**Mechanics:** `JellyfinMusicVideoLibraryScanner.ScanLibrary`/`TrashMissingMusicVideos`; integration tests extending the #488 harness
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
@@ -2609,13 +2599,8 @@ media-server identity those base scanners rely on.
## 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds
`key: scan.jellyfin-mixed-content-library` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind.
**Signals:** mixed-content library, `LibraryMediaKind.Mixed`, per-kind sequential scan · paths: `JellyfinApiClient.Project()`, `ScanMixedLibrary`, `SynchronizeJellyfinLibraryByIdHandler` · issues: #489, #474, #488
**Mechanics:** local mixed libraries deliberately unsupported (`LocalFolderScanner.VideoFileExtensions` hazard)
`key: scan.jellyfin-mixed-content-library` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** a Jellyfin library whose collection type is `mixed` or absent maps to `LibraryMediaKind.Mixed` and runs the movie, TV and music-video scanners against it in sequence, instead of being dropped.
**Signals:** mixed-content library, LibraryMediaKind, per-kind scan dispatch, silent-success bug · paths: `JellyfinApiClient.Project`, `ScanMixedLibrary`, `ScanLocalLibraryHandler`, `SynchronizeJellyfinLibraryByIdHandler` · issues: #489, #474, #488
**Mechanics:** `LibraryMediaKind.Mixed` dispatch in the Jellyfin sync handlers; per-kind scanners queried by `parentId` + `includeItemTypes`
**Signals:** mixed-content library, `LibraryMediaKind.Mixed`, per-kind sequential scan, per-kind scan dispatch, silent-success bug · paths: `JellyfinApiClient.Project()`, `ScanMixedLibrary`, `SynchronizeJellyfinLibraryByIdHandler`, `ScanLocalLibraryHandler` · issues: #489, #474, #488
**Mechanics:** local mixed libraries deliberately unsupported (`LocalFolderScanner.VideoFileExtensions` hazard); `LibraryMediaKind.Mixed` dispatch in the Jellyfin sync handlers; per-kind scanners queried by `parentId` + `includeItemTypes`
A Jellyfin library whose collection type is `mixed` — or absent — now maps to `LibraryMediaKind.Mixed`
instead of being dropped by `JellyfinApiClient.Project()`'s `_ => None`. Scanning it runs the movie,
@@ -2679,13 +2664,8 @@ fixed here:
## 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)
`key: iptv.logo-drives-bug-preset` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema.
**Signals:** channel logo, watermark bug preset, ChannelWatermark seeding · paths: `ChannelWatermarkImageSource.ChannelLogo`, `DbInitializer.Initialize`, `watermark.channel_bug_seeded` · issues: #67, #502
**Mechanics:** `DbInitializerChannelBugWatermarkTests`; `WatermarkResponseModel.imageSource`
`key: iptv.logo-drives-bug-preset` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** one uploaded logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset, not per-channel bug schema.
**Signals:** channel bug, ChannelWatermark, ChannelLogo imageSource, seed/adopt semantics, quick-add · paths: `ChannelWatermarkImageSource`, `DbInitializer.Initialize`, `WatermarkResponseModel`, `DbInitializerChannelBugWatermarkTests` · issues: #67, #286, #502
**Mechanics:** `DbInitializer` seeded `Channel Bug` watermark + `watermark.channel_bug_seeded` ConfigElement marker; `WatermarkResponseModel.imageSource`
**Signals:** channel logo, watermark bug preset, ChannelWatermark seeding, ChannelLogo imageSource, seed/adopt semantics, quick-add · paths: `ChannelWatermarkImageSource.ChannelLogo`, `DbInitializer.Initialize`, `watermark.channel_bug_seeded`, `WatermarkResponseModel` · issues: #67, #286, #502
**Mechanics:** `DbInitializerChannelBugWatermarkTests`; `WatermarkResponseModel.imageSource`; `DbInitializer` seeded `Channel Bug` watermark + `watermark.channel_bug_seeded` ConfigElement marker
#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably
overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo`
@@ -2726,14 +2706,9 @@ shared row already delivers the user-visible behavior with no schema change.
creating a second preset on the Watermarks screen.
## 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool
`key: ffmpeg.qsv-decode-encode-split` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder.
**Signals:** QSV, native VA-API decode, Dolby Vision passthrough · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder.SetTonemap` · issues: #498, #505
**Mechanics:** `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`; migration `HasDefaultValue(true)`
`key: ffmpeg.qsv-decode-encode-split` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `FFmpegProfile.QsvPreferNativeDecoder` (default on, Linux-only) decodes QSV profiles with the native VA-API decoder while still encoding with QSV, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum.
**Signals:** QSV, VA-API, Dolby Vision passthrough, hybrid decode/encode, HDR tonemap fallback · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder`, `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md` · issues: #498, #505
**Mechanics:** `QsvPipelineBuilder` decoder-mode branch; migration `HasDefaultValue(true)` on the nullable `bool?` column
**Rule:** QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum.
**Signals:** QSV, native VA-API decode, Dolby Vision passthrough, hybrid decode/encode, HDR tonemap fallback · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder.SetTonemap`, `QsvPipelineBuilder` · issues: #498, #505
**Mechanics:** `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`; migration `HasDefaultValue(true)` on the nullable `bool?` column; `QsvPipelineBuilder` decoder-mode branch
`FFmpegProfile.HardwareAcceleration` picked one pipeline builder for **both** decode and encode, so an
Intel QSV profile decoded with the QSV decoder — which is materially less tolerant of imperfect H.264 than
@@ -2775,13 +2750,8 @@ ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-desi
## 2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)
`key: ci.small-lane-git-only` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap.
**Signals:** CI lane definition, per-job memory cap, small lane widening · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml` · issues: server-management#639, #406, #604, #574
**Mechanics:** sum-of-caps rule (#406/#604); second jazz runner at `--cpu-shares=128`
`key: ci.small-lane-git-only` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** the `small` CI lane is defined by what a job does (git-only), not its typical runtime; both `docker build` jobs move to `ubuntu-latest` so `small`'s per-slot memory cap can shrink and its concurrency widen.
**Signals:** runner lane definition, memory cap vs capacity, act setup-phase hang, docker build placement · paths: `docker-build.yml`, `ci-image.yml`, runner config · issues: server-management#639, #406, #604, #574
**Mechanics:** `.gitea/workflows/docker-build.yml` and `ci-image.yml` `runs-on`; `docs/ci-cd.md`
**Signals:** CI lane definition, per-job memory cap, small lane widening, memory cap vs capacity, act setup-phase hang, docker build placement · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, runner config · issues: server-management#639, #406, #604, #574
**Mechanics:** sum-of-caps rule (#406/#604); second jazz runner at `--cpu-shares=128`; `docs/ci-cd.md`
- **The `small` lane is defined by what a job *does*, not by how long it usually takes.** Both jobs
removed from it here were justified as small on a runtime argument that only held in the common case:
+1 -1
View File
@@ -49,7 +49,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](../decisions.md#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) |
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502) |
| `ffmpeg.hls-cold-start-burst` | HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for. | 2026-07-20 | [link](../decisions.md#2026-07-20--hls-cold-start-is-fixed-with--readrate_initial_burst-not-by-raising-the-work-ahead-limit-350) |
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder. | 2026-07-20 | [link](../decisions.md#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool) |
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. | 2026-07-20 | [link](../decisions.md#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool) |
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](../decisions.md#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) |
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](../decisions.md#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) |
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](../decisions.md#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67) |
+21 -11
View File
@@ -85,15 +85,25 @@ check-then-set race on its lock flags?"
instead of following its `superseded-by` pointer to `docs.decision-lifecycle` is the scored
failure.
**Q6b** *(deferred — flag, do not score yet).* "Does volatile session/queue state still live in the
pinned Gitea tracker issue #237 handoff pattern, or has that moved to a parallel-orientation
mechanism?"
- **Current state in this corpus snapshot:** `docs.queue-state-gitea-tracker` is still `status:
active` (`docs/decisions.md`) — Task 8's `startup.parallel-orientation` record had not landed in
this worktree as of this bank's authoring (2026-07-21). Once Task 8 merges and supersedes it, this
becomes a third scored active-vs-superseded pair (expected key: `startup.parallel-orientation`;
superseded key: `docs.queue-state-gitea-tracker`). Left in the bank as a placeholder per the Task 9
brief; re-verify both keys' `status` before scoring it.
**Q6b.** "What is the current source of live queue state at session start?"
- **Expected answer:** Two concurrent tracks — Orientation (docs/README.md task-signal map → the
active decisions catalog) and, only when no issue is named, Selection
(`scripts/select-queue.sh N`, a deterministic query over live Gitea state). Selecting the archived
`docs.queue-state-gitea-tracker` ("queue state lives in pinned tracker issue #237") is a **FAILURE**
#237 closed 2026-07-13 and is now a single archival breadcrumb that MUST NOT be read for live
state.
- **Expected key:** `startup.parallel-orientation`
- **File:** `docs/decisions.md`
- **Superseded record a naive search might return instead:** `docs.queue-state-gitea-tracker`
(`docs/decisions/archive/startup.md`, `status: superseded`,
`superseded-by: startup.parallel-orientation@2026-07-21`)
- **Why:** `docs.queue-state-gitea-tracker`'s prose ("Pinned tracker issue #237 holds the goal +
ordered arc") still reads as a plausible, on-topic answer — that's the trap: the arc completed and
#237 closed, so `scripts/select-queue.sh` (2026-07-19) replaced the *mechanical* selection logic
with live Gitea queries, and #520 formalized the two-track protocol that retires #237 entirely.
Following `docs.queue-state-gitea-tracker`'s own `superseded-by` pointer to
`startup.parallel-orientation` (2026-07-21) rather than citing the archived record as current is
the scored behavior.
## 4. Retired feature
@@ -171,7 +181,7 @@ I design a new date-conditional scheduling feature?"
| Q4 | exact-lookup | `locking.entitylocker-atomic-flags` |
| Q5 | active-vs-superseded (verbatim #390) | `ci.runner-placement` |
| Q6 | active-vs-superseded | `docs.decision-lifecycle` (vs archived `docs.append-only-guard`) |
| Q6b | active-vs-superseded (deferred, unscored) | `startup.parallel-orientation` (pending Task 8) vs `docs.queue-state-gitea-tracker` |
| Q6b | active-vs-superseded | `startup.parallel-orientation` (vs archived `docs.queue-state-gitea-tracker`) |
| Q7 | retired-feature (no `retired` record exists; superseded used as analog) | `docs.decision-lifecycle` / `ci.root-screenshot-guard` (must stay active) |
| Q8 | rationale/rejected-alternative | `locking.entitylocker-atomic-flags` |
| Q9 | rationale/rejected-alternative | `ci.build-once-rejected` |
@@ -179,4 +189,4 @@ I design a new date-conditional scheduling feature?"
| Q11 | convention-already-implemented | `sched.clock-padding-existing` |
| Q12 | convention-already-implemented (negative control) | `sched.weighted-shuffle` |
12 scored questions (Q1Q12, excluding the deferred Q6b placeholder) across all six required classes.
13 scored questions (Q1Q12 plus Q6b) across all six required classes.
+12 -1
View File
@@ -98,7 +98,18 @@ def parse_file(path: Path) -> list[Record]:
return parse_text(path.read_text(encoding="utf-8"), path)
_NON_DECISION_FILES = {"README.md", "migration-map.md"}
def metadata_line_count(rec: Record) -> int:
"""Count how many lines in `rec.body` look like a metadata line (a line starting with
`` `key: `` after stripping). A well-formed record has exactly 1; more indicates a
duplicate/stacked metadata block left behind by a botched migration."""
count = 0
for line in rec.body.splitlines():
if line.strip().startswith("`key:"):
count += 1
return count
_NON_DECISION_FILES = {"README.md", "migration-map.md", "retrieval-eval.md"}
def active_files() -> list[Path]:
+3
View File
@@ -61,6 +61,9 @@ def validate(
errs.append(
f"{r.heading}: status {r.status} but still in the active set — relocate to docs/decisions/archive/"
)
n_meta = dl.metadata_line_count(r)
if n_meta > 1:
errs.append(f"{r.heading}: {n_meta} metadata blocks found (expected 1) — duplicate metadata block")
for r in archive_records:
if r.status == "active":
+29
View File
@@ -137,6 +137,35 @@ def test_demoted_heading_fails():
)
def test_duplicate_metadata_block_fails():
# A migration bug left records with two stacked metadata blocks; the parser only reads the
# first, so only an explicit body scan (metadata_line_count) can catch the leftover second block.
body = (
"`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n"
"**Rule:** first block rule.\n"
"\n"
"`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n"
"**Rule:** second block rule.\n"
"\n"
"Rationale prose goes here."
)
rec = _rec(key="a.b", body=body)
assert dl.metadata_line_count(rec) == 2
assert any("duplicate metadata block" in e and "2 metadata blocks" in e for e in _v([rec]))
def test_single_metadata_block_passes():
body = (
"`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n"
"**Rule:** the only rule.\n"
"\n"
"Rationale prose goes here."
)
rec = _rec(key="a.b", body=body)
assert dl.metadata_line_count(rec) == 1
assert not any("duplicate metadata block" in e for e in _v([rec]))
def test_contents_heading_is_skipped():
# docs/decisions/*.md topic files use "## Contents" as their index heading (the analog of
# decisions.md's "## Index") — it carries no metadata and must not be miscounted as a