fix(i18n): stop parallel native PRs from conflicting in the string inventory (#163330)

The generated native inventory stored one line per string with every
extraction site inline, sorted alphabetically. Two PRs reusing a string
("Retry") both rewrote its line, and a feature's new strings ("Group by",
"Group chat") landed in the same insertion gap, so parallel native PRs
conflicted in apps/.i18n/native-source.json and needed a rebase plus a
full CI rerun.

Inventory v3 writes one line per extraction site, clustered by source
file, with clusters ordered by a hash of the path so new sibling files do
not share a gap. Each row's position depends only on its own content, so
independent edits merge cleanly (including GitHub's server-side merge)
and the merged text equals a fresh baseline. The new
scripts/native-i18n-inventory.ts owns serialization and parsing; the
generator, Android/Gradle and Apple readers migrate to it together and
receive the same entries in the same order as before.
This commit is contained in:
Peter Steinberger 2026-10-02 08:48:17 -07:00 • committed by GitHub
parent 14fd53f8f6
commit f02d16e758
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 5954 additions and 4616 deletions

View file

@ -23,6 +23,7 @@ on:
- scripts/android-app-i18n.ts
- scripts/apple-app-i18n.ts
- scripts/native-app-i18n.ts
- scripts/native-i18n-inventory.ts
- scripts/native-i18n-locales.ts
- ui/src/i18n/.i18n/glossary.*.json
- .github/actions/create-generated-pr-tokens/action.yml
@ -293,6 +294,7 @@ jobs:
scripts/android-app-i18n.ts
scripts/apple-app-i18n.ts
scripts/native-app-i18n.ts
scripts/native-i18n-inventory.ts
scripts/native-i18n-locales.ts
ui/src/i18n/.i18n/glossary.*.json
.github/actions/create-generated-pr-tokens/action.yml

File diff suppressed because it is too large Load diff

View file

@ -299,7 +299,9 @@ val generateNativeI18n =
sourceFiles.from(
listOf(
"scripts/android-app-i18n.ts",
"scripts/native-i18n-inventory.ts",
"scripts/native-i18n-locales.ts",
"scripts/lib/canonical-json.mjs",
"scripts/lib/direct-run.mjs",
"packages/normalization-core/src/expect.ts",
"apps/.i18n/native-source.json",

View file

@ -5,6 +5,10 @@ import { fileURLToPath } from "node:url";
import { expectDefined } from "../packages/normalization-core/src/expect.ts";
import { compareAscii as compareText } from "./lib/canonical-json.mjs";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import {
type NativeI18nInventoryEntry,
parseNativeI18nInventory,
} from "./native-i18n-inventory.ts";
import { NATIVE_I18N_LOCALES } from "./native-i18n-locales.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@ -85,13 +89,6 @@ const WEAR_STRINGS_REPO_PATH = "apps/android/wear/src/main/res/values/strings.xm
const WEAR_GENERATED_RESOURCE_RE =
/^apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml$/;
type NativeInventoryEntry = {
id: string;
source: string;
sites: Array<{ kind: string; path: string }>;
surface: "android" | "apple";
};
type NativeTranslations = Record<string, string>;
type ResourceString = {
@ -1026,11 +1023,10 @@ export function selectDeterministicTranslation(source: string, values: readonly
);
}
async function readInventory(): Promise<NativeInventoryEntry[]> {
const parsed = JSON.parse(await readFile(INVENTORY_PATH, "utf8")) as {
entries?: NativeInventoryEntry[];
};
return (parsed.entries ?? []).filter((entry) => entry.surface === "android");
async function readInventory(): Promise<NativeI18nInventoryEntry[]> {
return parseNativeI18nInventory(await readFile(INVENTORY_PATH, "utf8")).filter(
(entry) => entry.surface === "android",
);
}
async function readArtifacts(): Promise<Map<string, NativeTranslations>> {
@ -1047,7 +1043,7 @@ async function readArtifacts(): Promise<Map<string, NativeTranslations>> {
}
function translationsBySource(
inventory: readonly NativeInventoryEntry[],
inventory: readonly NativeI18nInventoryEntry[],
translationsById: Readonly<NativeTranslations>,
): Map<string, string[]> {
const translations = new Map<string, string[]>();
@ -1062,7 +1058,7 @@ function translationsBySource(
function localizeManualStrings(
base: ReadonlyMap<string, ResourceString>,
inventoryBySource: ReadonlyMap<string, NativeInventoryEntry>,
inventoryBySource: ReadonlyMap<string, NativeI18nInventoryEntry>,
translations: Readonly<NativeTranslations>,
surface: string,
): ResourceString[] {

View file

@ -4,6 +4,10 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { decodeXml } from "../src/shared/xml.ts";
import { selectDeterministicTranslation } from "./android-app-i18n.ts";
import { compareAscii as compareCodeUnits } from "./lib/canonical-json.mjs";
import {
type NativeI18nInventoryEntry,
parseNativeI18nInventory,
} from "./native-i18n-inventory.ts";
import { NATIVE_I18N_LOCALES } from "./native-i18n-locales.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@ -314,18 +318,6 @@ type Catalog = {
version?: string;
};
type NativeSourceEntry = {
id: string;
source: string;
sites: Array<{ kind: string; path: string }>;
surface: string;
};
type NativeSourceArtifact = {
entries: NativeSourceEntry[];
version: number;
};
type NativeTranslationArtifact = {
locale: string;
translations: Record<string, string>;
@ -424,9 +416,11 @@ export function infoPlistTranslationCandidates(
return typeof translated === "string" ? [translated] : [];
}
function infoPlistSourceIds(nativeSource: NativeSourceArtifact): Map<string, string> {
function infoPlistSourceIds(
nativeSource: readonly NativeI18nInventoryEntry[],
): Map<string, string> {
return new Map(
nativeSource.entries.flatMap((entry) =>
nativeSource.flatMap((entry) =>
entry.sites
.filter((site) => site.kind === "plist-string")
.map((site) => [[site.path, entry.source].join("\u0000"), entry.id] as const),
@ -472,7 +466,7 @@ function isAppleCatalogKind(kind: string): boolean {
}
function isAppleCatalogEntry(
entry: NativeSourceEntry,
entry: NativeI18nInventoryEntry,
sourcePrefixes: readonly string[],
exclusions: ReadonlySet<string>,
): boolean {
@ -517,11 +511,11 @@ function appleCatalogValue(value: string): string {
function buildAppleCatalog(
existingCatalog: Catalog,
nativeSource: NativeSourceArtifact,
nativeSource: readonly NativeI18nInventoryEntry[],
translations: readonly NativeTranslationArtifact[],
includesEntry: (entry: NativeSourceEntry) => boolean,
includesEntry: (entry: NativeI18nInventoryEntry) => boolean,
): AppleCatalogBuild {
const catalogEntries = nativeSource.entries
const catalogEntries = nativeSource
.filter(includesEntry)
.map((entry) => [entry, appleCatalogValue(entry.source)] as const);
const sources = [...new Set(catalogEntries.map(([, source]) => source))].toSorted(
@ -529,7 +523,7 @@ function buildAppleCatalog(
);
const catalogIds = new Set(catalogEntries.map(([entry]) => entry.id));
const existingStrings = existingCatalog.strings ?? {};
const nativeEntryById = new Map(nativeSource.entries.map((entry) => [entry.id, entry]));
const nativeEntryById = new Map(nativeSource.map((entry) => [entry.id, entry]));
const translationsByLocale = new Map(
translations.map((artifact) => {
const bySource = new Map<string, string[]>();
@ -599,7 +593,7 @@ function buildAppleCatalog(
export function buildIosCatalog(
existingCatalog: Catalog,
nativeSource: NativeSourceArtifact,
nativeSource: readonly NativeI18nInventoryEntry[],
translations: readonly NativeTranslationArtifact[],
): AppleCatalogBuild {
return buildAppleCatalog(existingCatalog, nativeSource, translations, (entry) =>
@ -609,7 +603,7 @@ export function buildIosCatalog(
export function buildMacosCatalog(
existingCatalog: Catalog,
nativeSource: NativeSourceArtifact,
nativeSource: readonly NativeI18nInventoryEntry[],
translations: readonly NativeTranslationArtifact[],
): AppleCatalogBuild {
return buildAppleCatalog(existingCatalog, nativeSource, translations, (entry) =>
@ -702,9 +696,9 @@ async function readAppleCatalogBuild(
const existingCatalog = JSON.parse(
await readFile(path.join(ROOT, catalogPath), "utf8"),
) as Catalog;
const nativeSource = JSON.parse(
const nativeSource = parseNativeI18nInventory(
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
) as NativeSourceArtifact;
);
const translations = await readNativeTranslations();
return buildCatalog(existingCatalog, nativeSource, translations);
}
@ -779,9 +773,9 @@ function validateCatalog(
async function syncIosInfoPlist(write: boolean): Promise<number> {
const translations = await readNativeTranslations();
const nativeSource = JSON.parse(
const nativeSource = parseNativeI18nInventory(
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
) as NativeSourceArtifact;
);
const sourceIds = infoPlistSourceIds(nativeSource);
let checked = 0;
for (const target of IOS_INFO_PLIST_TARGETS) {
@ -941,9 +935,7 @@ export async function compileMacosLocalizations(outputDir: string) {
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG_PATH}`);
}
const [nativeSource, translations, infoPlistSource] = await Promise.all([
readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8").then(
(source) => JSON.parse(source) as NativeSourceArtifact,
),
readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8").then(parseNativeI18nInventory),
readNativeTranslations(),
readFile(path.join(ROOT, MACOS_INFO_PLIST_PATH), "utf8"),
]);

View file

@ -171,7 +171,7 @@ class NativeGeneratedArtifactsMixedError extends Error {}
const CHROMIUM_UI_TEST_SCOPE_RE =
/^(ui\/|extensions\/[^/]+\/browser(?:\/|$)|extensions\/browser\/chrome-extension\/|extensions\/qa-lab\/src\/[^/]+\.real-gateway\.e2e\.test\.ts$|test\/vitest\/vitest\.(?:ui-(?:e2e|browser)(?:-[^/.]+)?\.[^/]+\.ts|(?:pattern-file|performance-config|timeouts|weighted-sharding)\.ts|ui-(?:isolated-)?paths\.mjs)$|test\/helpers\/temp-dir\.ts$|scripts\/(?:ensure-playwright-chromium\.mts|test-desktop-resize-real\.mts|check-control-ui-(?:performance(?:-base)?|precompressed-assets)\.mts|ui\.(?:mts|js)|control-ui-mock-[^/]+\.ts|lib\/(?:ci-test-timings(?:-schema)?|desktop-resize-proof|vitest-local-scheduling)\.mts)$|config\/(?:ci-test-timings|control-ui-startup-budget-baseline)\.json$|\.github\/workflows\/ci\.yml$)/;
const NATIVE_I18N_SCOPE_RE =
/^(?:apps\/\.i18n\/|apps\/android\/(?:app\/src\/(?:main|play|thirdParty)\/|wear\/src\/main\/)|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-(?:app-i18n|i18n-locales))\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
/^(?:apps\/\.i18n\/|apps\/android\/(?:app\/src\/(?:main|play|thirdParty)\/|wear\/src\/main\/)|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-(?:app-i18n|i18n-(?:inventory|locales)))\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
const FAST_INSTALL_SMOKE_SCOPE_RE =
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
const FULL_INSTALL_SMOKE_SCOPE_RE =

View file

@ -15,26 +15,21 @@ import {
} from "./android-app-i18n.ts";
import { translateNativeEntries } from "./control-ui-i18n.ts";
import { compareAscii as compareCodePoints } from "./lib/canonical-json.mjs";
import {
type NativeI18nInventoryEntry,
type NativeI18nSite,
type NativeI18nSurface,
serializeNativeI18nInventory,
} from "./native-i18n-inventory.ts";
import { NATIVE_I18N_LOCALES } from "./native-i18n-locales.ts";
type NativeI18nSurface = "android" | "apple";
export { NATIVE_I18N_LOCALES };
export type NativeI18nEntry = {
id: string;
source: string;
surface: NativeI18nSurface;
sites: NativeI18nSite[];
export type NativeI18nEntry = NativeI18nInventoryEntry & {
/** Request-only owner excerpt; never persisted in the source inventory. */
sourceContext?: string;
};
export type NativeI18nSite = {
kind: string;
path: string;
};
type NativeInterpolation = {
start: number;
end: number;
@ -1129,27 +1124,15 @@ export function assignNativeI18nIds(entries: readonly Candidate[]): NativeI18nEn
);
}
async function readNativeI18nInventory(): Promise<{
raw: string;
}> {
let raw: string;
async function readNativeI18nInventory(): Promise<string> {
try {
raw = await readFile(OUTPUT_PATH, "utf8");
return await readFile(OUTPUT_PATH, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { raw: "" };
return "";
}
throw error;
}
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) {
throw new Error(`invalid native app i18n inventory: ${OUTPUT_PATH}`);
}
if ((parsed.version !== 1 && parsed.version !== 2) || !Array.isArray(parsed.entries)) {
throw new Error(`invalid native app i18n inventory: ${OUTPUT_PATH}`);
}
return { raw };
}
export async function collectNativeI18nEntries(): Promise<NativeI18nEntry[]> {
@ -1226,31 +1209,15 @@ export function collectNativeI18nEntriesFromSources(
return assignNativeI18nIds(entries);
}
export function serializeNativeI18nInventory(entries: readonly NativeI18nEntry[]): string {
return [
"{",
' "version": 2,',
' "entries": [',
...entries.map(
({ id, source, surface, sites }, index) =>
` ${JSON.stringify({ id, source, surface, sites })}${index === entries.length - 1 ? "" : ","}`,
),
" ]",
"}",
"",
].join("\n");
}
async function syncNativeI18n(options: {
checkInventory: boolean;
checkLocales: boolean;
reportObsolete?: (message: string) => void;
write: boolean;
}): Promise<NativeI18nEntry[]> {
const currentInventory = await readNativeI18nInventory();
const current = await readNativeI18nInventory();
const entries = await collectNativeI18nEntries();
const expected = serializeNativeI18nInventory(entries);
const current = currentInventory.raw;
if (options.checkInventory && current !== expected) {
throw new Error(
"native app i18n inventory drift detected. Run `pnpm native:i18n:baseline` and commit apps/.i18n/native-source.json.",

View file

@ -0,0 +1,110 @@
import { createHash } from "node:crypto";
import { compareAscii } from "./lib/canonical-json.mjs";
export type NativeI18nSurface = "android" | "apple";
export type NativeI18nSite = { kind: string; path: string };
export type NativeI18nInventoryEntry = {
id: string;
source: string;
surface: NativeI18nSurface;
sites: NativeI18nSite[];
};
export function serializeNativeI18nInventory(entries: readonly NativeI18nInventoryEntry[]): string {
const pathHashes = new Map<string, string>();
const rows = entries.flatMap(({ id, source, surface, sites }) =>
sites.map(({ path, kind }) => {
const hash = pathHashes.get(path) ?? createHash("sha256").update(path).digest("hex");
pathHashes.set(path, hash);
return { hash, row: { path, kind, surface, id, source } };
}),
);
// One line per site clusters a PR's rows beside its touched files; path-hash ordering
// keeps new sibling files out of the same insertion gap. Each position depends only
// on its row, so Git (including GitHub's server-side merge) combines independent edits
// cleanly and produces the same bytes as a fresh baseline.
rows.sort(
(left, right) =>
compareAscii(left.hash, right.hash) ||
compareAscii(left.row.path, right.row.path) ||
compareAscii(left.row.kind, right.row.kind) ||
compareAscii(left.row.surface, right.row.surface) ||
compareAscii(left.row.source, right.row.source),
);
return [
"{",
' "version": 3,',
' "sites": [',
...rows.map(
({ row }, index) => ` ${JSON.stringify(row)}${index === rows.length - 1 ? "" : ","}`,
),
" ]",
"}",
"",
].join("\n");
}
function invalidInventory(reason: string): never {
throw new Error(
`invalid native app i18n inventory: ${reason}; run \`pnpm native:i18n:baseline\``,
);
}
export function parseNativeI18nInventory(raw: string): NativeI18nInventoryEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
invalidInventory("malformed JSON");
}
if (
!parsed ||
typeof parsed !== "object" ||
!("version" in parsed) ||
parsed.version !== 3 ||
!("sites" in parsed) ||
!Array.isArray(parsed.sites)
) {
invalidInventory("expected version 3 and a sites array");
}
const entries = new Map<string, NativeI18nInventoryEntry>();
for (const value of parsed.sites) {
const row: unknown = value;
if (
!row ||
typeof row !== "object" ||
!("path" in row) ||
typeof row.path !== "string" ||
!("kind" in row) ||
typeof row.kind !== "string" ||
!("id" in row) ||
typeof row.id !== "string" ||
!("source" in row) ||
typeof row.source !== "string" ||
!("surface" in row) ||
(row.surface !== "android" && row.surface !== "apple")
) {
invalidInventory("invalid site row");
}
const entry = entries.get(row.id) ?? {
id: row.id,
source: row.source,
surface: row.surface,
sites: [],
};
if (entry.source !== row.source || entry.surface !== row.surface) {
invalidInventory(`inconsistent source or surface for ${row.id}`);
}
entry.sites.push({ kind: row.kind, path: row.path });
entries.set(row.id, entry);
}
for (const entry of entries.values()) {
entry.sites.sort(
(left, right) => compareAscii(left.path, right.path) || compareAscii(left.kind, right.kind),
);
}
return [...entries.values()].toSorted(
(left, right) =>
compareAscii(left.surface, right.surface) || compareAscii(left.source, right.source),
);
}

View file

@ -3375,7 +3375,7 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
),
["auth-monitor"],
],
[/^scripts\/native-app-i18n\.ts$/u, ["native-app-i18n", workflowGuards]],
[/^scripts\/native-(?:app-i18n|i18n-inventory)\.ts$/u, ["native-app-i18n", workflowGuards]],
[
/^scripts\/github\/(?:dependency-guard|guard-shared)\.mjs$/u,
["dependency-guard-script", "security-review-workflow"],

View file

@ -4,11 +4,13 @@ const { assertNativeGeneratedArtifactsIsolated, shouldRunNativeI18n, shouldStric
await import("../../scripts/ci-changed-scope.mjs");
describe("native i18n changed scope", () => {
it("routes native locale sources without requiring generated parity", () => {
const sourcePath = "scripts/native-app-i18n.ts";
expect(shouldRunNativeI18n([sourcePath])).toBe(true);
expect(shouldStrictNativeI18n([sourcePath])).toBe(false);
});
it.each(["scripts/native-app-i18n.ts", "scripts/native-i18n-inventory.ts"])(
"routes %s without requiring generated parity",
(sourcePath) => {
expect(shouldRunNativeI18n([sourcePath])).toBe(true);
expect(shouldStrictNativeI18n([sourcePath])).toBe(false);
},
);
it("routes Android flavor sources through native i18n", () => {
expect(

View file

@ -13,6 +13,7 @@ import {
selectDeterministicTranslation,
verifyAndroidAppI18n,
} from "../../scripts/android-app-i18n.ts";
import { parseNativeI18nInventory } from "../../scripts/native-i18n-inventory.ts";
import { NATIVE_I18N_LOCALES } from "../../scripts/native-i18n-locales.ts";
const { generatedOverrides } = vi.hoisted(() => ({
@ -61,8 +62,9 @@ describe("Android app i18n resources", () => {
const key = `native_${createHash("sha256").update(source).digest("hex").slice(0, 16)}`;
const artifactPath = path.resolve("apps/.i18n/native/de.json");
const artifact = JSON.parse(await readFile(artifactPath, "utf8"));
const inventory = JSON.parse(await readFile("apps/.i18n/native-source.json", "utf8"));
expect(inventory.entries).toContainEqual(
const inventoryRaw = await readFile("apps/.i18n/native-source.json", "utf8");
const inventory = parseNativeI18nInventory(inventoryRaw);
expect(inventory).toContainEqual(
expect.objectContaining({
id,
source,
@ -84,10 +86,11 @@ describe("Android app i18n resources", () => {
`<string name="${key}" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lesen &amp; \\"prüfen\\""</string>`,
);
inventory.entries.reverse();
const reorderedInventory = JSON.parse(inventoryRaw);
reorderedInventory.sites.reverse();
generatedOverrides.set(
path.resolve("apps/.i18n/native-source.json"),
JSON.stringify(inventory),
JSON.stringify(reorderedInventory),
);
const reordered = await buildAndroidAppI18nCatalog();
expect(reordered.kotlin).toBe(catalog.kotlin);
@ -111,11 +114,11 @@ describe("Android app i18n resources", () => {
});
it("routes compact token suffixes through generated resources", async () => {
const inventory = JSON.parse(await readFile("apps/.i18n/native-source.json", "utf8")) as {
entries: Array<{ sites: Array<{ kind: string; path: string }>; source: string }>;
};
const inventory = parseNativeI18nInventory(
await readFile("apps/.i18n/native-source.json", "utf8"),
);
const sources = new Set(["${decimal(count / 1_000_000.0)}M", "${thousands}k"]);
const entries = inventory.entries
const entries = inventory
.flatMap((entry) => entry.sites.map((site) => ({ ...site, source: entry.source })))
.filter(
(entry) => entry.path.endsWith("/ui/chat/ChatTurnRecap.kt") && sources.has(entry.source),

View file

@ -14,6 +14,10 @@ import {
serializeAppleCatalog,
verifyAppleAppI18n,
} from "../../scripts/apple-app-i18n.ts";
import {
type NativeI18nInventoryEntry,
parseNativeI18nInventory,
} from "../../scripts/native-i18n-inventory.ts";
import { NATIVE_I18N_LOCALES } from "../../scripts/native-i18n-locales.ts";
const probe = vi.hoisted(() => ({
@ -122,15 +126,9 @@ describe("Apple app i18n catalogs", () => {
});
it("derives shared discovery status coverage into the iOS catalog", async () => {
const inventory = JSON.parse(await readFile("apps/.i18n/native-source.json", "utf8")) as {
entries: Array<{
id: string;
source: string;
sites: Array<{ kind: string; path: string }>;
surface: string;
}>;
version: number;
};
const inventory = parseNativeI18nInventory(
await readFile("apps/.i18n/native-source.json", "utf8"),
);
const build = buildIosCatalog(
{ sourceLanguage: "en", strings: {}, version: "1.0" },
inventory,
@ -143,15 +141,9 @@ describe("Apple app i18n catalogs", () => {
});
it("derives broad macOS catalog coverage from the native source inventory", async () => {
const inventory = JSON.parse(await readFile("apps/.i18n/native-source.json", "utf8")) as {
entries: Array<{
id: string;
source: string;
sites: Array<{ kind: string; path: string }>;
surface: string;
}>;
version: number;
};
const inventory = parseNativeI18nInventory(
await readFile("apps/.i18n/native-source.json", "utf8"),
);
const build = buildMacosCatalog(
{ sourceLanguage: "en", strings: {}, version: "1.0" },
inventory,
@ -180,7 +172,7 @@ describe("Apple app i18n catalogs", () => {
});
it("warns only when obsolete Apple keys are the entire catalog drift", async () => {
const inventory: Parameters<typeof buildIosCatalog>[1] = JSON.parse(
const inventory = parseNativeI18nInventory(
await readFile("apps/.i18n/native-source.json", "utf8"),
);
const translations = await Promise.all(
@ -346,7 +338,7 @@ describe("Apple app i18n catalogs", () => {
});
it("routes merged sites by coupled path and kind while preserving shipped translations", () => {
const coveredMacosEntries = [
const coveredMacosEntries: NativeI18nInventoryEntry[] = [
{ kind: "ui-call-concatenated", source: "Call concatenated" },
{
kind: "ui-localized-call-concatenated",
@ -362,30 +354,27 @@ describe("Apple app i18n catalogs", () => {
surface: "apple",
sites: [{ kind, path: "apps/macos/Sources/OpenClaw/Example.swift" }],
}));
const inventory = {
version: 2,
entries: [
{
id: "native.apple.connect",
source: "Connect now",
surface: "apple",
sites: [
{ kind: "ui-call", path: "apps/ios/Sources/Example.swift" },
{ kind: "ui-call", path: "apps/macos/Sources/OpenClaw/Example.swift" },
],
},
{
id: "native.apple.decoy",
source: "Do not catalog",
surface: "apple",
sites: [
{ kind: "plist-string", path: "apps/ios/Sources/Info.plist" },
{ kind: "ui-call", path: "outside/Example.swift" },
],
},
...coveredMacosEntries,
],
};
const inventory: NativeI18nInventoryEntry[] = [
{
id: "native.apple.connect",
source: "Connect now",
surface: "apple",
sites: [
{ kind: "ui-call", path: "apps/ios/Sources/Example.swift" },
{ kind: "ui-call", path: "apps/macos/Sources/OpenClaw/Example.swift" },
],
},
{
id: "native.apple.decoy",
source: "Do not catalog",
surface: "apple",
sites: [
{ kind: "plist-string", path: "apps/ios/Sources/Info.plist" },
{ kind: "ui-call", path: "outside/Example.swift" },
],
},
...coveredMacosEntries,
];
const existing = {
sourceLanguage: "en",
strings: {
@ -442,23 +431,20 @@ describe("Apple app i18n catalogs", () => {
const translated = "^[\\(count) Eintrag](inflect: true)";
const build = buildCatalog(
{ sourceLanguage: "en", strings: {} },
{
version: 2,
entries: [
{
id: "native.apple.count",
source,
sites: [{ kind: "ui-localized-call", path: sourcePath }],
surface: "apple",
},
{
id: "native.apple.mixed-count",
source: "\\(name) has " + source,
sites: [{ kind: "ui-localized-call", path: sourcePath }],
surface: "apple",
},
],
},
[
{
id: "native.apple.count",
source,
sites: [{ kind: "ui-localized-call", path: sourcePath }],
surface: "apple",
},
{
id: "native.apple.mixed-count",
source: "\\(name) has " + source,
sites: [{ kind: "ui-localized-call", path: sourcePath }],
surface: "apple",
},
],
[
{
version: 2,

View file

@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
@ -12,11 +13,14 @@ import {
isConditionalBranchIdentifier,
NATIVE_I18N_LOCALES,
parseNativeI18nCommand,
serializeNativeI18nInventory,
syncNativeLocale,
type NativeI18nEntry,
validateNativeLocaleArtifact,
} from "../../scripts/native-app-i18n.ts";
import {
parseNativeI18nInventory,
serializeNativeI18nInventory,
} from "../../scripts/native-i18n-inventory.ts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@ -80,7 +84,7 @@ describe("native app i18n inventory", () => {
]);
});
it("serializes each complete entry on one line", () => {
it("serializes one extraction site per line in contiguous source-file clusters", () => {
const entries = [
{
id: "native.android.fixture",
@ -91,6 +95,12 @@ describe("native app i18n inventory", () => {
{ kind: "ui-call", path: "apps/android/src/Fixture.kt" },
],
},
{
id: "native.android.retry",
source: "Retry",
surface: "android",
sites: [{ kind: "ui-call", path: "apps/android/src/Fixture.kt" }],
},
{
id: "native.apple.fixture",
source: "Settings",
@ -107,15 +117,98 @@ describe("native app i18n inventory", () => {
const serialized = serializeNativeI18nInventory(contextualEntries);
const lines = serialized.trimEnd().split("\n");
expect(JSON.parse(serialized)).toEqual({ version: 2, entries });
expect(lines).toHaveLength(entries.length + 5);
expect(lines.slice(3, -2)).toEqual([
` ${JSON.stringify(entries[0])},`,
` ${JSON.stringify(entries[1])}`,
]);
const sites = [
{
path: "apps/android/res/values/strings.xml",
kind: "xml-string",
surface: "android",
id: "native.android.fixture",
source: 'A quoted "label"\nwith two lines',
},
{
path: "apps/ios/Sources/Fixture.swift",
kind: "ui-call",
surface: "apple",
id: "native.apple.fixture",
source: "Settings",
},
{
path: "apps/android/src/Fixture.kt",
kind: "ui-call",
surface: "android",
id: "native.android.fixture",
source: 'A quoted "label"\nwith two lines',
},
{
path: "apps/android/src/Fixture.kt",
kind: "ui-call",
surface: "android",
id: "native.android.retry",
source: "Retry",
},
];
expect(JSON.parse(serialized)).toEqual({ version: 3, sites });
expect(lines).toHaveLength(sites.length + 5);
expect(lines.slice(3, -2)).toEqual(
sites.map(
(site, index) => ` ${JSON.stringify(site)}${index === sites.length - 1 ? "" : ","}`,
),
);
expect(serialized).not.toContain("sourceContext");
expect(parseNativeI18nInventory(serialized)).toEqual(entries);
expect(serialized.endsWith("\n")).toBe(true);
});
it("merges independent source-file edits into a fresh combined baseline", async () => {
const sourceFile = (name: string, strings: string[]) => ({
repoPath: `apps/macos/Sources/OpenClaw/${name}.swift`,
surface: "apple" as const,
source: strings.map((source) => `Text(${JSON.stringify(source)})`).join("\n"),
});
const base = [
sourceFile("MainView", ["Welcome", "Retry"]),
sourceFile("ConnectionView", ["Connection", "Retry"]),
sourceFile("OverviewView", ["Overview", "Status"]),
sourceFile("SettingsView", ["Settings", "Preferences"]),
];
const branchA = sourceFile("SidebarFilters", ["Retry", "Group by", "Project"]);
const branchB = sourceFile("SidebarHovercard", ["Retry", "Group chat", "Project"]);
const serialize = (sources: typeof base) =>
serializeNativeI18nInventory(collectNativeI18nEntriesFromSources(sources));
const directory = tempDirs.make("openclaw-native-i18n-merge-");
const basePath = path.join(directory, "base.json");
const oursPath = path.join(directory, "ours.json");
const theirsPath = path.join(directory, "theirs.json");
await Promise.all([
writeFile(basePath, serialize(base)),
writeFile(oursPath, serialize([...base, branchA])),
writeFile(theirsPath, serialize([...base, branchB])),
]);
const merged = spawnSync("git", ["merge-file", "-p", oursPath, basePath, theirsPath], {
encoding: "utf8",
});
expect(merged.status, merged.stderr || merged.stdout).toBe(0);
expect(merged.stdout).toBe(serialize([...base, branchA, branchB]));
});
it("rejects stale or inconsistent inventories with a baseline repair hint", () => {
const row = {
path: "Fixture.swift",
kind: "ui-call",
surface: "apple",
id: "retry",
source: "Retry",
};
for (const raw of [
JSON.stringify({ version: 2, entries: [] }),
JSON.stringify({ version: 3, sites: [row, { ...row, source: "Cancel" }] }),
]) {
expect(() => parseNativeI18nInventory(raw)).toThrow(
/^invalid native app i18n inventory: .+; run `pnpm native:i18n:baseline`$/,
);
}
});
it("carries bounded nearby owner code without changing stable inventory data", () => {
const source = [
`// distant prefix ${"x".repeat(2000)}`,
@ -286,7 +379,7 @@ describe("native app i18n inventory", () => {
].join("\n"),
),
);
const { catalog } = buildMacosCatalog({}, { version: 2, entries }, []);
const { catalog } = buildMacosCatalog({}, entries, []);
expect(Object.keys(catalog.strings ?? {})).toEqual(["Expires in %lld minutes"]);
expect(catalog.strings?.["Expires in %lld minutes"]?.localizations?.en?.stringUnit?.value).toBe(
"Expires in %lld minutes",