fix(i18n): warn about obsolete Apple catalog rows in CI

The native CI check already warned about obsolete translation IDs and
Android resources, but Apple catalog checks required exact whole-file
parity. An unused iOS or macOS row therefore failed validation while the
serialized locale refresh caught up.

Apply the existing CI warning policy through one Apple catalog owner.
Only canonical catalogs differing by valid plain unused rows qualify.
Validate their structure before warning so malformed rows that Xcode
rejects remain errors. Active content, locales, placeholders, metadata,
and other parity checks remain blocking; local checks stay strict.
This commit is contained in:
Peter Steinberger
2026-09-25 05:17:05 -07:00
parent 2e74a58811
commit 0033a497e5
6 changed files with 205 additions and 66 deletions
+9 -4
View File
@@ -107,11 +107,16 @@ pnpm perf:kova:summary --report .artifacts/kova/reports/mock-provider/report.jso
```
Native locale checks remain strict locally. With `CI=true` or `CI=1`, the native
check warns about obsolete translation IDs and Android generated rows awaiting
the serialized locale refresh. Android warnings require canonical, unreferenced,
check warns about obsolete translation IDs, Android generated rows, and Apple catalog
rows awaiting the serialized locale refresh. Android warnings require canonical, unreferenced,
noninterpolated obsolete rows whose removal leaves every other byte unchanged.
Missing active translations or resources, invalid placeholders or artifact
syntax, and other generated-output differences remain blocking. Generator sync
Apple warnings require canonical plain generator rows absent from the active
inventory; removing those rows must leave the exact generated catalog, including
metadata. Obsolete rows still need valid dictionary and string-unit structure for
Xcode, but do not need active locales or translated/nonempty copy. Unsupported
metadata and variation shapes retain strict parity checks.
Missing active translations or resources, active placeholder drift, invalid
artifact syntax, and other generated-output differences remain blocking. Generator sync
and the standalone Android and Apple checks retain their strict behavior.
The Gateway watch regression check starts its idle CPU window only after readiness
+1 -1
View File
@@ -105,7 +105,7 @@ the job's uploaded artifacts.
| `check-additional-*` | Boundary check stripes (including prompt snapshot drift), session accessor/transcript reader/SQLite transaction boundaries, extension lint groups, package boundary compile/canary, and runtime topology architecture; the pure-reporting plugin SDK API diff runs on manual and release dispatches only | Node-relevant changes |
| `checks-node-compat-node24` | Node 24 minimum compatibility build and smoke lane | Full Release Validation and manual dispatches only |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed (PRs and manual dispatch) |
| `native-i18n` | Verify native source extraction and localization safety on source PRs and release gates; enforce generated parity on generated PRs, generated-scope release gates, and ordinary manual CI, with warnings for proven obsolete native IDs and Android rows | Native i18n-relevant changes |
| `native-i18n` | Verify native source extraction and localization safety on source PRs and release gates; enforce generated parity on generated PRs, generated-scope release gates, and ordinary manual CI, with warnings for proven obsolete native IDs, Android rows, and Apple catalog rows | Native i18n-relevant changes |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | Focused macOS TypeScript tests: launchd, Homebrew, runtime paths, packaging scripts, process-group wrapper | macOS-relevant changes |
@@ -11,7 +11,7 @@ Manual CI dispatch behavior, release-gate fallbacks, and the Windows Testbox Pro
## Manual dispatches
Ordinary manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 24 minimum compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, full iOS build/test and screenshot qualification, and Control UI/native app i18n. Their logical runner profile is always `github`, independent of the physical fallback selected by `runs-on`. Node 24 minimum compatibility runs in Full Release Validation and manual dispatches only; push and pull request CI skip it. The exact-head `release_gate` fallback instead keeps the pull request's macOS, iOS smoke, and generated-native-locale scope without selecting iOS screenshots or native tests. Automatic source PRs and release gates verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Native parity remains blocking for generated-artifact PRs, generated-scope release gates, ordinary manual CI, full-scope release validation, and release prep. CI reports proven obsolete native translation IDs and Android generated rows as warnings while the locale refresh catches up; active-key coverage, correctness, and all other parity checks remain blocking. See [local checks](/ci/local-proof) for the exact boundary. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); full-scope release validation enables Android by passing `include_android=true` without setting `release_gate`; npm qualification scopes defer Android. Plugin prerelease static checks, the full `agentic-plugins` sweep, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Ordinary manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 24 minimum compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, full iOS build/test and screenshot qualification, and Control UI/native app i18n. Their logical runner profile is always `github`, independent of the physical fallback selected by `runs-on`. Node 24 minimum compatibility runs in Full Release Validation and manual dispatches only; push and pull request CI skip it. The exact-head `release_gate` fallback instead keeps the pull request's macOS, iOS smoke, and generated-native-locale scope without selecting iOS screenshots or native tests. Automatic source PRs and release gates verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Native parity remains blocking for generated-artifact PRs, generated-scope release gates, ordinary manual CI, full-scope release validation, and release prep. CI reports proven obsolete native translation IDs, Android generated rows, and Apple catalog rows as warnings while the locale refresh catches up; active-key coverage, correctness, and all other parity checks remain blocking. See [local checks](/ci/local-proof) for the exact boundary. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); full-scope release validation enables Android by passing `include_android=true` without setting `release_gate`; npm qualification scopes defer Android. Plugin prerelease static checks, the full `agentic-plugins` sweep, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
PR baseline ratchets derive their comparison state from the checked-out synthetic merge tree and verify its head parent against the event head. The max-lines entry chains the environment-variable budget with the same fork-point ref before the assertion-safety check, so production source growth cannot first surface on `main`. Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref; ratchet baselines are compared with the target's merge base against the default-branch head resolved for that run. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pull_request_number` to identify the open PR whose merge tree is validated. Release-gate merge-tree lint uses the same five core stripes as hosted PR CI plus one extension stripe, so no single hosted runner owns the full type-aware lint workload.
+79 -36
View File
@@ -738,12 +738,49 @@ async function readAppleCatalogBuild(
return buildCatalog(existingCatalog, nativeSource, translations);
}
function validateCatalog(pathName: string, catalog: Catalog): number {
function isCatalogDictionary(
value: unknown,
fields?: readonly string[],
): value is Record<string, unknown> {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
(!fields || Object.keys(value).every((key) => fields.includes(key)))
);
}
function validateCatalog(
pathName: string,
catalog: Catalog,
activeKeys?: ReadonlySet<string>,
): number {
if (catalog.sourceLanguage !== "en" || catalog.version !== "1.0" || !catalog.strings) {
throw new Error(`invalid Apple string catalog: ${pathName}`);
}
let checked = 0;
for (const [key, entry] of Object.entries(catalog.strings)) {
if (activeKeys && !activeKeys.has(key)) {
// Retired rows still reach Xcode. Admit only valid plain generator shapes, without
// requiring inactive locales, translated state, nonempty copy or matching placeholders.
const valid =
isCatalogDictionary(entry, ["localizations"]) &&
(entry.localizations === undefined ||
(isCatalogDictionary(entry.localizations) &&
Object.values(entry.localizations).every(
(localization) =>
isCatalogDictionary(localization, ["stringUnit"]) &&
isCatalogDictionary(localization.stringUnit, ["state", "value"]) &&
typeof localization.stringUnit.state === "string" &&
typeof localization.stringUnit.value === "string",
)));
if (!valid) {
throw new Error(
`Apple catalog ${pathName} has an unsupported obsolete row for ${JSON.stringify(key)}; run native-app-i18n.ts sync --write`,
);
}
continue;
}
const sourceTokens = formatTokens(key);
for (const locale of REQUIRED_LOCALES) {
const unit = entry.localizations?.[locale]?.stringUnit;
@@ -813,43 +850,44 @@ async function syncIosInfoPlist(write: boolean): Promise<number> {
return checked;
}
export async function syncIosCatalog(write: boolean): Promise<AppleCatalogBuild> {
const build = await readAppleCatalogBuild(IOS_CATALOG_PATH, buildIosCatalog);
const catalogPath = path.join(ROOT, IOS_CATALOG_PATH);
async function syncAppleCatalog(
catalogName: string,
buildCatalog: typeof buildIosCatalog,
write: boolean,
reportObsolete?: (message: string) => void,
): Promise<AppleCatalogBuild> {
const build = await readAppleCatalogBuild(catalogName, buildCatalog);
const catalogPath = path.join(ROOT, catalogName);
const expected = serializeAppleCatalog(build.catalog);
const actual = await readFile(catalogPath, "utf8");
if (actual !== expected) {
if (!write) {
throw new Error(
`Apple catalog ${IOS_CATALOG_PATH} is stale; run apple-app-i18n.ts sync-ios --write`,
);
}
if (actual === expected) {
return build;
}
if (write) {
await writeFile(catalogPath, expected, "utf8");
return build;
}
return build;
}
export async function syncMacosCatalog(write: boolean): Promise<AppleCatalogBuild> {
const build = await readAppleCatalogBuild(MACOS_CATALOG_PATH, buildMacosCatalog);
const catalogPath = path.join(ROOT, MACOS_CATALOG_PATH);
const expected = serializeAppleCatalog(build.catalog);
const actual = await readFile(catalogPath, "utf8");
if (actual !== expected) {
if (!write) {
assertMacosCatalogCurrent(actual, build);
return build;
if (reportObsolete) {
const catalog = JSON.parse(actual) as Catalog;
const strings = catalog.strings;
if (isCatalogDictionary(strings) && actual === serializeAppleCatalog(catalog)) {
const active = build.catalog.strings ?? {};
const obsolete = Object.keys(strings).filter((key) => !Object.hasOwn(active, key));
// Ignore only complete obsolete rows; active values, metadata and formatting stay exact.
const filtered = {
...catalog,
strings: Object.fromEntries(
Object.entries(strings).filter(([key]) => Object.hasOwn(active, key)),
),
};
if (obsolete.length > 0 && serializeAppleCatalog(filtered) === expected) {
validateCatalog(catalogName, catalog, new Set(Object.keys(active)));
reportObsolete(`Apple obsolete catalog rows: ${catalogName} (keys=${obsolete.length})`);
return build;
}
}
await writeFile(catalogPath, expected, "utf8");
}
return build;
}
export function assertMacosCatalogCurrent(actual: string, build: AppleCatalogBuild): void {
if (actual !== serializeAppleCatalog(build.catalog)) {
throw new Error(
`Apple catalog ${MACOS_CATALOG_PATH} is stale; run native-app-i18n.ts sync --write`,
);
}
throw new Error(`Apple catalog ${catalogName} is stale; run native-app-i18n.ts sync --write`);
}
/**
@@ -862,7 +900,10 @@ export async function syncAppleAppI18n(): Promise<{
infoPlistFiles: number;
macosBuild: AppleCatalogBuild;
}> {
const [build, macosBuild] = await Promise.all([syncIosCatalog(true), syncMacosCatalog(true)]);
const [build, macosBuild] = await Promise.all([
syncAppleCatalog(IOS_CATALOG_PATH, buildIosCatalog, true),
syncAppleCatalog(MACOS_CATALOG_PATH, buildMacosCatalog, true),
]);
const infoPlistFiles = await syncIosInfoPlist(true);
return { build, infoPlistFiles, macosBuild };
}
@@ -894,11 +935,13 @@ export async function verifyAppleAppI18n() {
process.stdout.write(`apple-app-i18n: sourceMacosKeys=${macosKeys}\n`);
}
export async function checkAppleAppI18n() {
export async function checkAppleAppI18n(
options: { reportObsolete?: (message: string) => void } = {},
) {
await verifyAppleAppI18n();
const [iosBuild, macosBuild] = await Promise.all([
syncIosCatalog(false),
syncMacosCatalog(false),
syncAppleCatalog(IOS_CATALOG_PATH, buildIosCatalog, false, options.reportObsolete),
syncAppleCatalog(MACOS_CATALOG_PATH, buildMacosCatalog, false, options.reportObsolete),
]);
const iosKeys = validateCatalog(IOS_CATALOG_PATH, iosBuild.catalog);
const macosKeys = validateCatalog(MACOS_CATALOG_PATH, macosBuild.catalog);
+1 -1
View File
@@ -1824,7 +1824,7 @@ async function main() {
await apple.verifyAppleAppI18n();
} else {
await android.checkAndroidAppI18n({ reportObsolete });
await apple.checkAppleAppI18n();
await apple.checkAppleAppI18n({ reportObsolete });
}
}
if (parsed.command === "sync" && parsed.write && !parsed.locale) {
+114 -23
View File
@@ -1,12 +1,13 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core/expect";
import { describe, expect, it, vi } from "vitest";
import {
assertMacosCatalogCurrent,
buildIosCatalog,
buildMacosCatalog,
compileMacosLocalizations,
checkAppleAppI18n,
findAmbiguousRuntimeInterpolations,
infoPlistTranslationCandidates,
selectInfoPlistTranslation,
@@ -17,6 +18,7 @@ import { NATIVE_I18N_LOCALES } from "../../scripts/native-i18n-locales.ts";
const probe = vi.hoisted(() => ({
source: "",
catalogs: new Map<string, string>(),
paths: [
"apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
"apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift",
@@ -31,8 +33,12 @@ vi.mock("node:fs/promises", async (importOriginal) => {
...actual,
// Synthetic calls are opt-in and limited to production-source reads; all other I/O stays real.
readFile: async (...args: Parameters<typeof actual.readFile>) => {
const source = await actual.readFile(...args);
const file = typeof args[0] === "string" ? args[0].replaceAll("\\", "/") : "";
const catalog = probe.catalogs.get(path.resolve(file));
if (catalog !== undefined) {
return catalog;
}
const source = await actual.readFile(...args);
return probe.source &&
typeof source === "string" &&
probe.paths.some((entry) => file.endsWith("/" + entry))
@@ -177,29 +183,114 @@ describe("Apple app i18n catalogs", () => {
expect(keys.some((key) => key.includes("\\("))).toBe(false);
});
it("rejects a checked-in macOS catalog that lags the derived inventory", () => {
const build = buildMacosCatalog(
{ sourceLanguage: "en", strings: {}, version: "1.0" },
{
version: 2,
entries: [
{
id: "native.apple.settings",
source: "Settings",
sites: [{ kind: "ui-call", path: "apps/macos/Sources/OpenClaw/Settings.swift" }],
surface: "apple",
},
],
},
[],
it("warns only when obsolete Apple keys are the entire catalog drift", async () => {
const inventory: Parameters<typeof buildIosCatalog>[1] = JSON.parse(
await readFile("apps/.i18n/native-source.json", "utf8"),
);
expect(() =>
assertMacosCatalogCurrent(
`${JSON.stringify({ sourceLanguage: "en", strings: {}, version: "1.0" }, null, 2)}\n`,
build,
const translations = await Promise.all(
NATIVE_I18N_LOCALES.map(async (locale) =>
JSON.parse(await readFile(`apps/.i18n/native/${locale}.json`, "utf8")),
),
).toThrow("Apple catalog apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings is stale");
);
const catalogs = await Promise.all(
(
[
["apps/ios/Resources/Localizable.xcstrings", buildIosCatalog],
["apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings", buildMacosCatalog],
] as const
).map(async ([file, buildCatalog]) => {
const filePath = path.resolve(file);
const build = buildCatalog(
JSON.parse(await readFile(filePath, "utf8")),
inventory,
translations,
);
return { filePath, catalog: build.catalog };
}),
);
try {
// Source PRs can await generation; keep this fixture's active resources current.
for (const { filePath, catalog } of catalogs) {
probe.catalogs.set(filePath, serializeAppleCatalog(catalog));
}
for (const { filePath, catalog } of catalogs) {
const warnings: string[] = [];
const options = { reportObsolete: (message: string) => warnings.push(message) };
const strings = expectDefined(catalog.strings, "active catalog strings");
const activeKey = expectDefined(
Object.keys(strings).find((key) => key.includes("%@")),
"active format key",
);
const obsolete: typeof catalog & { strings: typeof strings } = {
...catalog,
strings: {
...strings,
"Retired synthetic %@": {
localizations: { en: { stringUnit: { state: "new", value: "" } } },
},
"Retired empty title": {},
},
};
const serialized = serializeAppleCatalog(obsolete);
probe.catalogs.set(filePath, serialized);
await expect(checkAppleAppI18n()).rejects.toThrow("is stale");
warnings.length = 0;
await expect(checkAppleAppI18n(options)).resolves.toBeUndefined();
expect(warnings).toEqual([
`Apple obsolete catalog rows: ${path.relative(process.cwd(), filePath).replaceAll("\\", "/")} (keys=2)`,
]);
for (const ineligibleRow of [
"null",
"42",
'{"localizations":null}',
'{"localizations":{"en":42}}',
'{"localizations":{"en":{"stringUnit":null}}}',
'{"localizations":{"en":{"stringUnit":{"state":"new"}}}}',
'{"localizations":{"en":{"stringUnit":{"state":42,"value":"Retired"}}}}',
'{"comment":42}',
'{"localizations":{"en":{"variations":{}}}}',
]) {
probe.catalogs.set(
filePath,
serialized.replace(
'"Retired empty title": {}',
`"Retired empty title": ${ineligibleRow}`,
),
);
await expect(checkAppleAppI18n(options)).rejects.toThrow();
}
const missingKey = structuredClone(obsolete);
delete missingKey.strings[activeKey];
const missingLocale = structuredClone(obsolete);
delete missingLocale.strings[activeKey]?.localizations?.de;
const placeholderDrift = structuredClone(obsolete);
expectDefined(
placeholderDrift.strings[activeKey]?.localizations?.de?.stringUnit,
"German format unit",
).value = "Missing format argument";
const metadataDrift = structuredClone(obsolete);
expectDefined(metadataDrift.strings[activeKey], "active catalog entry").comment =
"Unexpected metadata";
for (const invalid of [
serializeAppleCatalog(missingKey),
serializeAppleCatalog(missingLocale),
serializeAppleCatalog(placeholderDrift),
serializeAppleCatalog(metadataDrift),
serializeAppleCatalog({ ...obsolete, sourceLanguage: "fr" }),
serializeAppleCatalog({ ...obsolete, version: "2.0" }),
`${serialized}\n`,
`${serialized}malformed\n`,
]) {
probe.catalogs.set(filePath, invalid);
await expect(checkAppleAppI18n(options)).rejects.toThrow();
}
probe.catalogs.set(filePath, serializeAppleCatalog(catalog));
}
} finally {
probe.catalogs.clear();
}
});
it("serializes one complete localization key per line without losing nested metadata", () => {