build(frontend): knip also checks what ships; strict indexing in every project

`npm run knip` now runs a second, --production pass, which asks whether the
code that ships uses an export, not just whether anything does. Of the 47
exports only tests reached:
- 34 are exported for their test and say so with @internal, naming the test.
- 11 were module-private names with a stray `export`. Two of them were not
  used at all and are deleted.
- The unused alreadyReported re-export is gone.
Test kits, the e2e suite and the two build-tooling modules are excluded from
the production project, each named in knip.jsonc.

noUncheckedIndexedAccess is now on for the node and e2e tsconfigs too (0
errors), matching the app project. tsconfig.node.json listed a
tailwind.config.js that has not existed since Tailwind 4. That entry is now
playwright.config.ts, which the typed lint finds through this project.
This commit is contained in:
2026-10-03 16:44:28 +02:00
parent cff8fe9a6a
commit 7ed2ac4e95
34 changed files with 84 additions and 33 deletions
+1
View File
@@ -10,6 +10,7 @@
// again through `onAccountPinned`.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
/** @internal exported for 13 tests (BootGate.dom.test.tsx, …) */
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
// Re-readers for module-level per-account state, run each time the tab is pinned.
+2 -8
View File
@@ -6,16 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.)
import {
alreadyReported,
getActiveAccount,
HttpError,
setActiveAccount,
setUnauthorizedHandler,
} from "./api/core";
import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export { alreadyReported, HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
export { HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
import { accountApi } from "./api/account";
export type * from "./api/account";
+2 -2
View File
@@ -19,11 +19,11 @@ export type HeroTier = "resume" | "comfort" | "surprise";
* leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */
export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"];
export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
// What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned
// as `plex_show` because "put that show on" resolves to whichever episode is next.
export const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
export type PinKind = (typeof PIN_KINDS)[number];
export interface DiscoverCard {
+1 -1
View File
@@ -58,7 +58,7 @@ export interface PlexCard {
// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail.
// null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row,
// facts sidebar, keyword chips (→ filter), and the full crew.
export interface PlexTmdbCrew {
interface PlexTmdbCrew {
name: string;
job: string;
tmdb_id?: number | null;
+5 -2
View File
@@ -2,8 +2,11 @@
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
export type TagMode = "or" | "and";
/**
* A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL.
* @internal exported for its test
*/
type TagMode = "or" | "and";
/** The three tag categories, each with its OWN independent match mode. */
export type TagCategory = "language" | "topic" | "other";
export type TagModes = Record<TagCategory, TagMode>;
+1 -1
View File
@@ -11,7 +11,7 @@ export interface Tag {
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
export interface TagSuggestion {
interface TagSuggestion {
tag_id: number;
confidence: number;
}
+1
View File
@@ -34,6 +34,7 @@ export function hueGap(a: string, b: string): number {
/** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the
* browser does in the srgb space. */
/** @internal exported for theme.contract.test.ts */
export function mixHex(a: string, b: string, w: number): string {
const [x, y] = [rgb(a), rgb(b)];
return `#${x
+1
View File
@@ -60,6 +60,7 @@ export function formatSpeed(bps: number | null | undefined, lang: string): strin
* Module-level because it is constant, and exported so the tests can hold that invariant. */
// A non-empty tuple type (first element + rest) so `RELATIVE_UNITS[0]` is known-present under
// noUncheckedIndexedAccess — the "sub-minute" branch relies on that first entry always existing.
/** @internal exported for format.test.ts */
export const RELATIVE_UNITS: readonly [
[number, Intl.RelativeTimeFormatUnit],
...[number, Intl.RelativeTimeFormatUnit][],
+1
View File
@@ -58,6 +58,7 @@ export function draftSteps(
/** The operating system a user agent names. Checked most specific first: Android's agent also says
* "Linux" — and says it first — and an iPad's also says "Mac OS X". */
/** @internal exported for issueDraft.test.ts */
export function osName(ua: string): string {
const tests: [RegExp, (m: RegExpExecArray) => string][] = [
[/Android [\d.]+/, (m) => m[0]],
+1
View File
@@ -7,6 +7,7 @@ import { vocabKey } from "./vocab";
* one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a
* result, and `enrich`'s bare count ("50") into `{enriched: 50}` as `_result_data` names it; a nested
* phase (a Python dict repr) or anything else does not, and stays as it was. */
/** @internal exported for jobResult.dom.test.ts */
export function parseSummary(summary: string | null | undefined): JobResult | null {
if (!summary || /[{}[\]]/.test(summary)) return null;
if (/^\d+$/.test(summary)) return { enriched: Number(summary) };
+1
View File
@@ -259,6 +259,7 @@ function keyMatches(e: KeyboardEvent, name: string): boolean {
* Ctrl+A too, so select-all cycled the Plex audio track. It also means Shift+H is not `h` — a
* narrowing from the old `case "h": case "H":`, and the reason Shift chords can now be their own
* rows (Shift+← steps an episode without ← also seeking). */
/** @internal exported for keymap.test.ts */
export function matches(e: KeyboardEvent, chord: string): boolean {
const parts = chord.split("+");
const key = parts[parts.length - 1] ?? "";
+2
View File
@@ -48,6 +48,7 @@ export function anyBlockingLayer(): boolean {
return stack.some((id) => !passive.has(id));
}
/** @internal exported for layerStack.dom.test.ts */
export function isTopmost(id: number): boolean {
return stack.length > 0 && stack[stack.length - 1] === id;
}
@@ -78,6 +79,7 @@ export function anyBlockingLayerAbove(id: number): boolean {
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
// WeakSet so a spent event is GC'd without a manual sweep.
const handledEscapes = new WeakSet<KeyboardEvent>();
/** @internal exported for keymap.test.ts, layerStack.dom.test.ts */
export function markEscapeHandled(e: KeyboardEvent): void {
handledEscapes.add(e);
}
+4 -1
View File
@@ -3,7 +3,10 @@
// over hundreds of channels, the Plex pickers, the filter and sort menus — had only the arrows. One
// definition of "a page" here, so the palette and the dropdowns cannot drift apart.
/** A page when there is no layout to measure (jsdom, or a list not painted yet). */
/**
* A page when there is no layout to measure (jsdom, or a list not painted yet).
* @internal exported for listPaging.test.ts
*/
export const PAGE_FALLBACK = 10;
/** The nearest ancestor that scrolls vertically. */
+6 -1
View File
@@ -21,7 +21,10 @@ import { FRONTEND_VERSION } from "./version";
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
/** OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares). */
/**
* OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares).
* @internal exported for log.contract.test.ts
*/
export const SEVERITY: Record<Level, number> = {
TRACE: 1,
DEBUG: 5,
@@ -59,6 +62,7 @@ export interface LogEvent {
/** Events kept. At ~0.5 KB each the ring stays around a megabyte — a long session's worth of
* navigation and requests, without growing for as long as a tab stays open. */
/** @internal exported for log.test.ts */
export const RING_MAX = 2000;
// --- redaction (mirrors backend/app/logs.py) ------------------------------------------------------
@@ -97,6 +101,7 @@ function redactText(text: string): string {
/** Recursively scrub a structure destined for the log. A value whose KEY names a secret is dropped
* whole — a prefix of a token is still a leak. Everything else is scrubbed as text. */
/** @internal exported for log.contract.test.ts */
export function redact(value: unknown, key = ""): unknown {
const lowered = key.toLowerCase();
if (key && SECRET_KEY_PARTS.some((part) => lowered.includes(part))) return REDACTED;
+1
View File
@@ -95,6 +95,7 @@ const ERROR_TTL = 15000;
// A toast that carries an action (an "Undo") lives at least this long, whatever the reader's toast
// time: its button is the only way back, and a 3 s toast leaves no time to reach it (owner, E28).
// The setting still decides for longer, and "never" still means never.
/** @internal exported for notifications.actionTtl.dom.test.ts */
export const ACTION_TTL_MIN = 8000;
// 6b: per-account notification settings (persisted by the Settings panel into the
+1
View File
@@ -36,6 +36,7 @@ const MAX_CONSECUTIVE_ERRORS = 3;
export const YT_API_LOAD_FAILED = -2;
/** How long the script may take before the player gives up on it. The real answer comes in well
* under a second; anything near this is a blocked or stalled request, not a slow one. */
/** @internal exported for playerModalHooks.apiload.dom.test.tsx */
export const YT_API_TIMEOUT_MS = 15_000;
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
+4 -1
View File
@@ -17,7 +17,10 @@ export interface VolumeScale {
round: boolean;
}
/** YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted). */
/**
* YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted).
* @internal exported for playerVolume.test.ts
*/
export const YT_SCALE: VolumeScale = { min: 0, max: 100, default: 100, round: true };
/** HTML5 <video>: a 0..1 float (mute is a separate flag the caller carries). */
export const PLEX_SCALE: VolumeScale = { min: 0, max: 1, default: 1, round: false };
+8
View File
@@ -58,6 +58,7 @@ export interface StatRow {
const DASH = "—";
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
/** @internal exported for plexStats.test.ts */
export function fmtBitrate(bps: number | undefined, lang: string): string {
if (!bps || bps <= 0) return DASH;
if (bps >= 1_000_000) return `${formatDecimal(bps / 1_000_000, lang)} Mbps`;
@@ -65,12 +66,14 @@ export function fmtBitrate(bps: number | undefined, lang: string): string {
}
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
/** @internal exported for plexStats.test.ts */
export function fmtBufferSecs(s: number, lang: string): string {
if (!Number.isFinite(s) || s < 0) return DASH;
return formatUnit(s, "second", lang, 1);
}
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
/** @internal exported for plexStats.test.ts */
export function fmtFrames(dropped: number, total: number, lang: string): string {
if (total <= 0) return DASH;
const pct = (dropped / total) * 100;
@@ -80,12 +83,14 @@ export function fmtFrames(dropped: number, total: number, lang: string): string
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
// which is itself the tell for a not-yet-playable codec pre-transcode.
/** @internal exported for plexStats.test.ts */
export function fmtRes(w: number, h: number): string {
if (w <= 0 || h <= 0) return DASH;
return `${w}×${h}`;
}
// Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately).
/** @internal exported for plexStats.test.ts */
export function fmtClock(s: number): string {
if (!Number.isFinite(s) || s < 0) return DASH;
const total = Math.floor(s);
@@ -95,6 +100,7 @@ export function fmtClock(s: number): string {
}
// hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count.
/** @internal exported for plexStats.test.ts */
export function fmtLevel(l: HlsLevelInfo | null): string {
if (!l || l.total <= 0) return DASH;
const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`;
@@ -103,6 +109,7 @@ export function fmtLevel(l: HlsLevelInfo | null): string {
}
// Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash).
/** @internal exported for plexStats.test.ts */
export function fmtCodec(video: string | undefined, audio: string | undefined): string {
const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1"
const a = audio?.split(".")[0] || null;
@@ -115,6 +122,7 @@ export function fmtCodec(video: string | undefined, audio: string | undefined):
// "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
// HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens
// (not i18n'd), consistent with the other technical values in this overlay (e.g. transport "hls").
/** @internal exported for plexStats.test.ts */
export function fmtMode(
playable: string,
hwEncoder?: boolean | null,
+1
View File
@@ -38,6 +38,7 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null
* else the previous one, widening to the parent's neighbours — so its removal does not drop the
* focus to <body>. For a removal the caller makes itself (a chip's ×) anywhere, including a modal
* or a side panel, where the keeper below does not watch. False when nothing was found. */
/** @internal exported for useFocusKeeper.dom.test.tsx */
export function focusNeighbour(el: HTMLElement): boolean {
for (let at: HTMLElement | null = el; at && at !== document.body; at = at.parentElement) {
const hit = firstFocusable(at.nextSibling) ?? firstFocusable(at.previousSibling, true);