docs(specs): design-principles audit — five remediation specs
Audit of the principles in CLAUDE.md and docs/architecture/ against the actual code. Principles with a working automated check (poison-tolerant locking, Android source sync, one-directional playback state, graceful backend init, reachability-from-traffic) all held up. The two that drifted are exactly the two whose checks were broken or too narrow: - traceability-gate-repair: CI divided by hardcoded denominators (UR/39, IR/24, DR/48, JA/3, total 114) while requirements.md had grown to 211, reporting 158% coverage — the 50% threshold was unreachable and the job could not fail. - req-coverage-script-removal: check-req-coverage.sh reports "1 requirement" and prints "all requirements have implementations". - scoped-search-boundary-implementation: the founding boundary incident was specced but never built; the leak is still live. - boundary-tripwire-hardening: check:boundary passes on that same leak — the pattern is anchored to the query site, so a named const evades it. - player-facade-enforcement: 52 direct commands.player* call sites outside the facade, and no automated check at all. Each spec follows SPEC-TEMPLATE.md with a filled-in Layer assignment table and is checked against SPEC-REVIEW-CHECKLIST.md.
This commit is contained in:
@@ -0,0 +1,215 @@
|
|||||||
|
# Spec: Harden the frontend boundary tripwire
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** DR-094
|
||||||
|
**UX spec:** n/a — developer tooling.
|
||||||
|
**Supersedes / revises:** revises the detection rule in
|
||||||
|
[scripts/check-frontend-boundary.sh](../../scripts/check-frontend-boundary.sh);
|
||||||
|
the boundary *policy* in [scoped-search-boundary.md](scoped-search-boundary.md)
|
||||||
|
is unchanged.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`bun run check:boundary` passes on a tree that contains the exact leak it was
|
||||||
|
built to catch. It matches a multi-type array only when written **inline at the
|
||||||
|
query site**, so assigning the same array to a named const evades it entirely —
|
||||||
|
which is how [searchScope.ts](../../src/lib/utils/searchScope.ts) has kept a
|
||||||
|
category→item-type mapping through every green CI run. This spec broadens the
|
||||||
|
match to item-type array literals anywhere in `src/`, and resolves the handful
|
||||||
|
of legitimate hits that broadening surfaces.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The current pattern is anchored to the `includeItemTypes:` key:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||||
|
```
|
||||||
|
|
||||||
|
The live leak is not written that way:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/utils/searchScope.ts:29 — invisible to the tripwire
|
||||||
|
const SCOPE_ITEM_TYPES = { music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"], … };
|
||||||
|
```
|
||||||
|
|
||||||
|
The taxonomy and the query are one indirection apart, and the grep only sees the
|
||||||
|
query. The script's own header is admirably honest that it is "a TRIPWIRE, NOT A
|
||||||
|
PROOF" — but the gap here is not a subtle judgment call it was designed to
|
||||||
|
defer to human review. It is the *crudest form* of the violation, one `const`
|
||||||
|
away from the shape it does match, in the very file the founding incident was
|
||||||
|
written about.
|
||||||
|
|
||||||
|
Broadening the pattern to any item-type array literal finds it, with a
|
||||||
|
manageable number of other hits (measured, not estimated):
|
||||||
|
|
||||||
|
| Site | Verdict |
|
||||||
|
|------|---------|
|
||||||
|
| `searchScope.ts:30,32` | 🔴 The leak. Removed by [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md). |
|
||||||
|
| `PersonDetailView.svelte:30` | Already allowlisted, with a recorded reason. |
|
||||||
|
| `DownloadedBrowse.svelte:95` | Borderline — `["MusicAlbum","Series","Season","BoxSet"].includes(item.type)` as an "is this a container?" predicate. |
|
||||||
|
| `GenericMediaListPage.svelte:298` | Borderline — `["MusicAlbum","MusicArtist","Audio","Playlist"].includes(config.itemType)` as a music-styling predicate. |
|
||||||
|
| 6 hits in `*.test.ts` | Excluded; tests legitimately name types. |
|
||||||
|
|
||||||
|
Four non-test sites total. This is a tractable change, not a boil-the-ocean one.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
Tooling only — no application logic, nothing crosses IPC. The two borderline
|
||||||
|
*application* sites do get a layer decision, below.
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Detecting item-type array literals in `src/` | Build tooling (`scripts/`) | Static analysis of repo source; belongs beside the existing check. |
|
||||||
|
| "Is this item a container?" (`DownloadedBrowse`) | **Rust** (recommended) | Containers-vs-leaves is Jellyfin structure, and the set grows when Jellyfin adds a container type — the litmus test's "yes". Prefer a `MediaItem.isContainer` boolean from the backend over a type-set predicate in a component. |
|
||||||
|
| "Is this music content?" (`GenericMediaListPage`) | **Frontend, allowlisted** | Selects a grid *style*. It reads `config.itemType`, a value the page already declares about itself, and changes only if the UI is redesigned — the litmus test's "no". Single-type presentation is explicitly not the target of the rule. |
|
||||||
|
|
||||||
|
`DownloadedBrowse` defaults to Rust per the checklist's borderline rule; see
|
||||||
|
Out of scope for why the migration itself is deferred rather than bundled.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Broaden the pattern
|
||||||
|
|
||||||
|
Replace the key-anchored pattern with one matching an array literal of two or
|
||||||
|
more known Jellyfin item types, wherever it appears:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Two or more adjacent item-type string literals inside a bracket.
|
||||||
|
TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
|
||||||
|
PATTERN="\[[[:space:]]*\"($TYPES)\"[[:space:]]*,[[:space:]]*\"($TYPES)\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Properties worth stating, because each is a deliberate trade:
|
||||||
|
|
||||||
|
- **Not anchored to any key**, so a named const, a function return, a `Record`
|
||||||
|
value, or an inline query all match equally.
|
||||||
|
- **Requires two adjacent type literals**, preserving the existing and correct
|
||||||
|
carve-out that single-type presentation (`itemType: "Movie"`) is legitimate.
|
||||||
|
- **Requires string literals**, so `item.type === "Audio"` (display inspection)
|
||||||
|
still does not match.
|
||||||
|
- **Explicit type list**, not `[A-Z][a-z]+`, so arbitrary string arrays
|
||||||
|
(`["High","Low"]`, `["Songs","Albums"]`) do not produce noise.
|
||||||
|
|
||||||
|
Keep `grep -rInE`, the `*.test.*` exclusion, and the allowlist mechanism as they
|
||||||
|
are — all three work.
|
||||||
|
|
||||||
|
### 2. Resolve the surfaced sites
|
||||||
|
|
||||||
|
- `PersonDetailView.svelte` — already allowlisted; entry unchanged.
|
||||||
|
- `GenericMediaListPage.svelte` — **add to the allowlist** with the reason from
|
||||||
|
the layer table (grid styling over a self-declared `itemType`).
|
||||||
|
- `DownloadedBrowse.svelte` — **add to the allowlist with a `TODO` naming the
|
||||||
|
preferred fix** (backend `isContainer`). An allowlist entry that records a
|
||||||
|
known-borderline decision is honest; silently broadening the pattern to miss
|
||||||
|
it would not be.
|
||||||
|
- `searchScope.ts` — **not allowlisted.** It is the leak, and
|
||||||
|
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
|
||||||
|
deletes it.
|
||||||
|
|
||||||
|
### 3. 🔴 Sequencing
|
||||||
|
|
||||||
|
**This spec must land after Stage 1 of
|
||||||
|
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).**
|
||||||
|
Hardening the tripwire first turns `master` red on a violation with no fix
|
||||||
|
available, and the only ways out are reverting the hardening or allowlisting the
|
||||||
|
leak — the second of which is exactly how a boundary rule dies.
|
||||||
|
|
||||||
|
### 4. Keep the allowlist honest
|
||||||
|
|
||||||
|
The script already warns that a growing allowlist means the boundary is eroding.
|
||||||
|
This change takes it from 1 entry to 3, which is close to that line. Add a hard
|
||||||
|
cap so drift is caught mechanically rather than by whoever notices:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MAX_ALLOWLIST=4
|
||||||
|
if [ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]; then
|
||||||
|
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
|
||||||
|
echo " Push taxonomy into Rust instead of appending here."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
The cap is deliberately just above the current count: the next exception forces
|
||||||
|
a conversation instead of a one-line append.
|
||||||
|
|
||||||
|
### 5. Restate the limits
|
||||||
|
|
||||||
|
The header's "tripwire, not a proof" caveat stays and gets sharper. The broadened
|
||||||
|
pattern still cannot see:
|
||||||
|
|
||||||
|
- a type set built at run time (`[...musicTypes, "Playlist"]`),
|
||||||
|
- types split across variables (`const A = "Audio"; [A, B]`),
|
||||||
|
- taxonomy expressed as a `switch` or chained `||` rather than an array.
|
||||||
|
|
||||||
|
The spec-review checklist remains the real gate. This raises the floor; it does
|
||||||
|
not close the class.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Migrating `DownloadedBrowse` to a backend `isContainer` flag.** It touches
|
||||||
|
`MediaItem`, `bindings.ts`, and the offline path — its own spec. Allowlisted
|
||||||
|
with a TODO here so it is recorded, not forgotten.
|
||||||
|
- The scoped-search fix itself — [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).
|
||||||
|
- Detecting the run-time-construction cases listed above.
|
||||||
|
- Extending the check to Rust or Kotlin (the rule is about `src/`).
|
||||||
|
- Changing the boundary *policy* in CLAUDE.md.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] With `searchScope.ts` reverted to its leaking form, `bun run check:boundary`
|
||||||
|
**fails** and names `src/lib/utils/searchScope.ts`. This is the criterion
|
||||||
|
that proves the fix — verify it explicitly before landing.
|
||||||
|
- [ ] On the post-fix tree, `bun run check:boundary` passes.
|
||||||
|
- [ ] A newly introduced `const X = ["Movie", "Series"]` in any non-test `src/`
|
||||||
|
file fails the check (regression test for the const-indirection evasion).
|
||||||
|
- [ ] `itemType: "Movie"` and `item.type === "Audio"` do **not** trip the check.
|
||||||
|
- [ ] `["High", "Low"]` and other non-item-type arrays do **not** trip it.
|
||||||
|
- [ ] Test files are still excluded (the 6 known test hits stay silent).
|
||||||
|
- [ ] The allowlist has exactly 3 entries, each with a written reason; a 5th
|
||||||
|
entry fails the check via `MAX_ALLOWLIST`.
|
||||||
|
- [ ] The script header still states it is a tripwire, not a proof, and names the
|
||||||
|
evasions it cannot see.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `bun run test:all` passes.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The script is bash and has no test harness. Verify by construction — each is a
|
||||||
|
temporary edit, run, revert:
|
||||||
|
|
||||||
|
1. Reintroduce the `SCOPE_ITEM_TYPES` const → **must fail**.
|
||||||
|
2. Add `const T = ["Movie","Series"]` to a scratch `.svelte` file → **must fail**.
|
||||||
|
3. Add the same to a `.test.ts` file → **must pass** (exclusion holds).
|
||||||
|
4. Add `itemType: "Movie"` → **must pass**.
|
||||||
|
5. Add a 5th allowlist entry → **must fail** on the cap.
|
||||||
|
|
||||||
|
Record the five results in the PR description. A grep-based gate that has never
|
||||||
|
been observed failing is indistinguishable from one that cannot fail — which is
|
||||||
|
the precise condition this whole spec exists to correct.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
Allocate in `requirements.md`:
|
||||||
|
|
||||||
|
- **DR-094** — "Frontend boundary tripwire detects Jellyfin item-type array
|
||||||
|
literals anywhere in `src/` (not only inline at an `includeItemTypes:` query
|
||||||
|
site), so a category→type mapping cannot evade the check via a named const;
|
||||||
|
allowlist is capped to force taxonomy into Rust rather than accumulating
|
||||||
|
exceptions." Category: Tooling. Status: Done on merge.
|
||||||
|
|
||||||
|
Shell scripts carry no `TRACES:` comment convention in this repo; reference
|
||||||
|
DR-094 in the script header comment instead.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before
|
||||||
|
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||||
|
- **Land after Stage 1 of the scoped-search fix** — see §3. This is the one
|
||||||
|
ordering constraint that will break `master` if ignored.
|
||||||
|
- Test the regex against the current tree *before* committing:
|
||||||
|
`grep -rInE "$PATTERN" src/ | grep -v '\.test\.'` should return exactly the
|
||||||
|
four sites in the Motivation table.
|
||||||
|
- The `TYPES` list will need occasional extension as Jellyfin adds types.
|
||||||
|
That is acceptable for a tripwire — an unlisted type produces a false
|
||||||
|
negative, never a false positive, so the check degrades safely.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Spec: Enforce the unified player boundary
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Requirements:** DR-095 (new); relates to UR-005 and the unified-player-boundary
|
||||||
|
principle in CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md)
|
||||||
|
**UX spec:** n/a — refactor, no user-visible change.
|
||||||
|
**Supersedes / revises:** n/a
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The stated principle is that UI controls playback **only** through
|
||||||
|
`playerController` ([src/lib/player/index.ts](../../src/lib/player/index.ts)),
|
||||||
|
never by calling `commands.player*` directly. There are **52 direct call sites
|
||||||
|
outside** that facade. This spec routes the genuine playback-control calls
|
||||||
|
through the facade, narrows the principle's wording so it stops forbidding
|
||||||
|
things it never meant to forbid, and adds the lint rule that keeps it true —
|
||||||
|
because this rule is the one design principle in the audit with **no automated
|
||||||
|
check at all**, and it is also the one that drifted furthest.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Direct `commands.player*` usage outside `src/lib/player/`, by file:
|
||||||
|
|
||||||
|
| File | Sites |
|
||||||
|
|---|---|
|
||||||
|
| [queue.ts](../../src/lib/stores/queue.ts) | 10 |
|
||||||
|
| [player/[id]/+page.svelte](../../src/routes/player/[id]/+page.svelte) | 9 |
|
||||||
|
| [VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte) | 8 |
|
||||||
|
| [settings/+page.svelte](../../src/routes/settings/+page.svelte) | 5 |
|
||||||
|
| [sleepTimer.ts](../../src/lib/stores/sleepTimer.ts) / [auth.ts](../../src/lib/stores/auth.ts) / [autoplay.ts](../../src/lib/api/autoplay.ts) | 4 each |
|
||||||
|
| [preload.ts](../../src/lib/services/preload.ts) | 3 |
|
||||||
|
| [library/[id]](../../src/routes/library/[id]/+page.svelte), [playerEvents.ts](../../src/lib/services/playerEvents.ts), [playbackMode.ts](../../src/lib/stores/playbackMode.ts) | 1–2 each |
|
||||||
|
|
||||||
|
These are **not** equivalent violations, and treating them as one number is why
|
||||||
|
the rule has been easy to ignore. Three distinct groups:
|
||||||
|
|
||||||
|
**(a) Genuine violations — playback control with a facade method that already
|
||||||
|
exists.** `playerStop` ×6, `playerPlayTracks` ×4, `playerSeek` ×2,
|
||||||
|
`playerPlayAlbumTrack` ×2, `playerNext`, `playerPrevious`, `playerSkipTo`,
|
||||||
|
`playerToggleShuffle`, `playerCycleRepeat`, `playerRemoveFromQueue`,
|
||||||
|
`playerMoveInQueue`, `playerAddTrackById`, `playerAddTracksByIds`,
|
||||||
|
`playerSetSubtitleTrack`, `playerPlayItem`. The facade exposes `stop()`,
|
||||||
|
`seek()`, `next()`, `previous()`, `skipTo()`, `toggleShuffle()`,
|
||||||
|
`cycleRepeat()`, `removeFromQueue()`, `moveInQueue()`, `addTrackById()`,
|
||||||
|
`addTracksByIds()`, `setSubtitleTrack()`, `playTracks()`, `playAlbumTrack()`,
|
||||||
|
`playItem()` — every one of these has a facade equivalent that is simply not
|
||||||
|
being called. `queue.ts` is the starkest case: it imports `commands` directly
|
||||||
|
and re-implements ten methods the facade already provides.
|
||||||
|
|
||||||
|
**(b) Playback control with no facade method.** `playerPlayQueue`,
|
||||||
|
`playerGetQueue`, `playerGetStatus`, `playerEnterBackgroundAudio`,
|
||||||
|
`playerExitBackgroundAudio`, `playerSetSleepTimer`, `playerCancelSleepTimer`,
|
||||||
|
`playerPlayNextEpisode`, `playerCancelAutoplayCountdown`. In scope for the
|
||||||
|
principle, but currently *impossible* to comply with — the facade has no surface
|
||||||
|
for them. A rule that cannot be followed is not being broken so much as it is
|
||||||
|
unfinished.
|
||||||
|
|
||||||
|
**(c) Not playback control.** `playerConfigureJellyfin` ×3,
|
||||||
|
`playerDisableJellyfin`, `playerGet/SetAudioSettings`,
|
||||||
|
`playerGet/SetVideoSettings`, `playerGetEqPresets`,
|
||||||
|
`playerGet/SetAutoplaySettings`, `playerGet/SetCacheConfig`,
|
||||||
|
`playerPreloadUpcoming`. These are configuration and lifecycle calls that happen
|
||||||
|
to live under the `player_` command prefix. The principle is about *who is
|
||||||
|
authoritative for playback state* — settings CRUD isn't that.
|
||||||
|
|
||||||
|
The audit's read: the rule as written is violated 52 times, which makes real
|
||||||
|
drift indistinguishable from acceptable usage, and that ambiguity is what lets
|
||||||
|
group (a) persist. Note also that the principle **is** well-honoured where it
|
||||||
|
matters most — the read side is clean, with UI reading state exclusively from
|
||||||
|
the facade's re-exported stores. The write side is what drifted.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
Frontend-internal refactor. No domain logic moves and nothing new crosses IPC —
|
||||||
|
the same Rust commands are called, through one module instead of many.
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Playback command dispatch (adapter routing: native vs HTML5) | Frontend — `src/lib/player/` **only** | Presentation-layer plumbing, but must be centralised: the facade picks between the native backend and the HTML5 `<video>` adapter. A caller bypassing it silently skips that routing. |
|
||||||
|
| Playback *authority* (position, pause, rate, track changes) | **Rust / the player** | Unchanged. The player is authoritative; UI is a consumer. This spec does not touch that direction. |
|
||||||
|
| Queue mutation commands | Frontend facade → Rust | Rust owns queue state; the facade is the single call path to it. |
|
||||||
|
| Player settings CRUD (EQ, video, autoplay, cache) | Frontend, **outside** the facade | Configuration, not playback control — read/written on a settings page with no adapter routing. Explicitly carved out below. |
|
||||||
|
| Backend→frontend event handling | `playerEvents.ts` | Already correct. It is the facade's own plumbing, not a bypassing consumer. |
|
||||||
|
|
||||||
|
No Jellyfin taxonomy is involved, so no boundary-leak risk.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Narrow the principle to what it actually means
|
||||||
|
|
||||||
|
Amend CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md):
|
||||||
|
|
||||||
|
> **Unified player boundary.** UI controls **playback** — transport, queue
|
||||||
|
> mutation, track selection, playback initiation — *only* through
|
||||||
|
> `playerController`. Player **configuration** commands (`player_*_settings`,
|
||||||
|
> `player_configure_jellyfin`, `player_*_cache_config`, `player_preload_upcoming`)
|
||||||
|
> are ordinary IPC and may be called directly from settings surfaces.
|
||||||
|
|
||||||
|
This is a clarification, not a relaxation: it makes group (c) explicitly fine so
|
||||||
|
that a violation count means something. A rule with 52 nominal violations, most
|
||||||
|
of them acceptable, provides no signal.
|
||||||
|
|
||||||
|
### 2. Fill the facade gaps (group b)
|
||||||
|
|
||||||
|
Add to `playerController`, each a thin pass-through preserving current
|
||||||
|
behaviour:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
playQueue, getQueue, getStatus,
|
||||||
|
enterBackgroundAudio, exitBackgroundAudio,
|
||||||
|
setSleepTimer, cancelSleepTimer,
|
||||||
|
playNextEpisode, cancelAutoplayCountdown,
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this **first** — group (a) cannot be fully migrated while callers still need
|
||||||
|
a direct import for a neighbouring call, and a file that imports `commands` for
|
||||||
|
one reason will keep using it for others.
|
||||||
|
|
||||||
|
### 3. Migrate group (a)
|
||||||
|
|
||||||
|
Mechanical: replace `commands.playerX(...)` with `playerController.x(...)`.
|
||||||
|
Highest-value first: `queue.ts` (10 sites, all direct facade equivalents), then
|
||||||
|
`player/[id]/+page.svelte`, `VideoPlayer.svelte`, `sleepTimer.ts`,
|
||||||
|
`playbackMode.ts`, `library/[id]/+page.svelte`.
|
||||||
|
|
||||||
|
Two sites need care rather than substitution:
|
||||||
|
|
||||||
|
- **`playerEvents.ts`** (`playerOnPlaybackEnded`, `playerStop` in the error
|
||||||
|
path). This module *is* the facade's event plumbing — the counterpart to
|
||||||
|
`index.ts`, inside the boundary conceptually though not by directory. Treat
|
||||||
|
`src/lib/services/playerEvents.ts` as **inside** the boundary and exempt it,
|
||||||
|
rather than making it call the facade that calls back into it. Record this in
|
||||||
|
the lint config with the reason.
|
||||||
|
- **`VideoPlayer.svelte`** — registers its own adapter via `setActiveAdapter`.
|
||||||
|
Its `playerStop`/`playerPlayItem` calls interact with adapter lifecycle, and
|
||||||
|
CLAUDE.md's gotcha ("no lifecycle calls after an `await` in `onMount`") applies.
|
||||||
|
Migrate this file **last and on its own**, so an Android seek regression is
|
||||||
|
bisectable to one commit.
|
||||||
|
|
||||||
|
### 4. Add the lint rule (the part that makes it stick)
|
||||||
|
|
||||||
|
The audit's finding was that principles with working checks held up and
|
||||||
|
principles without them drifted. This principle has no check. Add
|
||||||
|
`scripts/check-player-boundary.sh`, wired as `bun run check:player-boundary` and
|
||||||
|
into `test-all.sh`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Playback-control commands that MUST go through the facade.
|
||||||
|
CONTROL='player(Play|Pause|Toggle|Stop|Seek|Next|Previous|SkipTo|ToggleShuffle|CycleRepeat|RemoveFromQueue|MoveInQueue|SetVolume|ToggleMute|SetSubtitleTrack|SeekVideo|SwitchAudioTrack|PlayTracks|PlayAlbumTrack|PlayItem|PlayQueue|AddTrackById|AddTracksByIds|GetQueue|GetStatus|EnterBackgroundAudio|ExitBackgroundAudio|SetSleepTimer|CancelSleepTimer|PlayNextEpisode|CancelAutoplayCountdown|OnPlaybackEnded)'
|
||||||
|
|
||||||
|
# Inside the boundary: the facade and its event plumbing.
|
||||||
|
EXEMPT='^src/lib/player/|^src/lib/services/playerEvents\.ts$'
|
||||||
|
```
|
||||||
|
|
||||||
|
Flag `commands.$CONTROL` in non-test `src/` files outside `EXEMPT`. Config
|
||||||
|
commands are deliberately absent from the list, matching §1 — so the check
|
||||||
|
encodes the narrowed rule rather than the aspirational one.
|
||||||
|
|
||||||
|
An ESLint `no-restricted-syntax` rule would give better editor feedback, but the
|
||||||
|
project has no ESLint config; a shell check matches the existing
|
||||||
|
`check:boundary` precedent and adds no dependency.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Changing playback *behaviour* — pure refactor.
|
||||||
|
- The one-directional state principle (audited clean; UI reads from facade
|
||||||
|
stores only).
|
||||||
|
- Moving settings CRUD behind the facade (§1 explicitly carves it out).
|
||||||
|
- Introducing ESLint.
|
||||||
|
- Refactoring `VideoPlayer.svelte`'s 2079 lines generally, beyond its facade
|
||||||
|
call sites.
|
||||||
|
- The `commands.player*` calls **inside** `src/lib/player/` — that is the
|
||||||
|
facade doing its job.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `playerController` exposes the group-(b) methods listed in §2.
|
||||||
|
- [ ] `grep -rn "commands\.player" src/ --include='*.ts' --include='*.svelte' | grep -v '^src/lib/player/' | grep -v 'playerEvents\.ts' | grep -v '\.test\.' | grep -v bindings.ts`
|
||||||
|
returns **only** configuration commands per §1 — no transport, queue, or
|
||||||
|
playback-initiation call.
|
||||||
|
- [ ] `queue.ts` no longer imports `commands` from bindings.
|
||||||
|
- [ ] `bun run check:player-boundary` exists, is wired into `test-all.sh`, and
|
||||||
|
passes.
|
||||||
|
- [ ] The check **fails** when a `commands.playerStop()` is added to a non-exempt
|
||||||
|
file — verify explicitly, as with the other gates in this batch.
|
||||||
|
- [ ] The check does **not** fail on `commands.playerSetAudioSettings()` in
|
||||||
|
`settings/+page.svelte` (the §1 carve-out works).
|
||||||
|
- [ ] CLAUDE.md and `02-svelte-frontend.md` carry the narrowed wording, including
|
||||||
|
the config carve-out and the `playerEvents.ts` exemption with its reason.
|
||||||
|
- [ ] **No behavioural change**: audio and video playback, queue reorder,
|
||||||
|
shuffle/repeat, sleep timer, background audio, and autoplay all behave as
|
||||||
|
before on **both Linux and Android**.
|
||||||
|
- [ ] Android seek and `onMount` lifecycle still correct after the
|
||||||
|
`VideoPlayer.svelte` migration (the known-fragile path).
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `bun run check:boundary` passes.
|
||||||
|
- [ ] Changed code carries `// TRACES:` comments.
|
||||||
|
- [ ] No Rust change, so no `bindings.ts` regeneration.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Frontend** (`bun run test`):
|
||||||
|
- Extend the existing facade tests to cover each new group-(b) method: it
|
||||||
|
forwards to the right command with the right arguments, and routes to the
|
||||||
|
active adapter where applicable.
|
||||||
|
- `queue.ts` tests: assert calls land on `playerController`, not `commands`. Mock
|
||||||
|
the facade — a test that mocks `commands` would pass either way and guard
|
||||||
|
nothing.
|
||||||
|
- Keep `tauriIntegration.test.ts` and the other IPC param-naming tests green;
|
||||||
|
they cover the camelCase rule this refactor must not disturb.
|
||||||
|
|
||||||
|
**Manual** (no automated coverage for these paths):
|
||||||
|
- Linux: play/pause/seek/next/prev, queue reorder, shuffle, repeat, sleep timer,
|
||||||
|
transcoded video (HLS), background audio enter/exit.
|
||||||
|
- Android: the same, plus lockscreen/MediaSession controls, and **seek after
|
||||||
|
entering the player** — the specific regression CLAUDE.md warns about.
|
||||||
|
|
||||||
|
Because this is a pure refactor, the strongest signal is that no test *changes
|
||||||
|
expectation*. A test needing its assertions rewritten means behaviour moved —
|
||||||
|
investigate rather than update it.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
Allocate in `requirements.md`:
|
||||||
|
|
||||||
|
- **DR-095** — "UI playback control is routed exclusively through the
|
||||||
|
`playerController` facade (`src/lib/player/`), with `playerEvents.ts` inside
|
||||||
|
the boundary as its event plumbing and player *configuration* commands
|
||||||
|
explicitly outside it; enforced by `scripts/check-player-boundary.sh`."
|
||||||
|
Category: Player. Traces to UR-005. Status: Done on merge.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/player/index.ts
|
||||||
|
// TRACES: UR-005 | DR-095
|
||||||
|
```
|
||||||
|
|
||||||
|
New facade tests take `@req-test: UT-089` onward (next free UT is **UT-089**;
|
||||||
|
coordinate if landing alongside the sibling specs, which draw from the same
|
||||||
|
pool).
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before
|
||||||
|
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||||
|
- **Order matters**: §2 (fill gaps) → §3 (migrate, `VideoPlayer.svelte` last and
|
||||||
|
alone) → §4 (add the check). Adding the check first turns `master` red.
|
||||||
|
- 🔴 **`VideoPlayer.svelte`**: no lifecycle calls after an `await` in `onMount` —
|
||||||
|
it flips to HTML5 mode and breaks Android seek. Do not let a mechanical
|
||||||
|
substitution introduce an `await` before a lifecycle call.
|
||||||
|
- The facade's `requireHandle()` may throw where a raw `commands` call did not.
|
||||||
|
Check each migrated call site's error handling rather than assuming the
|
||||||
|
try/catch still covers the same cases.
|
||||||
|
- `playbackMode.ts` interacts with remote-mode routing (`play_on_session` vs
|
||||||
|
local MPV). Verify remote casting still works after migrating its
|
||||||
|
`playerPlayTracks` call.
|
||||||
|
- This spec is deliberately the *lowest* priority of the audit batch: it is the
|
||||||
|
largest diff and the only one carrying real regression risk, while the
|
||||||
|
traceability gate is a few lines and restores a dead safety net.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Spec: Remove the broken `check-req-coverage.sh`
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** supports DR-093 (see [traceability-gate-repair.md](traceability-gate-repair.md))
|
||||||
|
**UX spec:** n/a — developer tooling.
|
||||||
|
**Supersedes / revises:** n/a
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`scripts/check-req-coverage.sh` is broken, orphaned, and actively misleading: it
|
||||||
|
reports `Total Requirements: 1`, zeros in every category, and then prints
|
||||||
|
**"✨ All requirements have implementations!"**. Nothing references it — not CI,
|
||||||
|
not `package.json`, not the docs. This spec deletes it, with a narrowly-scoped
|
||||||
|
alternative (repair it) documented and rejected below.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Running it today produces:
|
||||||
|
|
||||||
|
```
|
||||||
|
Category Breakdown:
|
||||||
|
UR: 0 requirements
|
||||||
|
IR: 0 requirements
|
||||||
|
DR: 0 requirements
|
||||||
|
JA: 0 requirements
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
Total Requirements: 1
|
||||||
|
✅ Fully Implemented: 0 (0%)
|
||||||
|
|
||||||
|
✨ All requirements have implementations!
|
||||||
|
```
|
||||||
|
|
||||||
|
Every number is wrong (the real totals are UR 61, IR 29, DR 89, JA 32), and the
|
||||||
|
concluding message is the *opposite* of a warning — a developer running this to
|
||||||
|
sanity-check coverage is told everything is fine.
|
||||||
|
|
||||||
|
This is worse than having no script. It is a trap, and it sits in `scripts/`
|
||||||
|
next to tools that do work, with nothing marking it as dead.
|
||||||
|
|
||||||
|
Verification that it is genuinely orphaned:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ grep -rn "check-req-coverage" . --include='*.yml' --include='*.json' \
|
||||||
|
--include='*.sh' --include='*.md' | grep -v node_modules
|
||||||
|
(no output)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
Developer tooling only; no application logic and nothing crosses the IPC
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Requirement-coverage reporting | Build tooling — `extract-traces.ts` | One tool should own coverage analysis. A second, divergent implementation is how the two answers ("1 requirement" vs "211") came to disagree unnoticed. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
**Delete `scripts/check-req-coverage.sh`.**
|
||||||
|
|
||||||
|
Coverage reporting is owned by [scripts/extract-traces.ts](../../scripts/extract-traces.ts),
|
||||||
|
which is correct, is what CI runs, and gains a first-class local coverage mode
|
||||||
|
in [traceability-gate-repair.md](traceability-gate-repair.md):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run traces:coverage # the supported way to check coverage locally
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check the sibling scripts for the same rot. `scripts/` also contains
|
||||||
|
`check-test-coverage.sh` and `find-req-implementations.sh`, neither of which is
|
||||||
|
referenced from `package.json`. An unreferenced script is never run and so rots
|
||||||
|
silently — that is the actual failure mode being fixed here, and fixing only the
|
||||||
|
one instance found by audit leaves the others to be rediscovered later.
|
||||||
|
|
||||||
|
### Findings (investigation, 2026-07)
|
||||||
|
|
||||||
|
All three scripts turned out to share a **single root cause**, and all three are
|
||||||
|
deleted:
|
||||||
|
|
||||||
|
| Script | Defect |
|
||||||
|
|---|---|
|
||||||
|
| `check-req-coverage.sh` | Reads `README.md`, which has held **zero** requirement rows since they moved to `docs/requirements.md` → `total_reqs=1`, every category 0, "✨ All requirements have implementations!" Also greps `src-tauri/` unscoped. |
|
||||||
|
| `check-test-coverage.sh` | Greps `src-tauri/` unscoped — including **40 GB** of `target/` build artifacts. Hangs indefinitely; produces no output at all. |
|
||||||
|
| `find-req-implementations.sh` | Same unscoped `src-tauri/` grep. Same hang. |
|
||||||
|
|
||||||
|
So none of them were subtly wrong — two could never terminate, and the third
|
||||||
|
inverted its own conclusion.
|
||||||
|
|
||||||
|
They were nonetheless *salvageable*: scoping the greps to `src-tauri/src` and
|
||||||
|
repointing at `docs/requirements.md` would be a few lines, and the `@req:` /
|
||||||
|
`@req-test:` tags they read are still present in the tree (**146** and **76**
|
||||||
|
occurrences).
|
||||||
|
|
||||||
|
**Decision: delete all three anyway.** The tags are an undocumented parallel
|
||||||
|
convention — `@req:` appears in no doc, and CLAUDE.md describes only `TRACES:`.
|
||||||
|
Repairing the scripts would re-establish a second traceability system to keep in
|
||||||
|
sync with the first, which is the same two-sources-of-truth condition that let
|
||||||
|
"1 requirement" and "211 requirements" coexist unnoticed. `TRACES:` plus the
|
||||||
|
repaired coverage engine ([traceability-gate-repair.md](traceability-gate-repair.md))
|
||||||
|
already cover this ground.
|
||||||
|
|
||||||
|
The existing `@req:` / `@req-test:` comments are left in place: they are
|
||||||
|
harmless as prose, several encode genuinely useful test intent, and stripping
|
||||||
|
222 comments across the tree is a large diff with no functional gain. They are
|
||||||
|
simply no longer read by any tool.
|
||||||
|
|
||||||
|
### Alternative considered: repair rather than delete
|
||||||
|
|
||||||
|
Rejected. The script's output format duplicates what `traces:markdown` already
|
||||||
|
generates, it has no tests, no caller, and no documented purpose distinct from
|
||||||
|
`extract-traces.ts`. Repairing it recreates the two-sources-of-truth condition
|
||||||
|
that produced the contradiction. If a shell-based coverage check is ever wanted,
|
||||||
|
it should shell out to `traces:json` and `jq` rather than re-parse
|
||||||
|
`requirements.md` independently.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The CI workflow denominators — [traceability-gate-repair.md](traceability-gate-repair.md).
|
||||||
|
- Any change to `extract-traces.ts`'s output (that spec owns it).
|
||||||
|
- Auditing scripts that *are* referenced from `package.json` — they run
|
||||||
|
regularly and would fail visibly.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `scripts/check-req-coverage.sh` no longer exists.
|
||||||
|
- [ ] `grep -rn "check-req-coverage" .` (excluding `node_modules` and this spec)
|
||||||
|
returns nothing — no dangling reference in CI, docs, or `package.json`.
|
||||||
|
- [ ] `scripts/check-test-coverage.sh` and `find-req-implementations.sh` have each
|
||||||
|
been run and either wired into `package.json` or deleted; the decision and
|
||||||
|
reason are recorded in `scripts/README.md`. **Outcome: all three deleted —
|
||||||
|
see Findings.**
|
||||||
|
- [ ] `scripts/README.md` documents `bun run traces:coverage` as the supported
|
||||||
|
way to check requirement coverage locally.
|
||||||
|
- [ ] `bun run test:all` passes (confirms nothing invoked the deleted script).
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `bun run check:boundary` passes.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
No unit tests — this is a deletion. Verification is the grep in the acceptance
|
||||||
|
criteria plus a green `bun run test:all`, which exercises the script paths that
|
||||||
|
actually run.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
No new requirement. The deletion is covered by **DR-093**
|
||||||
|
([traceability-gate-repair.md](traceability-gate-repair.md)), which establishes
|
||||||
|
`extract-traces.ts` as the single owner of coverage reporting. Note the removal
|
||||||
|
in that DR's text when both land.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before
|
||||||
|
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||||
|
- Land this **after** or alongside [traceability-gate-repair.md](traceability-gate-repair.md),
|
||||||
|
so `bun run traces:coverage` exists before the broken script is removed and
|
||||||
|
developers are never left without a coverage command.
|
||||||
|
- Check `docs/traceability-ci.md` and `docs/traces-quick-ref.md` for prose
|
||||||
|
references to the deleted script; the grep above covers `.md`, but read the
|
||||||
|
surrounding sentence rather than deleting the line mechanically.
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Spec: Land the scoped-search boundary fix (implementation)
|
||||||
|
|
||||||
|
**Status:** Stage 1 Implemented — Stage 2 (result-side grouping) outstanding
|
||||||
|
**Requirements:** UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs)
|
||||||
|
**UX spec:** n/a — zero user-visible change is the point (see Acceptance criteria).
|
||||||
|
**Supersedes / revises:** implements [scoped-search-boundary.md](scoped-search-boundary.md),
|
||||||
|
which specified this fix but was never built. That spec remains the **design
|
||||||
|
authority**; this one is the delivery plan and status correction.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
[scoped-search-boundary.md](scoped-search-boundary.md) diagnosed a domain-taxonomy
|
||||||
|
leak, specified the fix in full detail, and became the justification for the
|
||||||
|
project's boundary rule in CLAUDE.md, the `check:boundary` tripwire, and the
|
||||||
|
spec-review checklist. **The fix was never implemented.** The leak it describes
|
||||||
|
is still live in `main`. This spec exists to close that gap and to correct the
|
||||||
|
record — the codebase currently enforces a rule against a violation it still
|
||||||
|
contains.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The mapping the rule forbids is present and in use:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/utils/searchScope.ts:29-32
|
||||||
|
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
|
||||||
|
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||||
|
movies: ["Movie"],
|
||||||
|
tv: ["Series", "Episode"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not dead code. [library.ts:262](../../src/lib/stores/library.ts#L262)
|
||||||
|
calls `scopeItemTypes(scope)` and puts the result straight into
|
||||||
|
`options.includeItemTypes`. Meanwhile there is **no `SearchScope` anywhere in
|
||||||
|
`src-tauri/`**:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ grep -rn "SearchScope" src-tauri/src --include='*.rs'
|
||||||
|
(no output)
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things make this the highest-value item found in the design-principles
|
||||||
|
audit:
|
||||||
|
|
||||||
|
1. **The rule's own founding incident is unremediated.** CLAUDE.md cites this
|
||||||
|
spec as "the incident this rule came from." A rule whose originating
|
||||||
|
violation is still shipping is not credible.
|
||||||
|
2. **The tripwire cannot see it.** `bun run check:boundary` passes — it greps for
|
||||||
|
a multi-type array literal *at the query site*, and this one is assigned to a
|
||||||
|
named const and dereferenced elsewhere. Broadening the tripwire is specified
|
||||||
|
separately in [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md);
|
||||||
|
note that hardening it **without** landing this fix would turn `master` red.
|
||||||
|
3. **The spec's own acceptance criterion fails today.** "Adding a hypothetical
|
||||||
|
new type to a scope requires editing only Rust" — adding a type to the Music
|
||||||
|
scope right now requires editing `searchScope.ts`.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
Unchanged from [scoped-search-boundary.md](scoped-search-boundary.md) §Design;
|
||||||
|
restated so this spec is reviewable on its own.
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Scope → Jellyfin item types (`music` → `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`) | **Rust** | Domain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed. |
|
||||||
|
| Result item → search group bucketing | **Rust** | Same taxonomy, result side. Classifying a `MediaItem` as a Song vs Album is Jellyfin vocabulary, not layout. |
|
||||||
|
| `All` sends no filter at all (≠ union of enumerated types) | **Rust** | A query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies. |
|
||||||
|
| Group display order, labels, reordering, persistence | Frontend | Pure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side. |
|
||||||
|
| `resolveSearchScope(pathname)` — route → initial scope | Frontend | Routing/navigation, no Jellyfin vocabulary. Stays exactly as-is. |
|
||||||
|
| Chip labels (`SCOPE_LABELS`), scope order (`SEARCH_SCOPES`) | Frontend | Display strings over an opaque enum. |
|
||||||
|
| `GROUP_SCOPE` (which group belongs to which scope) | **Delete** | Borderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to *gone*. |
|
||||||
|
|
||||||
|
The `SearchScope` and `SearchGroupId` **types** come to the frontend from
|
||||||
|
generated `bindings.ts`. Naming an opaque enum variant is not taxonomy; knowing
|
||||||
|
what item types it expands to is.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
**Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Design as
|
||||||
|
written** — `SearchScope` enum + `item_types()` in `repository/types.rs`,
|
||||||
|
`SearchOptions.scope`, `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
||||||
|
scope-wins precedence, `All` → `None` → no filter. It is not restated here;
|
||||||
|
duplicating it would create two drifting copies of the same design.
|
||||||
|
|
||||||
|
This spec adds only the delivery sequencing that the original left implicit.
|
||||||
|
|
||||||
|
### Staging: land it in two reviewable pieces
|
||||||
|
|
||||||
|
The original bundles the query side and the result side into one change. That is
|
||||||
|
a large diff touching Rust types, `bindings.ts`, the store, and a component, with
|
||||||
|
the `search-event` dual-payload hazard in the middle. Split it:
|
||||||
|
|
||||||
|
**Stage 1 — query side (closes the leak).**
|
||||||
|
`SearchScope` enum, `SearchOptions.scope`, command resolves scope →
|
||||||
|
`include_item_types` in Rust, `library.ts` sends `{ scope }`, delete
|
||||||
|
`SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.
|
||||||
|
|
||||||
|
After Stage 1 the actual boundary violation is gone and
|
||||||
|
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md) can land safely.
|
||||||
|
|
||||||
|
**Stage 2 — result side.** `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
||||||
|
Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk,
|
||||||
|
`GROUP_ITEM_TYPES`/`groupItemTypes()`/`GROUP_SCOPE` deleted.
|
||||||
|
|
||||||
|
Both stages are required for the original spec's acceptance criteria to pass;
|
||||||
|
Stage 1 alone leaves `GROUP_ITEM_TYPES` in the frontend. **Stage 1 is not a
|
||||||
|
stopping point** — it is a review boundary. Do not mark the parent spec
|
||||||
|
Implemented until Stage 2 lands.
|
||||||
|
|
||||||
|
### Stage 1 — delivered (July 2026)
|
||||||
|
|
||||||
|
- `SearchScope` enum + `item_types()` in [repository/types.rs](../../src-tauri/src/repository/types.rs);
|
||||||
|
`All` → `None` → no filter.
|
||||||
|
- `SearchOptions.scope` with `resolve_scope()`; scope wins over
|
||||||
|
`include_item_types`, which stays for the non-search `get_items` callers.
|
||||||
|
- `repository_search` resolves the scope **once, before** the cache/server split,
|
||||||
|
so both phases filter identically.
|
||||||
|
- `SCOPE_ITEM_TYPES` and `scopeItemTypes()` deleted; `searchScope.ts` now
|
||||||
|
re-exports `SearchScope` from the generated bindings instead of a hand-written
|
||||||
|
union.
|
||||||
|
- [library.ts](../../src/lib/stores/library.ts) sends `{ scope }`.
|
||||||
|
- 8 Rust tests (`search_scope_tests`); the frontend suite now asserts the
|
||||||
|
*opaque scope* is sent rather than an item-type list.
|
||||||
|
|
||||||
|
Verified: adding `"AudioBook"` to the Music scope changed **zero** files under
|
||||||
|
`src/` — the criterion that failed before this work.
|
||||||
|
|
||||||
|
**Stage 2 remains open**: `GROUP_ITEM_TYPES` / `groupItemTypes()` (result-side
|
||||||
|
bucketing, single-type-per-group) are still in `searchScope.ts`, and both search
|
||||||
|
payloads still carry a flat `MediaItem[]` rather than `GroupedSearchResult`.
|
||||||
|
|
||||||
|
### 🔴 The `search-event` dual payload (Stage 2)
|
||||||
|
|
||||||
|
The original flags this as "the single largest part of the change and the
|
||||||
|
easiest to half-do." Restating because it is the one thing that silently breaks:
|
||||||
|
search resolves **twice** — the command returns instant cache results, then the
|
||||||
|
merged cache+server union arrives via `search-event`. Both payloads must carry
|
||||||
|
`GroupedSearchResult`. Convert one and the UI flickers between shapes as server
|
||||||
|
results land.
|
||||||
|
|
||||||
|
Write the failing test for the *event* payload first — the command return is the
|
||||||
|
obvious half, the event is the half that gets forgotten.
|
||||||
|
|
||||||
|
### Note on `SearchOptions.scope` and specta
|
||||||
|
|
||||||
|
`SearchOptions` is already `#[serde(rename_all = "camelCase")]` with
|
||||||
|
`skip_serializing_if = "Option::is_none"`. Add `scope: Option<SearchScope>`
|
||||||
|
following that pattern so `All`/absent omits the key. Regenerate `bindings.ts`
|
||||||
|
— `SearchOptions` there is currently
|
||||||
|
`{ limit?, includeItemTypes?, searchTerm? }` and must gain `scope?`. Never
|
||||||
|
hand-edit it.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Redesigning anything in [scoped-search-boundary.md](scoped-search-boundary.md).
|
||||||
|
If implementation shows the design wrong, revise **that** spec, don't fork it.
|
||||||
|
- Online/offline `include_item_types` **filtering** — already correct; only the
|
||||||
|
source of the type list moves.
|
||||||
|
- Ranking within or across groups (DR-090 territory).
|
||||||
|
- Chip UX, scope persistence, group-order persistence — unchanged.
|
||||||
|
- The two lesser type-set sites in `DownloadedBrowse.svelte` and
|
||||||
|
`GenericMediaListPage.svelte`, handled in
|
||||||
|
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md).
|
||||||
|
- Broadening the tripwire itself — same sibling spec.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Inherits every criterion from [scoped-search-boundary.md](scoped-search-boundary.md)
|
||||||
|
§Acceptance criteria. Additionally:
|
||||||
|
|
||||||
|
- [ ] `grep -rn "SearchScope" src-tauri/src --include='*.rs'` returns matches —
|
||||||
|
the enum exists in Rust (it does not today).
|
||||||
|
- [ ] `grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts`
|
||||||
|
returns nothing.
|
||||||
|
- [ ] `grep -rn "scopeItemTypes" src/` returns nothing — including the
|
||||||
|
`library.ts` import and call site.
|
||||||
|
- [ ] `SearchOptions` in `bindings.ts` includes `scope`; regenerated, not
|
||||||
|
hand-edited.
|
||||||
|
- [ ] **Behaviour is byte-identical for the user**: same scoping, same groups,
|
||||||
|
same order, same empty-group omission, offline included. This spec is a
|
||||||
|
pure refactor — any visible change is a defect.
|
||||||
|
- [ ] `All` scope sends no `includeItemTypes` (asserted in a Rust test, not by
|
||||||
|
inspection).
|
||||||
|
- [ ] Adding a type to the Music scope requires editing **only** Rust —
|
||||||
|
demonstrate by making the edit and confirming no `src/` file changes.
|
||||||
|
- [ ] `scoped-search-boundary.md` status flips to **Implemented**, and
|
||||||
|
`scoped-search.md`'s "frontend only, no Rust changes" framing gets a
|
||||||
|
banner pointing at the corrected design.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||||
|
- [ ] `bun run check:boundary` passes.
|
||||||
|
- [ ] Changed code carries `// TRACES:` comments (IDs below).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Testing. Emphases:
|
||||||
|
|
||||||
|
**Rust** (`cargo test`):
|
||||||
|
- `SearchScope::item_types()` per scope; `All` → `None`.
|
||||||
|
- Scope resolution happens **before** the online/offline split, so both paths
|
||||||
|
get the same filter — a regression here is invisible until someone searches
|
||||||
|
offline.
|
||||||
|
- `scope` set + `include_item_types` set → scope wins (the documented
|
||||||
|
precedence; assert it rather than trusting the doc).
|
||||||
|
- Stage 2: mixed `Vec<MediaItem>` buckets correctly; unknown types dropped;
|
||||||
|
canonical group order; **the `search-event` payload is the grouped shape**.
|
||||||
|
|
||||||
|
**Frontend** (`bun run test`):
|
||||||
|
- `resolveSearchScope()` tests in `searchScope.test.ts` must pass **unchanged** —
|
||||||
|
they cover the part that is not moving, and are the regression net proving the
|
||||||
|
refactor didn't disturb routing.
|
||||||
|
- `library.ts` sends `{ scope }` and never `includeItemTypes` for search.
|
||||||
|
- `composeSearchGroups()` over fixture `SearchGroup[]` with no `.type`
|
||||||
|
inspection in the implementation.
|
||||||
|
|
||||||
|
**Offline parity:** run a scoped search with the server unreachable and confirm
|
||||||
|
identical grouping. The offline repository path honours `include_item_types`
|
||||||
|
independently, and this is the case most likely to be missed.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
No new requirement IDs — this implements existing ones. Retag as the code moves:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/repository/types.rs
|
||||||
|
/// TRACES: UR-049 | DR-063
|
||||||
|
pub enum SearchScope { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/utils/searchScope.ts — keep the file header; it retains
|
||||||
|
// resolveSearchScope + group-order presentation logic.
|
||||||
|
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
|
||||||
|
```
|
||||||
|
|
||||||
|
Update DR-063's text in `requirements.md` to state that scope expansion is owned
|
||||||
|
by Rust, so the requirement stops describing the leaked design. New Rust tests
|
||||||
|
take `@req-test: UT-089` onward (next free UT is **UT-089**).
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before
|
||||||
|
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||||
|
- **Read [scoped-search-boundary.md](scoped-search-boundary.md) first.** This
|
||||||
|
spec is deliberately thin on design; that one is the authority.
|
||||||
|
- Sequence with the sibling specs: **Stage 1 here → then
|
||||||
|
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md)**. Hardening
|
||||||
|
the tripwire first turns `master` red on a known-unfixed violation.
|
||||||
|
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look
|
||||||
|
before starting — understanding why the fix stalled may surface a constraint
|
||||||
|
the spec didn't record.
|
||||||
|
- The user-visible-change count for this spec is zero. If QA reports a
|
||||||
|
difference in search results, that is a bug in the refactor, not an
|
||||||
|
improvement.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Spec: Repair the traceability coverage gate
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** DR-093 → supports the traceability practice described in CLAUDE.md
|
||||||
|
**UX spec:** n/a — developer tooling, no user-facing surface.
|
||||||
|
**Supersedes / revises:** n/a
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The CI traceability gate has been passing unconditionally for an unknown length
|
||||||
|
of time because it divides traced-requirement counts by **hardcoded denominators
|
||||||
|
that no longer match [requirements.md](../requirements.md)**. It currently
|
||||||
|
reports **158% overall coverage** (and `JA 24 / 3 = 800%`), so the 50% threshold
|
||||||
|
is mathematically unreachable and the job cannot fail. This spec makes the gate
|
||||||
|
derive its denominators from `requirements.md` at run time, so it reports the
|
||||||
|
real number (**85%** today) and can actually fail again.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`.gitea/workflows/traceability-check.yml` hardcodes `UR/39, IR/24, DR/48, JA/3`
|
||||||
|
and `TOTAL_REQS=114`. The real counts are **UR 61, IR 29, DR 89, JA 32 — 211
|
||||||
|
total**. Requirements were added over time; the divisors were never updated.
|
||||||
|
|
||||||
|
The consequence is not a cosmetic reporting bug. The gate is the *only*
|
||||||
|
automated defence for the traceability practice, and it is dead:
|
||||||
|
|
||||||
|
```
|
||||||
|
CI today: 181 / 114 = 158% → threshold 50% can never trip
|
||||||
|
Reality: 181 / 211 = 85% → healthy, but unguarded
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage could collapse to 30% and CI would still print a green
|
||||||
|
"✅ Coverage is acceptable". An audit of the design principles found that every
|
||||||
|
principle with a *working* automated check is in good shape, and the ones that
|
||||||
|
drifted are exactly the ones whose checks were broken or too narrow — this is
|
||||||
|
the clearest instance.
|
||||||
|
|
||||||
|
A second, related defect is handled in a sibling spec: `scripts/check-req-coverage.sh`
|
||||||
|
is separately broken and orphaned (see
|
||||||
|
[req-coverage-script-removal.md](req-coverage-script-removal.md)).
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
This spec touches only CI/build tooling — no application logic crosses the
|
||||||
|
Rust/Svelte boundary. The table is filled in for completeness.
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Counting requirement IDs defined in `requirements.md` | Build tooling (`scripts/`) | Neither runtime layer; it is repo metadata analysis. Belongs beside `extract-traces.ts`, not in the workflow YAML, so it is runnable and testable locally. |
|
||||||
|
| Counting *traced* requirement IDs | Build tooling — existing `extract-traces.ts` | Already implemented and correct; this spec consumes it rather than duplicating it. |
|
||||||
|
| Threshold policy (the 50% number) | CI workflow | Deployment policy, not analysis. Keeping it in YAML lets it be tuned without touching the script. |
|
||||||
|
|
||||||
|
No frontend or Rust logic is added, so no taxonomy leak is possible.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Denominators come from `requirements.md`, not literals
|
||||||
|
|
||||||
|
`requirements.md` defines requirements in markdown tables with a stable leading
|
||||||
|
cell, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
| DR-001 | Player state machine (idle, loading, …) | Player | UR-005 | Done |
|
||||||
|
| UR-002 | Access media when online or offline | High | Done |
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend [scripts/extract-traces.ts](../../scripts/extract-traces.ts) to also emit
|
||||||
|
the *defined* counts, so one tool owns both sides of the fraction and CI does no
|
||||||
|
arithmetic on stale literals. Add a `defined` key to the JSON report:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"byType": { "UR": [...], "IR": [...], "DR": [...], "JA": [...] }, // traced (existing)
|
||||||
|
"defined": { "UR": 61, "IR": 29, "DR": 89, "JA": 32 }, // NEW
|
||||||
|
"coverage": { "covered": 181, "total": 211, "percent": 85 }, // NEW
|
||||||
|
"requirements": { ... }, // existing
|
||||||
|
"totalTraces": 318, "totalFiles": …, "timestamp": "…" // existing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsing rule for a *defined* requirement: a line in `docs/requirements.md`
|
||||||
|
matching `^\|\s*(UR|IR|DR|JA)-\d{3}\s*\|` — the ID must be the table's first
|
||||||
|
cell. This deliberately does **not** count IDs mentioned in the `Traces To`
|
||||||
|
column or in prose, which is why a naive `grep -o` over the whole file
|
||||||
|
overcounts.
|
||||||
|
|
||||||
|
`defined` counts IDs that exist in the spec; `byType` counts IDs that appear in
|
||||||
|
a `TRACES:` comment somewhere in the source. Coverage is
|
||||||
|
`|byType ∩ defined| / |defined|`.
|
||||||
|
|
||||||
|
> **Intersection, not raw length.** A `TRACES:` comment naming an ID that
|
||||||
|
> `requirements.md` does not define (a typo, or a requirement later deleted)
|
||||||
|
> must **not** inflate the numerator — that is how a ratio exceeds 100% in the
|
||||||
|
> first place. Such IDs are reported separately as `orphaned` so they get fixed
|
||||||
|
> rather than silently counted or silently dropped.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"orphaned": ["DR-097"] // traced in code but not defined in requirements.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. The workflow consumes the computed number
|
||||||
|
|
||||||
|
Replace the arithmetic in `.gitea/workflows/traceability-check.yml` (lines
|
||||||
|
46–76) with reads of the precomputed fields:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
COVERAGE=$(jq '.coverage.percent' traces-report.json)
|
||||||
|
COVERED=$(jq '.coverage.covered' traces-report.json)
|
||||||
|
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
|
||||||
|
|
||||||
|
for T in UR IR DR JA; do
|
||||||
|
TRACED=$(jq --arg t "$T" '.byType[$t] | length' traces-report.json)
|
||||||
|
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
|
||||||
|
echo " $T: $TRACED / $DEFINED"
|
||||||
|
done
|
||||||
|
|
||||||
|
MIN_THRESHOLD=50
|
||||||
|
[ "$COVERAGE" -lt "$MIN_THRESHOLD" ] && { echo "❌ …"; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
No hardcoded denominator survives anywhere in the workflow.
|
||||||
|
|
||||||
|
### 3. A self-check so this cannot silently rot again
|
||||||
|
|
||||||
|
The root cause was a number that drifted with nothing watching it. Add a
|
||||||
|
guard that fails the job on an arithmetically impossible result:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
if [ "$COVERAGE" -gt 100 ]; then
|
||||||
|
echo "❌ Coverage > 100% — the gate is miscomputing; orphaned IDs: $(jq -c '.orphaned' traces-report.json)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
A >100% reading is now a hard failure rather than a green tick.
|
||||||
|
|
||||||
|
### 4. Local parity
|
||||||
|
|
||||||
|
Add a script so the gate is runnable outside CI:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage"
|
||||||
|
```
|
||||||
|
|
||||||
|
Prints the same table CI prints and exits non-zero below threshold.
|
||||||
|
|
||||||
|
### Threshold
|
||||||
|
|
||||||
|
Keep `MIN_THRESHOLD=50` in this spec. Real coverage is 85%, so raising the bar
|
||||||
|
is tempting, but doing it in the same change that repairs the gate conflates
|
||||||
|
"restore the safety net" with "tighten the policy" — if the build then fails, it
|
||||||
|
is ambiguous which change caused it. Ratcheting is deliberately deferred to
|
||||||
|
follow-up work once the honest number has been observed on `master` for a few
|
||||||
|
builds.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Raising `MIN_THRESHOLD` above 50 (see above).
|
||||||
|
- Fixing/removing `scripts/check-req-coverage.sh` — [req-coverage-script-removal.md](req-coverage-script-removal.md).
|
||||||
|
- Adding TRACES comments to raise the actual coverage number.
|
||||||
|
- Changing the `TRACES:` comment format or the extractor's parsing of it.
|
||||||
|
- The PR "modified files missing TRACES" step (lines 78–126), which is advisory
|
||||||
|
by design and stays advisory.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `bun run traces:json` emits `defined`, `coverage`, and `orphaned` keys.
|
||||||
|
- [ ] `coverage.total` equals the count of requirement IDs defined in
|
||||||
|
`requirements.md` (**211** at time of writing), not a literal.
|
||||||
|
- [ ] `coverage.percent` reports **85** (±1 for rounding) on the current tree —
|
||||||
|
i.e. the honest number, not 158.
|
||||||
|
- [ ] No hardcoded requirement denominator (`39`, `24`, `48`, `3`, `114`) remains
|
||||||
|
in `.gitea/workflows/traceability-check.yml`. Verify:
|
||||||
|
`grep -nE '/ *(39|24|48|3|114)\b' .gitea/workflows/traceability-check.yml`
|
||||||
|
returns nothing.
|
||||||
|
- [ ] Adding a new requirement row to `requirements.md` **lowers** reported
|
||||||
|
coverage until it is traced (proves the denominator is live).
|
||||||
|
- [ ] A `TRACES:` comment naming an undefined ID appears in `orphaned` and does
|
||||||
|
**not** raise `coverage.percent`.
|
||||||
|
- [ ] The job fails if coverage is forced below 50% (test by temporarily raising
|
||||||
|
`MIN_THRESHOLD` to 99 locally) — proving the gate can fail again.
|
||||||
|
- [ ] The job fails if coverage computes >100%.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `bun run check:boundary` passes.
|
||||||
|
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||||
|
- [ ] No Rust types changed, so no `bindings.ts` regeneration needed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`extract-traces.ts` currently has no test coverage. Add
|
||||||
|
`scripts/extract-traces.test.ts` (vitest) over fixture strings rather than the
|
||||||
|
live `requirements.md`, so the tests do not change meaning as requirements are
|
||||||
|
added:
|
||||||
|
|
||||||
|
- **UT:** counts a well-formed table row as a defined requirement.
|
||||||
|
- **UT:** does **not** count an ID appearing only in the `Traces To` column or
|
||||||
|
in prose — the specific overcounting bug this parse rule avoids.
|
||||||
|
- **UT:** coverage is the intersection — a traced-but-undefined ID lands in
|
||||||
|
`orphaned` and does not inflate the numerator.
|
||||||
|
- **UT:** coverage of an empty trace set is 0%, not a divide-by-zero.
|
||||||
|
- **UT:** all-traced fixture reports exactly 100%, never above.
|
||||||
|
|
||||||
|
CI behaviour is verified by the acceptance criteria above (the forced-failure
|
||||||
|
check is the important one — a gate nobody has watched fail is not known to
|
||||||
|
work).
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
Allocate in `requirements.md`:
|
||||||
|
|
||||||
|
- **DR-093** — "Traceability coverage gate derives requirement denominators from
|
||||||
|
`requirements.md` at run time (not hardcoded literals), computes coverage as
|
||||||
|
the intersection of traced and defined IDs, reports IDs traced but undefined
|
||||||
|
as orphaned, and fails on an impossible >100% result." Category: Tooling.
|
||||||
|
Status: Done on merge.
|
||||||
|
|
||||||
|
Tag:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/extract-traces.ts
|
||||||
|
// TRACES: | DR-093
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests carry `@req-test: UT-089 …` onward (next free UT is **UT-089**).
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session may be active in this repo — run `git diff` before
|
||||||
|
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||||
|
- **Do not add tooling to the CI image for this.** `jq` and `bun` are already in
|
||||||
|
`jellytau-builder`; this spec needs nothing else. Installing a system package
|
||||||
|
in a workflow step violates the hard CI rule in CLAUDE.md.
|
||||||
|
- Keep `traces:json`'s existing keys intact — `release-notes.ts` and
|
||||||
|
`traces:markdown` consume the same report, and the CI workflow uploads it as
|
||||||
|
an artifact. This is an additive change.
|
||||||
|
- The `head -50 docs/traceability.md` and artifact-upload steps are unaffected.
|
||||||
|
- Expect the first green build after this change to print a *lower* number than
|
||||||
|
before (85% vs 158%). That is the fix working, not a regression.
|
||||||
Reference in New Issue
Block a user