feat(i18n): localize Settings → Workspace (#2212)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user