fix(i18n): stop one key template from whitelisting a whole namespace

The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 16:23:58 +02:00
1 parent f9a5e6a11f
commit d8a3c08e12
17 files changed
+680 -1155

No files matched your search

+334
View File
@@ -0,0 +1,334 @@
/**
* v1.32.36 — the reachability matcher behind `i18n-reverse-coverage.test.ts`,
* extracted so it can be unit-tested in isolation.
*
* The reverse guard asserts that every leaf in `messages/en.json` is reachable
* from at least one call site. It is a pair guard: one end is the bundle, the
* other end is the tree. A pair guard is only worth its green state while its
* matcher stays honest, and this one had degraded — see the bare-namespace note
* below.
*
* ## Recognised construction patterns
*
* - a literal t("a.b.c"), and a t("a.b") that returns a subtree;
* - pluralKey("a.b", n) / tCount("a.b", n), which resolve to the three
* One / Few / Other tiers and nothing else;
* - a **pair template** — a t(template) call carrying BOTH a literal prefix and
* a literal suffix, as in the per-provider OAuth status keys. Both ends must
* match, so the template covers settings.polarOauthConnected and nothing else
* under settings.;
* - a **prefix template** — a literal prefix and no suffix, as in the cycle
* phase labels, covering the cycle.phase.* subtree;
* - a **suffix template** — a leading interpolation and a literal tail, as in
* the per-metric insight page titles. The tail alone means nothing, so it is
* matched only against a base that some other arm has established;
* - a declared i18n base — a dotted literal assigned to an `i18nPrefix` prop or
* variable. This is the convention the OAuth provider card, the cadence
* picker and the HealthKit metric page already follow. A declared base covers
* the exact key and serves as the base half of a suffix template; it does NOT
* open its subtree;
* - a full string-literal node path, covering the subtree it names (map values
* handed to t());
* - an explicit allowlist for keys resolved through a DB column.
*
* ## Why a bare namespace is refused rather than accepted
*
* Before v1.32.36, a prefix ending in a dot was recorded as a subtree base
* regardless of depth. The per-provider OAuth template therefore registered the
* base `settings` and marked every one of the several thousand keys under
* settings.* as reachable, and a second instance did the same for
* measurementReminders.*. The guard still passed, so the degradation was
* invisible: a pair guard had quietly become a one-end guard.
*
* Accepting a bare namespace silently is a false pass. Refusing it silently
* would only trade that for a false orphan report. So a suffix-less template
* rooted at a bare top-level namespace is a **collector violation**: the guard
* fails and names the template. The two honest remedies are to build a
* fully-qualified key at the call site (as the measurement-reminder due helpers
* do) or to give the template a literal suffix so it becomes a pair.
*/
/** A template whose prefix is a bare top-level namespace and which has no suffix. */
export interface BareNamespaceViolation {
/** The offending literal, as it appears in the source. */
literal: string;
/** Which ingestion arm saw it first. */
arm: "template" | "assignment" | "concat";
}
/** A template carrying both a literal prefix and a literal suffix. */
interface KeyPair {
/** Raw leading literal, dot-terminated or not. */
prefix: string;
/** Raw trailing literal, leading dot preserved. */
suffix: string;
}
export interface Reference {
/** Literal t("…") keys plus the plural tiers. */
literalKeys: Set<string>;
/** Dotted bases (deep prefix template / a full string-literal node path): cover the subtree. */
dottedBases: Set<string>;
/** Dot-less concat prefixes: cover any leaf that string-starts with them. */
concatPrefixes: Set<string>;
/** Prefix+suffix templates: both ends must match. */
pairs: KeyPair[];
/** Trailing literals of a leading-interpolation template. */
suffixes: Set<string>;
/** Bases declared through an `i18nPrefix` literal. Exact match plus suffix base. */
declaredPrefixes: Set<string>;
}
export interface CollectResult {
ref: Reference;
violations: BareNamespaceViolation[];
}
/** `settings` is bare; `settings.sections` is not. */
function isBareNamespace(base: string): boolean {
return !base.includes(".");
}
/**
* Split a template body into its leading and trailing literals: the admin
* section title template yields the prefix "admin.section." and the suffix
* ".title".
*/
function splitTemplate(content: string): {
prefix: string | null;
suffix: string | null;
} {
const open = content.indexOf("${");
if (open === -1) return { prefix: null, suffix: null };
const rawPrefix = open > 0 ? content.slice(0, open) : "";
const close = content.lastIndexOf("}");
const rawSuffix =
close !== -1 && close < content.length - 1 ? content.slice(close + 1) : "";
return {
// A prefix with no dot is not an i18n path root; ignore it.
prefix: rawPrefix.includes(".") ? rawPrefix : null,
suffix: rawSuffix || null,
};
}
/**
* Body of the template literal whose opening backtick sits at `start`.
*
* Scanning forward from a known opening backtick rather than pairing backticks
* across the whole tree matters: the concatenated sources hold tens of
* thousands of backticks, plenty of them inside comments and strings, so naive
* pairing silently shifts and whole files fall out of the scan. That is how a
* matcher stops seeing call sites without anyone noticing.
*/
function templateBodyAt(text: string, start: number): string | null {
let depth = 0;
for (let i = start + 1; i < text.length; i++) {
const c = text[i];
if (c === "\\") {
i++;
continue;
}
if (c === "$" && text[i + 1] === "{") {
depth++;
i++;
continue;
}
if (c === "}" && depth > 0) {
depth--;
continue;
}
if (c === "`" && depth === 0) return text.slice(start + 1, i);
}
return null;
}
export function collectReferences(
allText: string,
allNodes: Set<string>,
topNamespaces: Set<string>,
): CollectResult {
const literalKeys = new Set<string>();
const dottedBases = new Set<string>();
const concatPrefixes = new Set<string>();
const suffixes = new Set<string>();
const declaredPrefixes = new Set<string>();
const pairs: KeyPair[] = [];
const pairSeen = new Set<string>();
const violations: BareNamespaceViolation[] = [];
const violationSeen = new Set<string>();
const addPair = (prefix: string, suffix: string) => {
const id = [prefix, suffix].join(" ");
if (pairSeen.has(id)) return;
pairSeen.add(id);
pairs.push({ prefix, suffix });
};
/**
* The single door into `dottedBases` for every ingestion arm. A bare
* top-level namespace never opens a subtree — it is reported instead.
* Violations dedupe on the literal, not on the arm: one template is seen by
* both the t() arm and the generic backtick arm, and it is one defect.
*/
const addDottedBase = (
base: string,
arm: BareNamespaceViolation["arm"],
literal: string,
) => {
if (isBareNamespace(base)) {
if (violationSeen.has(literal)) return;
violationSeen.add(literal);
violations.push({ literal, arm });
return;
}
dottedBases.add(base);
};
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]);
// Plural-tier composition: tCount("dashboard.staleHintWeeks", n) and the
// underlying pluralKey("…", n, locale) both resolve to <base>One / <base>Few
// / <base>Other at runtime, so the base literal accounts for exactly those
// three leaves and no others. Kept as an explicit tier list rather than a
// prefix match so a stray sibling key under the same base still reports as
// an orphan.
const pluralKeyRe =
/(?:pluralKey|tCount)\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
for (const m of allText.matchAll(pluralKeyRe)) {
for (const tier of ["One", "Few", "Other"])
literalKeys.add(`${m[1]}${tier}`);
}
const ingestTemplate = (
content: string,
arm: BareNamespaceViolation["arm"],
) => {
const { prefix, suffix } = splitTemplate(content);
if (prefix && suffix) {
addPair(prefix, suffix);
return;
}
if (prefix) {
if (prefix.endsWith("."))
addDottedBase(prefix.slice(0, -1), arm, content);
else concatPrefixes.add(prefix);
return;
}
if (suffix) suffixes.add(suffix);
};
// Templates passed directly to t(…).
const tStartRe = /(?<![a-zA-Z0-9_])t\(\s*`/g;
for (const m of allText.matchAll(tStartRe)) {
const body = templateBodyAt(allText, m.index + m[0].length - 1);
if (body && body.includes("${")) ingestTemplate(body, "template");
}
// 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 templateStartRe = /`[a-zA-Z][a-zA-Z0-9_.-]*\$\{/g;
for (const m of allText.matchAll(templateStartRe)) {
const body = templateBodyAt(allText, m.index);
if (!body) continue;
const { prefix } = splitTemplate(body);
if (!prefix) continue;
if (!topNamespaces.has(prefix.split(".")[0])) continue;
ingestTemplate(body, "assignment");
}
// String-concatenation prefixes: "notifications.event" + eventType builds
// keys like notifications.eventMoodReminder. Recognised only when the
// literal is rooted at a real top-level namespace. The concat arm sees no
// suffix, so a dot-terminated namespace root here is the same hole shape the
// template arm refuses.
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(".")) addDottedBase(lit.slice(0, -1), "concat", lit);
else concatPrefixes.add(lit);
}
// Declared i18n bases. The component that receives one builds keys by
// appending a literal suffix, whose trailing literals live in `suffixes`;
// the declaration supplies the base half. Deliberately NOT a subtree base —
// only the exact key and the suffix pairing.
const declaredPrefixRe = /i18nPrefix\s*[=:]\s*["']([a-zA-Z][\w.-]*)["']/g;
for (const m of allText.matchAll(declaredPrefixRe)) {
const lit = m[1];
if (!lit.includes(".")) continue;
if (!topNamespaces.has(lit.split(".")[0])) continue;
declaredPrefixes.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 {
ref: {
literalKeys,
dottedBases,
concatPrefixes,
pairs,
suffixes,
declaredPrefixes,
},
violations,
};
}
/** Bases a suffix template may attach to. */
function suffixBaseMatches(base: string, ref: Reference): boolean {
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 ref.declaredPrefixes.has(base);
}
export 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;
}
if (ref.declaredPrefixes.has(leaf)) return true;
for (const { prefix, suffix } of ref.pairs) {
if (
leaf.length > prefix.length + suffix.length &&
leaf.startsWith(prefix) &&
leaf.endsWith(suffix)
) {
return true;
}
}
for (const suffix of ref.suffixes) {
if (!leaf.endsWith(suffix) || leaf.length === suffix.length) continue;
const base = leaf.slice(0, leaf.length - suffix.length).replace(/\.$/, "");
if (!base) continue;
if (suffixBaseMatches(base, ref)) return true;
}
return false;
}
@@ -0,0 +1,147 @@
/**
* v1.32.36 — unit tests for the reverse-i18n reachability matcher.
*
* The reverse-coverage guard is a pair guard, and a pair guard is worthless the
* moment its matcher over-accepts: it stays green while proving nothing. That
* is what happened here — one template anchored at a bare `settings.` marked
* several thousand keys reachable and nobody could tell from the green run.
*
* So the matcher gets its own tests over synthetic sources, where the expected
* answer is known exactly. The first test below is the regression: it plants
* the old hole and asserts the collector now refuses it out loud.
*/
import { describe, it, expect } from "vitest";
import { collectReferences, isReferenced } from "./helpers/i18n-reference";
const NAMESPACES = new Set([
"settings",
"cycle",
"measurementReminders",
"notifications",
"insights",
]);
function run(source: string, nodes: string[] = []) {
return collectReferences(source, new Set(nodes), NAMESPACES);
}
describe("i18n reference matcher", () => {
describe("bare top-level namespaces", () => {
it("refuses a suffix-less template anchored at a bare namespace", () => {
const { violations } = run("t(`measurementReminders.${due.key}`)");
expect(violations).toHaveLength(1);
expect(violations[0].literal).toContain("measurementReminders.");
});
it("does not silently whitelist the namespace it refused", () => {
const { ref } = run("t(`measurementReminders.${due.key}`)");
expect(isReferenced("measurementReminders.somethingDead", ref)).toBe(
false,
);
});
it("refuses the same shape arriving through the concat arm", () => {
const { violations, ref } = run('const k = "settings." + provider;');
expect(violations).toHaveLength(1);
expect(violations[0].arm).toBe("concat");
expect(isReferenced("settings.anythingAtAll", ref)).toBe(false);
});
it("refuses the same shape arriving outside a t() call", () => {
const { violations } = run("const key = `cycle.${phase}`;");
expect(violations[0].arm).toBe("assignment");
});
it("accepts a deep prefix, which confines itself", () => {
const { violations, ref } = run("t(`cycle.phase.${phase}`)");
expect(violations).toHaveLength(0);
expect(isReferenced("cycle.phase.follicular", ref)).toBe(true);
expect(isReferenced("cycle.somethingElse", ref)).toBe(false);
});
});
describe("pair templates", () => {
it("covers only leaves matching both ends", () => {
const { violations, ref } = run(
"t(`settings.${provider}OauthConnected`)",
);
expect(violations).toHaveLength(0);
expect(isReferenced("settings.polarOauthConnected", ref)).toBe(true);
expect(isReferenced("settings.withingsOauthConnected", ref)).toBe(true);
expect(isReferenced("settings.anythingElse", ref)).toBe(false);
expect(isReferenced("settings.polarOauthFailed", ref)).toBe(false);
});
it("honours a dot-rooted suffix as a path boundary", () => {
const { ref } = run("t(`insights.section.${slug}.title`)");
expect(isReferenced("insights.section.sleep.title", ref)).toBe(true);
// `subtitle` ends in "title" but not in ".title".
expect(isReferenced("insights.section.sleep.subtitle", ref)).toBe(false);
});
});
describe("suffix templates", () => {
it("means nothing without a base another arm established", () => {
const { ref } = run("t(`${i18nPrefix}SyncResult`)");
expect(isReferenced("settings.withingsSyncResult", ref)).toBe(false);
});
it("attaches to a declared i18nPrefix base", () => {
const { ref } = run(
'i18nPrefix="settings.withings"\nt(`${i18nPrefix}SyncResult`)',
);
expect(isReferenced("settings.withingsSyncResult", ref)).toBe(true);
expect(isReferenced("settings.withings", ref)).toBe(true);
// The declaration is a base, not a subtree licence.
expect(isReferenced("settings.withingsSomethingDead", ref)).toBe(false);
expect(isReferenced("settings.polarSyncResult", ref)).toBe(false);
});
it("attaches to a base established by a literal node path", () => {
const { ref } = run(
'const p = "insights.walkingSpeed";\nt(`${p}.title`)',
["insights.walkingSpeed"],
);
expect(isReferenced("insights.walkingSpeed.title", ref)).toBe(true);
});
});
describe("literal keys", () => {
it("covers the key and its subtree", () => {
const { ref } = run('t("settings.export.title")\nt("cycle.phase")');
expect(isReferenced("settings.export.title", ref)).toBe(true);
expect(isReferenced("cycle.phase.luteal", ref)).toBe(true);
expect(isReferenced("settings.export.subtitle", ref)).toBe(false);
});
it("covers exactly the three plural tiers of a counted key", () => {
const { ref } = run('tCount("insights.staleWeeks", n)');
expect(isReferenced("insights.staleWeeksOne", ref)).toBe(true);
expect(isReferenced("insights.staleWeeksFew", ref)).toBe(true);
expect(isReferenced("insights.staleWeeksOther", ref)).toBe(true);
expect(isReferenced("insights.staleWeeksMany", ref)).toBe(false);
});
});
describe("scope", () => {
it("ignores an interpolating template that is not namespace-rooted", () => {
const { violations, ref } = run("const cacheKey = `coach.${userId}`;");
expect(violations).toHaveLength(0);
expect(isReferenced("coach.anything", ref)).toBe(false);
});
});
});
+80 -153
View File
@@ -6,18 +6,11 @@
* 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.
* v1.32.36 — the matcher moved to `helpers/i18n-reference.ts` (unit-tested in
* `helpers/__tests__/i18n-reference.test.ts`) and stopped accepting a bare
* top-level namespace as a subtree base. See that file's header for the
* recognised construction patterns and for why the bare-namespace case is a
* loud failure rather than a silent accept.
*
* Dynamic detection is scoped to `t(`…`)` and to namespace-rooted key
* templates only — NOT to every backtick string — so a cache key or
@@ -27,11 +20,17 @@
* 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.
*
* Mutation check: change any `t(`ns.sub.${x}`)` call site to `t(`ns.${x}`)`
* and the collector-violation assertion goes red; delete a live call site and
* its keys report as orphans.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { collectReferences, isReferenced } from "./helpers/i18n-reference";
const ROOT = join(__dirname, "../..");
const SRC = join(ROOT, "src");
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
@@ -45,7 +44,30 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
* 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;
const DYNAMIC_ALLOWLIST_PREFIXES = [
"mood.tag",
"mood.tagCategory",
// The whole namespace is a runtime lookup table: the client resolves an
// error envelope's `meta.errorCode` through it (`localizedApiError`), so no
// leaf ever appears as a literal at a call site. Adding a key here is only
// useful together with a server code that emits it.
"apiErrors",
] as const;
/**
* Interpolating strings that LOOK like key templates and are not. Each entry
* whitelists nothing — the template still contributes no reachable keys — it
* only says the collector's bare-namespace complaint has been read and
* answered. Keep the reason checkable in one line of the cited file.
*/
const EXEMPT_TEMPLATES: Record<string, string> = {
"insights.${scope}-status.${locale}":
"auditLog action name for the per-metric status cache (statusCacheAction), not an i18n key",
"insights.${scope}.${locale}":
"auditLog action name for the previous-insight memory read (getPreviousInsightContext), not an i18n key",
"apiErrors.${errorCode}":
"the runtime error-code lookup; its namespace is allowlisted above",
};
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
@@ -84,153 +106,58 @@ function nodePaths(obj: unknown, prefix: string, out: Set<string>): void {
}
}
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]);
// Plural-tier composition: `tCount("dashboard.staleHintWeeks", n)` and the
// underlying `pluralKey("…", n, locale)` both resolve to `<base>One` /
// `<base>Few` / `<base>Other` at runtime, so the base literal accounts for
// exactly those three leaves and no others. Kept as an explicit tier list
// rather than a prefix match so a stray sibling key under the same base still
// reports as an orphan.
const pluralKeyRe =
/(?:pluralKey|tCount)\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
for (const m of allText.matchAll(pluralKeyRe)) {
for (const tier of ["One", "Few", "Other"])
literalKeys.add(`${m[1]}${tier}`);
}
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);
function scan() {
const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8"));
const leaves: string[] = [];
leafPaths(en, "", leaves);
const allNodes = new Set<string>();
nodePaths(en, "", allNodes);
const topNamespaces = new Set<string>(Object.keys(en));
const ref = collectReferences(allNodes, topNamespaces);
const allNodes = new Set<string>();
nodePaths(en, "", allNodes);
const topNamespaces = new Set<string>(Object.keys(en));
const allText = walk(SRC)
.map((f) => readFileSync(f, "utf8"))
.join("\n");
return { leaves, ...collectReferences(allText, allNodes, topNamespaces) };
}
describe("i18n reverse coverage", () => {
it("no key template widens to a bare top-level namespace", () => {
const all = scan().violations;
const violations = all.filter((v) => !(v.literal in EXEMPT_TEMPLATES));
for (const literal of Object.keys(EXEMPT_TEMPLATES)) {
expect(
all.map((v) => v.literal),
`${literal} is exempted but no longer present — drop the entry`,
).toContain(literal);
}
if (violations.length > 0) {
const report = violations
.map((v) => ` ❌ [${v.arm}] ${v.literal}`)
.join("\n");
throw new Error(
`Found ${violations.length} key template(s) whose only static anchor is a bare ` +
`top-level namespace:\n${report}\n\n` +
"Such a template marks the whole namespace as reachable and turns this guard " +
"into a no-op for it. Either build a fully-qualified key at the call site " +
"(see the measurement-reminder due helpers), or give the template a literal " +
"suffix so both ends confine it.",
);
}
}, 15_000);
it("every key in messages/en.json has a call site or is allowlisted", () => {
const { leaves, ref } = scan();
expect(leaves.length).toBeGreaterThan(1000);
// Sanity: the static scan must see the bulk of the bundle.
expect(ref.literalKeys.size).toBeGreaterThan(1000);
@@ -11,10 +11,11 @@ import { join } from "node:path";
* t(`insights.subPage.explainer.${metric}Body`) // inline definition
*
* v1.8.6 — the round `?` popover that also read the `<metric>Title` leaf
* is gone; the shell now renders only the inline `Body` caption. The
* `Title` leaf is still pinned here so the bundle keeps a stable, fully
* populated key pair per metric (the locale-integrity guard propagates
* it across the other five locales).
* is gone; the shell now renders only the inline `Body` caption. v1.32.36
* finished that removal: the `Title` leaves were kept as a "stable, fully
* populated key pair" reserve, which is translated copy no surface renders,
* so they were dropped from all six locales and this guard now pins the
* `Body` leaf alone.
*
* The static `i18n-call-site-coverage.test.ts` only sees literal
* `t("…")` calls, so a typo in an `explainerMetric=` prop would resolve
@@ -22,19 +23,16 @@ import { join } from "node:path";
* no CI signal. This test closes that gap the same way the
* Health-Score provenance key tests pin their dynamic keys: it walks
* every `explainerMetric="…"` value used under `src/app/insights/**`
* (plus the shared `healthkit-metric-page` scaffold) and asserts that
* both the `<value>Title` and `<value>Body` leaves exist in
* `messages/en.json`. `i18n-locale-integrity.test.ts` then propagates
* the EN guarantee across the other five locales.
* (plus the shared `healthkit-metric-page` scaffold) and asserts that the
* `<value>Body` leaf exists in `messages/en.json`.
* `i18n-locale-integrity.test.ts` then propagates the EN guarantee across
* the other five locales.
*
* v1.15.12 B1 — the score-anatomy detail page (`scores/[metric]/page.tsx`)
* picks its explainer key at runtime from a closed `Record<…, string>`
* literal (`explainerMetric={EXPLAINER_METRIC[metric]}`), one of five score
* suffixes. That single dynamic site is resolved STATICALLY here by parsing
* the record's string-literal values (the contract stays verifiable). Score
* explainers render only the inline `Body` caption — there is no `?` popover
* `Title` for them — so the resolved score values are checked for a `Body`
* leaf only, while literal sites keep the full Title + Body pairing.
* the record's string-literal values (the contract stays verifiable).
*/
const ROOT = join(__dirname, "../../../..");
@@ -62,8 +60,6 @@ interface PropSite {
value: string;
file: string;
line: number;
/** Score explainers render only the inline Body caption (no `?` Title). */
bodyOnly?: boolean;
}
// Match `explainerMetric="someValue"` — a string-literal prop. The
@@ -116,12 +112,12 @@ function collectSites(): { sites: PropSite[]; dynamic: PropSite[] } {
}
if (DYNAMIC_PATTERN.test(line)) {
// The score-anatomy record lookup is statically resolvable; every
// resolved value becomes a Body-only site. Any OTHER dynamic shape
// is unverifiable and trips the no-dynamic guard.
// resolved value becomes a site. Any OTHER dynamic shape is
// unverifiable and trips the no-dynamic guard.
const lookup = RECORD_LOOKUP_PATTERN.exec(line);
if (file.endsWith(SCORES_PAGE) && lookup) {
for (const value of resolveRecordValues(source, lookup[1])) {
sites.push({ value, file: rel, line: i + 1, bodyOnly: true });
sites.push({ value, file: rel, line: i + 1 });
}
} else {
dynamic.push({ value: line.trim(), file: rel, line: i + 1 });
@@ -165,15 +161,10 @@ describe("insights explainerMetric key coverage", () => {
expect(dynamic).toEqual([]);
});
it("every explainerMetric value resolves a Title + Body leaf in messages/en.json", () => {
it("every explainerMetric value resolves a Body leaf in messages/en.json", () => {
const missing: string[] = [];
for (const site of sites) {
const titleKey = `insights.subPage.explainer.${site.value}Title`;
const bodyKey = `insights.subPage.explainer.${site.value}Body`;
// Score explainers (bodyOnly) carry only the inline Body caption.
if (!site.bodyOnly && !hasLeaf(en, titleKey)) {
missing.push(` ❌ ${titleKey} (${site.file}:${site.line})`);
}
if (!hasLeaf(en, bodyKey)) {
missing.push(` ❌ ${bodyKey} (${site.file}:${site.line})`);
}
@@ -37,16 +37,29 @@ import {
const DAY_MS = 24 * 60 * 60 * 1000;
const SUMMARY_LIMIT = 3;
/**
* v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site
* that interpolates the tail onto the namespace root anchors the
* reverse-coverage guard at that bare namespace, which marks every key under
* it reachable and turns the guard into a no-op for the whole namespace. The
* guard now refuses that shape outright, so the key is built whole here.
*/
function relativeDueKey(
nextDueAt: string | null,
now: number,
): { key: string; days: number } {
if (!nextDueAt) return { key: "nextDue.none", days: 0 };
if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 };
const deltaDays = Math.round((new Date(nextDueAt).getTime() - now) / DAY_MS);
if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) };
if (deltaDays === 0) return { key: "nextDue.today", days: 0 };
if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };
return { key: "nextDue.inDays", days: deltaDays };
if (deltaDays < 0)
return {
key: "measurementReminders.overdueByDays",
days: Math.abs(deltaDays),
};
if (deltaDays === 0)
return { key: "measurementReminders.nextDue.today", days: 0 };
if (deltaDays === 1)
return { key: "measurementReminders.nextDue.tomorrow", days: 1 };
return { key: "measurementReminders.nextDue.inDays", days: deltaDays };
}
function resolveLabel(
@@ -141,7 +154,7 @@ export function VorsorgeDashboardCard() {
{resolveLabel(reminder, t)}
</p>
<Badge variant="secondary" className="text-xs">
{t(`measurementReminders.${due.key}`, {
{t(due.key, {
days: due.days,
})}
</Badge>
@@ -143,13 +143,21 @@ const CADENCE_CUSTOM = "custom";
const CADENCE_RRULE = "rrule";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site
* that interpolates the tail onto the namespace root anchors the
* reverse-coverage guard at that bare namespace, which marks every key under
* it reachable and turns the guard into a no-op for the whole namespace. The
* guard now refuses that shape outright, so the key is built whole here.
*/
function relativeDueKey(
nextDueAt: string | null,
now: number,
): { key: string; days: number } {
if (!nextDueAt) return { key: "nextDue.none", days: 0 };
if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 };
const due = new Date(nextDueAt);
if (Number.isNaN(due.getTime())) return { key: "nextDue.none", days: 0 };
if (Number.isNaN(due.getTime()))
return { key: "measurementReminders.nextDue.none", days: 0 };
// v1.18.9 (recon) — compare CALENDAR-day deltas in the user's local zone,
// not a rolling-24h delta. A reminder due at 09:00 viewed the same evening
// at 20:00 must still read "heute", and one whose due calendar-day is before
@@ -159,10 +167,16 @@ function relativeDueKey(
const dueDay = startOfLocalDayInTz(due, undefined).getTime();
const nowDay = startOfLocalDayInTz(new Date(now), undefined).getTime();
const deltaDays = Math.round((dueDay - nowDay) / DAY_MS);
if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) };
if (deltaDays === 0) return { key: "nextDue.today", days: 0 };
if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };
return { key: "nextDue.inDays", days: deltaDays };
if (deltaDays < 0)
return {
key: "measurementReminders.overdueByDays",
days: Math.abs(deltaDays),
};
if (deltaDays === 0)
return { key: "measurementReminders.nextDue.today", days: 0 };
if (deltaDays === 1)
return { key: "measurementReminders.nextDue.tomorrow", days: 1 };
return { key: "measurementReminders.nextDue.inDays", days: deltaDays };
}
/**
@@ -831,7 +845,9 @@ function VorsorgeCard({
// v1.27.6 — a screening reminder's action is "start the check-in" (it
// routes to the check-in page), never the numeric value-entry wording.
const isScreening = isScreeningReminderType(reminder.measurementType);
const isDue = due.key === "nextDue.today" || due.key === "overdueByDays";
const isDue =
due.key === "measurementReminders.nextDue.today" ||
due.key === "measurementReminders.overdueByDays";
const progress = intervalProgress(reminder, now);
// Category-style header badge: the measurement label, or "self-planned"
@@ -984,7 +1000,7 @@ function VorsorgeCard({
metric text (no shaded pill). */}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground">
{t(`measurementReminders.${due.key}`, { days: due.days })}
{t(due.key, { days: due.days })}
</span>
<span>{categoryLabel}</span>
{cadenceText ? <span>{cadenceText}</span> : null}
@@ -1037,8 +1053,8 @@ function VorsorgeCard({
// a card-background tint): due today → green, overdue → orange, otherwise the
// normal muted-resolved date. The card surface stays neutral per the house
// rule; only this inline label carries the status hue.
const dueIsToday = due.key === "nextDue.today";
const dueIsOverdue = due.key === "overdueByDays";
const dueIsToday = due.key === "measurementReminders.nextDue.today";
const dueIsOverdue = due.key === "measurementReminders.overdueByDays";
const nextLastSlot = (
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -1055,7 +1071,7 @@ function VorsorgeCard({
: "text-foreground",
)}
>
{t(`measurementReminders.${due.key}`, { days: due.days })}
{t(due.key, { days: due.days })}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -208,9 +208,7 @@ export function CheckInWizard({
>
{onFunctionalStep
? t(`mentalHealth.functional.${v}`)
: t(
`mentalHealth.${optionLabelKey(instrument, questionIndex, v)}`,
)}
: t(optionLabelKey(instrument, questionIndex, v))}
</Button>
);
})}
@@ -93,6 +93,23 @@ const OAUTH_OUTCOME_KEYS: Record<
strava: queryKeys.strava,
};
/**
* v1.32.36 — the error-copy subtree per provider, spelled out rather than
* interpolated from the provider id. Building the key from the bare `settings`
* root left the reverse-i18n guard with no anchor deeper than the namespace,
* which marked every key under `settings.*` reachable and let genuinely dead
* copy sit in the bundle unnoticed. Written out, each base is a full node path
* the guard resolves on its own.
*/
const OAUTH_ERROR_KEY_BASE: Record<OAuthOutcomeProvider, string> = {
polar: "settings.polarOauthError",
oura: "settings.ouraOauthError",
whoop: "settings.whoopOauthError",
fitbit: "settings.fitbitOauthError",
googleHealth: "settings.googleHealthOauthError",
strava: "settings.stravaOauthError",
};
/**
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
* message; anything else falls back to the provider's `generic` copy. Union of
@@ -143,9 +160,10 @@ export function oauthReasonKey(
provider: OAuthOutcomeProvider,
reason: string,
): string {
const base = OAUTH_ERROR_KEY_BASE[provider];
return OAUTH_OUTCOME_REASONS.has(reason)
? `settings.${provider}OauthError.${reason}`
: `settings.${provider}OauthError.generic`;
? `${base}.${reason}`
: `${base}.generic`;
}
/**
@@ -388,7 +388,6 @@ describe("i18n locale file integrity", () => {
it("Health-Score provenance keys resolve in every locale", () => {
const PROVENANCE_KEYS = [
"insights.healthScore.provenance.toggle",
"insights.healthScore.provenance.weightLabel",
"insights.healthScore.provenance.mixedBanner",
"insights.healthScore.provenance.footnote",
"insights.healthScore.provenance.asOfLabel",
@@ -165,14 +165,24 @@ describe("mental-health instruments", () => {
});
it("resolves option-label keys per scheme (shared vs SCI per-item groups)", () => {
expect(optionLabelKey("PHQ9", 0, 2)).toBe("options.2");
expect(optionLabelKey("WHO5", 4, 5)).toBe("who5Options.5");
expect(optionLabelKey("PHQ9", 0, 2)).toBe("mentalHealth.options.2");
expect(optionLabelKey("WHO5", 4, 5)).toBe("mentalHealth.who5Options.5");
// SCI: items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration.
expect(optionLabelKey("SCI", 0, 4)).toBe("sciOptions.duration.4");
expect(optionLabelKey("SCI", 2, 0)).toBe("sciOptions.nights.0");
expect(optionLabelKey("SCI", 3, 2)).toBe("sciOptions.quality.2");
expect(optionLabelKey("SCI", 6, 1)).toBe("sciOptions.impact.1");
expect(optionLabelKey("SCI", 7, 3)).toBe("sciOptions.problemDuration.3");
expect(optionLabelKey("SCI", 0, 4)).toBe(
"mentalHealth.sciOptions.duration.4",
);
expect(optionLabelKey("SCI", 2, 0)).toBe(
"mentalHealth.sciOptions.nights.0",
);
expect(optionLabelKey("SCI", 3, 2)).toBe(
"mentalHealth.sciOptions.quality.2",
);
expect(optionLabelKey("SCI", 6, 1)).toBe(
"mentalHealth.sciOptions.impact.1",
);
expect(optionLabelKey("SCI", 7, 3)).toBe(
"mentalHealth.sciOptions.problemDuration.3",
);
});
it("maps recall stems (WHO-5 one stem; SCI three sections; PHQ/GAD none)", () => {
+9 -7
View File
@@ -49,10 +49,12 @@ export interface InstrumentDefinition {
*/
optionOrder: readonly number[];
/**
* i18n namespace for the response-option labels under `mentalHealth.`:
* without `optionGroups` one shared anchor set (`<ns>.<value>`); with
* Fully-qualified i18n namespace for the response-option labels: without
* `optionGroups` one shared anchor set (`<ns>.<value>`); with
* `optionGroups` (SCI) per-item anchor groups (`<ns>.<group>.<value>`,
* group = `optionGroups[itemIndex]`).
* group = `optionGroups[itemIndex]`). v1.32.36 — qualified rather than
* relative so the reverse-i18n guard resolves the subtree from the literal
* instead of being handed the bare `mentalHealth` namespace.
*/
optionNamespace: string;
optionGroups?: readonly string[];
@@ -113,7 +115,7 @@ const PHQ9: InstrumentDefinition = {
itemCount: 9,
itemMax: 3,
optionOrder: [0, 1, 2, 3],
optionNamespace: "options",
optionNamespace: "mentalHealth.options",
scoreMultiplier: 1,
minScore: 0,
maxScore: 27,
@@ -139,7 +141,7 @@ const GAD7: InstrumentDefinition = {
itemCount: 7,
itemMax: 3,
optionOrder: [0, 1, 2, 3],
optionNamespace: "options",
optionNamespace: "mentalHealth.options",
scoreMultiplier: 1,
minScore: 0,
maxScore: 21,
@@ -172,7 +174,7 @@ const WHO5: InstrumentDefinition = {
itemCount: 5,
itemMax: 5,
optionOrder: [5, 4, 3, 2, 1, 0],
optionNamespace: "who5Options",
optionNamespace: "mentalHealth.who5Options",
stemKeys: Array(5).fill("who5.period"),
scoreMultiplier: 4,
minScore: 0,
@@ -207,7 +209,7 @@ const SCI: InstrumentDefinition = {
itemCount: 8,
itemMax: 4,
optionOrder: [4, 3, 2, 1, 0],
optionNamespace: "sciOptions",
optionNamespace: "mentalHealth.sciOptions",
optionGroups: [
"duration",
"duration",