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:
gsxdsm
2026-06-03 08:51:41 -07:00
parent 959557ade8
commit f707b62155
7 changed files with 61 additions and 7 deletions

View File

@@ -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

View File

@@ -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[] = [

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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}`,
);
}
}