docs: repair the traceability matrix, link integrity and site nav

Every file link in docs/traceability.md was broken — all 2,840. The generator
emitted repo-root-relative hrefs from a file that lives in docs/, so the
artefact the whole TRACES system exists to produce was unnavigable in the repo
browser and on the published site alike. Fixed at the generator and covered by
a regression test, since the markdown output had no test at all.

Also: repairs the remaining broken relative links, adds
scripts/check-doc-links.sh so this class of defect fails a build instead of
rotting, publishes all 50 docs in the mdBook nav (was 21), moves the root-level
build docs under docs/build/ for consistency, retires the stale v0.6.0 audit
after confirming every still-open finding survives in the technical-debt table,
and records the oversized-module debt.

TRACES: | DR-208 | UT-202
This commit is contained in:
2026-08-20 19:33:36 +02:00
16 changed files with 7424 additions and 7592 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ For the full set of build, test, and Android helper scripts, see
|-------|----------| |-------|----------|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) | | Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) | | Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
| Build & release process | [docs/build-release.md](docs/build-release.md) | | Build & release process | [docs/build/build-release.md](docs/build/build-release.md) |
| Docker builds | [docs/build/docker.md](docs/build/docker.md) | | Docker builds | [docs/build/docker.md](docs/build/docker.md) |
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) | | Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) | | Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
+47 -2
View File
@@ -22,15 +22,60 @@
- [Database Design](architecture/08-database-design.md) - [Database Design](architecture/08-database-design.md)
- [Security](architecture/09-security.md) - [Security](architecture/09-security.md)
# UX & Specs # UX
- [UX Flows](ux-flows.md) - [UX Flows](ux-flows.md)
# Specs — Writing One
- [Spec Template](specs/SPEC-TEMPLATE.md)
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
# Specs — Playback & Player
- [Playback Backend Unification](specs/playback-backend-unification.md)
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
- [Playback Documentation Corrections](specs/playback-docs-corrections.md)
- [Video Background Audio](specs/video-background-audio.md) - [Video Background Audio](specs/video-background-audio.md)
- [Android Native Video Spike](specs/android-native-video-spike.md)
- [Android Audio Settings Parity](specs/android-audio-settings-parity.md)
- [Audio Equalizer](specs/audio-equalizer.md)
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
- [libmpv2 Migration](specs/libmpv2-migration.md)
- [Streaming Bitrate Cap](specs/streaming-bitrate-cap.md)
- [Read-Through Media Cache](specs/read-through-media-cache.md)
# Specs — Library & Browsing
- [Scoped Search](specs/scoped-search.md)
- [Scoped Search Boundary](specs/scoped-search-boundary.md)
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
- [Locally-Indexed Search](specs/catalog-index-search.md)
- [Favourites Browsing](specs/favorites-browsing.md)
- [Library Mosaic](specs/library-mosaic.md)
- [Series Current-Episode Navigation](specs/series-current-episode-navigation.md)
- [Account Menu](specs/account-menu.md)
- [Frontend Domain Model](specs/frontend-domain-model.md)
# Specs — Downloads & Offline
- [Downloads as an Offline Library](specs/downloads-as-offline-library.md)
- [Offline Downloaded-Only Filter](specs/offline-downloaded-only-filter.md)
# Specs — Tooling & Build
- [Traceability Gate Repair](specs/traceability-gate-repair.md)
- [Boundary Tripwire Hardening](specs/boundary-tripwire-hardening.md)
- [Requirement-Coverage Script Removal](specs/req-coverage-script-removal.md)
- [Build Provenance](specs/build-provenance.md)
# Build & Release # Build & Release
- [Build & Release](build-release.md) - [Build & Release](build/build-release.md)
- [Release Checklist](release-checklist.md) - [Release Checklist](release-checklist.md)
- [Desktop Packaging](build/build-desktop-packages.md)
- [Windows Build](build/build-windows.md)
- [Defect Windows](defect-windows.md)
- [Docker](build/docker.md) - [Docker](build/docker.md)
- [Builder Image](build/build-builder-image.md) - [Builder Image](build/build-builder-image.md)
@@ -6,14 +6,14 @@ run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
## One builder image (shared with CI) ## One builder image (shared with CI)
The deb/rpm and Windows-cross flows build on the **unified registry builder** The deb/rpm and Windows-cross flows build on the **unified registry builder**
([../Dockerfile.builder](../Dockerfile.builder) → ([../Dockerfile.builder](../../Dockerfile.builder) →
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It `gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler), carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target `cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
(Windows). There is **one** dependency source of truth — no per-stage tool (Windows). There is **one** dependency source of truth — no per-stage tool
installs. installs.
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM The desktop stages in [../Dockerfile](../../Dockerfile) are thin `FROM
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on ${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
your bind-mounted source (like the `dev` service), so source edits need no image your bind-mounted source (like the `dev` service), so source edits need no image
rebuild. rebuild.
@@ -28,7 +28,7 @@ docker build -f Dockerfile.builder -t jellytau-builder:latest .
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
``` ```
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch)) Arch uses a separate `archlinux` image ([../Dockerfile.arch](../../Dockerfile.arch))
because `makepkg` is Arch-specific — it is not part of the unified builder. because `makepkg` is Arch-specific — it is not part of the unified builder.
| Target | Format | Docker command | Functional? | | Target | Format | Docker command | Functional? |
@@ -40,7 +40,7 @@ because `makepkg` is Arch-specific — it is not part of the unified builder.
## Linux: deb + rpm ## Linux: deb + rpm
Tauri's bundler produces these natively. The build runs on the existing Ubuntu Tauri's bundler produces these natively. The build runs on the existing Ubuntu
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage): builder image ([../Dockerfile](../../Dockerfile), `desktop-linux-build` stage):
```bash ```bash
bun run docker:build:linux # deb + rpm -> ./dist bun run docker:build:linux # deb + rpm -> ./dist
@@ -58,8 +58,8 @@ transcoded video). The deb/rpm declare these.
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets **Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with [../packaging/arch/PKGBUILD](../../packaging/arch/PKGBUILD) and build it with
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)): `makepkg` on an Arch base image ([../Dockerfile.arch](../../Dockerfile.arch)):
```bash ```bash
bun run docker:build:arch # .pkg.tar.zst -> ./dist bun run docker:build:arch # .pkg.tar.zst -> ./dist
+2 -2
View File
@@ -294,8 +294,8 @@ bun run tauri build # Local build test
``` ```
### Documentation ### Documentation
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes 1. Update [CHANGELOG.md](../../CHANGELOG.md) with changes
2. Update [README.md](../README.md) with new features 2. Update [README.md](../../README.md) with new features
3. Document breaking changes 3. Document breaking changes
4. Add migration guide if needed 4. Add migration guide if needed
+3 -3
View File
@@ -12,10 +12,10 @@ job / SMTC lockscreen), but it runs and plays media.
h264 fine. No Windows-specific code. h264 fine. No Windows-specific code.
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and - **Audio-only (music)** — the native audio backends are libmpv (Linux) and
ExoPlayer (Android); neither exists on Windows. Instead ExoPlayer (Android); neither exists on Windows. Instead
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs) `create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
URL to a webview `<audio>` element (see URL to a webview `<audio>` element (see
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)), [../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
which reports state back through the same `player_report_*` round-trip the video which reports state back through the same `player_report_*` round-trip the video
path uses. Pure Rust + Tauri events. path uses. Pure Rust + Tauri events.
@@ -33,7 +33,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into > `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
> Windows mode and enables the `nsis`/`msi` bundlers on Linux. > Windows mode and enables the `nsis`/`msi` bundlers on Linux.
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`, whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
`llvm`, and `nsis`. `llvm`, and `nsis`.
-532
View File
@@ -1,532 +0,0 @@
# JellyTau Codebase Audit
**Date:** 2026-08-16 · **Version:** v0.6.0 · **Commit:** `be907b49` (master)
A review of the Rust/Svelte/Android codebase against its own requirements matrix
and against current Android and Tauri v2 platform practice. Every finding was
verified by running the project's own tooling or reading the code it points at —
nothing here is inferred from documentation alone.
**Scale:** 55,835 LOC Rust · 50,490 LOC TS/Svelte · 530 requirements · 824 traces
| Severity | Count |
|----------|-------|
| High | 5 |
| Medium | 9 |
| Low | 6 |
| Tests passing | 1,719 |
| Untraced requirements | 86 |
| Traceability coverage | 86% (285/330) |
> **Revisions, 2026-08-16.** Three rankings changed after device testing and
> platform research, all documented in place:
> - **B1 High → Low.** The predicted impact was refuted on a physical Android 16
> device. The residual risk turned out to be a different, narrower one.
> - **B7 Low → Medium, re-framed.** The original reading of predictive back was
> backwards: at targetSdk 36 it is already enabled, not merely un-opted-into.
> - **B8 added (Medium).** Android 16 Local Network Protections versus a
> LAN-hosted Jellyfin server.
> - **D3 Medium → Low.** The "820 unwraps" figure was a measurement error; the
> real number is 19, and none are in command handlers.
> - **B1's stated mechanism was wrong** even though its conclusion held. FGS
> notifications are *not* exempt from `POST_NOTIFICATIONS`; media-session
> notifications are. See B1 — the distinction changes what the fix should be.
>
> Original ranking was 6 High / 8 Medium / 5 Low.
**Verified by running:** `bun run check` · `bun run test` · `cargo test` ·
`cargo clippy --all-targets` · `bun run check:boundary` · `bun run traces:json`
**Device-verified (2026-08-16):** B1 and B2 were checked against a physical HONOR
ROD2-W09 running Android 16 (SDK 36) with the shipped app installed. B2 was
confirmed; B1 was refuted and downgraded.
**Not covered:** the e2e suite (`test:e2e` is not wired into CI and was not run),
Windows and Arch packaging paths, and the docs-site build. B3, C1 and C2 still
need a device/desktop playback pass.
---
## A. Requirements versus code
The traceability matrix is the project's own claim about what is built. Of 530
defined requirement IDs, 86 carry no `TRACES:` tag anywhere in the tree. Most of
those gaps are documentation debt rather than missing features — which is
precisely the problem, because it makes the matrix unreliable as evidence.
### A1 · High · Twelve requirements are marked "Done" but have zero traces
`UR-006` (lockscreen/BLE control), `UR-037` (video library presentation),
`IR-006` (Android MediaSession), `IR-008` (audio focus), `IR-022` (person/cast
API), `IR-024` (home-screen API) and six Jellyfin API requirements (`JA-006`,
`JA-009`, `JA-013`, `JA-014`, `JA-015`, `JA-018`) all claim completion with
nothing pointing at an implementation.
These features demonstrably work — lockscreen control, Next Up, favourites are
all shipped. The code is there; the tags are not. That means the matrix currently
over-reports on exactly the requirements a reviewer would most want to verify,
and a regression in any of them would leave no trace to follow.
**Fix:** Tag the existing implementations. Highest value per keystroke in the
whole audit: six of the twelve are single Jellyfin API call sites.
### A2 · Medium · Requirement statuses contradict each other across layers
`UR-020` (subtitle selection) and `UR-021` (audio track selection) are marked
*Done*, while the integration requirements they decompose into — `IR-018` and
`IR-019`, both libmpv-specific — are still *Planned*. Similarly `IR-005` (MPRIS)
sits at *Planned* under a *Done* `UR-006`.
The likely truth is that these user requirements were satisfied through a
different path than the one originally specified (HTML5 `<video>` and ExoPlayer
rather than libmpv), and the IRs were never re-scoped. Left as-is, the matrix
reads as though shipped features depend on unbuilt integrations.
**Fix:** Re-scope or retire the stale IRs so each Done UR rests on Done IRs.
### A3 · Medium · The traceability gate is set far below actual coverage
`traceability-check.yml` fails only below 50%. Real coverage is well above that,
so the gate cannot catch a coverage regression until roughly half the matrix has
rotted. A gate that can only fire after a catastrophe is not protecting anything.
**Measured coverage: 86% (285/330)** — UR 71/75, IR 19/32, DR 166/187, JA 29/36.
IR is by far the weakest dimension, which corroborates A1.
**Fix applied:** `MIN_THRESHOLD` ratcheted 50 → 82, with the ratchet policy
written into the workflow (only goes up; never lowered to make a red build pass).
The same figure is mirrored as `MIN_COVERAGE_PERCENT` in
`scripts/extract-traces.ts` so local `traces:coverage` gates on the same bar, and
a test parses the workflow YAML and fails if the two drift apart.
### A4 · Low · Two traced IDs do not exist in the requirements document
`DR-189` and `UT-188` are referenced by `TRACES:` comments but are defined
nowhere in `docs/requirements.md`. The extraction tool accepts them silently, so
typos and renames pass unnoticed.
**Fix:** Add a dangling-ID check to the extractor and fail CI on it — cheap, and
it keeps the matrix honest in both directions.
### A5 · Not a gap · The remaining untraced requirements are legitimately unbuilt
`UR-016`, `UR-022` and `UR-070` are Planned or Proposed, and `UR-031`
(crossfade) is explicitly blocked by `DR-034`. Their absence from the trace graph
is correct and needs no action — noted so it does not get swept into the fix list.
---
## B. Android platform practice
The app targets SDK 36 with a minSdk of 24. Several manifest and WebView settings
still reflect an earlier target level.
### B1 · Low · `POST_NOTIFICATIONS` is declared but never requested at runtime
*Downgraded from High. The original ranking was refuted by device testing — the
evidence is below, and it is the reason this finding is now near-trivial.*
The permission appears in the manifest, but there is no `requestPermissions` call
anywhere in the Kotlin, Rust or TypeScript sources, and
`JellyTauPlaybackService.startForeground()` runs with no `checkSelfPermission`
guard. On Android 13+ notification permission defaults to denied.
This was ranked High on the theory that it would suppress the media notification
and with it the lockscreen transport controls (`UR-006`). Testing on an HONOR
ROD2-W09 running **Android 16 (SDK 36)**, with the shipped app installed and
playing, shows otherwise. The permission is genuinely denied:
```
POST_NOTIFICATIONS: granted=false, flags=[USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
appops POST_NOTIFICATION: ignore
```
and the notification is nonetheless live and complete:
```
ServiceRecord{... com.dtourolle.jellytau/.player.JellyTauPlaybackService}
isForeground=true foregroundId=1 types=0x00000002
foregroundNoti=Notification(flags=NO_CLEAR|FOREGROUND_SERVICE
category=transport actions=3 vis=PUBLIC)
```
**`UR-006` is not at risk.** But the *reason* is not the one this audit first
gave, and the correction is load-bearing rather than pedantic.
The first explanation here was "foreground-service notifications are exempt." That
is wrong. Android's own wording is that the permission covers "non-exempt
(**including Foreground Services (FGS)**) notifications", and that users who deny
it see FGS notices "in the Task Manager but [not] in the notification drawer" — an
FGS notification is explicitly *not* exempt. What is exempt is **media-session**
notifications. The platform predicate is `Notification.isMediaNotification()`,
requiring `MediaStyle`/`DecoratedMediaCustomViewStyle` **and** a non-null
`EXTRA_MEDIA_SESSION`; it is byte-identical across API 3336, and
`NotificationManagerService` has no FGS clause in either enforcement site.
Why the difference matters: under the FGS theory, anything the service posts is
safe, and the code needs no care. Under the correct one, the exemption is earned
per-notification by the token — so losing the token loses not just the shade entry
but the lockscreen controls entirely, since SystemUI's media carousel
(`MediaDataProcessor.onNotificationAdded`) gates on the *same* predicate. A
token-less notification never even reaches the notification listener.
**The real risk here is not the permission — it is how narrowly the exemption is
earned.** AOSP's `Notification.isMediaNotification()` grants it only when the
style is `MediaStyle`/`DecoratedMediaCustomViewStyle` **and**
`Notification.EXTRA_MEDIA_SESSION` holds a non-null *platform* session token. If
either is missing while the permission is denied, the notification is **silently
suppressed** — no exception, no log.
JellyTau earns it at two sites, both of which hang it on a null-safe call:
```kotlin
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken) // :273 and :466
```
Ordering currently saves it — `mediaSessionCompat` is assigned in `onCreate`
(:195) and `createBasicNotification()` is only reached from `onStartCommand`
(:251) — and the device test confirms it works. But it is one reordering away
from breaking invisibly, and only for users who denied the permission, which is
a population most developers never test as.
**Fix:** Keep the permission declared — download-service FGS notifications are
*not* covered by the media exemption, and this app has a downloads feature that
may want them. Comment both `setMediaSession` sites to record what earns the
exemption, and log loudly if the token is ever null at build time, converting a
silent failure into a diagnosable one.
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt:251`, `:273`, `:466`
### B2 · High · Cloud backup is on by default, and it will break credential restore
The manifest sets neither `android:allowBackup="false"` nor a
`dataExtractionRules`/`fullBackupContent` file, so Android's default applies: the
app's data directory is backed up to the user's Google account. That ships the
SQLite catalogue — library metadata and watch history — off the device.
The credential path makes it worse rather than better. `SecureStorage.kt`
encrypts with AES/GCM under an Android Keystore key, and Keystore keys are never
backed up. A user restoring onto a new phone therefore gets the ciphertext
without the key: undecryptable credentials and a silent authentication failure,
with no code path that recognises the situation.
**Fix applied.** `allowBackup="false"`. Extraction rules that merely excluded the
DB and credential prefs would have left nothing worth backing up: the SQLite
catalogue is a rebuildable mirror of the server and watch state lives server-side,
so there is no user-authored data to preserve.
**A gap this audit missed:** on API 31+, `allowBackup="false"` disables *cloud*
backup but **not device-to-device transfer**, which reproduces the identical
failure — the prefs travel, the Keystore key does not. A
`data_extraction_rules.xml` excluding all five domains from both `<cloud-backup>`
and `<device-transfer>` was added to close it.
**A real bug found while fixing this:** the Rust encrypted-file fallback in
`credentials.rs` propagated a decrypt failure as `CredentialError::Encryption`,
which `storage_get_access_token` turned into a hard `Err` — so an undecryptable
blob was an error state, not a logout. It now logs and returns an empty map, so
the caller sees `NotFound``Ok(None)` → login screen, and the next sign-in
self-heals the file. `SecureStorage.getCredential` on the Kotlin side already
returned null, but could not distinguish "nothing stored" from "unreadable" and
left the dead blob in prefs forever; it now separates the cases and discards it.
Three tests written and watched fail first, per the red→green rule.
### B3 · High · `MIXED_CONTENT_ALWAYS_ALLOW` undoes the network security config
`network_security_config.xml` is careful and well-argued: cleartext blocked
everywhere, exempted only for `127.0.0.1` so the local media server can serve
downloads. Its own comment warns "this must not become a blanket cleartext
opt-in."
But `MainActivity.kt` sets `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW`, which
permits the WebView to load http subresources into an https page from any origin.
Alongside it, `allowFileAccess = true` and `allowContentAccess = true` are both
broader than anything the app needs, since Tauri serves the UI from its own scheme
and media comes from the token-guarded loopback server. These read as leftovers
from before the media server existed.
**Fix:** Drop to `MIXED_CONTENT_COMPATIBILITY_MODE` and set both file and content
access to false, then verify offline video still plays.
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt:504-507`
### B4 · Medium · Android TV is half-declared
The manifest advertises `LEANBACK_LAUNCHER` and a non-required leanback feature,
but omits `<uses-feature android:name="android.hardware.touchscreen"
android:required="false"/>` and an `android:banner`. That combination fails Play's
TV validation, and on a real TV the app would launch into a UI with no D-pad focus
model behind it.
**Fix:** Either commit to TV — add the feature declaration, a banner, and a focus
pass — or remove the leanback category until you do.
### B5 · Medium · `jvmTarget` is pinned to 1.8 under compileSdk 36
The Kotlin target has not moved with the SDK. AGP 8 warns on it, and it locks the
Kotlin sources out of APIs and desugaring behaviour that everything else in the
toolchain assumes.
**Fix:** Move `jvmTarget` and the Java source/target compatibility to 17.
### B6 · Low · Media3 is several minor versions behind
`androidx.media3` is pinned at 1.5.0 across exoplayer, hls, session and common.
Given how much of this app's hard-won behaviour lives in ExoPlayer edge cases —
truncated progressive streams, background audio handoff, HLS resume — staying
current on its bug-fix releases has unusually high value here.
**Fix:** Schedule a Media3 bump with a device pass over the playback regression list.
### B7 · Medium · Predictive back is already on, not merely un-opted-into
*Upgraded from Low, and re-framed — the original framing was backwards.*
The audit first read the absent `enableOnBackInvokedCallback` as the app
*forgoing* the Android 13+ back-gesture preview. That is not what the flag means
at this target level. Predictive back is enabled by default for apps targeting
recent SDKs, and Android 16's own behaviour-change list carries "Migration or
opt-out required for predictive back" — with the opt-out being removed. Targeting
36, JellyTau is already getting predictive back; it simply hasn't been checked
against it.
That matters more than a missing opt-in would, because the app does not use
ordinary Android back. It runs a WebView with its own history model —
`src/lib/utils/navigation.ts` tracks a depth counter, applies a popstate delta,
and falls back to a path when `history.back()` would trap the user, with
`scrollRestore.ts` keying off the same popstate events. That is exactly the kind
of custom back handling predictive back is most likely to disagree with.
**Fix:** This is a device test, not a code change — exercise the back gesture
(including the drag-and-release preview and the cancel) from a library page, a
detail page, the player, and the settings screen, and watch for the depth counter
desynchronising. Only change code if it misbehaves.
Separately and unrelatedly: `JellyTauPlaybackService` is `exported="true"` with a
`MediaSessionService` intent filter — conventional for Media3, but it means any
app on the device can attempt to bind and drive playback. Confirm the session's
`onConnect` callback rejects unknown packages.
### B8 · Medium (forward-looking) · Android 16 Local Network Protections vs a LAN Jellyfin server
*New finding, surfaced while researching B1.*
Android 16's behaviour-change list includes **Local Network Permission**. JellyTau's
entire purpose is reaching a Jellyfin server that, for most users, sits on the
local network — so a permission gate on local-network access is a direct threat to
the app's core function, not a peripheral concern.
Stated carefully, because the timing matters: in Android 16 this is **opt-in for
testing**, not enforced by default, with enforcement signalled for a future
release. Nothing is broken today, and the device test will not surface it. But
this is the rare platform change that could stop the app working at all, and it
is much cheaper to handle before it is mandatory.
**Fix:** Investigate what the permission will require, then test the app against
it with the opt-in flag enabled on the Android 16 device already to hand. Track it
as a release-blocking item for whichever Android version enforces it.
---
## C. Tauri v2 configuration
The capability model here is genuinely well done — see section E. The gaps are in
the two settings that govern what a compromised web layer could reach.
### C1 · High · `"csp": null` contradicts the project's own security convention
`CLAUDE.md` lists "keep the CSP restrictive in `tauri.conf.json`" as a standing
rule; the config disables CSP entirely. With it off, any script that reaches the
web layer inherits the full IPC surface.
The realistic exposure today is low, and worth stating plainly rather than
inflating: the frontend has a single `{@html}` — an app-owned icon in
`GenericGenreBrowser.svelte`, not server data — and no `innerHTML`, `eval` or
`new Function` outside tests. So this is a missing defence rather than an open
hole. But it is the defence that stops the next careless interpolation of a
Jellyfin-supplied string from becoming a full compromise.
**Fix:** Set a CSP permitting `'self'`, `asset.localhost`, `http://127.0.0.1:*`
for media, and the configured Jellyfin origin for images. Expect one or two
iterations against HLS playback.
### C2 · Medium · The asset protocol scope is wider than what it serves
`assetProtocol.scope` is `$APPDATA/**`, which covers the whole app data directory
— the SQLite database and the credential store included — while the protocol only
needs to reach cached thumbnails and downloaded media.
Since `DR-137` introduced the token-guarded loopback media server, the asset
protocol's remaining job may be thumbnails alone, which would make the narrowing
nearly free.
**Fix applied:** scoped to `$APPDATA/thumbnails/**`. Confirmed on device that
`jellytau.db` (8 MB catalogue) and `shared_prefs` sit in the `$APPDATA` root and
are now outside the grant.
**But device testing found the finding was aimed at the wrong thing.** The asset
protocol is not narrowly used — it is **entirely unused at runtime**:
- `getCachedImageUrl` in `imageCache.ts` has **no production callers**. Its only
references are its own test file. `convertFileSrc`'s sole production mention
sits inside that uncalled function, so it never executes.
- The real path is `MediaCard``CachedImage``commands.imageGetUrl()`, which
returns **base64 from Rust**. Every image in the app is a `data:` URI delivered
over IPC.
- Confirmed on device: zero `asset.localhost` requests across a full session of
browsing home, the library list and a poster grid; the thumbnail cache stayed
at 12 files and never grew, because nothing calls `thumbnailSave` either.
Two consequences worth acting on, neither yet done:
1. **The `protocol-asset` Cargo feature and the whole `assetProtocol` config
block can likely be removed**, which retires the attack surface rather than
shrinking it. `imageCache.ts` is dead code and can go with it.
2. **`img-src` in the new CSP can be much tighter.** It currently grants
`http: https:` on the reasoning that thumbnails are fetched direct-from-server
on a cache miss — but they are not; they arrive as data URIs. With no
webview-side server image loads anywhere in `src/`, `img-src 'self' data:
blob:` should suffice. That is a real tightening the CSP work left on the
table because it reasoned from the dead code path.
Both need their own device pass, since a wrong `img-src` blanks every image.
### C3 · Low · Shipped desktop bundles have no update path
The bundle targets deb, rpm and nsis, but `tauri-plugin-updater` is not among the
dependencies. Every desktop user upgrades by manually fetching a new package,
which in practice means a long tail of installs pinned to whatever version they
first downloaded.
**Fix:** Add the updater plugin with a signed release manifest, or document the
manual upgrade path in the README so the omission is at least deliberate.
---
## D. CI and code health
Local discipline in this project is strong and well documented. CI enforces only
part of it, which means the discipline holds exactly as long as every contributor
remembers it.
### D1 · High · CI runs neither `cargo clippy` nor `cargo fmt --check`
`CLAUDE.md` requires both before committing. Neither appears anywhere in
`.gitea/workflows/`. The build-and-test job runs the boundary check, the frontend
tests, the Rust tests and an Android `cargo check` — a good set, with the two lint
gates missing.
Clippy currently reports 51 warnings across the lib and its tests, including
unused imports and a redundant import that a gate would have stopped at the door.
**Fix:** Add both to the test job. Start with `-D warnings` on new code only if
clearing the existing 51 is too large a first step.
### D2 · Medium · A flaky test will intermittently redden CI
`offlineCatalog.test.ts` — "pushes include=true while the server is reachable"
(`UT-068`) — timed out at the 5 s limit during a full-suite run, then passed twice
in isolation taking 1.13 s and 0.61 s.
**Root cause (corrected):** this audit originally attributed it to a real
wall-clock timer. It isn't. The cost is the **first dynamic
`import("./offlineCatalog")`**, which pays to transform the service and its whole
dependency graph (~1072 ms cold) inside a test body, charged against vitest's 5 s
default. Later re-imports after `vi.resetModules()` cost ~30 ms. Under full-suite
contention the cold transform alone crosses the limit.
**Fix applied:** warm the import once at collection time with a top-level
`await import(...)`, so no test is timing the compiler. Slowest test 1072 ms →
129 ms; file total 1170 ms → 238 ms. Timeout deliberately left at the default.
A latent cross-test leak was also fixed alongside it — the store shim's
subscribers were never cleared, so every module instance discarded by
`resetModules()` kept pushing its own visibility value.
**Location:** `src/lib/services/offlineCatalog.test.ts:58`
### D3 · Low · ~~820~~ **19** production `unwrap()`/`expect()` calls
*Downgraded from Medium. This audit substantially overstated the problem, and the
correction is worth recording because the measurement error is instructive.*
The original 820 figure came from grepping for `unwrap()`/`expect()` and filtering
lines containing "test". That does not exclude test *modules* — it only excludes
lines with "test" in them. Scripting the actual `#[cfg(test)]` boundaries gives
**19 real production sites**, not 820. `player/mod.rs`'s 154 hits, for instance,
are *all* past its `#[cfg(test)]` at line 2183, as are the bulk of
`repository/offline.rs`, `storage/mod.rs` and `commands/download/mod.rs`.
**More importantly: zero bare unwraps exist in any `#[tauri::command]` handler.**
The specific risk this finding was built around — a panic inside a command killing
the task and stranding shared player state — is already absent.
The same correction applies to the lock half: all 33 raw `.lock().unwrap()` hits
were in test modules (three weren't even code, but prose in `utils/lock.rs`'s doc
comment). Production was already fully on `lock_safe()`/`read_safe()`/
`write_safe()`. Converting them was consistency work, not a bug fix.
**What is genuinely worth doing** is a three-site cluster, all the same pattern —
`Runtime::new().unwrap()` in threads owning playback-critical state:
| | Site | Consequence of a panic |
|---|------|------------------------|
| 1 | `session_poller/mod.rs:102` | Poller thread dies silently; it drives remote-mode state *and* offline→online recovery, so the app strands offline with nothing surfaced |
| 2 | `player/mpv_backend.rs:424` | Position reporting stops mid-playback; the scrubber freezes while audio keeps going |
| 3 | `player/android/mod.rs:761` | Same pattern across a JNI boundary; progress reporting dies and no resume points are written |
**Fix:** One shared helper returning `Option<Runtime>` and logging on failure
retires all three. The remaining 16 are startup `expect()`s and two provably
infallible calls.
### D4 · Low · Five files carry a disproportionate share of the complexity
`player/mod.rs` (4,726 lines), `repository/offline.rs` (4,696),
`repository/online.rs` (3,702), `commands/player/mod.rs` (3,299) and
`commands/download/mod.rs` (3,226), plus `VideoPlayer.svelte` (2,778) on the
frontend.
These are the same files the changelog keeps returning to for deadlocks and
playback regressions. Not a defect in itself, and not worth a speculative
refactor — but the next time one of them needs substantial work, splitting it is
likely cheaper than continuing to grow it.
---
## E. Verified sound
Things this audit specifically went looking for and found in good order —
including one that looked alarming from the warning output and turned out to be
fine.
| Area | Finding |
|------|---------|
| **The 9 "MutexGuard across await" warnings are test-only** | All nine sit in `#[tokio::test]` functions holding a serialization lock, not in the production async paths that `CLAUDE.md`'s deadlock gotcha warns about. |
| **The local media server is exemplary** | Loopback-only bind, a 32-hex-char per-session token, lexical `..` folding rather than `canonicalize`, and a test asserting reads stay inside the data directory. |
| **Tauri capabilities are minimal** | Three permissions total — `core:default`, `opener:default`, `core:path:default`. No blanket grants, no `withGlobalTauri`. |
| **SQL is parameterised** | Two `format!`-built statements in the whole Rust tree, neither interpolating caller-controlled input into a query. |
| **R8 keep rules are correct and explained** | JNI-loaded player and security classes, the JavascriptInterface bridges and Media3 are all kept, each with a comment naming the crash it prevents. |
| **Type and boundary gates are green** | `svelte-check`: 0 errors, 0 warnings. `check:boundary` passes with three reviewed allowlist entries. 698 Rust tests and 1,021 frontend tests pass. |
---
## F. Suggested order
Sequenced so the cheap gates land before the work they would have caught. B2
leads because it is the finding a user is most likely to actually feel.
*(B1 originally led this list. It was demoted to row 10 after device testing —
see B1. This is a good advertisement for testing a finding before scheduling
work against it.)*
| # | Finding | What it buys | Effort |
|---|---------|--------------|--------|
| 1 | B2 | Catalogue and credentials stop leaving the device; restore stops failing silently | S — **confirmed on device**: `ALLOW_BACKUP` set, Google transport active |
| 3 | D1 | Lint discipline becomes enforced rather than remembered | S |
| 4 | B3 | The network security config actually holds | S — needs an offline-playback check |
| 5 | A1 | The matrix stops over-reporting on twelve shipped requirements | M — mostly mechanical |
| 6 | D2 | CI stops flaking | S |
| 7 | C1 · C2 | The web layer stops being one interpolation away from full IPC | M — iterate against HLS |
| 8 | A2 · A3 · A4 | The matrix becomes self-consistent and defended by a real gate | M |
| 9 | B4 · B5 · B6 · B7 | Platform hygiene brought level with the SDK target | M |
| 10 | B1 · D3 · C3 · D4 | Long-tail robustness; opportunistic rather than scheduled | L |
+2 -3
View File
@@ -255,9 +255,8 @@ First build takes longer (cache warming). Subsequent releases are faster due to
**Android:** 8.0+ **Android:** 8.0+
### 🔗 Links ### 🔗 Links
- [Changelog](../../CHANGELOG.md) - [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
- [Issues](../../issues) - [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
- [Discussion](../../discussions)
--- ---
Built with Tauri, SvelteKit, and Rust 🦀 Built with Tauri, SvelteKit, and Rust 🦀
+39 -12
View File
@@ -85,6 +85,7 @@ For a narrative overview of the system design, see
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done | | UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done | | UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done | | UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Proposed |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done | | UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
--- ---
@@ -392,6 +393,12 @@ Internal architecture, components, and application logic.
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done | | DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done | | DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done | | DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
| DR-204 | A leveled logging facade for the frontend, replacing raw `console.*` calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a `console.log` nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talking | Tooling | - | Proposed |
| DR-205 | ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how `check:boundary` and the traceability gate already work | Tooling | - | Proposed |
| DR-206 | The Rust toolchain is pinned in-repo (`rust-toolchain.toml`) and the pin is what both a developer's machine and CI use. Without it, `cargo fmt --check` and `cargo clippy` are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a *declaration*, not a CI-time install (see the no-toolchain-installs rule) | Tooling | - | Proposed |
| DR-207 | A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, `cargo fmt`, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually binding | Tooling | - | Proposed |
| DR-208 | Documentation link integrity is checked mechanically (`scripts/check-doc-links.sh`): every relative markdown link in every tracked `.md` must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to `docs/` while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates *paths*, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the script | Tooling | - | Done |
| DR-209 | Library folders are excluded from music browsing **server-side, by folder id**, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item *is* (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhere | Repository | UR-076 | Proposed |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done | | DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
--- ---
@@ -476,6 +483,7 @@ Internal architecture, components, and application logic.
| UR-073 | - | DR-158 | | UR-073 | - | DR-158 |
| UR-074 | - | DR-162, DR-177, DR-181 | | UR-074 | - | DR-162, DR-177, DR-181 |
| UR-075 | - | DR-174, DR-175 | | UR-075 | - | DR-174, DR-175 |
| UR-076 | - | DR-209 |
--- ---
@@ -678,6 +686,9 @@ Internal architecture, components, and application logic.
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done | | UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done | | UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done | | UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
| UT-201 | The logging facade gates by level: a message below the active level is not emitted at all, one at or above it reaches the sink, changing the level at run time changes what passes without touching the call sites, and a scoped logger tags its output with the subsystem | DR-204 | Proposed |
| UT-202 | Generated traceability-matrix file links resolve from `docs/`: an emitted href, resolved against the directory `traceability.md` is written to, points at a file that exists on disk; the visible link text stays repo-root-relative; the `#Lnn` anchor survives; and a bare repo-root href — the regression that made every link 404 as `docs/<path>` — is rejected | DR-093 | Done |
| UT-203 | Library folder exclusion filters by id, not by name: an excluded folder's items are absent from a music query, an item whose *title* merely contains an excluded folder's name is kept, and clearing the exclusion restores the items | DR-209 | Proposed |
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done | | UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
### Integration Tests ### Integration Tests
@@ -704,12 +715,28 @@ Internal architecture, components, and application logic.
## 5. Technical Debt ## 5. Technical Debt
### Open items from the codebase audit (2026-08-16) ### Open items carried over from the v0.6.0 codebase audit
Findings from [codebase-audit.md](codebase-audit.md) that were **not** addressed The 2026-08-16 audit (v0.6.0, commit `be907b49`) was a point-in-time snapshot
in v0.8.0, plus items the device-verification pass turned up. Ordered by what with no status markers, and by v0.8.2 most of it had been either fixed or
would hurt most if left. The audit doc carries the full reasoning and evidence overtaken. It was **retired** rather than left to rot into a document that
for each. half-describes the code: what survived it is the table below, which is now the
record. Each row is self-contained — the audit is not needed to act on it.
What was dropped as demonstrably closed, so it is not re-raised: the CSP and
asset-protocol scope findings (now DR-198), cloud backup and credential restore,
the WebView mixed-content override (DR-199), `POST_NOTIFICATIONS` and the
media-session exemption (DR-200), the `jvmTarget` 1.8 pin (now 17), the
half-declared Android TV leanback category (removed), the untraced-but-Done
requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50%
traceability gate (ratcheted, and gated on a live denominator by DR-093), the
flaky `offlineCatalog` test, the clippy warning backlog (cleared, and `cargo
fmt --check` plus clippy now run in CI), and the "820 production `unwrap()`s"
figure — a measurement error that counted test modules, corrected in the audit
itself to ~19 and standing at 27 today, none of them in a command handler. The
three `Runtime::new().unwrap()` sites that genuinely matter survive as row 5.
Ordered by what would hurt most if left.
> **Closed 2026-08-17:** the R8-minified release APK was validated on device. > **Closed 2026-08-17:** the R8-minified release APK was validated on device.
> That was the last item gating confidence in the v0.8.0 release itself; R8 > That was the last item gating confidence in the v0.8.0 release itself; R8
@@ -719,17 +746,17 @@ for each.
| # | Item | Why it matters | Size | | # | Item | Why it matters | Size |
|---|------|----------------|------| |---|------|----------------|------|
| 1 | **Android 16 Local Network Protections** (audit B8) | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M | | 1 | **Android 16 Local Network Protections** | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
| 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S | | 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S |
| 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S | | 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S |
| 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S | | 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S |
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** (audit D3) | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S | | 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
| 6 | **Confirm the playback service rejects unknown callers** (audit B7, second half) | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (The predictive-back half of B7 was verified working on device and needs nothing) | S | | 6 | **Confirm the playback service rejects unknown callers** | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (Predictive back, raised alongside this, was verified working on device and needs nothing) | S |
| 7 | **Media3 is several minor versions behind** (audit B6) | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M | | 7 | **Media3 is several minor versions behind** | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
| 8 | **Shipped desktop bundles have no update path** (audit C3) | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M | | 8 | **Shipped desktop bundles have no update path** | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
| 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S | | 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S |
| 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S | | 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S |
| 11 | **Five files carry a disproportionate share of complexity** (audit D4) | `player/mod.rs` (4.7k lines), `repository/offline.rs` (4.7k), `repository/online.rs` (3.7k), `commands/player/mod.rs` (3.3k), `commands/download/mod.rs` (3.2k), plus `VideoPlayer.svelte` (2.8k). The same files the changelog keeps returning to for deadlocks and playback regressions. Not worth a speculative refactor — but the next time one needs substantial work, splitting it is likely cheaper than growing it | L | | 11 | **Six modules carry a disproportionate share of the complexity** | `src-tauri/src/player/mod.rs` (4,732 lines), `src-tauri/src/repository/offline.rs` (4,705), `src-tauri/src/repository/online.rs` (3,760), `src-tauri/src/commands/player/mod.rs` (3,327), `src-tauri/src/commands/download/mod.rs` (3,238) and `src/lib/components/player/VideoPlayer.svelte` (2,786) — all still growing. The cost is not the line count itself, it is that **these are the same modules `CLAUDE.md`'s Gotchas section keeps having to warn about**: the deadlock rule about locking in event callbacks, the `AutoplayDecision` scrutinee, the "no lifecycle calls after an `await` in `onMount`" rule, the HLS `master.m3u8` rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be *told* rather than one the structure enforces. **Recorded, not scheduled** — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to remember | L |
### Linux Keyring Integration Workaround ### Linux Keyring Integration Workaround
@@ -843,7 +870,7 @@ deprecated in current Media3.)
**Affected Files**: **Affected Files**:
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers - [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers - [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion - [src/lib/utils/playbackUnits.ts](../src/lib/utils/playbackUnits.ts) - Position conversion (the shared helper the "Future Fix" below called for; `playbackControl.ts`, previously listed here, has since been removed)
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion - [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion - [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
+2 -1
View File
@@ -50,7 +50,8 @@ Copy the boxes into the review comment (or the PR) and tick them.
- [ ] Linked to existing URs, or new URs/DRs are allocated in - [ ] Linked to existing URs, or new URs/DRs are allocated in
[requirements.md](../requirements.md). [requirements.md](../requirements.md).
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md). - [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
- [ ] Traceability coverage stays ≥ 50% (the CI gate). - [ ] Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check
`bun run traces:coverage` rather than trusting this number).
## Conflicts & hygiene ## Conflicts & hygiene
+1 -1
View File
@@ -332,7 +332,7 @@ Frontend (`bun run test`):
|------|--------| |------|--------|
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` | | UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) | | UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
| IT-0xx | `repositoryGetFavorites` param naming — add to [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): camelCase top-level params, scope serialised as `"movies"` etc. | | IT-0xx | `repositoryGetFavorites` param naming — add to the IPC param-naming suite under `src/lib/utils/` (`tauriIntegration.test.ts` no longer exists — see the current camelCase guards in `src/lib/stores/`): camelCase top-level params, scope serialised as `"movies"` etc. |
Any component logic worth testing gets extracted into a plain `.ts` module first Any component logic worth testing gets extracted into a plain `.ts` module first
(`favoritesView.ts`), rather than tested through the component. (`favoritesView.ts`), rather than tested through the component.
+14 -13
View File
@@ -15,7 +15,7 @@ The CI/CD pipeline automatically validates that code changes are properly traced
Traceability validation lives in `.gitea/workflows/traceability-check.yml`: Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
- ✅ Automatic trace extraction - ✅ Automatic trace extraction
- ✅ Coverage validation against minimum threshold (82%, ratcheted) - ✅ Coverage validation against minimum threshold (88%, ratcheted)
- ✅ Modified file checking - ✅ Modified file checking
- ✅ Artifact preservation - ✅ Artifact preservation
- ✅ Summary reports - ✅ Summary reports
@@ -43,7 +43,7 @@ Extracts all TRACES comments from:
### 2. Coverage Thresholds ### 2. Coverage Thresholds
The workflow checks: The workflow checks:
- **Minimum overall coverage:** 82% (`MIN_THRESHOLD`) - **Minimum overall coverage:** 88% (`MIN_THRESHOLD`)
Denominators are **derived from `docs/requirements.md` at run time** — they are Denominators are **derived from `docs/requirements.md` at run time** — they are
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
@@ -67,9 +67,10 @@ or if it computes above 100%, which can only mean the gate is miscounting.
#### Ratchet policy #### Ratchet policy
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below `MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
the coverage actually achieved (82 against a real 86%), so a genuine regression the coverage actually achieved (88 against a real ~90%), so a genuine regression
trips it. It previously sat at 50 while true coverage was 86%: nearly half the trips it. It previously sat at 50 while true coverage was 86%: nearly half the
matrix could have rotted before CI objected. matrix could have rotted before CI objected. It was ratcheted 50 → 82 when that
was found, and 82 → 88 once coverage had held above 88% for several releases.
When coverage rises durably, raise the threshold to just under the new figure. When coverage rises durably, raise the threshold to just under the new figure.
**Never lower it to make a red build pass** — add the missing TRACES comments **Never lower it to make a red build pass** — add the missing TRACES comments
@@ -151,13 +152,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### On Push to Main Branch ### On Push to Main Branch
1. ✅ Extracts all traces from code 1. ✅ Extracts all traces from code
2. ✅ Validates coverage is >= 82% 2. ✅ Validates coverage is >= 88%
3. ✅ Generates full traceability report 3. ✅ Generates full traceability report
4. ✅ Saves report as artifact 4. ✅ Saves report as artifact
### On Pull Request ### On Pull Request
1. ✅ Extracts all traces 1. ✅ Extracts all traces
2. ✅ Validates coverage >= 82% 2. ✅ Validates coverage >= 88%
3. ✅ Checks modified files for TRACES 3. ✅ Checks modified files for TRACES
4. ✅ Warns if new code lacks TRACES 4. ✅ Warns if new code lacks TRACES
5. ✅ Suggests proper format 5. ✅ Suggests proper format
@@ -165,7 +166,7 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### Failure Scenarios ### Failure Scenarios
The workflow **fails** (blocks merge) if: The workflow **fails** (blocks merge) if:
- Coverage drops below 82% - Coverage drops below 88%
- A `TRACES:` comment names an ID `docs/requirements.md` does not define - A `TRACES:` comment names an ID `docs/requirements.md` does not define
- JSON extraction fails - JSON extraction fails
- Invalid trace format - Invalid trace format
@@ -203,12 +204,12 @@ below threshold. Numbers are deliberately not pinned here; the previous snapshot
in this section (51%, 56/114) was stale by roughly 100 requirements and was what in this section (51%, 56/114) was stale by roughly 100 requirements and was what
made the broken CI arithmetic look plausible for so long. made the broken CI arithmetic look plausible for so long.
As of July 2026 overall coverage is ~86% (182/212). As of August 2026 overall coverage is ~90%.
### Targets ### Targets
- **Short term** (Sprint): Maintain ≥82% overall (the current ratchet) - **Short term** (Sprint): Maintain ≥88% overall (the current ratchet)
- **Medium term** (Month): Reach 70% overall coverage - **Medium term** (Month): Hold above 90% and ratchet the gate to match
- **Long term** (Release): Reach 90% coverage with focus on: - **Long term** (Release): Reach 95% coverage with focus on:
- IR requirements (API clients) - IR requirements (API clients)
- JA requirements (Jellyfin API endpoints) - JA requirements (Jellyfin API endpoints)
- Remaining UR/DR requirements - Remaining UR/DR requirements
@@ -241,14 +242,14 @@ When submitting a pull request:
- [ ] All new code has TRACES comments linking to requirements - [ ] All new code has TRACES comments linking to requirements
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002` - [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
- [ ] Workflow passes (coverage ≥ 82%) - [ ] Workflow passes (coverage ≥ 88%)
- [ ] No coverage regressions - [ ] No coverage regressions
- [ ] Artifact traceability report was generated - [ ] Artifact traceability report was generated
## Troubleshooting ## Troubleshooting
### "Coverage below minimum threshold" ### "Coverage below minimum threshold"
**Problem:** Workflow fails with coverage < 82% **Problem:** Workflow fails with coverage < 88%
**Solution:** **Solution:**
1. Run `bun run traces:json` locally 1. Run `bun run traces:json` locally
+7016 -6999
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -52,10 +52,10 @@ fn test_queue_next() {
## Where to Find Requirements ## Where to Find Requirements
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements) 1. **User Requirements (UR):** [requirements.md](requirements.md#1-user-requirements)
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements) 2. **Integration Requirements (IR):** [requirements.md](requirements.md#21-integration-requirements)
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements) 3. **Development Requirements (DR):** [requirements.md](requirements.md#23-development-requirements)
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements) 4. **Jellyfin API (JA):** [requirements.md](requirements.md#22-jellyfin-api-requirements)
## How to Add TRACES ## How to Add TRACES
@@ -139,13 +139,13 @@ bun run traces:json | jq '.requirements."UR-005"'
## CI/CD Validation ## CI/CD Validation
The workflow automatically checks: The workflow automatically checks:
- ✅ Coverage stays >= 82% (a ratchet — raise it, never lower it) - ✅ Coverage stays >= 88% (a ratchet — raise it, never lower it)
- ✅ Every traced ID is defined in `docs/requirements.md` - ✅ Every traced ID is defined in `docs/requirements.md`
- ✅ New files have TRACES - ✅ New files have TRACES
- ✅ JSON format is valid - ✅ JSON format is valid
- ✅ Reports are generated - ✅ Reports are generated
See [traceability-ci.md](docs/traceability-ci.md) for details. See [traceability-ci.md](traceability-ci.md) for details.
## Tips & Tricks ## Tips & Tricks
@@ -199,10 +199,10 @@ A: Yes! TRACES show your implementation plan.
## See Also ## See Also
- [Full Traceability Matrix](docs/traceability.md) - [Full Traceability Matrix](traceability.md)
- [CI/CD Pipeline Guide](docs/traceability-ci.md) - [CI/CD Pipeline Guide](traceability-ci.md)
- [Requirements Specification](README.md) - [Requirements Specification](requirements.md)
- [Extraction Script](scripts/README.md#extract-tracests) - [Extraction Script](../scripts/README.md#extract-tracests)
--- ---
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# Documentation link integrity: every relative markdown link must point at a
# file that exists.
#
# Implements DR-208 (see docs/requirements.md).
#
# Why this exists: docs/traceability.md is generated into docs/ while its file
# links were emitted repo-root-relative, so all ~2,800 of them resolved to
# docs/src-tauri/… and 404'd — in the Gitea repo browser and on the published
# mdBook site alike. Nobody clicks 2,800 links, so it went unnoticed for months.
# Several hand-written docs had the same defect at smaller scale: links to files
# that had been deleted, and links written as if the doc lived at the repo root.
# A link that does not resolve is a documentation defect of the same kind as a
# compile error, and a grep is enough to catch the whole class.
#
# What it checks: for every tracked `.md` file, every inline markdown link
# `[text](target)` whose target is a *path* — the target is resolved relative to
# the directory of the file containing it, and must exist on disk.
#
# ⚠️ It validates PATHS, NOT ANCHORS. A green run does not mean the links land
# where the text claims.
#
# 🔴 What it deliberately CANNOT see (do not read a green run as proof):
# - **Anchor fragments.** `foo.md#some-heading` is checked only as `foo.md`.
# Resolving the fragment needs a markdown renderer's heading-slug rules
# (which differ between Gitea, GitHub and mdBook), so a link to a heading
# that was renamed still passes here. That is a deliberate scope cut, not an
# oversight.
# - **External URLs.** http(s):// and mailto: are skipped. Checking them means
# network I/O in a gate, which makes the gate flaky and slow; link rot in an
# external URL is also not something a commit can break.
# - **Reference-style links** (`[text][ref]` with a separate `[ref]: target`
# definition) and bare autolinks. This project writes inline links; add the
# pattern here if that changes.
# - **Links inside fenced code blocks**, which are intentionally skipped —
# a template being *shown* to the reader (e.g. the release-notes template in
# docs/release-checklist.md) is sample text, not a live link, and its targets
# are resolved wherever it is eventually pasted, not from the docs tree.
# - **A link that resolves to the wrong existing file.** Existence is not
# correctness.
#
# Usage: bash scripts/check-doc-links.sh
# Exits non-zero, listing file:line and the unresolved target, on any failure.
set -euo pipefail
cd "$(dirname "$0")/.."
# Generated, vendored or build-output trees. Their markdown is not authored here
# and their link targets are not ours to fix.
EXCLUDES=(
"./node_modules/*"
"./.svelte-kit/*"
"./build/*"
"./dist/*"
"./src-tauri/gen/*"
"./src-tauri/target/*"
"./.git/*"
)
# Targets that do not exist in the repo *by design* because the publish-docs job
# writes them into docs/ at build time (see .gitea/workflows/publish-docs.yml).
# Keep this list to genuinely generated pages — anything else here is a broken
# link being hidden.
GENERATED_TARGETS=(
"./docs/README.md" # the site's landing page, written by publish-docs
"./docs/api-redirect.md" # the rustdoc redirect stub, likewise
)
is_generated() {
local candidate="$1"
for generated in "${GENERATED_TARGETS[@]}"; do
[[ "$candidate" == "$generated" ]] && return 0
done
return 1
}
echo "🔎 Checking relative markdown links resolve to files on disk…"
# Build the find(1) prune expression from EXCLUDES.
find_args=(. )
for pattern in "${EXCLUDES[@]}"; do
find_args+=(-path "$pattern" -prune -o)
done
find_args+=(-name "*.md" -type f -print)
mapfile -t md_files < <(find "${find_args[@]}" | sort)
echo " ${#md_files[@]} markdown files"
broken=""
checked=0
for md in "${md_files[@]}"; do
dir="$(dirname "$md")"
# One documented exception: docs-site/SUMMARY.md is mdBook's table of
# contents, and the publish-docs job copies it *into* docs/ before rendering
# (book.toml sets src = "../docs"). Its links are therefore written relative
# to docs/, not to the directory the file is stored in. Resolving it from
# docs/ is what actually validates it — and it is the check that catches a
# SUMMARY entry pointing at a page that does not exist, which mdBook itself
# only warns about.
if [[ "$md" == "./docs-site/SUMMARY.md" ]]; then
dir="./docs"
fi
# Strip fenced code blocks (``` and ~~~) before extracting links, so sample
# markdown shown to the reader is not checked as if it were a live link.
# Line numbers are preserved by blanking the lines rather than deleting them.
#
# Then emit "lineno<TAB>target" for each inline link on each surviving line.
while IFS=$'\t' read -r lineno target; do
[[ -z "${target:-}" ]] && continue
# Skip external schemes and pure-anchor links.
case "$target" in
http://*|https://*|mailto:*|ftp://*|"#"*|"") continue ;;
# A protocol-relative or scheme-ish target we do not resolve.
//*) continue ;;
esac
# Drop any anchor fragment and query string — we check the path only.
path="${target%%#*}"
path="${path%%\?*}"
[[ -z "$path" ]] && continue
# Percent-decode: SvelteKit route directories are literally named `[id]`,
# which docs link as `%5Bid%5D`, and spaces appear as `%20`.
if [[ "$path" == *%* ]]; then
path="$(printf '%b' "${path//%/\\x}")"
fi
checked=$((checked + 1))
if is_generated "$dir/$path"; then
continue
fi
if [[ ! -e "$dir/$path" ]]; then
broken+="${md}:${lineno} -> ${target}"$'\n'
fi
done < <(
awk '
/^[[:space:]]*(```|~~~)/ { fence = !fence; print ""; next }
fence { print ""; next }
{ print }
' "$md" |
grep -noE '\]\([^)[:space:]]+' |
sed -E 's/^([0-9]+):\]\(/\1\t/'
)
done
echo " $checked relative links checked"
if [[ -n "$broken" ]]; then
echo ""
echo "❌ Broken documentation links — these targets do not exist on disk:"
echo ""
echo "$broken" | sed 's/^/ /'
echo " Each link is resolved relative to the directory of the file it is in."
echo " The usual causes:"
echo " • the target file was moved or deleted — update or drop the link;"
echo " • the link was written as if the doc lived at the repo root — a doc"
echo " in docs/ needs '../' to reach src/, scripts/ or CHANGELOG.md;"
echo " • a generated doc emits repo-root-relative hrefs — fix the"
echo " generator, not the output (see scripts/extract-traces.ts)."
exit 1
fi
echo "✅ All relative documentation links resolve."
echo " (Reminder: paths only — anchors and external URLs are NOT checked.)"
+79 -4
View File
@@ -11,6 +11,7 @@
* *
* @req-test: UT-089 - Requirement definitions parsed from requirements.md * @req-test: UT-089 - Requirement definitions parsed from requirements.md
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs * @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
* @req-test: UT-202 - Generated matrix links resolve from docs/
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@@ -20,7 +21,10 @@ import {
countDefinedRequirements, countDefinedRequirements,
computeCoverage, computeCoverage,
findDanglingIds, findDanglingIds,
formatMatrixFileLink,
generateMarkdown,
MIN_COVERAGE_PERCENT, MIN_COVERAGE_PERCENT,
type TracesData,
} from "./extract-traces"; } from "./extract-traces";
// import.meta.dir is Bun-only; derive from import.meta.url under vitest. // import.meta.dir is Bun-only; derive from import.meta.url under vitest.
@@ -250,6 +254,73 @@ describe("computeCoverage", () => {
}); });
}); });
describe("generated matrix file links", () => {
// Regression: the generator emitted the repo-root-relative path as the href
// (`](src-tauri/src/…)`), but writes its output to docs/traceability.md — so
// every one of the ~2,800 links resolved to docs/src-tauri/… and 404'd, in
// the repo browser and on the published mdBook site. The markdown generator
// had no test at all, which is why it survived. UT-202.
//
// @req-test: UT-202
/** A minimal TracesData whose single entry points at a file that really exists. */
function fixture(file: string, line = 12): TracesData {
return {
timestamp: new Date().toISOString(),
totalFiles: 1,
totalTraces: 1,
requirements: {
"DR-093": [{ file, line, context: "export function x() {}" }],
},
byType: { UR: [], IR: [], DR: ["DR-093"], JA: [] },
} as TracesData;
}
/** Pull the href out of the first `- **File:** [`x`](href)` line. */
function firstHref(md: string): string {
const m = md.match(/^- \*\*File:\*\* \[`[^`]+`\]\(([^)]+)\)/m);
expect(m).not.toBeNull();
return m![1];
}
it("emits an href that resolves, from docs/, to a file that exists", () => {
// Use a real repo file so "exists on disk" is a genuine assertion.
const target = "scripts/extract-traces.ts";
const md = generateMarkdown(fixture(target));
const href = firstHref(md);
const [relPath] = href.split("#");
// traceability.md is written to docs/, so links resolve from there.
const resolved = path.resolve(HERE, "../docs", relPath);
expect(fs.existsSync(resolved)).toBe(true);
expect(resolved).toBe(path.resolve(HERE, "..", target));
});
it("keeps the repo-root-relative path as the visible link text", () => {
// The text is what a developer copies into an editor or a grep; only the
// href is rewritten for the docs/ location.
const md = generateMarkdown(fixture("src-tauri/src/lib.rs"));
expect(md).toContain("[`src-tauri/src/lib.rs`]");
expect(md).not.toContain("[`../src-tauri/src/lib.rs`]");
});
it("keeps the #Lnn line anchor on the href", () => {
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
expect(link).toBe(
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
);
});
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
const md = generateMarkdown(fixture("scripts/extract-traces.ts"));
const href = firstHref(md);
expect(href.startsWith("../")).toBe(true);
// The pre-fix output — the exact shape that produced docs/scripts/….
expect(href.startsWith("scripts/")).toBe(false);
});
});
describe("live requirements.md", () => { describe("live requirements.md", () => {
it("parses the real file to the counts the CI gate must use", () => { it("parses the real file to the counts the CI gate must use", () => {
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3 // Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
@@ -262,7 +333,7 @@ describe("live requirements.md", () => {
); );
const defined = countDefinedRequirements(md); const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(75); expect(defined.UR).toBe(76);
expect(defined.IR).toBe(32); expect(defined.IR).toBe(32);
// 192 = 187 + four requirements added independently on four audit branches, // 192 = 187 + four requirements added independently on four audit branches,
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four // plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
@@ -272,9 +343,13 @@ describe("live requirements.md", () => {
// merge, where it collided). Each branch bumped for its own — merged, // merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side. 193 adds // they sum. Resolve this by summing, never by taking one side. 193 adds
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff // DR-202 (video keeps the display awake), 194 DR-203 (the handoff
// transcode refusing the player's own load-error retry). // transcode refusing the player's own load-error retry). 200 adds the
expect(defined.DR).toBe(194); // six tooling/quality requirements DR-204..DR-209 (logging facade, lint
// gate, pinned toolchain, pre-commit hook, doc-link check, server-side
// library folder exclusion); UR rises to 76 with UR-076, which DR-209
// serves.
expect(defined.DR).toBe(200);
expect(defined.JA).toBe(36); expect(defined.JA).toBe(36);
expect(defined.total).toBe(337); expect(defined.total).toBe(344);
}); });
}); });
+30 -3
View File
@@ -23,7 +23,7 @@ interface RequirementMapping {
[reqId: string]: TraceEntry[]; [reqId: string]: TraceEntry[];
} }
interface TracesData { export interface TracesData {
timestamp: string; timestamp: string;
totalFiles: number; totalFiles: number;
totalTraces: number; totalTraces: number;
@@ -366,7 +366,34 @@ export function readDefinedRequirements(): DefinedRequirements {
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8")); return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
} }
function generateMarkdown(data: TracesData): string { /**
* Path prefix that turns a repo-root-relative file path into a link target that
* resolves from `docs/traceability.md`, where this markdown is written.
*
* The generated matrix lives one directory below the repo root, so a bare
* `src-tauri/src/player/mod.rs` href resolves to `docs/src-tauri/…` and 404s
* in the repo browser and on the published mdBook site alike. Every file link
* in the matrix was dead for this reason. The *display text* stays
* repo-root-relative (that is the path a developer types and greps for); only
* the href is rewritten.
*
* TRACES: | DR-093 | UT-202
*/
export const MATRIX_LINK_PREFIX = "../";
/**
* Build the ``[`path`](href#Lnn)`` link used for one trace entry in the matrix.
*
* Exported so extract-traces.test.ts can resolve a generated href against
* `docs/` and assert the target exists on disk.
*
* TRACES: | DR-093 | UT-202
*/
export function formatMatrixFileLink(file: string, line: number): string {
return `[\`${file}\`](${MATRIX_LINK_PREFIX}${file}#L${line})`;
}
export function generateMarkdown(data: TracesData): string {
let md = `# Code Traceability Matrix let md = `# Code Traceability Matrix
**Generated:** ${new Date(data.timestamp).toLocaleString()} **Generated:** ${new Date(data.timestamp).toLocaleString()}
@@ -424,7 +451,7 @@ ${data.byType.JA.join(", ")}
md += `**Locations:** ${entries.length} file(s)\n\n`; md += `**Locations:** ${entries.length} file(s)\n\n`;
for (const entry of entries) { for (const entry of entries) {
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`; md += `- **File:** ${formatMatrixFileLink(entry.file, entry.line)}\n`;
md += ` - **Line:** ${entry.line}\n`; md += ` - **Line:** ${entry.line}\n`;
const contextPreview = entry.context.substring(0, 70); const contextPreview = entry.context.substring(0, 70);
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`; md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;