mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
14fd53f8f6
commit
f02d16e758
13 changed files with 5954 additions and 4616 deletions
|
|
@ -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
|
||||
|
|
|
|||
10075
apps/.i18n/native-source.json
generated
10075
apps/.i18n/native-source.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
110
scripts/native-i18n-inventory.ts
Normal file
110
scripts/native-i18n-inventory.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 & \\"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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue