Files
João Salavisa 5257dfbaab fix(web): close the i18n coverage gaps in the non-JSX scanner and locale catalogs (#2727)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:03:29 +00:00

352 lines
14 KiB
JavaScript

/**
* Find user-facing copy sitting in a position `i18next/no-literal-string`
* structurally cannot see.
*
* That rule runs in `jsx-only` mode, so it inspects literals in JSX and nothing
* else. docs/i18n.md enumerates what falls through, and every shape below was
* found in already-migrated code that lint, `i18n:check`, the ratchet and the
* pseudo-locale walk all reported clean:
*
* const ROWS = [{ label: "Disk usage" }] // SCREAMING_CASE is skipped
* function badge() { return "Waiting for input" } // a plain helper holds no JSX
* function C({ label = "Running..." }) {} // evaluated before the hook
* toast({ title: "Rename failed" }) // never a JSX literal
*
* The last one is the expensive class: the string is handed to a TRANSLATED
* frame, so under the pseudo-locale it renders inside accented copy and reads as
* done. `t("common:gitOperationFailed", { operation: "Stage all" })` is the
* worked example — its five sibling call sites pass a `t()` result.
*
* Scope is the same contract as the eslint guard: callers pass the files to
* judge, and `check-nonjsx-copy.mjs` passes `i18nGuardFiles`. Judging only
* already-migrated paths is what keeps this from becoming the repo-wide treadmill
* that killed the first migration attempt (docs/i18n.md, "The guard is scoped").
*
* A finding is silenced with `// i18n-exempt: <reason>` on the enclosing
* statement. The reason is mandatory — the shapes this finds are genuinely
* ambiguous (a persisted default, a prompt sent verbatim to a model, a wire
* value), and the next reader needs to know which one it is without re-deriving
* it. See `exemptionsFor`.
*/
import { parseSync } from "@babel/core";
import { looksLikeCopy } from "./looks-like-copy.mjs";
export { looksLikeCopy };
/** Property names whose value is displayed rather than used as data. */
const COPY_KEYS = new Set([
"alt",
"ariaLabel",
"aria-label",
"body",
"caption",
"cancelText",
"confirmText",
"description",
"emptyMessage",
"emptyText",
"errorMessage",
"heading",
"helpText",
"hint",
"label",
"message",
"placeholder",
"subtitle",
"successMessage",
"summary",
"text",
"title",
"tooltip",
]);
const FUNCTION_TYPES = new Set([
"ArrowFunctionExpression",
"ClassMethod",
"FunctionDeclaration",
"FunctionExpression",
"ObjectMethod",
]);
/**
* Calls whose string arguments reach the user directly.
*
* The `set*` half is matched by SHAPE rather than by an enumerated list. Every
* screen names its own state setter — `setPreviewError`, `setSaveWarning`,
* `setJoinStatus` — so a fixed list only ever catches the ones someone
* remembered to add, and the first file this check was pointed at held a
* `setPreviewError` two lines above a name that was on the list.
*/
const DISPLAY_CALLS =
/^(alert|confirm|prompt|toast|toast\.\w+|window\.(alert|confirm|prompt)|set[A-Za-z]*(Error|Message|Notice|Warning|Status|Label|Text|Title|Reason|Summary|Hint))$/;
/**
* Identifiers that hold styling rather than prose. Tailwind class lists are the
* single largest source of false positives here — they are long, spaced, and
* word-like — and they arrive through exactly these names.
*/
const STYLE_NAME = /(class(name)?|style|css|selector|query|variant|token)s?$/i;
/**
* The copy a literal carries, or null when it carries none.
*
* A template literal is judged on its LONGEST static chunk, not on the chunks
* joined: `` `Every day at ${at} ${zone}` `` is copy because "Every day at "
* is, while `` `${w}px ${h}em` `` is not — joining would splice "px" and "em"
* into a two-word phrase and report a CSS value as a sentence.
*/
function literalCopy(node, excludes) {
if (node.type === "StringLiteral") {
return looksLikeCopy(node.value, excludes) ? node.value : null;
}
if (node.type !== "TemplateLiteral") return null;
const chunks = node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw);
const longest = chunks.reduce((a, b) => (b.trim().length > a.trim().length ? b : a), "");
if (!looksLikeCopy(longest, excludes)) return null;
return chunks.join("${…}");
}
function walk(node, visit, ancestors = []) {
if (!node || typeof node.type !== "string") return;
visit(node, ancestors);
const next = [...ancestors, node];
for (const key of Object.keys(node)) {
if (key === "loc" || key.endsWith("Comments")) continue;
const child = node[key];
if (Array.isArray(child)) {
for (const item of child) if (item && typeof item.type === "string") walk(item, visit, next);
} else if (child && typeof child.type === "string") {
walk(child, visit, next);
}
}
}
function nameOf(node) {
if (!node) return "";
if (node.type === "Identifier") return node.name;
if (node.type === "StringLiteral") return node.value;
return "";
}
/**
* The names a call could be recognised by: the whole callee, and — for a member
* call — the property on its own.
*
* The bare property matters because setters are routinely passed around in a
* bag: `setters.setError("Failed to resume session")` is the same display call
* as `setError(...)`, and matching only the dotted form misses it. This file's
* first run over `use-session-resumption.ts` caught the free-function call two
* lines from a `setters.` one and reported only the former.
*/
function calleeNames(callee) {
if (!callee) return [];
if (callee.type === "Identifier") return [callee.name];
if (callee.type === "MemberExpression") {
const property = nameOf(callee.property);
return [`${nameOf(callee.object)}.${property}`, property];
}
return [];
}
/** The function a node sits in, and the name it was declared under. */
function enclosingFunction(ancestors) {
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
const node = ancestors[index];
if (!FUNCTION_TYPES.has(node.type)) continue;
const parent = ancestors[index - 1];
const name =
node.id?.name ||
(parent?.type === "VariableDeclarator" ? nameOf(parent.id) : "") ||
(parent?.type === "ObjectProperty" ? nameOf(parent.key) : "");
return { node, name };
}
return null;
}
/**
* The nodes an exemption comment may attach to, innermost first.
*
* Two granularities, because the legitimate cases come in two sizes. A
* `const TABLE = [...]` is one decision, so a comment above the declaration
* covers every row. A prompt builder is also one decision, but its strings sit
* in `return` statements inside the function — attaching only to the nearest
* statement would need a marker on each, repeating the same sentence four times
* and inviting whoever adds the fifth to skip it.
*
* So the enclosing FUNCTION counts too — but ONLY via a marker that LEADS the
* function declaration itself. An earlier version scanned every line from each
* target's start down to the finding, which meant a marker on one statement
* inside a function silenced every literal after it in that function: exempting
* a persisted assignment on line 10 also passed a `return "Visible label"` on
* line 30. Matching leading comments makes the attachment explicit rather than
* positional.
*/
function exemptionTargets(ancestors) {
// Every ancestor, because a leading comment is attached by Babel to exactly
// the node it precedes and so cannot leak sideways. That makes the natural
// placements all work and all stay precise: above one row of a config table
// it covers that row, above the `const TABLE = [...]` it covers the table,
// above a prompt builder it covers that function. What it will not do is
// reach a sibling — the case that made the previous line-range version wrong.
return [...ancestors].reverse();
}
/**
* Lines carrying `// i18n-exempt: <reason>`, mapped to the reason.
*
* A marker with no reason after the colon is reported rather than honoured — an
* unexplained exemption is the thing this check exists to prevent.
*/
export function exemptionsFor(comments) {
const withReason = new Set();
const withoutReason = [];
for (const comment of comments ?? []) {
const match = /i18n-exempt:?(.*)$/.exec(comment.value);
if (!match) continue;
const line = comment.loc.start.line;
if (match[1].trim().length >= 3) withReason.add(line);
else withoutReason.push(line);
}
return { withReason, withoutReason };
}
/** Positions this check never judges, whatever the literal says. */
function isJudgeablePosition(node, parent, ancestors) {
// JSX belongs to the eslint rule; reporting it here would double every
// finding on a guarded path.
if (ancestors.some((ancestor) => ancestor.type.startsWith("JSX"))) return false;
// Types, imports and enum members are never rendered.
if (/^(TS|Import|Export)/.test(parent.type)) return false;
// An object KEY is an identifier even when it is quoted.
return !(parent.type === "ObjectProperty" && parent.key === node);
}
/** Whether a literal's position is one this check judges, and under what name. */
function classify(node, ancestors) {
const parent = ancestors[ancestors.length - 1];
if (!parent || !isJudgeablePosition(node, parent, ancestors)) return null;
const propertyKey =
parent.type === "ObjectProperty" && parent.value === node ? nameOf(parent.key) : null;
const screaming = ancestors.find(
(ancestor) =>
ancestor.type === "VariableDeclarator" &&
ancestor.id?.type === "Identifier" &&
/^[A-Z][A-Z0-9_]{2,}$/.test(ancestor.id.name),
);
// Every prose-shaped value in a config table, not only the slots named `label`
// or `title`. A lookup table keys its copy by an ENUM — `INTERVAL_LABELS` is
// `{ "every-5m": "Every 5 minutes" }` — so a copy-key filter reads the key
// `every-5m`, decides the slot is data, and skips the sentence in it.
// `looksLikeCopy` already rejects ids, so the ids and icon names sitting
// beside the copy never reach here.
if (screaming && !STYLE_NAME.test(screaming.id.name)) {
const suffix = propertyKey ? `.${propertyKey}` : "";
return { kind: "config-table", context: `${screaming.id.name}${suffix}` };
}
if (propertyKey && COPY_KEYS.has(propertyKey)) {
return { kind: "copy-property", context: propertyKey };
}
if (parent.type === "CallExpression") {
const name = calleeNames(parent.callee).find((candidate) => DISPLAY_CALLS.test(candidate));
if (name) return { kind: "display-call", context: name };
}
if (parent.type === "AssignmentPattern" && parent.right === node) {
return isParameterDefault(parent, ancestors)
? { kind: "param-default", context: nameOf(parent.left) }
: null;
}
return classifyReturn(node, parent, ancestors);
}
/** A default in a function's parameter list, which runs before the body. */
function isParameterDefault(pattern, ancestors) {
const fn = enclosingFunction(ancestors);
return Boolean(fn?.node.params?.some((param) => ancestors.includes(param) || param === pattern));
}
/**
* A string handed back by a plain helper.
*
* Components are excluded by name: they return JSX, which the eslint rule
* already covers, and their `className` ternaries would otherwise all land here.
*/
function classifyReturn(node, parent, ancestors) {
const returnish =
parent.type === "ReturnStatement" ||
(parent.type === "ArrowFunctionExpression" && parent.body === node) ||
((parent.type === "ConditionalExpression" || parent.type === "LogicalExpression") &&
parent.test !== node);
if (!returnish) return null;
const fn = enclosingFunction(ancestors);
if (!fn || /^[A-Z]/.test(fn.name) || STYLE_NAME.test(fn.name)) return null;
const declarator = ancestors.find(
(ancestor) => ancestor.type === "VariableDeclarator" && ancestor.id?.type === "Identifier",
);
if (declarator && STYLE_NAME.test(nameOf(declarator.id))) return null;
return { kind: "helper-return", context: fn.name };
}
/**
* Copy in a non-JSX position, one entry per string.
*
* `source` is parsed as TS/TSX. Anything that fails to parse yields no findings
* rather than throwing: this runs over whole directories, and a gate that dies on
* one unparseable file reports nothing about the other fourteen hundred.
*
* Returns `{ findings, markersWithoutReason }` on every path, including that one.
*
* @param {string} source
* @param {{ filename?: string, excludes?: string[] }} [options]
*/
export function findNonJsxCopy(source, options = {}) {
const { filename = "input.tsx", excludes = [] } = options;
let ast;
try {
ast = parseSync(source, {
filename,
babelrc: false,
configFile: false,
sourceType: "module",
parserOpts: { plugins: ["typescript", "jsx"] },
});
} catch {
// Same shape as a clean parse: callers destructure `{ findings }`, and
// handing them a bare array here would throw on the file after the broken
// one rather than skipping it.
return { findings: [], markersWithoutReason: [] };
}
const { withReason, withoutReason } = exemptionsFor(ast.comments);
const findings = [];
const seen = new Set();
const report = (node, ancestors, kind, context, value) => {
const line = node.loc.start.line;
// A marker silences a finding when it LEADS one of the finding's enclosing
// statements or functions, or trails the finding's own line. Babel's
// `leadingComments` is what makes "leads" precise; a line-range scan let a
// marker anywhere inside a function cover everything after it.
if (withReason.has(line)) return;
for (const target of exemptionTargets(ancestors)) {
const leading = target.leadingComments ?? [];
if (leading.some((comment) => withReason.has(comment.loc.start.line))) return;
}
const id = `${line}:${node.loc.start.column}`;
if (seen.has(id)) return;
seen.add(id);
findings.push({ line, column: node.loc.start.column, kind, context, value });
};
walk(ast.program, (node, ancestors) => {
const value = literalCopy(node, excludes);
if (value === null) return;
const hit = classify(node, ancestors);
if (hit) report(node, ancestors, hit.kind, hit.context, value);
});
return { findings, markersWithoutReason: withoutReason };
}