build(frontend)!: knip drops --production; plain knip stays

The production pass (7ed2ac4e, E32) asked "is this export used by what
ships?". No real hit: its only findings were 37 exports a test imports,
answered with 36 `@internal` JSDoc tags (E34 S2) — test-only exports, not
defects. Since then it has only ever asked for another tag. The tags go
with it (36, no longer read by anything), and so do the production-only
`!…!` exclusions in knip.jsonc and their register rows. Plain knip (unused
files, exports and dependencies) keeps running in the gate.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent 7e681e112e
commit 36653886d5
30 changed files with 12 additions and 68 deletions
+1 -13
View File
@@ -1,18 +1,6 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
// `--production` (E32) asks a second question besides "is this used?": "is it used by the code
// that SHIPS?" — an export only a test reaches is either dead in the bundle or exported for its
// test, which then says so with a JSDoc `@internal` tag (knip skips those in production mode).
// What is not production code at all is excluded from the production project with a `!…!`
// pattern: the test kits, the e2e suite, and the two build-tooling modules the vite config
// imports (they shape the bundle but are not in it).
"project": [
"**/*.{ts,tsx,js}!",
"!**/*.testkit.ts!",
"!e2e/**!",
"!reactPlugin.ts!",
"!devOnlyGuard.ts!",
],
"project": ["**/*.{ts,tsx,js}"],
"playwright": {
"config": ["playwright.config.ts"],
"entry": ["e2e/**/*.@(spec|setup|measure).ts"],
+1 -1
View File
@@ -14,7 +14,7 @@
"typecheck:e2e": "tsc --noEmit -p e2e/tsconfig.json",
"build": "npm run typecheck:app && npm run typecheck:node && vite build",
"preview": "vite preview",
"knip": "knip && knip --production",
"knip": "knip",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -45,7 +45,6 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
/** @internal exported for channelColumns.dom.test.tsx */
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
return values.some((v) => c.tag_ids.includes(Number(v)));
@@ -22,7 +22,6 @@ import UserPicker from "./UserPicker";
const GiB = 1024 ** 3;
/** @internal exported for downloadAdmin.dom.test.tsx */
export function QuotaModal({
userId,
email,
@@ -184,7 +184,6 @@ export function DetailKeywords({
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
// text is the hero's tallest part, and twelve names ran it ~170px past the text (E31, the owner's call).
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
/** @internal exported for PlexInfo.dom.test.tsx */
export const CREW_SHOWN = 6;
const CREW_FOLD_OVER = CREW_SHOWN + 2;
@@ -13,7 +13,6 @@ import { SURFACE } from "./surfaces";
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
/** @internal exported for FilterSelect.dom.test.tsx */
export interface FilterSelectOption {
value: string;
label: string;
@@ -12,7 +12,6 @@ import { startTransition, useEffect, useState } from "react";
//
// Not virtualization: every row still ends up in the DOM, so browser find, keyboard nav past the screen,
// and the Back focus/scroll restore (which wait for the list to grow) keep working as they did.
/** @internal exported for DataTable.progressive.dom.test.tsx */
export const FIRST_ROWS = 30;
const STEP_ROWS = 40;
-4
View File
@@ -8,16 +8,12 @@
// - ceiling MAX_LOOKAHEAD_ROWS: a tall window renders many rows, and an uncapped lookahead would
// eagerly prefetch several pages on every mount / filter change — wasted work on the app's
// heaviest query (the feed). The cap bounds that while still buffering a couple of screens ahead.
/** @internal exported for readAhead.test.ts */
export const PREFETCH_ROWS = 4;
/** @internal exported for readAhead.test.ts */
export const PREFETCH_VIEWPORTS = 2;
/** @internal exported for readAhead.test.ts */
export const MAX_LOOKAHEAD_ROWS = 40;
/** How many rows ahead of the last rendered row to keep buffered, for a viewport rendering
* `renderedRows` rows. Clamped to [PREFETCH_ROWS, MAX_LOOKAHEAD_ROWS]. */
/** @internal exported for readAhead.test.ts */
export function readAheadLookahead(renderedRows: number): number {
return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS));
}
-2
View File
@@ -10,7 +10,6 @@ import { CONTENT_ID } from "./landmarks";
// back (`room - saving > FOLD_AT`): a page barely taller than the window would otherwise fold, lose its
// scroll, land at the top, unfold, and so on, with every wheel tick;
// - unfold only at the very top, not at the fold point: between the two the header keeps whatever it was.
/** @internal exported for scrollFold.test.ts */
export const FOLD_AT = 32;
interface ScrollPosition {
@@ -21,7 +20,6 @@ interface ScrollPosition {
}
// `saving` = the height the header gives back when it folds (measured while it is unfolded).
/** @internal exported for scrollFold.test.ts */
export function nextFolded(
folded: boolean,
{ top, room }: ScrollPosition,
-1
View File
@@ -10,7 +10,6 @@
// 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.
-1
View File
@@ -4,7 +4,6 @@
/**
* 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. */
-1
View File
@@ -34,7 +34,6 @@ 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,7 +60,6 @@ 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][],
+5 -5
View File
@@ -542,20 +542,20 @@ describe("the gate register (docs/gates.md)", () => {
});
// --- knip -----------------------------------------------------------------------------------------
it("lists every knip production exclusion and every other key, and runs both modes", () => {
it("lists every knip exclusion and every other key", () => {
const cfg = jsonc("frontend/knip.jsonc") as { project?: string[] } & Record<string, unknown>;
const found: [string, string][] = (cfg.project ?? [])
.filter((p) => p.startsWith("!"))
.map((p): [string, string] => ["--production", p]);
.map((p): [string, string] => ["knip", p]);
for (const key of Object.keys(cfg).filter(
(k) => !["$schema", "project", "playwright"].includes(k),
))
found.push(["knip", key]);
expect(missingFrom(found)).toEqual([]);
const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
expect(pkg.scripts.knip).toBe("knip && knip --production");
// the positive pattern is the project itself: narrowing it drops files from both modes
expect((cfg.project ?? []).filter((p) => !p.startsWith("!"))).toEqual(["**/*.{ts,tsx,js}!"]);
expect(pkg.scripts.knip).toBe("knip");
// the positive pattern is the project itself: narrowing it drops files from the lane
expect((cfg.project ?? []).filter((p) => !p.startsWith("!"))).toEqual(["**/*.{ts,tsx,js}"]);
// and knip.jsonc is the config knip reads: a knip.json (read first) or a package.json `knip` key
// (merged in) would narrow it unseen (E32 review round 3)
const KNIP_FILES = /^\.?knip(\.config)?\.(json|jsonc|js|ts|mjs|cjs|mts|cts|toml|ya?ml)$/;
+2 -2
View File
@@ -2217,7 +2217,7 @@
"e2e": "playwright test",
"format": "prettier --write .",
"format:check": "prettier --check .",
"knip": "knip && knip --production",
"knip": "knip",
"lint": "eslint . --max-warnings 0",
"preview": "vite preview",
"test": "vitest run",
@@ -2313,7 +2313,7 @@
"lane \"tsc (src + configs + e2e)\" frontend npm run --silent typecheck",
"lane \"eslint (warnings fail too)\" frontend npm run --silent lint",
"lane \"prettier --check\" frontend npm run --silent format:check",
"lane \"knip (unused, and unused by what ships)\" frontend npm run --silent knip",
"lane \"knip (unused files, exports, dependencies)\" frontend npm run --silent knip",
"pytest_lane() { # the full suite against a throwaway Postgres; --pull keeps the base image current",
" docker compose -f \"$TESTCOMPOSE\" build --pull -q test || return 1",
" local rc=0",
-1
View File
@@ -58,7 +58,6 @@ 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,7 +7,6 @@ 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
@@ -258,7 +258,6 @@ 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,7 +48,6 @@ 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;
}
@@ -79,7 +78,6 @@ 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);
}
-1
View File
@@ -5,7 +5,6 @@
/**
* 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;
-3
View File
@@ -23,7 +23,6 @@ export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL
/**
* 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,
@@ -62,7 +61,6 @@ 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) ------------------------------------------------------
@@ -101,7 +99,6 @@ 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,7 +95,6 @@ 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,7 +36,6 @@ 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> {
-1
View File
@@ -19,7 +19,6 @@ export interface VolumeScale {
/**
* 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). */
-8
View File
@@ -58,7 +58,6 @@ 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`;
@@ -66,14 +65,12 @@ 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;
@@ -83,14 +80,12 @@ 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);
@@ -100,7 +95,6 @@ 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}`;
@@ -109,7 +103,6 @@ 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;
@@ -122,7 +115,6 @@ 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,7 +38,6 @@ 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);