merge(W2): i18n dead-key sweep, reverse-coverage guard, primitives, docs

Remove 177 verified-dead i18n keys across all six locales and add a
reverse-coverage test so orphans cannot re-accumulate. Drop forwardRef
from textarea/native-select (React 19 ref-as-prop), route a consumption
write through toJson(), cap the offline persister snapshot at 2 MB and
surface quota errors instead of swallowing them. Refresh CLAUDE.md drift
(model count, React pin, release line, directory paths, symbol anchors).
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 18:48:14 +02:00
commit e15e8ef428
15 files changed
+465 -1144

No files matched your search

+1 -1
View File
@@ -68,7 +68,7 @@ const REQUIRED_KEY_SETS: Array<{
}> = [
{
label: "admin.carrier (B3 — login carrier chip)",
required: ["admin.carrier", "admin.carrierUnknown"],
required: ["admin.carrier"],
},
{
label: "insights.emptyState.* (B4 — per-metric empty states)",
+232
View File
@@ -0,0 +1,232 @@
/**
* v1.18.6 — reverse i18n coverage guard.
*
* The forward `i18n-call-site-coverage.test.ts` asserts every `t("ns.key")`
* call site resolves to a leaf in `messages/en.json`. This test asserts the
* REVERSE: every leaf in `messages/en.json` is reachable from at least one
* call site, so orphan keys can't accumulate in the bundle unnoticed.
*
* "Reachable" recognises the key-construction patterns the codebase actually
* uses:
* - a literal `t("a.b.c")` (hyphens allowed — admin section slugs use them);
* - a `t("a.b")` that returns a subtree, covering every descendant leaf;
* - a dynamic key built inside a `t(`…`)` template, e.g. the per-metric
* `${i18nPrefix}.title` insight pages, `cycle.calendar.flow${LEVEL}`, or
* the `medications.site${Suffix}` label keys — captured by the static
* leading-literal prefix and/or trailing-literal suffix of the template;
* - a key string assembled outside the call (`return `medications.site${s}``
* in `describeInjectionSite`) and passed to `t()`, recognised when the
* template's leading literal is rooted at a real top-level namespace;
* - an explicit allowlist for keys resolved through a DB column.
*
* Dynamic detection is scoped to `t(`…`)` and to namespace-rooted key
* templates only — NOT to every backtick string — so a cache key or
* wide-event action name that happens to start with a namespace word doesn't
* mask a genuinely dead key.
*
* If this fails after adding a key, either wire a call site, or — for a key
* resolved through a runtime value the static scan can't see — add its prefix
* to `DYNAMIC_ALLOWLIST_PREFIXES` with a one-line note on why.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(__dirname, "../..");
const SRC = join(ROOT, "src");
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
/**
* Keys resolved through a runtime value no static scan can see. Each entry is
* a leaf or a namespace prefix that is always live.
* - `mood.tag` / `mood.tagCategory`: the catalogue is DB-driven; the label key
* is built from a `MoodTag.messageKey` / `MoodTagCategory.messageKey` column,
* so the leaf never appears as a string literal in the tree.
*/
const DYNAMIC_ALLOWLIST_PREFIXES = ["mood.tag", "mood.tagCategory"] as const;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const stat = statSync(p);
if (stat.isDirectory()) {
walk(p, out);
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
out.push(p);
}
}
return out;
}
function leafPaths(obj: unknown, prefix: string, out: string[]): void {
if (!obj || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const path = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) {
leafPaths(v, path, out);
} else {
out.push(path);
}
}
}
function nodePaths(obj: unknown, prefix: string, out: Set<string>): void {
if (!obj || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const path = prefix ? `${prefix}.${k}` : k;
out.add(path);
if (v && typeof v === "object" && !Array.isArray(v)) {
nodePaths(v, path, out);
}
}
}
interface Reference {
/** Literal `t("…")` keys. */
literalKeys: Set<string>;
/** Dotted bases (`base.${…}` / a full string-literal node path): cover the subtree. */
dottedBases: Set<string>;
/** Dot-less concat prefixes (`medications.site${…}`): cover any leaf that string-starts with them. */
concatPrefixes: Set<string>;
/** Trailing static suffixes of a template key (`${…}.title`). */
dynamicSuffixes: Set<string>;
}
function collectReferences(
allNodes: Set<string>,
topNamespaces: Set<string>,
): Reference {
const allText = walk(SRC)
.map((f) => readFileSync(f, "utf8"))
.join("\n");
const literalKeys = new Set<string>();
const literalKeyRe =
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
for (const m of allText.matchAll(literalKeyRe)) literalKeys.add(m[1]);
const dottedBases = new Set<string>();
const concatPrefixes = new Set<string>();
const dynamicSuffixes = new Set<string>();
const ingestTemplate = (content: string) => {
const open = content.indexOf("${");
if (open > 0) {
const lit = content.slice(0, open);
if (lit.includes(".")) {
if (lit.endsWith(".")) dottedBases.add(lit.slice(0, -1));
else concatPrefixes.add(lit);
}
}
const close = content.lastIndexOf("}");
if (close !== -1 && close < content.length - 1) {
const tail = content.slice(close + 1).replace(/^\./, "");
if (tail) dynamicSuffixes.add(tail);
}
};
// Templates passed directly to t(`…`).
const tTemplateRe = /(?<![a-zA-Z0-9_])t\(\s*`([^`]*)`/g;
for (const m of allText.matchAll(tTemplateRe)) ingestTemplate(m[1]);
// Key strings assembled outside the call and handed to t() (the
// `describeInjectionSite` pattern). Recognised only when the template's
// leading literal is rooted at a real top-level i18n namespace, so a cache
// key / action name that interpolates can't mask a dead key.
const assignTemplateRe = /`([a-zA-Z][a-zA-Z0-9_.-]*)\$\{/g;
for (const m of allText.matchAll(assignTemplateRe)) {
const lit = m[1];
if (!lit.includes(".")) continue;
if (!topNamespaces.has(lit.split(".")[0])) continue;
if (lit.endsWith(".")) dottedBases.add(lit.slice(0, -1));
else concatPrefixes.add(lit);
}
// String-concatenation prefixes: `"notifications.event" + eventType…`
// builds keys like `notifications.eventMoodReminder`. Recognised only when
// the literal is rooted at a real top-level namespace.
const plusConcatRe = /["']([a-zA-Z][a-zA-Z0-9_.]*)["']\s*\+/g;
for (const m of allText.matchAll(plusConcatRe)) {
const lit = m[1];
if (!lit.includes(".")) continue;
if (!topNamespaces.has(lit.split(".")[0])) continue;
if (lit.endsWith(".")) dottedBases.add(lit.slice(0, -1));
else concatPrefixes.add(lit);
}
// Full string-literal node paths (covers map values returned to t()).
const litPathRe = /["']([a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z0-9_-]+)+)["']/g;
for (const m of allText.matchAll(litPathRe)) {
if (allNodes.has(m[1])) dottedBases.add(m[1]);
}
return { literalKeys, dottedBases, concatPrefixes, dynamicSuffixes };
}
function isReferenced(leaf: string, ref: Reference): boolean {
if (ref.literalKeys.has(leaf)) return true;
for (const ck of ref.literalKeys) {
if (leaf.startsWith(`${ck}.`)) return true;
}
for (const base of ref.dottedBases) {
if (leaf === base || leaf.startsWith(`${base}.`)) return true;
}
for (const pre of ref.concatPrefixes) {
if (leaf.startsWith(pre)) return true;
}
for (const suffix of ref.dynamicSuffixes) {
if (leaf !== suffix && !leaf.endsWith(`.${suffix}`)) continue;
const base = leaf.slice(0, leaf.length - suffix.length).replace(/\.$/, "");
for (const b of ref.dottedBases) {
if (base === b || base.startsWith(`${b}.`) || b.startsWith(`${base}.`)) {
return true;
}
}
for (const ck of ref.literalKeys) {
if (base === ck || base.startsWith(`${ck}.`) || ck.startsWith(`${base}.`)) {
return true;
}
}
for (const pre of ref.concatPrefixes) {
if (base.startsWith(pre)) return true;
}
}
return false;
}
function isAllowlisted(leaf: string): boolean {
return DYNAMIC_ALLOWLIST_PREFIXES.some(
(p) => leaf === p || leaf.startsWith(`${p}.`),
);
}
describe("i18n reverse coverage", () => {
it("every key in messages/en.json has a call site or is allowlisted", () => {
const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8"));
const leaves: string[] = [];
leafPaths(en, "", leaves);
expect(leaves.length).toBeGreaterThan(1000);
const allNodes = new Set<string>();
nodePaths(en, "", allNodes);
const topNamespaces = new Set<string>(Object.keys(en));
const ref = collectReferences(allNodes, topNamespaces);
// Sanity: the static scan must see the bulk of the bundle.
expect(ref.literalKeys.size).toBeGreaterThan(1000);
const orphans = leaves.filter(
(leaf) => !isAllowlisted(leaf) && !isReferenced(leaf, ref),
);
if (orphans.length > 0) {
const report = orphans.map((k) => ` ❌ ${k}`).join("\n");
throw new Error(
`Found ${orphans.length} key(s) in messages/en.json with no call site:\n${report}\n\n` +
"Wire a call site, or add a DB-driven prefix to DYNAMIC_ALLOWLIST_PREFIXES.",
);
}
});
});
@@ -198,7 +198,6 @@ describe("dashboard.*Short keys exist across all six locales", () => {
"sleepShort",
"stepsShort",
"vo2MaxShort",
"bpInTargetShort",
];
// Resolved at test time so the assertion runs against the current
+7 -6
View File
@@ -59,13 +59,14 @@ const NATIVE_SELECT_CLASS =
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
export const NativeSelect = React.forwardRef<
HTMLSelectElement,
NativeSelectProps
>(function NativeSelect({ className, children, style, ...props }, ref) {
export function NativeSelect({
className,
children,
style,
...props
}: NativeSelectProps) {
return (
<select
ref={ref}
data-slot="native-select"
className={cn(NATIVE_SELECT_CLASS, className)}
style={style}
@@ -74,4 +75,4 @@ export const NativeSelect = React.forwardRef<
{children}
</select>
);
});
}
+8 -9
View File
@@ -39,13 +39,13 @@ import { cn } from "@/lib/utils";
* Composition is via `cn(…)` so callers can override anything — the
* primitive defaults are the strict pass, not the only pass.
*/
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(function Textarea(
{ className, autoComplete, autoCapitalize, spellCheck, ...props },
ref,
) {
function Textarea({
className,
autoComplete,
autoCapitalize,
spellCheck,
...props
}: React.ComponentProps<"textarea">) {
const resolvedAutoComplete = autoComplete ?? "off";
const resolvedAutoCapitalize = autoCapitalize ?? "sentences";
const resolvedSpellCheck = spellCheck ?? true;
@@ -53,7 +53,6 @@ const Textarea = React.forwardRef<
return (
<textarea
ref={ref}
data-slot="textarea"
autoComplete={resolvedAutoComplete}
autoCapitalize={resolvedAutoCapitalize}
@@ -71,6 +70,6 @@ const Textarea = React.forwardRef<
{...props}
/>
);
});
}
export { Textarea };
+2 -1
View File
@@ -34,6 +34,7 @@
*/
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import { toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
computeExpiresAt,
@@ -263,7 +264,7 @@ export async function consumeForIntake(input: {
// registered later when a sync replay revisits the row.
await tx.medicationIntakeEvent.update({
where: { id: eventId },
data: { inventoryConsumption: stamp as unknown as Prisma.InputJsonValue },
data: { inventoryConsumption: toJson(stamp) },
});
annotate({
@@ -323,3 +323,125 @@ describe("persister round-trips through a fake IndexedDB", () => {
expect(store.get("react-query")).toBeUndefined();
});
});
describe("persister is bounded and surfaces quota failures", () => {
const original = (globalThis as { indexedDB?: unknown }).indexedDB;
afterEach(() => {
if (original !== undefined) {
(globalThis as { indexedDB?: unknown }).indexedDB = original;
} else {
delete (globalThis as { indexedDB?: unknown }).indexedDB;
}
vi.restoreAllMocks();
});
const makeReq = (run: () => void) => {
const req: Record<string, unknown> = {};
queueMicrotask(() => {
run();
(req.onsuccess as (() => void) | undefined)?.();
});
return req;
};
function installFakeIdb(opts: {
onPut: (value: unknown, key: string) => void;
store: Map<string, unknown>;
}) {
const fakeDb = {
close() {},
transaction() {
return {
objectStore() {
return {
put(value: unknown, key: string) {
opts.onPut(value, key);
return makeReq(() => {});
},
get(key: string) {
const req = makeReq(() => {});
(req as { result?: unknown }).result = opts.store.get(key);
return req;
},
delete(key: string) {
opts.store.delete(key);
return makeReq(() => {});
},
};
},
set oncomplete(fn: () => void) {
queueMicrotask(fn);
},
set onerror(_fn: () => void) {},
};
},
};
(globalThis as { indexedDB?: unknown }).indexedDB = {
open() {
const req: Record<string, unknown> = { result: fakeDb };
queueMicrotask(() => (req.onsuccess as () => void)?.());
return req;
},
};
}
it("skips a snapshot that exceeds the size cap, leaving the last write intact", async () => {
const store = new Map<string, unknown>();
let putCount = 0;
installFakeIdb({ store, onPut: () => (putCount += 1) });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const source = new QueryClient();
// A persistable family with a payload well over the 2 MB ceiling.
const huge = "x".repeat(3 * 1024 * 1024);
source.setQueryData(["chart-data", "WEIGHT"], [{ blob: huge }]);
const stop = startPersistingQueryCache(source, "v1.18.6");
vi.useFakeTimers();
source.setQueryData(["chart-data", "WEIGHT"], [{ blob: huge }]);
vi.advanceTimersByTime(1100);
vi.useRealTimers();
await new Promise((r) => setTimeout(r, 0));
stop();
// The oversized snapshot was never written, and the skip was logged
// rather than silently dropped.
expect(putCount).toBe(0);
expect(store.get("react-query")).toBeUndefined();
expect(warn).toHaveBeenCalled();
});
it("clears the cache and warns on an IndexedDB quota error instead of swallowing it", async () => {
const store = new Map<string, unknown>();
let firstPut = true;
installFakeIdb({
store,
onPut: (value, key) => {
if (firstPut) {
firstPut = false;
// First write hits the storage quota.
throw new DOMException("quota", "QuotaExceededError");
}
store.set(key, value);
},
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const source = new QueryClient();
source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 });
const stop = startPersistingQueryCache(source, "v1.18.6");
vi.useFakeTimers();
source.setQueryData(["chart-data", "WEIGHT"], [{ v: 81 }]);
vi.advanceTimersByTime(1100);
vi.useRealTimers();
await new Promise((r) => setTimeout(r, 0));
// Let the clear-on-quota microtasks settle.
await new Promise((r) => setTimeout(r, 0));
stop();
// The quota failure was surfaced, not swallowed, and the cache key was
// cleared so the next flush has room.
expect(warn).toHaveBeenCalled();
expect(store.get("react-query")).toBeUndefined();
});
});
+61 -9
View File
@@ -38,6 +38,17 @@ const KEY = "react-query";
const VERSION_KEY = "react-query-version";
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day
/**
* Upper bound on the serialized snapshot. The allowlist already caps WHAT is
* persisted (dashboard snapshot + widget layout + daily series), but a tenant
* with a long chart history can still grow a single series past what's worth
* keeping on disk — and an oversized write is the most likely trigger for an
* IndexedDB `QuotaExceededError`. A snapshot above this ceiling is skipped
* rather than written: the offline read value of a multi-megabyte blob is
* marginal, and skipping keeps the last good (smaller) snapshot in place.
*/
const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB
/**
* Strict allowlist of the only query families written to disk — the
* low-sensitivity reads the dashboard needs to paint last-known values when
@@ -78,13 +89,30 @@ function openDb(): Promise<IDBDatabase> {
async function idbSet(value: unknown): Promise<void> {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(value, KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(value, KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
}
/** True for the storage-quota error class across browsers. */
function isQuotaError(err: unknown): boolean {
if (err instanceof DOMException) {
// Chromium/WebKit throw `QuotaExceededError`; Firefox historically
// throws `NS_ERROR_DOM_QUOTA_REACHED` (code 22).
return (
err.name === "QuotaExceededError" ||
err.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
err.code === 22
);
}
return false;
}
async function idbGet<T>(): Promise<T | undefined> {
@@ -211,8 +239,32 @@ export function startPersistingQueryCache(
userId: currentUserId(queryClient),
state,
};
void idbSet(payload).catch(() => {
/* best effort */
// Size-cap before touching disk: a snapshot past the ceiling is the
// likeliest quota trigger and its marginal offline value isn't worth
// evicting the last good (smaller) snapshot. Skip the write, keep what's
// there.
const serialized = JSON.stringify(payload);
if (serialized.length > MAX_PERSIST_BYTES) {
console.warn(
`[query-persister] skipping persist: snapshot ${serialized.length}B exceeds ${MAX_PERSIST_BYTES}B cap`,
);
return;
}
void idbSet(payload).catch((err) => {
// Don't silently swallow a full-disk failure: a quota error means the
// offline cache stopped updating, and a clear signal beats a stale
// snapshot the user can't explain. Drop the persisted blob so the next
// flush has room rather than failing again against a full store.
if (isQuotaError(err)) {
console.warn(
"[query-persister] IndexedDB quota exceeded; dropping persisted cache",
);
void clearPersistedQueryCache();
return;
}
console.warn("[query-persister] persist failed", err);
});
};