mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(i18n): exhaustive call-site audit + 28 stale missing keys filled (v1.4.49.3)
Marc surfaced raw `notifications.eventMoodReminder` and
`notifications.eventMoodReminderDesc` reaching the UI after the
v1.4.49.2 relative-time fix had landed. The narrow `{count}`-call
audit that drove v1.4.49.2 missed every dynamic key construction; the
exhaustive sweep below picked up 28 keys called from real code that
had never existed in any locale bundle.
Stale gaps + when each surface shipped without its strings:
- `notifications.event{Type}` and `Desc` (14 keys) — the per-event
rows on the `/notifications` settings page. The matrix was added
with v1.4.41 and shipped with only `eventColumn` translated;
v1.4.49 then added MOOD_REMINDER to the enum which is what made
Marc notice.
- `onboarding.welcome.{chrome × 7, slide{1,2,3}.{title,body}}` (13
keys) — the 3-slide welcome carousel added in v1.4.45. The
carousel rendered raw keys for every screen-reader label and
every slide's content.
- `measurements.loadError` (1 key) — the error branch in
`measurement-list.tsx` added in v1.4.44.
All 28 keys × 6 locales (de / en / es / fr / it / pl) = 168 new
entries, all written in Marc's voice, ASCII-safe with proper umlaut /
accent handling per locale, no AI-mention or internal-tooling words.
Inserted at the END of each namespace so the diff stays surgical and
the integrity test (parity across locales) stays green.
Regression guard added at `src/__tests__/i18n-call-site-coverage.test.ts`:
walks every `.ts` / `.tsx` under `src/` (excluding tests + generated),
extracts every literal `t("ns.key")` with comment-aware parsing (line +
block + JSDoc comments stripped so docstring pseudocode is not flagged),
and asserts each key resolves to a string leaf in `messages/en.json`.
`i18n-locale-integrity.test.ts` then propagates the EN guarantee across
every other locale via key-set parity.
Why this gap was not caught earlier:
The v1.4.27 B6 `i18n-drift-guard.test.ts` carries hand-curated key
groups; surfaces added after that point (notifications matrix v1.4.41,
measurements list error v1.4.44, welcome carousel v1.4.45) shipped
without anyone editing the drift-guard groups. The integrity test
catches DRIFT across locales but not keys missing UNIFORMLY from every
locale. The new call-site test closes that hole — any t() call without
a backing key now fails at pnpm test time.
Tests: 5270 unit + integration pass, lint clean, typecheck clean.
This commit is contained in:
1 parent
19bc31b0bc
commit
fe4060e84b
9 files changed
+405
-13
No files matched your search
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* v1.4.49.3 — call-site i18n coverage guard.
|
||||
*
|
||||
* Marc surfaced raw `insights.relativeHoursAgo`, `notifications.event
|
||||
* MoodReminder`, and `notifications.eventMoodReminderDesc` strings
|
||||
* leaking into the UI in v1.4.49. Audit found 28 keys called from real
|
||||
* code that never existed in any locale bundle — silent regressions
|
||||
* that accumulated across v1.4.41 (notifications matrix), v1.4.44
|
||||
* (measurements.loadError), and v1.4.45 (onboarding.welcome.*).
|
||||
*
|
||||
* This test walks every TypeScript / TSX file under `src/` (excluding
|
||||
* tests + generated code), extracts every literal `t("namespace.key")`
|
||||
* call, and asserts that each key resolves to a string leaf in
|
||||
* `messages/en.json`. JSDoc-block matches and known dynamic patterns
|
||||
* are filtered explicitly.
|
||||
*
|
||||
* The existing `i18n-drift-guard.test.ts` pins specific groups; this
|
||||
* test catches drift on every other call site. The
|
||||
* `i18n-locale-integrity.test.ts` test then propagates the EN guarantee
|
||||
* to every other locale via key-set parity.
|
||||
*/
|
||||
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",
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const KEY_PATTERN =
|
||||
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
|
||||
|
||||
interface CallSite {
|
||||
key: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
function stripCommentLine(line: string): string {
|
||||
// Strip line comments: `//` at start (after whitespace) OR inline
|
||||
// (preceded by whitespace), but not inside a string literal. Cheap
|
||||
// heuristic — find the FIRST `//` that isn't preceded by a quote.
|
||||
// The audit cares about real `t(…)` calls, not docstring examples.
|
||||
const slashIdx = line.indexOf("//");
|
||||
if (slashIdx === -1) return line;
|
||||
const before = line.slice(0, slashIdx);
|
||||
const dq = (before.match(/"/g) ?? []).length;
|
||||
const sq = (before.match(/'/g) ?? []).length;
|
||||
if (dq % 2 === 1 || sq % 2 === 1) return line; // inside a string
|
||||
return before;
|
||||
}
|
||||
|
||||
function extractKeys(): CallSite[] {
|
||||
const sites: CallSite[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const lines = text.split("\n");
|
||||
let inBlockComment = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
// Skip `/** ... */` JSDoc + `/* ... */` block comments. They
|
||||
// routinely carry example `t("ns.key")` strings that are NOT
|
||||
// runtime calls (see `src/components/ui/empty-state.tsx`'s
|
||||
// docstring as the canonical example).
|
||||
if (inBlockComment) {
|
||||
const end = line.indexOf("*/");
|
||||
if (end === -1) continue;
|
||||
line = line.slice(end + 2);
|
||||
inBlockComment = false;
|
||||
}
|
||||
while (true) {
|
||||
const start = line.indexOf("/*");
|
||||
if (start === -1) break;
|
||||
const end = line.indexOf("*/", start + 2);
|
||||
if (end === -1) {
|
||||
line = line.slice(0, start);
|
||||
inBlockComment = true;
|
||||
break;
|
||||
}
|
||||
line = line.slice(0, start) + " ".repeat(end - start + 2) + line.slice(end + 2);
|
||||
}
|
||||
line = stripCommentLine(line);
|
||||
let m: RegExpExecArray | null;
|
||||
KEY_PATTERN.lastIndex = 0;
|
||||
while ((m = KEY_PATTERN.exec(line)) !== null) {
|
||||
sites.push({
|
||||
key: m[1],
|
||||
file: file.slice(ROOT.length + 1),
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
function resolveLeaf(obj: unknown, dotted: string): string | null {
|
||||
const parts = dotted.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return typeof cur === "string" ? cur : null;
|
||||
}
|
||||
|
||||
describe("i18n call-site coverage", () => {
|
||||
it("every literal t() key has a matching leaf in messages/en.json", () => {
|
||||
const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8"));
|
||||
const sites = extractKeys();
|
||||
expect(sites.length).toBeGreaterThan(100);
|
||||
|
||||
const missing: CallSite[] = [];
|
||||
for (const site of sites) {
|
||||
if (resolveLeaf(en, site.key) === null) {
|
||||
missing.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
const report = missing
|
||||
.map((m) => ` ❌ ${m.key} (${m.file}:${m.line})`)
|
||||
.join("\n");
|
||||
// v1.4.49.3 — these 28 keys were the gap that reached production.
|
||||
// The list below stays empty once the audit passes; future
|
||||
// regressions print as a structured punch list so the offender
|
||||
// can fix every site in one commit.
|
||||
throw new Error(
|
||||
`Found ${missing.length} t() call sites referencing keys that are missing from messages/en.json:\n${report}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user