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:
+13
-43
@@ -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:
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 (Q1–Q12, excluding the deferred Q6b placeholder) across all six required classes.
|
||||
13 scored questions (Q1–Q12 plus Q6b) across all six required classes.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user