feat(i18n): localize Settings → Workspace (#2212)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-03 20:52:09 +02:00
committed by GitHub
parent 8ae850956f
commit 957cbb910e
25 changed files with 1656 additions and 180 deletions
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* Migration fidelity: every string a change REMOVES from source must reappear
* verbatim in a catalog.
*
* A directory migration replaces literals with `t()` calls. If the English a
* user saw is gone from the source and is not a catalog value either, then the
* copy silently CHANGED — and nothing else in the toolchain notices:
*
* live surface : "Create a diagnostic ZIP with frontend and backend logs."
* reused key : "Download a bounded diagnostic ZIP containing frontend and backend logs."
*
* That shape survives `pnpm lint`, all four `i18n:check` gates, the ratchet and
* the whole unit suite, because every string IS externalized, the key DOES
* exist and the catalogs ARE in sync. It happens when two surfaces render the
* same copy, one of them was migrated earlier (often a dead route), and the
* later migration adopts the existing, plausible-looking key. The pseudo-locale
* cannot see it either: the text is translated, just into different words.
*
* This check compares the literals present before and after, so it reports at
* the point of the edit and covers every route in the change automatically,
* rather than relying on whichever one happens to have an e2e assertion
* pinning its sentence.
*
* NOT part of `i18n:check`, and deliberately not in CI: it is diff-based, so it
* needs a base ref, and a finding is a prompt to CLASSIFY rather than proof of
* a bug. Benign findings are normal and expected — a value extracted into an
* interpolation, a placeholder hoisted to a constant, a literal that was never
* copy. It narrows the diff for a human; it does not decide.
*
* COVERAGE IS PARTIAL. Rule 2 is only as tight as the literal text around a
* message's placeholders, which is why a message needs two words of prose to
* earn one at all (see `canAnchorPattern`). Simulating a rewrite of every
* removed string measured 89/96 caught on this migration. The remainder are
* messages too thin to distinguish a rewrite from anything else. A clean run is
* evidence, not proof — which is why the mutation probe in docs/i18n.md is a
* separate step.
*
* Usage: node scripts/check-removed-literals.mjs [--base <ref>] [--all]
*
* --all also report literals that look like identifiers/values, not just
* the ones that look like prose.
*/
import fs from "node:fs";
import path from "node:path";
import { git, resolveBase, REPO_ROOT } from "./lib/git-base.mjs";
import { changedFiles, WEB_PREFIX } from "./lib/changed-files.mjs";
import { accountedFor, buildCatalog, literals, looksLikeCopy } from "./lib/removed-literals.mjs";
const ROOT = path.resolve(import.meta.dirname, "..");
const LOCALES = path.join(ROOT, "src", "locales", "en");
const REPORT_ALL = process.argv.includes("--all");
function catalogValues() {
const out = new Set();
for (const file of fs.readdirSync(LOCALES).filter((f) => f.endsWith(".json"))) {
for (const value of Object.values(
JSON.parse(fs.readFileSync(path.join(LOCALES, file), "utf8")),
))
if (typeof value === "string") out.add(value);
}
return out;
}
/** The committed content of a path at `ref`, or "" if absent there. */
function committedSide(ref, repoPath) {
try {
return git(["show", `${ref}:${repoPath}`], { quiet: true });
} catch {
return ""; // Absent on that side (added or deleted).
}
}
/**
* The CURRENT content of a path — working tree, not `HEAD`.
*
* This side must not be `git show HEAD:…`. `changedFiles` diffs the base against
* the working tree, so it lists files you have edited but not committed; reading
* the "after" side from HEAD compared those files against themselves and found
* nothing. The result was a no-op that printed a tick WITH THE CORRECT FILE
* COUNT, which is what made it convincing — and the natural workflow (finish,
* run checks, commit) walks straight into it.
*
* A missing file is a deletion, so its strings all count as removed.
*/
function workingTreeSide(repoPath) {
try {
return fs.readFileSync(path.join(REPO_ROOT, repoPath), "utf8");
} catch {
return "";
}
}
const resolved = resolveBase();
if (resolved.skip) {
console.log(`↷ skipped removed-literal check — ${resolved.skip}`);
process.exit(0);
}
const { base } = resolved;
const values = catalogValues();
const catalog = buildCatalog(values);
const files = [
...new Set([...changedFiles(base, "M", REPO_ROOT), ...changedFiles(base, "D", REPO_ROOT)]),
];
const findings = [];
let unparseable = 0;
for (const repoPath of files) {
const before = literals(committedSide(base, repoPath), repoPath);
const after = literals(workingTreeSide(repoPath), repoPath) ?? new Set();
if (before === null) {
unparseable += 1;
continue;
}
for (const value of before) {
if (after.has(value)) continue;
if (!REPORT_ALL && !looksLikeCopy(value)) continue;
if (accountedFor(value, catalog)) continue;
findings.push({ file: repoPath.slice(WEB_PREFIX.length), value });
}
}
if (unparseable) console.log(`ℹ ${unparseable} file(s) could not be parsed on the base side.`);
// A tick over nothing is the shape of every silent no-op this check has had.
// Say plainly that there was nothing to compare rather than reporting a verdict.
if (files.length === 0) {
console.log(
`↷ no changed .ts/.tsx under ${WEB_PREFIX} between ${base.slice(0, 9)} and the working tree — nothing to compare.`,
);
process.exit(0);
}
if (findings.length === 0) {
console.log(
`✓ removed literals OK — every string ${files.length} changed file(s) dropped is still a catalog value.`,
);
process.exit(0);
}
console.log(
`\nStrings removed from source that do NOT reappear verbatim in a catalog (${findings.length}):\n`,
);
for (const { file, value } of findings.sort(
(a, b) => a.file.localeCompare(b.file) || a.value.localeCompare(b.value),
))
console.log(` ${file}\n ${JSON.stringify(value)}\n`);
console.log(
"Each is one of:\n" +
" - a VALUE extracted into an interpolation (reconstruct it and compare — it should be byte-identical)\n" +
" - a literal hoisted to a constant, or one that was never copy\n" +
" - COPY THAT CHANGED, because a reused key's English differs from what this surface rendered\n" +
"\nThe last one is the bug this check exists for. Classify every entry before merging.\n",
);
process.exit(1);
+180
View File
@@ -0,0 +1,180 @@
/**
* Pure helpers for `check-removed-literals.mjs`: extracting every user-visible
* string from a source file, and deciding whether a string a change removed is
* still accounted for by the catalog.
*
* Split out so the matching rules can be unit-tested against the failure they
* exist to catch, without needing a git checkout or a real diff.
*/
import { parseSync } from "@babel/core";
export function walk(node, visit) {
if (!node || typeof node.type !== "string") return;
visit(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 c of child) if (c && typeof c.type === "string") walk(c, visit);
} else if (child && typeof child.type === "string") walk(child, visit);
}
}
/**
* Every user-visible string shape in one file.
*
* JSX TEXT is the load-bearing case and the one a regex over quoted strings
* misses entirely: `<Link>Back to Workspaces</Link>` carries no quotes, and it
* is the majority of migrated copy. Template quasis matter because the guard
* validates them too.
*/
export function literals(source, filename) {
let ast;
try {
ast = parseSync(source, {
filename,
babelrc: false,
configFile: false,
sourceType: "module",
parserOpts: { plugins: ["typescript", "jsx"] },
});
} catch {
return null; // Unparseable side of the diff — reported by the caller.
}
const found = new Set();
walk(ast, (node) => {
if (node.type === "StringLiteral") found.add(node.value);
else if (node.type === "JSXText") found.add(node.value);
else if (node.type === "TemplateLiteral")
for (const q of node.quasis) found.add(q.value.cooked ?? q.value.raw);
});
return new Set([...found].map((s) => s.replace(/\s+/g, " ").trim()).filter(Boolean));
}
/**
* A message and its source prose compare equal once the markup a migration adds
* is removed: `<Trans>` tag indices, `{{interpolations}}`, and the whitespace
* JSX collapses. Without this the check is pure noise — every `<Trans>` and
* every value extraction reports.
*/
export function normalize(value) {
return value
.replace(/<\/?\d+>/g, "")
.replace(/\{\{[^}]*\}\}/g, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* A message as a pattern, with each `{{value}}` free to be anything.
*
* This is what accounts for a VALUE EXTRACTION — the commonest benign finding.
* `"Files over 5 MiB are skipped…"` in the source becomes
* `"Files over {{limit}} are skipped…"` in the catalog, which is the same
* sentence with the units pulled out, not a rewrite.
*/
export function messagePattern(value) {
const escaped = value
.replace(/<\/?\d+>/g, "")
.replace(/\s+/g, " ")
.trim()
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\\\{\\\{[^}]*\\\}\\\}/g, "[\\s\\S]+?");
return new RegExp(`^${escaped}$`);
}
/**
* Is this removed string still accounted for by some catalog message?
*
* Three ways, in increasing looseness:
* 1. it IS a message;
* 2. it is an INSTANCE of one, with interpolated values filled in;
* 3. it is a FRAGMENT of one — a `<Trans>` splits its sentence into a JSX text
* node per element boundary, so the halves either side of a `<code>` arrive
* here separately and can never match a whole message.
*
* (3) trades a little sensitivity for usability: a fragment could in principle
* be a coincidental substring of an unrelated message. That is a far better
* trade than the alternative, because a check nobody runs catches nothing — and
* the failure this exists for is not subtle. A rewritten sentence is not a
* substring of the sentence that replaced it.
*/
export function accountedFor(value, { exact, normalized, patterns }) {
if (exact.has(value)) return true;
const norm = normalize(value);
if (normalized.has(norm)) return true;
if (patterns.some((re) => re.test(norm) || re.test(value))) return true;
return [...normalized].some((message) => message.length >= norm.length && message.includes(norm));
}
/** Prose, as opposed to a classname, enum token, path or identifier. */
export function looksLikeCopy(value) {
if (value.length < 4) return false;
if (!/[A-Za-z]{3}/.test(value)) return false;
if (!/[A-Z]/.test(value) && !/[a-z]+ [a-z]+/.test(value)) return false;
if (/^[/~.]/.test(value)) return false; // paths
if (/^(https?|file|ssh|git):\/\//.test(value)) return false; // URLs
if (/^[a-z][a-zA-Z0-9]*$/.test(value)) return false; // camelCase identifier
if (/^[a-z0-9]+([-_][a-z0-9]+)+$/.test(value)) return false; // kebab/snake token
// A `namespace:key` catalog reference. Swapping one `t()` key for another
// registers as a removed literal, because the key is itself a StringLiteral —
// so every migration that re-points a call site reports its own old key. It is
// an identifier, never copy, and leaving it in reads alarmingly to someone
// expecting a clean run.
if (/^[a-z][a-zA-Z0-9]*:[a-zA-Z0-9_]+$/.test(value)) return false;
return true;
}
/**
* Can this message anchor a pattern, or would its pattern match anything?
*
* A message that is NOTHING BUT a placeholder — `settings:externalMcpConfigPath`
* is literally `"{{path}}"` — compiles to `/^[\s\S]+?$/`, which matches every
* string in the repository. Because rule 2 runs before rule 3, ONE such key
* makes `accountedFor` return true for everything and the whole check becomes a
* silent no-op that still prints ✓. That is strictly worse than not running it,
* because it converts "I verified" into "I ran something".
*
* The requirement is TWO WORDS of prose, not merely a non-empty remainder and
* not merely one word. The empty case is the extreme of a continuum, not a
* distinct bug — 44 interpolating messages have a residue of six characters or
* fewer — and one short word clears any "is there a word?" bar while leaving the
* pattern almost unbounded:
*
* "{{a}} —" -> /^[\s\S]+? —$/ matches anything ending " —"
* "{{count}}m" -> /^[\s\S]+?m$/ matches "Confirm", "Custom", "System"
* "Delete {{name}}" -> /^Delete [\s\S]+?$/ matches EVERY "Delete …" sentence
*
* Measured on two migrations, requiring two words dropped 340 pattern-bearing
* messages to 292, added ZERO findings to either diff, and made ten more
* rewrites detectable on one of them. It costs nothing real and closes the
* largest remaining hole.
*
* The honest limit of this rule: `"{{count}} of {{total}}"` (a legitimate value
* extraction) and `"{{a}} of {{b}}"` (far too permissive to anchor anything)
* both reduce to the residue `"of"` and compile to the SAME regex, so nothing
* computed from the message can accept one and reject the other. Both are
* rejected here, which means a string like `"3 of 9"` is REPORTED and classified
* by a human. That is the correct direction for an advisory check: a false
* positive costs someone ten seconds, a false negative is the failure this
* exists to prevent.
*
* A rejected message is still matched exactly and as a fragment — it only loses
* the wildcard, which is all it could honestly support.
*/
function canAnchorPattern(message) {
const words = normalize(message)
.split(/\s+/)
.filter((word) => /[A-Za-z]{2,}/.test(word));
return words.length >= 2;
}
/** Build the three lookup shapes `accountedFor` needs from a set of messages. */
export function buildCatalog(values) {
const set = values instanceof Set ? values : new Set(values);
return {
exact: set,
normalized: new Set([...set].map(normalize)),
patterns: [...set].filter((v) => v.includes("{{") && canAnchorPattern(v)).map(messagePattern),
};
}
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import { accountedFor, buildCatalog, literals, looksLikeCopy } from "./removed-literals.mjs";
import { isCandidate } from "./changed-files.mjs";
/**
* The failure this check exists for, reproduced exactly.
*
* A live route rendered one sentence; a dead route migrated earlier had left a
* plausible-looking key holding a DIFFERENT sentence. Pointing the live route at
* that key silently rewrote user-visible English, and lint, all four
* `i18n:check` gates, the ratchet and 8,144 unit tests were clean — every string
* was externalized, the key existed, the catalogs were in sync.
*/
const LIVE_COPY = "Create a diagnostic ZIP with frontend and backend logs.";
const REUSED_KEY_COPY = "Download a bounded diagnostic ZIP containing frontend and backend logs.";
describe("accountedFor", () => {
it("reports a removed sentence whose replacement key holds different English", () => {
const catalog = buildCatalog([REUSED_KEY_COPY]);
expect(accountedFor(LIVE_COPY, catalog)).toBe(false);
});
it("accepts a string that became a catalog message unchanged", () => {
expect(accountedFor(LIVE_COPY, buildCatalog([LIVE_COPY]))).toBe(true);
});
it("accepts a value extracted into an interpolation", () => {
const catalog = buildCatalog(["Files over {{limit}} are skipped when copying to remote."]);
expect(accountedFor("Files over 5 MiB are skipped when copying to remote.", catalog)).toBe(
true,
);
});
it("accepts either half of a <Trans> sentence split around an element", () => {
const catalog = buildCatalog([
"Type the workspace name <0>{{name}}</0> to confirm deletion. This action cannot be undone.",
]);
expect(accountedFor("Type the workspace name", catalog)).toBe(true);
expect(accountedFor("to confirm deletion. This action cannot be undone.", catalog)).toBe(true);
});
it("does not accept a rewrite merely because it shares words with the message", () => {
const catalog = buildCatalog(["Delete the repository and every script attached to it."]);
expect(accountedFor("Delete the repository and its scripts.", catalog)).toBe(false);
});
it("accepts a plural pair rendered from the singular source sentence", () => {
const catalog = buildCatalog(["{{count}} custom script", "{{count}} custom scripts"]);
expect(accountedFor("2 custom scripts", catalog)).toBe(true);
});
});
describe("literals", () => {
it("sees JSX text, which carries no quotes and is most migrated copy", () => {
const found: Set<string> | null = literals(
`export const A = () => <Link>Back to Workspaces</Link>;`,
"a.tsx",
);
expect(found?.has("Back to Workspaces")).toBe(true);
});
it("sees string literals in attributes and template quasis", () => {
const found: Set<string> | null = literals(
'export const A = () => <input placeholder="Filter repositories..." aria-label={`Select ${x}`} />;',
"a.tsx",
);
expect(found?.has("Filter repositories...")).toBe(true);
expect(found?.has("Select")).toBe(true);
});
it("returns null for an unparseable file rather than reporting it as empty", () => {
expect(literals("export const = ??", "a.tsx")).toBeNull();
});
});
describe("looksLikeCopy", () => {
it.each([
["Delete Repository", true],
["Manage your workspaces and workflows", true],
["/absolute/path/to/repository", false],
["https://example.com/docs", false],
["worktree_branch_template", false],
["repositoryName", false],
["move_to_next", false],
["px-1 py-0.5", false],
// A catalog reference. Re-pointing a t() call reports the OLD key as a
// removed literal, because the key is itself a StringLiteral.
["workspaces:noScriptsYet", false],
["common:requestFailed", false],
// Guard: prose containing a colon is still copy.
["Supported patterns:", true],
["Note: this cannot be undone", true],
])("classifies %j as copy=%s", (value, expected) => {
expect(looksLikeCopy(value)).toBe(expected);
});
});
/**
* Two false positives were reported from a large migration and traced to an
* earlier regex PROTOTYPE of this check rather than to this module. Pinned here
* because the reports were circulated as bugs, and the obvious "fix" for either
* would make the check worse: scanning catalogs would report keys as copy, and
* widening `{{…}}` further would weaken the rewrite detection that is the whole
* point.
*/
describe("reported false positives that this module does not have", () => {
it("matches a literal whose leading number moved into an interpolation", () => {
// Reported as "permanently unmatchable". Rule 2 turns {{…}} into a wildcard,
// so a placeholder at the START of the message matches just as well as an
// interior one — provided the message has two words to anchor on.
expect(accountedFor("3 of 9 selected", buildCatalog(["{{count}} of {{total}} selected"]))).toBe(
true,
);
expect(accountedFor("50 items remaining", buildCatalog(["{{count}} items remaining"]))).toBe(
true,
);
});
it("still reports a rewrite that only differs where a placeholder is not", () => {
// The guard on the rule above: a wildcard must not swallow changed prose.
expect(accountedFor("50 max", buildCatalog(["{{count}} maximum"]))).toBe(false);
});
it("never treats a catalog as a source of candidate literals", () => {
// Reported as "a whole-file JSON reformat floods the output with catalog
// KEYS". Catalogs are what the check compares *against*; `isCandidate`
// admits only .ts/.tsx, so reformatting one cannot produce a single
// candidate. Scanning them would report keys as removed copy.
expect(isCandidate("apps/web/src/locales/en/common.json")).toBe(false);
expect(isCandidate("apps/web/src/locales/pseudo/workspaces.json")).toBe(false);
expect(isCandidate("apps/web/components/settings/repository-card.tsx")).toBe(true);
});
});
/**
* The assertions the rest of this file structurally cannot make: they all assume
* a well-formed catalog, so none of them can tell a working check from one that
* accepts everything. A check that silently passes is worse than no check, so
* these prove it can still FAIL.
*/
describe("a degenerate catalog message cannot disable the check", () => {
it("does not let a message that is only a placeholder match everything", () => {
// `settings:externalMcpConfigPath` was literally "{{path}}", which compiled
// to /^[\s\S]+?$/ and made accountedFor return true for every string in the
// repo — the whole check became a no-op that still printed a tick.
expect(accountedFor("anything at all", buildCatalog(["{{x}}"]))).toBe(false);
expect(accountedFor("Delete a workflow and all its steps.", buildCatalog(["{{path}}"]))).toBe(
false,
);
});
it("does not let a message with no word in it anchor a pattern", () => {
// "{{a}} —" normalizes to "—" and would compile to /^[\s\S]+? —$/.
expect(accountedFor("totally unrelated prose —", buildCatalog(["{{a}} —"]))).toBe(false);
});
it("still matches such a message exactly and as a fragment", () => {
// Dropping its PATTERN must not drop the message from the catalog.
expect(accountedFor("{{path}}", buildCatalog(["{{path}}"]))).toBe(true);
});
it("reports a value extraction whose message is too thin to anchor", () => {
// "{{count}} of {{total}}" reduces to the residue "of" — BYTE-IDENTICAL to
// "{{a}} of {{b}}", which would match "Anything at all of anything else".
// Nothing computed from the message can accept one and reject the other, so
// both lose their pattern and "3 of 9" is reported for a human to classify.
// A false positive costs ten seconds; a false negative is the failure this
// check exists to prevent.
expect(accountedFor("3 of 9", buildCatalog(["{{count}} of {{total}}"]))).toBe(false);
});
it("does not let one short word anchor a nearly unbounded pattern", () => {
// "Delete {{name}}" compiled to /^Delete [\s\S]+?$/ and accepted every
// "Delete …" sentence — 19 of 43 strings in the file this check was first
// pointed at were unprotected by exactly this shape.
expect(
accountedFor("Delete a workflow and all its steps.", buildCatalog(["Delete {{name}}"])),
).toBe(false);
expect(accountedFor("Confirm", buildCatalog(["{{count}}m"]))).toBe(false);
});
it("keeps anchoring once a message has two words of prose", () => {
expect(
accountedFor(
"Files over 5 MiB are skipped",
buildCatalog(["Files over {{limit}} are skipped"]),
),
).toBe(true);
expect(
accountedFor("totally unrelated prose", buildCatalog(["Files over {{limit}} are skipped"])),
).toBe(false);
});
});