fix(i18n): address code-review findings
- Validate GlobalSettings.language at the write boundary (store.ts) via validateLocale, so invalid locales are dropped not persisted (api-contract P1). - Fix detectEnvLocale: Traditional-script env tags (zh_Hant/zh_Hant_TW/zh_HK/ zh_MO) now resolve to zh-TW instead of Simplified; use replaceAll for multi-underscore POSIX tags (adversarial P2). Add coverage. - Agent-native parity: add 'language' to the CLI settings allowlist (VALID_SETTINGS + GLOBAL_ONLY + enum) so 'fn settings set language' works like the dashboard switcher. - Document --lang in the bin.ts help table (api-contract P3). - Derive the expected locale-chunk count from the locales dir instead of a hardcoded 5 (maintainability), and add prebuild:client so a raw vite build on a fresh clone still syncs catalogs (adversarial P2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -420,6 +420,7 @@ Options:
|
||||
--interactive Interactive mode (port selection for dashboard, issue selection for import)
|
||||
--paused Start with engine paused (automation disabled)
|
||||
--dev Start dashboard only (no AI engine)
|
||||
--lang <locale> UI locale for this run (en, zh-CN, zh-TW, fr, es)
|
||||
--attach <file> Attach file(s) on task create (repeatable)
|
||||
--depends <id> Declare dependency on task create (repeatable)
|
||||
--no-dedup Bypass deterministic duplicate guard on task create
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type GlobalSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
resolveWorktrunkSettings,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@fusion/core";
|
||||
import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
@@ -28,9 +29,10 @@ export const VALID_SETTINGS = [
|
||||
"worktrunk.enabled",
|
||||
"worktrunk.binaryPath",
|
||||
"worktrunk.onFailure",
|
||||
"language",
|
||||
] as const;
|
||||
|
||||
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
|
||||
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const;
|
||||
const PROJECT_ONLY_SETTINGS = [
|
||||
"maxConcurrent",
|
||||
"maxWorktrees",
|
||||
@@ -64,6 +66,7 @@ const ENUM_SETTINGS: Record<string, readonly string[]> = {
|
||||
worktreeNaming: ["random", "task-id", "task-title"],
|
||||
unavailableNodePolicy: ["block", "fallback-local"],
|
||||
"worktrunk.onFailure": ["fail", "fallback-native"],
|
||||
language: SUPPORTED_LOCALES,
|
||||
};
|
||||
|
||||
const STRING_SETTINGS: readonly string[] = [
|
||||
|
||||
@@ -23,6 +23,21 @@ describe("detectEnvLocale", () => {
|
||||
expect(detectEnvLocale({ LANG: "de_DE.UTF-8" })).toBeUndefined();
|
||||
expect(detectEnvLocale({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves Traditional-script Chinese tags to zh-TW, not Simplified", () => {
|
||||
expect(detectEnvLocale({ LANG: "zh_Hant_TW.UTF-8" })).toBe("zh-TW");
|
||||
expect(detectEnvLocale({ LANG: "zh_Hant" })).toBe("zh-TW");
|
||||
expect(detectEnvLocale({ LANG: "zh_HK.UTF-8" })).toBe("zh-TW");
|
||||
expect(detectEnvLocale({ LANG: "zh_MO" })).toBe("zh-TW");
|
||||
// Simplified-script and mainland tags stay Simplified.
|
||||
expect(detectEnvLocale({ LANG: "zh_Hans_CN" })).toBe("zh-CN");
|
||||
expect(detectEnvLocale({ LANG: "zh_SG" })).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("normalizes multi-underscore POSIX tags", () => {
|
||||
expect(detectEnvLocale({ LANG: "en_US_POSIX" })).toBe("en");
|
||||
expect(detectEnvLocale({ LANGUAGE: "fr_FR.UTF-8" })).toBe("fr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCliLocale precedence", () => {
|
||||
|
||||
@@ -26,13 +26,27 @@ export function detectEnvLocale(env: NodeJS.ProcessEnv = process.env): Locale |
|
||||
if (!raw) return undefined;
|
||||
|
||||
// Strip encoding/modifier (`.UTF-8`, `@euro`) and normalize `_` to `-`.
|
||||
const code = raw.split(/[.:@\s]/)[0].replace("_", "-");
|
||||
const code = raw.split(/[.:@\s]/)[0].replaceAll("_", "-");
|
||||
if (isLocale(code)) return code;
|
||||
|
||||
const lang = code.split("-")[0].toLowerCase();
|
||||
const lower = code.toLowerCase();
|
||||
// Script/region-aware Chinese resolution BEFORE the bare-language fallback,
|
||||
// so Traditional-script tags (zh_Hant, zh_Hant_TW, zh_HK, zh_MO) are not
|
||||
// silently served Simplified. Region-only zh_CN/zh_TW already matched above.
|
||||
if (lower.startsWith("zh")) {
|
||||
if (
|
||||
lower.includes("hant") ||
|
||||
lower.includes("-tw") ||
|
||||
lower.includes("-hk") ||
|
||||
lower.includes("-mo")
|
||||
) {
|
||||
return "zh-TW";
|
||||
}
|
||||
return "zh-CN";
|
||||
}
|
||||
|
||||
const lang = lower.split("-")[0];
|
||||
if (isLocale(lang)) return lang;
|
||||
// A bare `zh` resolves to Simplified, mirroring the shared fallback chain.
|
||||
if (lang === "zh") return "zh-CN";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSn
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
|
||||
import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js";
|
||||
import { validateLocale } from "./settings-validation.js";
|
||||
import { normalizeTaskPriority } from "./task-priority.js";
|
||||
import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
@@ -3251,6 +3252,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
(globalPatch as Record<string, unknown>)["experimentalFeatures"] = mergedMap;
|
||||
}
|
||||
|
||||
// Validate the optional UI locale at the write boundary: drop unrecognized
|
||||
// values rather than persisting junk into settings.json. Runtime consumers
|
||||
// also guard via isLocale, but the contract is `language?: Locale`.
|
||||
if ("language" in globalPatch) {
|
||||
const validatedLanguage = validateLocale((globalPatch as Record<string, unknown>)["language"]);
|
||||
if (validatedLanguage === undefined) {
|
||||
delete (globalPatch as Record<string, unknown>)["language"];
|
||||
} else {
|
||||
globalPatch.language = validatedLanguage;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGlobal = await this.globalSettingsStore.updateSettings(globalPatch);
|
||||
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
|
||||
try {
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"verify:locale-chunks": "node scripts/assert-locale-chunks.mjs",
|
||||
"prebuild": "node scripts/sync-locales.mjs",
|
||||
"build": "vite build && tsc",
|
||||
"prebuild:client": "node scripts/sync-locales.mjs",
|
||||
"build:client": "vite build",
|
||||
"predev:serve": "node scripts/sync-locales.mjs",
|
||||
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
|
||||
|
||||
@@ -12,6 +12,13 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const assetsDir = join(here, "..", "dist", "client", "assets");
|
||||
const namespaces = ["common", "app", "errors"];
|
||||
|
||||
// Derive the expected per-namespace chunk floor from the authored locale set
|
||||
// rather than hardcoding a count, so adding a locale needs no edit here.
|
||||
const localesDir = join(here, "..", "..", "i18n", "locales");
|
||||
const expectedLocaleCount = readdirSync(localesDir, { withFileTypes: true }).filter(
|
||||
(d) => d.isDirectory(),
|
||||
).length;
|
||||
|
||||
if (!existsSync(assetsDir)) {
|
||||
console.error(`assert-locale-chunks: ${assetsDir} not found — run the client build first.`);
|
||||
process.exit(1);
|
||||
@@ -23,9 +30,9 @@ const errors = [];
|
||||
// One chunk per dashboard namespace per locale (5 locales) → at least 5 each.
|
||||
for (const ns of namespaces) {
|
||||
const matches = files.filter((f) => new RegExp(`^${ns}-[^/]+\\.js$`).test(f));
|
||||
if (matches.length < 5) {
|
||||
if (matches.length < expectedLocaleCount) {
|
||||
errors.push(
|
||||
`expected >=5 split chunks for namespace "${ns}" (one per locale), found ${matches.length}`,
|
||||
`expected >=${expectedLocaleCount} split chunks for namespace "${ns}" (one per locale), found ${matches.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user