Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions scripts/check-protected-terms.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`);
const re = new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`);
reCache.set(term, re);
return re;
}

const terms = config.preserveVerbatim ?? [];

// Some protected terms are the same word in two shapes — a singular and its
// plural — and a translation may satisfy the term with any form DECLARED
// equivalent to it in translation/protected-terms.json. The pairing is never
// inferred from spelling; see translation/lib/term-forms.mjs for why that
// matters (`Argos` is not `Argo`). A malformed declaration is fatal rather
// than ignored, since a silently dropped group would weaken this gate in the
// way least likely to be noticed.
let equivalence;
try {
equivalence = buildEquivalence(terms, config.equivalentForms);
} catch (e) {
die(`translation/protected-terms.json: ${e.message}`);
}

// The SOURCE side always tests the exact term — the English page really does
// use that form. Only the TRANSLATION side accepts an equivalent.
function satisfiedIn(translated, term, forms = equivalence) {
return formsFor(term, forms).some((f) => termRegExp(f).test(translated));
}

// ---- pass 1: every violation in the working tree ---------------------------

const violations = [];
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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:
Expand All @@ -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";
}
Expand Down
104 changes: 104 additions & 0 deletions translation/lib/term-forms.mjs
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>}
*/
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<string, Set<string>>} equivalence
* @returns {string[]}
*/
export function formsFor(term, equivalence) {
const group = equivalence.get(term);
if (!group) return [term];
return [term, ...[...group].filter((f) => f !== term)];
}
105 changes: 105 additions & 0 deletions translation/lib/term-forms.test.mjs
Original file line number Diff line number Diff line change
@@ -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"));
});
12 changes: 11 additions & 1 deletion translation/protected-terms.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading