diff --git a/scripts/check-protected-terms.mjs b/scripts/check-protected-terms.mjs index 0d840d1f3..0308f1aaf 100644 --- a/scripts/check-protected-terms.mjs +++ b/scripts/check-protected-terms.mjs @@ -40,6 +40,8 @@ import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; +import { buildEquivalence, formsFor } from "../translation/lib/term-forms.mjs"; + const root = new URL("../", import.meta.url).pathname; const config = JSON.parse( readFileSync(join(root, "translation/protected-terms.json"), "utf8"), @@ -87,16 +89,41 @@ function sourceForTranslation(absTranslated) { return sourceRel; } +const reCache = new Map(); function termRegExp(term) { + const hit = reCache.get(term); + if (hit) return hit; // Word-boundary match so short terms don't false-positive inside other // words (e.g. "mining" must not match "deter*mining*"; "chain" is still // satisfied by "block*chain*" only via the standalone token's boundaries). const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`(? termRegExp(f).test(translated)); +} + // ---- pass 1: every violation in the working tree --------------------------- const violations = []; @@ -125,8 +152,7 @@ for (const translatedAbs of walk(translationsDir)) { checked += 1; for (const term of terms) { - const re = termRegExp(term); - if (re.test(source) && !re.test(translated)) { + if (termRegExp(term).test(source) && !satisfiedIn(translated, term)) { violations.push({ translatedRel, sourceRel, term }); } } @@ -234,6 +260,29 @@ function baseContentAt(basePath) { return content; } +// Base content must be judged by the BASE declaration, not this change's. +// The config comes from the working tree while base translations come from +// git, so a change that removes an equivalence group would otherwise make the +// base look as though it had already been violating — bucketing a genuine +// regression as "pre-existing" and exiting 0. A base config that cannot be +// read, parsed or validated is not this change's fault, so fall back to the +// head declaration rather than failing the PR over history. +let baseEquivalence = equivalence; +if (mergeBase) { + const rawBaseConfig = baseContentAt("translation/protected-terms.json"); + if (rawBaseConfig !== null) { + try { + const baseConfig = JSON.parse(rawBaseConfig); + baseEquivalence = buildEquivalence( + baseConfig.preserveVerbatim ?? [], + baseConfig.equivalentForms, + ); + } catch { + baseEquivalence = equivalence; + } + } +} + function classify(v) { if (!mergeBase) return "introduced"; // no base requested → whole-tree audit // Judge pre-existence against the pairing that ACTUALLY existed at the base: @@ -253,7 +302,7 @@ function classify(v) { baseTranslated !== null && baseSource !== null && termRegExp(v.term).test(baseSource) && - !termRegExp(v.term).test(baseTranslated); + !satisfiedIn(baseTranslated, v.term, baseEquivalence); if (violatedAtBase) return "pre-existing"; return changedTranslations.has(v.translatedRel) ? "introduced" : "stale-source"; } diff --git a/translation/lib/term-forms.mjs b/translation/lib/term-forms.mjs new file mode 100644 index 000000000..cefce599a --- /dev/null +++ b/translation/lib/term-forms.mjs @@ -0,0 +1,104 @@ +// Equivalent forms of a protected term. +// +// Some protected terms are the same word in two shapes — a singular and its +// plural. Whether an English loanword takes an -s is a decision each language +// makes for itself, not one English makes for it: Italian writes "gli Unified +// Address" because Italian does not pluralize borrowed nouns, and Japanese, +// Korean and Chinese do not inflect plurals at all. Demanding one exact shape +// therefore forces English grammar into 18 languages that do not share it, to +// assert a term the other shape already carries. +// +// So a translation may satisfy a protected term with any form declared +// equivalent to it. The pairing is DECLARED, never inferred from spelling. +// Deriving it by stripping a trailing "s" would make `Argos` interchangeable +// with `Argo` and `zec.rocks` with `zec.rock` — different things that merely +// look related — and would silently miss irregular plurals such as +// `Technologies` / `Technology`. A maintainer states which forms are the same +// word; the checker never guesses. +// +// Shape in translation/protected-terms.json (the key is optional): +// +// "equivalentForms": [ +// ["ZK-SNARK", "ZK-SNARKs"] +// ] +// +// Membership is symmetric by construction: the group says "these are one +// term", so any member asserts it. This module is pure — no filesystem, no +// process, no clock — so it can be unit tested directly. + +export class TermFormsError extends Error {} + +/** + * Build a lookup of term -> Set of forms that satisfy it. + * + * Every group is validated against the protected list, and every failure is + * loud. A malformed group that was quietly ignored would weaken the gate in + * exactly the way nobody would notice. + * + * @param {string[]} terms the preserveVerbatim list + * @param {string[][]} groups the equivalentForms list (may be undefined) + * @returns {Map>} + */ +export function buildEquivalence(terms, groups) { + const protectedSet = new Set(terms); + const map = new Map(); + if (groups === undefined) return map; + + if (!Array.isArray(groups)) { + throw new TermFormsError("equivalentForms must be an array of groups"); + } + + const seen = new Map(); // term -> index of the group that already claimed it + groups.forEach((group, i) => { + if (!Array.isArray(group) || group.length < 2) { + throw new TermFormsError( + `equivalentForms[${i}]: a group needs at least two terms`, + ); + } + if (new Set(group).size !== group.length) { + throw new TermFormsError(`equivalentForms[${i}]: contains a duplicate`); + } + for (const term of group) { + // Reject anything that is not a clean, non-blank string. A blank or + // padded entry is the one shape that could do real damage: paired with a + // junk entry in preserveVerbatim, a term of " " matches nearly every + // page and would silently satisfy whatever it was grouped with. + if (typeof term !== "string" || !term.trim() || term.trim() !== term) { + throw new TermFormsError( + `equivalentForms[${i}]: entries must be non-empty strings without surrounding whitespace`, + ); + } + if (!protectedSet.has(term)) { + // Otherwise a typo would create a group that silently relaxes nothing, + // or worse, relaxes a term nobody protects. + throw new TermFormsError( + `equivalentForms[${i}]: "${term}" is not in preserveVerbatim`, + ); + } + if (seen.has(term)) { + throw new TermFormsError( + `equivalentForms[${i}]: "${term}" is already in group ${seen.get(term)}`, + ); + } + seen.set(term, i); + } + const forms = new Set(group); + for (const term of group) map.set(term, forms); + }); + + return map; +} + +/** + * The forms that satisfy `term`, always including the term itself first so the + * common case costs a single test. + * + * @param {string} term + * @param {Map>} equivalence + * @returns {string[]} + */ +export function formsFor(term, equivalence) { + const group = equivalence.get(term); + if (!group) return [term]; + return [term, ...[...group].filter((f) => f !== term)]; +} diff --git a/translation/lib/term-forms.test.mjs b/translation/lib/term-forms.test.mjs new file mode 100644 index 000000000..b2b9f7685 --- /dev/null +++ b/translation/lib/term-forms.test.mjs @@ -0,0 +1,105 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { buildEquivalence, formsFor, TermFormsError } from "./term-forms.mjs"; + +const TERMS = [ + "ZK-SNARK", "ZK-SNARKs", + "Unified Address", "Unified Addresses", + "Argos", "Zcash", "CSS", +]; + +test("no equivalentForms key leaves every term alone", () => { + const eq = buildEquivalence(TERMS, undefined); + assert.equal(eq.size, 0); + assert.deepEqual(formsFor("ZK-SNARKs", eq), ["ZK-SNARKs"]); +}); + +test("a declared pair satisfies in both directions", () => { + const eq = buildEquivalence(TERMS, [["ZK-SNARK", "ZK-SNARKs"]]); + assert.deepEqual(formsFor("ZK-SNARKs", eq).sort(), ["ZK-SNARK", "ZK-SNARKs"]); + assert.deepEqual(formsFor("ZK-SNARK", eq).sort(), ["ZK-SNARK", "ZK-SNARKs"]); +}); + +test("the term itself is always tried first", () => { + const eq = buildEquivalence(TERMS, [["ZK-SNARK", "ZK-SNARKs"]]); + assert.equal(formsFor("ZK-SNARKs", eq)[0], "ZK-SNARKs"); +}); + +test("an -es plural pairs correctly when declared", () => { + // The case a naive strip-one-character derivation gets wrong: + // "Unified Addresses" -> "Unified Addresse", which is nothing. + const eq = buildEquivalence(TERMS, [["Unified Address", "Unified Addresses"]]); + assert.ok(formsFor("Unified Addresses", eq).includes("Unified Address")); +}); + +test("terms that merely look related are NOT equivalent", () => { + // Argos/Argo and CSS/CS are the reason pairing is declared, not derived. + const eq = buildEquivalence(TERMS, [["ZK-SNARK", "ZK-SNARKs"]]); + assert.deepEqual(formsFor("Argos", eq), ["Argos"]); + assert.deepEqual(formsFor("CSS", eq), ["CSS"]); + assert.deepEqual(formsFor("Zcash", eq), ["Zcash"]); +}); + +test("a group naming an unprotected term is rejected", () => { + assert.throws( + () => buildEquivalence(TERMS, [["ZK-SNARK", "ZK-SNARKz"]]), + (e) => e instanceof TermFormsError && /not in preserveVerbatim/.test(e.message), + ); +}); + +test("a term cannot belong to two groups", () => { + assert.throws( + () => buildEquivalence(TERMS, [["ZK-SNARK", "ZK-SNARKs"], ["ZK-SNARK", "Zcash"]]), + (e) => e instanceof TermFormsError && /already in group 0/.test(e.message), + ); +}); + +test("a group needs at least two members", () => { + assert.throws( + () => buildEquivalence(TERMS, [["ZK-SNARK"]]), + (e) => e instanceof TermFormsError && /at least two/.test(e.message), + ); +}); + +test("a duplicate inside a group is rejected", () => { + assert.throws( + () => buildEquivalence(TERMS, [["ZK-SNARK", "ZK-SNARK"]]), + (e) => e instanceof TermFormsError && /duplicate/.test(e.message), + ); +}); + +test("a blank or padded entry is rejected", () => { + // The dangerous shape: a term of " " matches nearly every page, so pairing + // it with a real term would silently satisfy that term everywhere. + for (const junk of ["", " ", "\t", " ZK-SNARK"]) { + assert.throws( + () => buildEquivalence([...TERMS, junk], [["ZK-SNARK", junk]]), + (e) => e instanceof TermFormsError && /non-empty strings/.test(e.message), + `expected "${junk}" to be rejected`, + ); + } +}); + +test("a non-string entry is rejected", () => { + for (const junk of [null, 42, {}, []]) { + assert.throws( + () => buildEquivalence(TERMS, [["ZK-SNARK", junk]]), + (e) => e instanceof TermFormsError, + ); + } +}); + +test("equivalentForms must be an array", () => { + assert.throws( + () => buildEquivalence(TERMS, { "ZK-SNARKs": ["ZK-SNARK"] }), + (e) => e instanceof TermFormsError && /must be an array/.test(e.message), + ); +}); + +test("groups of three or more are supported", () => { + const terms = [...TERMS, "zk-SNARK"]; + const eq = buildEquivalence(terms, [["ZK-SNARK", "ZK-SNARKs", "zk-SNARK"]]); + assert.equal(formsFor("zk-SNARK", eq).length, 3); + assert.ok(formsFor("ZK-SNARK", eq).includes("zk-SNARK")); +}); diff --git a/translation/protected-terms.json b/translation/protected-terms.json index 136de1c13..c12684e89 100644 --- a/translation/protected-terms.json +++ b/translation/protected-terms.json @@ -81,7 +81,17 @@ "Zcash Engineering Office Hours", "NU6.1", "NU6.2", - "NU7" + "NU7", + "Full Viewing Key", + "Incoming Viewing Key", + "Zcash.me", + "Zingo!", + "ZGo", + "Zgo", + "ZK-SNARK" + ], + "equivalentForms": [ + ["ZK-SNARK", "ZK-SNARKs"] ], "glossaryOnly": [ "token",