fix(ui): unblock rebases with generated i18n conflicts (#109279)

* fix(ui): automate i18n conflict resolution

* fix(ui): preserve i18n cache merge intent

* fix(ui): restrict resolver to generated locales

* fix(ui): prune stale locales before baseline

* fix(ui): handle removed locale conflicts
This commit is contained in:
Peter Steinberger
2026-07-16 12:52:28 -07:00
committed by GitHub
parent 8dfb310b56
commit ce919593bf
10 changed files with 416 additions and 3 deletions
+1
View File
@@ -1998,6 +1998,7 @@
"ui:i18n:baseline": "node --import tsx scripts/control-ui-i18n-verify.ts baseline",
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
"ui:i18n:resolve-conflicts": "node --import tsx scripts/control-ui-i18n-resolve-conflicts.ts",
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
"ui:i18n:verify": "node --import tsx scripts/control-ui-i18n-verify.ts verify",
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
+1 -1
View File
@@ -48,7 +48,7 @@ const DEPRECATION_HYGIENE_PATH_RE =
const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE =
/^(?:pnpm-lock\.yaml$|apps\/shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mjs$)/u;
const CONTROL_UI_I18N_VERIFY_PATH_RE =
/^(?:package\.json$|ui\/src\/|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
/^(?:package\.json$|ui\/src\/|scripts\/(?:control-ui-i18n(?:-(?:report|resolve-conflicts|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
const EXTENSIONS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.extensions.json";
const SCRIPTS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.scripts.json";
+1 -1
View File
@@ -55,7 +55,7 @@ const WINDOWS_TEST_SCOPE_RE =
const WINDOWS_DAEMON_SCOPE_RE =
/^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/;
const CONTROL_UI_I18N_SCOPE_RE =
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-(?:resolve-conflicts|verify))?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
const CONTROL_UI_TEST_SCOPE_RE =
/^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
const NATIVE_I18N_SCOPE_RE =
@@ -0,0 +1,294 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { isDeepStrictEqual } from "node:util";
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
const GENERATED_ASSET_CONFLICT_RE =
/^ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks|raw-copy-baseline)\.json$|^ui\/src\/i18n\/\.i18n\/[^/]+\.(?:meta\.json|tm\.jsonl)$/u;
const GENERATED_LOCALE_PATHS = new Set(
CONTROL_UI_LOCALE_ENTRIES.map((entry) => `ui/src/i18n/locales/${entry.fileName}`),
);
const GENERATED_LOCALE_PATH_RE = /^ui\/src\/i18n\/locales\/[^/]+\.ts$/u;
const GENERATED_LOCALE_HEADER = "// Generated locale bundle for Control UI translations.\n";
const TRANSLATION_MEMORY_RE = /^ui\/src\/i18n\/\.i18n\/[^/]+\.tm\.jsonl$/u;
const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
type TranslationMemoryEntry = Record<string, unknown> & { cache_key: string };
export function isControlUiGeneratedI18nPath(filePath: string): boolean {
return GENERATED_ASSET_CONFLICT_RE.test(filePath) || GENERATED_LOCALE_PATHS.has(filePath);
}
export function isControlUiGeneratedI18nConflictPath(
filePath: string,
stages: readonly (string | null)[],
): boolean {
return (
isControlUiGeneratedI18nPath(filePath) ||
(GENERATED_LOCALE_PATH_RE.test(filePath) &&
stages.some((contents) => contents?.startsWith(GENERATED_LOCALE_HEADER)))
);
}
function parseTranslationMemory(raw: string, label: string): TranslationMemoryEntry[] {
return raw
.split("\n")
.filter((line) => line.trim())
.map((line, index) => {
const parsed = JSON.parse(line) as unknown;
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
!("cache_key" in parsed) ||
typeof parsed.cache_key !== "string" ||
!parsed.cache_key.trim()
) {
throw new Error(`${label}:${index + 1} has no cache_key`);
}
return parsed as TranslationMemoryEntry;
});
}
function indexTranslationMemory(raw: string, label: string): Map<string, TranslationMemoryEntry> {
const indexed = new Map<string, TranslationMemoryEntry>();
for (const entry of parseTranslationMemory(raw, label)) {
if (indexed.has(entry.cache_key)) {
throw new Error(`${label} has duplicate cache_key ${entry.cache_key}`);
}
indexed.set(entry.cache_key, entry);
}
return indexed;
}
export function mergeControlUiTranslationMemory(
base: string,
ours: string,
theirs: string,
): string {
const baseEntries = indexTranslationMemory(base, "stage 1 translation memory");
const oursEntries = indexTranslationMemory(ours, "stage 2 translation memory");
const theirsEntries = indexTranslationMemory(theirs, "stage 3 translation memory");
const cacheKeys = new Set([
...baseEntries.keys(),
...oursEntries.keys(),
...theirsEntries.keys(),
]);
const merged = new Map<string, TranslationMemoryEntry>();
for (const cacheKey of cacheKeys) {
const baseEntry = baseEntries.get(cacheKey);
const oursEntry = oursEntries.get(cacheKey);
const theirsEntry = theirsEntries.get(cacheKey);
let resolved: TranslationMemoryEntry | undefined;
if (isDeepStrictEqual(oursEntry, theirsEntry)) {
resolved = oursEntry;
} else if (isDeepStrictEqual(oursEntry, baseEntry)) {
resolved = theirsEntry;
} else if (isDeepStrictEqual(theirsEntry, baseEntry)) {
resolved = oursEntry;
} else {
// Both sides changed the same cache key. During rebase, stage 3 is the
// replayed branch; keep its deliberate value or deletion.
resolved = theirsEntry;
}
if (resolved) {
merged.set(cacheKey, resolved);
}
}
const ordered = [...merged.values()].toSorted((left, right) =>
left.cache_key.localeCompare(right.cache_key),
);
return ordered.length === 0
? ""
: `${ordered.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
}
export function resolveControlUiGeneratedConflict(
filePath: string,
base: string | null,
ours: string | null,
theirs: string | null,
): string | null {
if (theirs === null) {
return null;
}
if (!TRANSLATION_MEMORY_RE.test(filePath)) {
return theirs;
}
const merged = mergeControlUiTranslationMemory(base ?? "", ours ?? "", theirs);
return ours === null && base !== null && !merged ? null : merged;
}
function runCapture(cwd: string, executable: string, args: string[]): string {
const result = spawnSync(executable, args, {
cwd,
encoding: "utf8",
maxBuffer: GIT_OUTPUT_MAX_BYTES,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `${executable} ${args.join(" ")} failed`);
}
return result.stdout;
}
function runInherited(cwd: string, executable: string, args: string[]): void {
const result = spawnSync(executable, args, { cwd, stdio: "inherit" });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`${executable} ${args.join(" ")} failed with status ${result.status}`);
}
}
function runPnpm(cwd: string, args: string[]): void {
if (process.platform === "win32") {
runInherited(cwd, process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", "pnpm", ...args]);
return;
}
runInherited(cwd, "pnpm", args);
}
function readIndexStage(cwd: string, stage: 1 | 2 | 3, filePath: string): string | null {
const result = spawnSync("git", ["show", `:${stage}:${filePath}`], {
cwd,
encoding: "utf8",
maxBuffer: GIT_OUTPUT_MAX_BYTES,
});
if (result.error) {
throw result.error;
}
return result.status === 0 ? result.stdout : null;
}
function listUnmerged(cwd: string): string[] {
return runCapture(cwd, "git", ["diff", "--name-only", "--diff-filter=U", "-z"])
.split("\0")
.filter(Boolean);
}
function writeResolvedFile(root: string, filePath: string, contents: string): void {
const absolutePath = path.join(root, filePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, contents, "utf8");
}
function resolveGeneratedConflict(root: string, filePath: string): void {
const base = readIndexStage(root, 1, filePath);
const ours = readIndexStage(root, 2, filePath);
const theirs = readIndexStage(root, 3, filePath);
if (ours === null && theirs === null) {
throw new Error(`cannot read either conflict stage for ${filePath}`);
}
const resolved = resolveControlUiGeneratedConflict(filePath, base, ours, theirs);
if (resolved === null) {
rmSync(path.join(root, filePath), { force: true });
return;
}
writeResolvedFile(root, filePath, resolved);
}
function assertNoRecordedFallbacks(root: string): void {
const assetsDir = path.join(root, "ui", "src", "i18n", ".i18n");
const failures = readdirSync(assetsDir)
.filter((fileName) => fileName.endsWith(".meta.json"))
.flatMap((fileName) => {
const meta = JSON.parse(readFileSync(path.join(assetsDir, fileName), "utf8")) as {
fallbackKeys?: unknown;
};
return Array.isArray(meta.fallbackKeys) && meta.fallbackKeys.length > 0
? [`${fileName}: ${meta.fallbackKeys.length}`]
: [];
});
if (failures.length > 0) {
throw new Error(`generated locales still contain fallbacks:\n${failures.join("\n")}`);
}
}
function main(): void {
const root = runCapture(process.cwd(), "git", ["rev-parse", "--show-toplevel"]).trim();
const conflicts = listUnmerged(root);
if (conflicts.length === 0) {
throw new Error("no unmerged files found");
}
const formerGeneratedLocales = new Set(
conflicts.filter((filePath) => {
if (GENERATED_LOCALE_PATHS.has(filePath) || !GENERATED_LOCALE_PATH_RE.test(filePath)) {
return false;
}
const stages = ([1, 2, 3] as const).map((stage) => readIndexStage(root, stage, filePath));
return isControlUiGeneratedI18nConflictPath(filePath, stages);
}),
);
const unsupported = conflicts.filter(
(filePath) => !isControlUiGeneratedI18nPath(filePath) && !formerGeneratedLocales.has(filePath),
);
if (unsupported.length > 0) {
throw new Error(
`resolve and stage non-generated conflicts first:\n${unsupported.map((filePath) => `- ${filePath}`).join("\n")}`,
);
}
for (const filePath of conflicts) {
if (formerGeneratedLocales.has(filePath)) {
// A removed or renamed locale is absent from the active config. Its old
// generated bundle must stay deleted even when the replayed branch edited it.
rmSync(path.join(root, filePath), { force: true });
} else {
resolveGeneratedConflict(root, filePath);
}
}
// Sync first so removed English keys are pruned from generated locales before
// baseline validation; otherwise stale replayed locale keys block regeneration.
runInherited(root, process.execPath, [
"--import",
"tsx",
"scripts/control-ui-i18n.ts",
"sync",
"--write",
]);
runPnpm(root, ["ui:i18n:baseline"]);
assertNoRecordedFallbacks(root);
runPnpm(root, ["ui:i18n:check"]);
runInherited(root, "git", [
"add",
"-A",
"--",
"ui/src/i18n/.i18n/catalog-fallbacks.json",
"ui/src/i18n/.i18n/raw-copy-baseline.json",
":(glob)ui/src/i18n/.i18n/*.meta.json",
":(glob)ui/src/i18n/.i18n/*.tm.jsonl",
...GENERATED_LOCALE_PATHS,
...formerGeneratedLocales,
]);
const remaining = listUnmerged(root);
if (remaining.length > 0) {
throw new Error(
`unmerged files remain:\n${remaining.map((filePath) => `- ${filePath}`).join("\n")}`,
);
}
process.stdout.write(
`control-ui-i18n: resolved, regenerated, verified, and staged ${conflicts.length} generated conflict(s)\n`,
);
}
function isCliEntrypoint(): boolean {
const entrypoint = process.argv[1];
return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href);
}
if (isCliEntrypoint()) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+1
View File
@@ -826,6 +826,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/periphery-intersection.mjs", ["test/scripts/periphery-intersection.test.ts"]],
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/control-ui-i18n-resolve-conflicts.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/apple-app-i18n.ts", ["test/scripts/apple-app-i18n.test.ts"]],
[
"scripts/native-app-i18n.ts",
+1
View File
@@ -851,6 +851,7 @@ describe("detectChangedScope", () => {
for (const scriptPath of [
"scripts/control-ui-i18n.ts",
"scripts/control-ui-i18n-resolve-conflicts.ts",
"scripts/control-ui-i18n-verify.ts",
"scripts/lib/control-ui-i18n-raw-copy.ts",
]) {
+1 -1
View File
@@ -886,7 +886,7 @@ describe("scripts/changed-lanes", () => {
});
it("routes Control UI i18n tooling changes through keyless catalog verification", () => {
const result = detectChangedLanes(["scripts/control-ui-i18n.ts"]);
const result = detectChangedLanes(["scripts/control-ui-i18n-resolve-conflicts.ts"]);
const plan = createChangedCheckPlan(result);
expect(shouldRunControlUiI18nVerify(result.paths)).toBe(true);
+111
View File
@@ -6,6 +6,12 @@ import process from "node:process";
import { pathToFileURL } from "node:url";
import * as ts from "typescript";
import { describe, expect, it } from "vitest";
import {
isControlUiGeneratedI18nConflictPath,
isControlUiGeneratedI18nPath,
mergeControlUiTranslationMemory,
resolveControlUiGeneratedConflict,
} from "../../scripts/control-ui-i18n-resolve-conflicts.ts";
import {
analyzeControlUiCatalogs,
assertScopedCatalogFallbackUpdate,
@@ -59,6 +65,111 @@ async function waitForChildClose(
}
describe("control-ui-i18n process runner", () => {
it("recognizes only generated conflict paths", () => {
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/catalog-fallbacks.json")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/raw-copy-baseline.json")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/de.meta.json")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/de.tm.jsonl")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/de.ts")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/glossary.de.json")).toBe(false);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/en.ts")).toBe(false);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/en-agents.ts")).toBe(false);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/unknown.ts")).toBe(false);
expect(
isControlUiGeneratedI18nConflictPath("ui/src/i18n/locales/removed.ts", [
"// Generated locale bundle for Control UI translations.\nexport const removed = {};\n",
null,
]),
).toBe(true);
expect(
isControlUiGeneratedI18nConflictPath("ui/src/i18n/locales/en-agents.ts", [
"// Agent-specific English strings stay split from the oversized source locale.\n",
]),
).toBe(false);
});
it("uses the replayed entry when both sides change the same cache key", () => {
const base = [{ cache_key: "shared", translated: "base" }];
const ours = [
{ cache_key: "b", translated: "upstream-only" },
{ cache_key: "shared", translated: "upstream" },
];
const theirs = [
{ cache_key: "a", translated: "branch-only" },
{ cache_key: "shared", translated: "branch" },
];
const merged = mergeControlUiTranslationMemory(
`${base.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
`${ours.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
`${theirs.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
)
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cache_key: string; translated: string });
expect(merged).toEqual([
{ cache_key: "a", translated: "branch-only" },
{ cache_key: "b", translated: "upstream-only" },
{ cache_key: "shared", translated: "branch" },
]);
});
it("preserves one-sided translation-memory changes and deletions", () => {
const base = [
{ cache_key: "branch-change", translated: "base" },
{ cache_key: "branch-delete", translated: "base" },
{ cache_key: "upstream-change", translated: "base" },
{ cache_key: "upstream-delete", translated: "base" },
];
const ours = [
{ cache_key: "branch-change", translated: "base" },
{ cache_key: "branch-delete", translated: "base" },
{ cache_key: "upstream-add", translated: "upstream" },
{ cache_key: "upstream-change", translated: "upstream" },
];
const theirs = [
{ cache_key: "branch-add", translated: "branch" },
{ cache_key: "branch-change", translated: "branch" },
{ cache_key: "upstream-change", translated: "base" },
{ cache_key: "upstream-delete", translated: "base" },
];
const encode = (entries: Array<{ cache_key: string; translated: string }>) =>
`${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
const merged = mergeControlUiTranslationMemory(encode(base), encode(ours), encode(theirs))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cache_key: string; translated: string });
expect(merged).toEqual([
{ cache_key: "branch-add", translated: "branch" },
{ cache_key: "branch-change", translated: "branch" },
{ cache_key: "upstream-add", translated: "upstream" },
{ cache_key: "upstream-change", translated: "upstream" },
]);
});
it("preserves replayed-side generated-file deletion before regeneration", () => {
expect(
resolveControlUiGeneratedConflict(
"ui/src/i18n/.i18n/de.tm.jsonl",
'{"cache_key":"stale"}\n',
'{"cache_key":"stale"}\n',
null,
),
).toBeNull();
});
it("preserves stage-2 translation-memory deletion when no records survive", () => {
expect(
resolveControlUiGeneratedConflict(
"ui/src/i18n/.i18n/de.tm.jsonl",
'{"cache_key":"a"}\n{"cache_key":"b"}\n',
null,
'{"cache_key":"a"}\n',
),
).toBeNull();
});
it("builds a deterministic fallback list without accepting catalog drift", () => {
const source = flattenControlUiCatalog(
{ group: { first: "First {count}", second: "Second" } },
+4
View File
@@ -316,6 +316,10 @@ describe("scripts/test-projects changed-target routing", () => {
mode: "targets",
targets: ["test/scripts/control-ui-i18n.test.ts", "src/scripts/control-ui-i18n.test.ts"],
});
expect(resolveChangedTestTargetPlan(["scripts/control-ui-i18n-resolve-conflicts.ts"])).toEqual({
mode: "targets",
targets: ["test/scripts/control-ui-i18n.test.ts"],
});
});
it("routes top-level scripts through conventional owner tests", () => {
+1
View File
@@ -11,6 +11,7 @@ This directory owns Control UI-specific guidance that should not live in the rep
- `ui/src/i18n/lib/types.ts`
- `ui/src/i18n/lib/registry.ts`
- Contributor flow: update English strings and locale wiring, run keyless `pnpm ui:i18n:baseline`, and commit `en.ts` plus changed baseline files. The command records intentional runtime fallbacks without rewriting foreign-language bundles.
- Rebase/merge conflicts: resolve and stage source conflicts first, then run `pnpm ui:i18n:resolve-conflicts`. It combines translation-memory entries from both sides, regenerates every derived artifact, runs the strict check, and stages the generated result. Provider auth is required when the resolved keys are not already cached. Never hand-merge generated locale files.
- `pnpm ui:i18n:verify` is deterministic and keyless. `pnpm lint` and the changed-check UI lane run it. It validates catalog shape, English-key coverage or explicit fallback listing, orphan keys, placeholder parity, canonical ordering, runtime locale wiring, and raw-copy baseline drift.
- Translation flow: the `control-ui-locale-refresh` workflow translates after merge and opens a generated PR. `pnpm ui:i18n:sync` remains the authenticated maintainer path; do not run it without provider auth when new keys exist. `pnpm ui:i18n:check` is the strict generated-output/release gate.
- Prioritization report: `pnpm ui:i18n:report [--surface <name>] [--locale <locale>] [--top <n>]` shows current hardcoded-copy focus areas and locale fallback metadata. It is not a drift gate; use `pnpm ui:i18n:check` for that.