mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
* fix(ci): stop the reconciliation range and the fragment sweep from hiding work Two release-tooling defects found during the v3.8.49 run (gaps 4 and 7 of the process dossier). Both fail by hiding work rather than announcing themselves, which is why each one had already cost a real mistake. ## The reconciliation range was 62× too wide `list-uncovered-commits.mjs` bounded its scan with `git describe --tags`. Releases reach `main` by SQUASH, so no commit on a release branch is ever an ancestor of the tag, and `vPREV..HEAD` re-lists the un-squashed history of every earlier cycle. Measured on release/v3.8.50 @7eca04fd12: v3.8.49..HEAD ....... 1361 commits cycle open..HEAD ..... 22 commits The report drowns in noise, and that is how a previous reconciliation let ~200 PRs through with no CHANGELOG bullet. The base is now resolved by CONTENT — the oldest commit that introduced this version string into package.json — deliberately NOT by commit subject, because the subject has already changed format once: chore(release): bump v3.8.49 (development cycle version) older chore(release): open v3.8.50 development cycle current A message-matching resolver would have silently reverted to the broken tag base the first time someone reworded the bump. The fallback now writes a WARNING to stderr explaining that a tag range re-lists previous cycles, so a shallow clone degrades loudly instead of quietly reproducing the bug. One of the five tests asserts exactly that the warning says "squash" and "noise". ## The back-merge resurrects fragments that already shipped The release lands on `main` as one squash commit, so `main` still carries every `changelog.d/` fragment the reconciliation folded in and deleted. Back-merging `main` restores all of them — 191 in the v3.8.49 run. Nothing breaks at that instant; the next aggregation folds them in a SECOND time and the section grows duplicates that have to be hand-unpicked. New `scripts/release/sweep-stale-fragments.mjs` (`npm run sweep:stale-fragments`) reports them, and `--apply` removes them. Report mode exits 1 so the back-merge step can gate on it. The identity rule took two attempts, and the second one exists because running the script against the live repo refuted the first. Matching on any `#N` in the bullet flagged `changelog.d/features/8980-deprecate-gemini-cli-provider.md` as stale, because that bullet cites issue **#7034** for context and #7034 shipped in an earlier cycle — it would have deleted an unreleased fragment and dropped its credit. A bullet routinely cites issues it merely references; only the `<PR-number>-<slug>.md` filename says which PR the fragment *is*. That case is now a regression test. Every ambiguous case resolves toward KEEPING: no number in the filename falls back to normalized text, text shorter than 12 chars is never matched, and anything matching neither is kept. A surviving duplicate is a nuisance someone notices; a deleted fragment silently costs a contributor their credit. node --import tsx/esm --test tests/unit/release-cycle-base-resolver.test.ts # 5 pass node --import tsx/esm --test tests/unit/sweep-stale-fragments.test.ts # 11 pass node scripts/release/list-uncovered-commits.mjs --json → baseed2db6cb19, baseSource "cycle-open", total 22 (was 1361) node scripts/release/sweep-stale-fragments.mjs → 4 fragments, 0 stale, exit 0 * docs(changelog): fragment for #8985 * fix(ci): four quality gates that punished the wrong thing Gaps 6, 9, 10 and 23 of the v3.8.49 process dossier. Each one either blocked work it should have waved through, or reported a number that was never the code's. ## 6 — test-masking is unusable at release scale My own dossier entry for this was WRONG and the measurement says so: tracked test files ............ 3977 (I had written 1277) absolute tautology scan ....... ~1 s (I had written >30 min) the diff uses base...HEAD three dots — already merge-base diff vs release branch ........ 0 files, 0 s diff vs main (today) .......... 3 files, 0 s The base choice was never the problem, and it cannot be reproduced today at all: `main` has since received the v3.8.49 squash, so the merge-base is recent. The pathology only exists DURING a release, in the window before `main` gets the squash — then the merge-base is the PREVIOUS cycle's fork point and the diff legitimately spans the whole cycle (~1277 changed test files, each costing a `git show` process plus a full regex pass). That is the same squash-merge topology as gap 4, and it is why the check ran twice without finishing. Fix: above 300 changed test files the per-file diff subchecks are skipped, since every one of those files was already gated by this check on its own PR. The absolute tautology scan still runs unconditionally over all 3977 files, so the floor is untouched. The skip is deliberately loud — a silent skip is gap 12, which cost two production bugs this cycle. `shouldSkipDiffSubchecks` never skips on unparseable input, so a broken count cannot disable the gate. ## 9 — a capital letter invalidated 41 translations `"Reset Defaults"` → `"Reset defaults"` marked the key stale in 41 locales. Every translation was still correct, and in locales with no letter case the "fix" is not expressible. Worse, the escape hatch (`__MISSING__:`) is BANNED in `vi` by tests/unit/i18n-vi-completeness.test.ts, so `vi` had no legitimate way out. `isCosmeticRewrite` folds case, whitespace runs and trailing punctuation — and nothing else. Most of the nine tests exist to pin what is NOT cosmetic: a changed word, an added word, and any edit inside an interpolation like `{count}` all still flag. Two end-to-end tests hold both directions: a cosmetic edit leaves every locale alone, a real rewrite still flags all of them. ## 10 — the ratchet compared numbers from two different auditors `pipx install zizmor` was unpinned, so the runner installed whatever PyPI served that day and measured 1 finding MORE than the devbox on the identical commit (190 vs 189) — a second rebaseline push per release, chasing a number that was never the code's. Pinned to 1.25.2 (what the devbox runs), and check-workflows.mjs now prints `zizmorVersion=` next to the count so any future rebaseline is traceable to the tool that produced it. ## 23 — a PR pointed at its own branch #8912 has head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with a full check board on every push to that branch. It survived because nothing looks wrong — the checks pass, since there is nothing to check. New guard in the `changes` job (one field comparison, before anything is spent). The distinction that makes it safe to block on: an equal head/base BRANCH is conclusive, an equal head/base SHA is NOT — a branch cut moments ago has an identical tip and is legitimate, so that case warns instead of failing. Half a signal never fails either. node --import tsx/esm --test tests/unit/test-masking-release-scale.test.ts # 6 pass node --import tsx/esm --test tests/unit/ui-value-drift-cosmetic.test.ts # 9 pass node --import tsx/esm --test tests/unit/pr-self-target-guard.test.ts # 7 pass check:workflows --ratchet → 178 findings, zizmorVersion=zizmor 1.25.2, baseline 190 the i18n suite is unaffected (5 files re-run, all green) * fix(ci): allowlist the four CI-only env vars the new gates read The env-doc-sync gate failed three unit shards plus Docs Gates on this PR, and it was right to: it requires every `process.env.X` read in code to be documented in `.env.example`, and this PR introduced four new reads. They do not belong in `.env.example`. That file is OmniRoute's runtime configuration; these are CI signals with no meaning in a user's `.env`: HEAD_REF / HEAD_SHA / BASE_SHA the `changes` job passes github.head_ref, github.base_ref and the PR head/base SHAs to the self-targeting-PR guard TEST_MASKING_MAX_CHANGED_TESTS the escape hatch that raises the test-masking gate's release-scale skip threshold So they go in IGNORE_FROM_CODE, which exists for exactly this and already carries the precedent one line above: `BASE_REF`, allowlisted because CI passes it to the OpenAPI breaking-change gate. `BASE_REF` being already listed is also why only four of my five reads failed. Each entry carries its justification and the script that reads it, per the allowlist policy. node --import tsx/esm --test tests/unit/issue-7793-env-doc-sync-repro.test.ts # 1 pass npm run check:env-doc-sync → all three directions in sync * fix(i18n): narrow the cosmetic-rewrite exemption to the scope actually reported The gap-9 fix folded whitespace in addition to case, and that collided with a pre-existing test which pins the opposite — tests/unit/i18n-ui-value-drift.test.ts, "a value that only changes whitespace still counts as an edit". Its comment states the reasoning: Conservative on purpose: trailing-space churn is rare, and treating it as a no-op would let a real reword slip through behind an innocuous-looking diff. That is a documented decision by whoever wrote it. The problem actually reported was CASE — `"Reset Defaults"` → `"Reset defaults"` invalidating 41 correct translations — and whitespace was scope I added on my own. Reversing someone else's reasoned call, silently, to fix something nobody reported is not this change's job, so the exemption is narrowed to case + trailing terminal punctuation. No test pins either of those. The reported case is still fixed, verified end to end: that rewrite invalidates 0 locales. And whitespace is now asserted NON-cosmetic in my own test file too, so a later tidy-up cannot quietly fold it back in. node --import tsx/esm --test tests/unit/i18n-ui-value-drift.test.ts # 11 pass (pre-existing) node --import tsx/esm --test tests/unit/ui-value-drift-cosmetic.test.ts # 10 pass --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* OmniRoute — UI i18n VALUE-drift gate (CI gate, blocking).
|
|
*
|
|
* Catches the one i18n regression no existing gate can see: an English **value** is
|
|
* rewritten and the translations derived from the previous English are left behind, so
|
|
* every non-English user keeps reading confidently-worded, now-wrong copy.
|
|
*
|
|
* Real incident (fixed in #8463): `oauthModal.googleOAuthWarning` was rewritten when the
|
|
* Antigravity login helper shipped (#5203). 39 of 43 locales kept the old text, which
|
|
* told operators to "copy the full URL and paste it below" — a flow that cannot complete
|
|
* for that provider family. Nobody noticed, because:
|
|
*
|
|
* - `sync-ui-keys.mjs` only backfills keys that are ABSENT, never ones that are STALE;
|
|
* - `check-ui-keys-coverage.mjs` counts key presence, so a stale translation scores as
|
|
* fully covered;
|
|
* - `check-translation-drift.mjs` tracks the `docs/i18n/<locale>/**.md` documentation
|
|
* mirrors — it never looks at `src/i18n/messages/*.json` at all.
|
|
*
|
|
* How this gate works: it is DIFF-AWARE, not baseline-backed. It compares the English
|
|
* catalog at the merge base against the working tree; for every key whose English value
|
|
* changed, any locale still holding an untouched translation of it is stale.
|
|
*
|
|
* That deliberately freezes pre-existing debt: a diff cannot reveal which old English a
|
|
* long-standing translation was derived from, so the gate judges only what the current
|
|
* change touches. The alternative — a per-key hash baseline — would cost a ~600 KB
|
|
* generated file (3x the largest existing baseline) that churns on every i18n PR.
|
|
*
|
|
* Two ways to satisfy it:
|
|
* 1. update the affected translations, or
|
|
* 2. set them to `__MISSING__:<new english>`, which makes the runtime fall back to the
|
|
* correct English (src/i18n/request.ts::deepMergeFallback, #7258) and queues the key
|
|
* for the translation pipeline.
|
|
*
|
|
* Usage:
|
|
* node scripts/i18n/check-ui-value-drift.mjs # strict (default), exit 1 on drift
|
|
* node scripts/i18n/check-ui-value-drift.mjs --warn # report, exit 0
|
|
* node scripts/i18n/check-ui-value-drift.mjs --json # machine-readable report
|
|
* BASE_REF=origin/release/vX.Y.Z node scripts/i18n/check-ui-value-drift.mjs
|
|
*
|
|
* Graceful SKIP (exit 0) when the base English catalog cannot be resolved — a shallow
|
|
* clone without the base ref, or a brand-new catalog. Mirrors the base-unresolved SKIP in
|
|
* `scripts/check/check-openapi-breaking.mjs`.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { execFileSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
|
|
const MESSAGES_REL = "src/i18n/messages";
|
|
const MESSAGES_DIR = path.join(ROOT, MESSAGES_REL);
|
|
const PLACEHOLDER_PREFIX = "__MISSING__:";
|
|
|
|
/** Flatten a nested message catalog into `{ "a.b.c": value }`. */
|
|
export function flattenLeaves(node, prefix = "", out = {}) {
|
|
for (const [key, value] of Object.entries(node ?? {})) {
|
|
const dotted = prefix ? `${prefix}.${key}` : key;
|
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
flattenLeaves(value, dotted, out);
|
|
} else {
|
|
out[dotted] = value;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Pure core: which (key, locale) pairs carry a translation of a now-changed English value?
|
|
*
|
|
* @param {object} args
|
|
* @param {object} args.baseEn en.json at the base ref
|
|
* @param {object} args.headEn en.json in the working tree
|
|
* @param {Record<string, object>} args.baseLocales locale -> catalog at the base ref
|
|
* @param {Record<string, object>} args.headLocales locale -> catalog in the working tree
|
|
* @returns {Array<{ key: string, locale: string }>} sorted, stable
|
|
*/
|
|
/**
|
|
* Whether an English rewrite is COSMETIC — the same sentence, differently cased, spaced, or
|
|
* terminally punctuated. A translation of the old string is still a correct translation of the
|
|
* new one, so it must not be marked stale.
|
|
*
|
|
* Why this exists: `"Reset Defaults"` → `"Reset defaults"` marked the key stale in 41 locales
|
|
* during the v3.8.49 cycle. Every one of those translations was still correct, and in locales
|
|
* with no letter case the "fix" is not even expressible. Worse, the documented escape hatch
|
|
* (a `__MISSING__:` placeholder) is BANNED in `vi` by tests/unit/i18n-vi-completeness.test.ts,
|
|
* so `vi` had no legitimate way out of a purely cosmetic English edit.
|
|
*
|
|
* Deliberately narrow: ONLY letter case and trailing terminal punctuation.
|
|
*
|
|
* **Whitespace is NOT folded, on purpose.** A first version of this folded whitespace runs and
|
|
* trimmed, and it collided with a pre-existing, deliberately conservative decision —
|
|
* tests/unit/i18n-ui-value-drift.test.ts, "a value that only changes whitespace still counts as
|
|
* an edit", whose comment reads: *"trailing-space churn is rare, and treating it as a no-op would
|
|
* let a real reword slip through behind an innocuous-looking diff."* That call belongs to whoever
|
|
* made it; the problem actually reported here was CASE
|
|
* (`"Reset Defaults"` → `"Reset defaults"`, 41 locales invalidated), and quietly reversing
|
|
* someone else's documented decision to fix something they never reported is not this change's
|
|
* job. Narrowed to the reported scope instead.
|
|
*
|
|
* Any change to the words themselves — added or removed words, and any change inside an
|
|
* interpolation like `{count}` — is a real rewrite and still flags.
|
|
*/
|
|
export function isCosmeticRewrite(before, after) {
|
|
if (typeof before !== "string" || typeof after !== "string") return false;
|
|
if (before === after) return true;
|
|
const norm = (s) => s.replace(/[.:!?;,]+$/u, "").toLowerCase();
|
|
return norm(before) === norm(after);
|
|
}
|
|
|
|
export function findStaleTranslations({ baseEn, headEn, baseLocales, headLocales }) {
|
|
const baseFlat = flattenLeaves(baseEn);
|
|
const headFlat = flattenLeaves(headEn);
|
|
|
|
// Keys whose English copy was REWRITTEN by this change. A key absent from either side
|
|
// is an add or a delete: nothing can be stale against text that did not exist, and a
|
|
// renamed key (delete + add) is exactly the safe fix pattern.
|
|
//
|
|
// Cosmetic rewrites (case, spacing, trailing punctuation) are excluded — see
|
|
// isCosmeticRewrite. They used to invalidate correct translations in 41 locales at once.
|
|
const rewritten = Object.keys(headFlat).filter(
|
|
(key) =>
|
|
key in baseFlat &&
|
|
baseFlat[key] !== headFlat[key] &&
|
|
!isCosmeticRewrite(baseFlat[key], headFlat[key])
|
|
);
|
|
if (rewritten.length === 0) return [];
|
|
|
|
const flatCache = new Map();
|
|
const flatOf = (bag, locale) => {
|
|
const cacheKey = `${bag === baseLocales ? "base" : "head"}:${locale}`;
|
|
if (!flatCache.has(cacheKey)) flatCache.set(cacheKey, flattenLeaves(bag[locale]));
|
|
return flatCache.get(cacheKey);
|
|
};
|
|
|
|
const stale = [];
|
|
for (const locale of Object.keys(headLocales)) {
|
|
if (locale === "en") continue; // the source of truth is never its own target
|
|
const head = flatOf(headLocales, locale);
|
|
const base = flatOf(baseLocales, locale);
|
|
|
|
for (const key of rewritten) {
|
|
const headValue = head[key];
|
|
// Never translated here — that is `check-ui-keys-coverage`'s concern, not ours.
|
|
if (headValue === undefined) continue;
|
|
// Explicitly pending: the runtime serves the corrected English instead.
|
|
if (typeof headValue === "string" && headValue.startsWith(PLACEHOLDER_PREFIX)) continue;
|
|
// Refreshed alongside the English in this very change.
|
|
if (base[key] !== headValue) continue;
|
|
|
|
stale.push({ key, locale });
|
|
}
|
|
}
|
|
|
|
return stale.sort((a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale));
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const opts = { mode: "strict", json: false };
|
|
for (const arg of argv.slice(2)) {
|
|
if (arg === "--warn") opts.mode = "warn";
|
|
else if (arg === "--strict") opts.mode = "strict";
|
|
else if (arg === "--json") opts.json = true;
|
|
else if (arg === "--help" || arg === "-h") {
|
|
console.log(
|
|
[
|
|
"Usage: node scripts/i18n/check-ui-value-drift.mjs [--strict|--warn] [--json]",
|
|
"",
|
|
" --strict (default) exit 1 when a rewritten English value leaves a stale translation",
|
|
" --warn report drift but exit 0",
|
|
" --json machine-readable report on stdout",
|
|
"",
|
|
" BASE_REF ref to diff against (default: derived from package.json version)",
|
|
].join("\n")
|
|
);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function readPackageVersion() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** `3.8.49` -> `origin/release/v3.8.49`, matching check-openapi-breaking's convention. */
|
|
function defaultBaseRef() {
|
|
const version = readPackageVersion();
|
|
return version && /^\d+\.\d+\.\d+$/.test(version) ? `origin/release/v${version}` : null;
|
|
}
|
|
|
|
function git(args) {
|
|
return execFileSync("git", args, {
|
|
cwd: ROOT,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
/** Prefer the merge base so a base branch that advanced independently cannot false-positive. */
|
|
function resolveDiffBase(baseRef) {
|
|
try {
|
|
return git(["merge-base", "HEAD", baseRef]).trim();
|
|
} catch {
|
|
return baseRef; // shallow clone without common history — the tip is the best available
|
|
}
|
|
}
|
|
|
|
function readCatalogAtRef(ref, relPath) {
|
|
try {
|
|
return JSON.parse(git(["show", `${ref}:${relPath}`]));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readCatalogFromDisk(absPath) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(absPath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const opts = parseArgs(process.argv);
|
|
const baseRef = process.env.BASE_REF || defaultBaseRef();
|
|
|
|
const emitSkip = (reason) => {
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify({ ok: true, skipped: true, reason }) + "\n");
|
|
} else {
|
|
console.log(`[i18n-value-drift] SKIP reason=${reason}`);
|
|
}
|
|
process.exit(0);
|
|
};
|
|
|
|
if (!baseRef) emitSkip("base-unresolved");
|
|
|
|
const diffBase = resolveDiffBase(baseRef);
|
|
const baseEn = readCatalogAtRef(diffBase, `${MESSAGES_REL}/en.json`);
|
|
if (!baseEn) emitSkip("base-unresolved");
|
|
|
|
const headEn = readCatalogFromDisk(path.join(MESSAGES_DIR, "en.json"));
|
|
if (!headEn) emitSkip("head-en-unreadable");
|
|
|
|
const locales = fs
|
|
.readdirSync(MESSAGES_DIR)
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => f.slice(0, -5));
|
|
|
|
const headLocales = {};
|
|
const baseLocales = {};
|
|
for (const locale of locales) {
|
|
headLocales[locale] = readCatalogFromDisk(path.join(MESSAGES_DIR, `${locale}.json`)) ?? {};
|
|
baseLocales[locale] = readCatalogAtRef(diffBase, `${MESSAGES_REL}/${locale}.json`) ?? {};
|
|
}
|
|
|
|
const stale = findStaleTranslations({ baseEn, headEn, baseLocales, headLocales });
|
|
|
|
if (opts.json) {
|
|
process.stdout.write(
|
|
JSON.stringify({ ok: stale.length === 0, baseRef, diffBase, stale }, null, 2) + "\n"
|
|
);
|
|
process.exit(stale.length === 0 || opts.mode === "warn" ? 0 : 1);
|
|
}
|
|
|
|
if (stale.length === 0) {
|
|
console.log(
|
|
`[i18n-value-drift] PASS — no rewritten English value left a stale translation (base ${baseRef}).`
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Group by key so the report reads as "this string changed; these locales lag".
|
|
const byKey = new Map();
|
|
for (const { key, locale } of stale) {
|
|
if (!byKey.has(key)) byKey.set(key, []);
|
|
byKey.get(key).push(locale);
|
|
}
|
|
|
|
console.log(
|
|
`[i18n-value-drift] ${byKey.size} English value(s) rewritten with ${stale.length} stale translation(s):`
|
|
);
|
|
for (const [key, affected] of byKey) {
|
|
console.log(` - ${key}`);
|
|
console.log(` stale in ${affected.length} locale(s): ${affected.join(", ")}`);
|
|
}
|
|
console.log("");
|
|
console.log(" Fix either way:");
|
|
console.log(" 1. update those translations to match the new English, or");
|
|
console.log(` 2. set them to "${PLACEHOLDER_PREFIX}<new english>" so the runtime serves`);
|
|
console.log(" the corrected English until the translation pipeline catches up.");
|
|
console.log("");
|
|
console.log(" If the string's MEANING changed, prefer renaming the key instead — a new key");
|
|
console.log(" cannot inherit a stale translation (see #8463).");
|
|
|
|
if (opts.mode === "warn") {
|
|
console.log("[i18n-value-drift] WARN — drift detected (warn mode, exiting 0).");
|
|
process.exit(0);
|
|
}
|
|
console.log("[i18n-value-drift] FAIL — stale translations detected.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Only run the CLI when invoked directly, so the pure helpers stay importable in tests.
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
|
|
) {
|
|
main();
|
|
}
|