Revert "Decouple feature copy from translated locale catalogs (#8488)" (#8500)

This reverts commit a5e9e139b1.
This commit is contained in:
Brennan Benson 2026-07-12 23:43:07 -07:00 committed by GitHub
parent a5e9e139b1
commit 43e481b1c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 4470 additions and 218596 deletions

9
.gitattributes vendored
View File

@ -6,12 +6,3 @@
/config/scripts/run-internal-dev-setup.mjs text eol=lf
/config/scripts/verify-cli-bin.mjs text eol=lf
/config/scripts/verify-release-required-assets.mjs text eol=lf
/config/localization-extraction/en.json text eol=lf
/config/localization-state/*.json text eol=lf
/config/localization-reviewed-corrections.json text eol=lf
/config/localization-reviewed-commit-classifications.json text eol=lf
/config/localization-extraction-dispositions.json text eol=lf
/config/localization-dynamic-call-allowlist.json text eol=lf
/config/localization-main-process-allowlist.json text eol=lf
/src/renderer/src/i18n/locales/*.json text eol=lf
/src/shared/localization-keys.ts text eol=lf

13
.github/CODEOWNERS vendored
View File

@ -1,13 +0,0 @@
# Product source remains owned by its normal code reviewers; locale/state changes need localization review.
/src/renderer/src/i18n/locales/ @brennanbenson
/config/localization-state/ @brennanbenson
/config/localization-extraction/ @brennanbenson
/config/localization-extraction-dispositions.json @brennanbenson
/config/localization-policy-disposition.md @brennanbenson
/config/localization-*-allowlist.json @brennanbenson
/config/localization-reviewed-corrections.json @brennanbenson
/config/localization-reviewed-commit-classifications.json @brennanbenson
/config/scripts/*localization*.mjs @brennanbenson
/config/scripts/*locale*.mjs @brennanbenson
/i18next.config.ts @brennanbenson
/src/shared/localization-keys.ts @brennanbenson

View File

@ -9,42 +9,7 @@ on:
- ready_for_review
jobs:
localization-extraction-platforms:
name: Localization extraction (${{ matrix.os }})
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: extraction is build-time only; skipping unrelated native postinstalls keeps this matrix portable.
- name: Install extraction dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Verify deterministic localization extraction
run: pnpm run verify:localization-extraction
- name: Verify generated localization key types
run: pnpm run verify:localization-key-types
verify-core:
verify:
runs-on: ubuntu-latest
steps:
@ -98,10 +63,6 @@ jobs:
- name: Lint
run: pnpm exec oxlint --format github
# Why: product checks share one AST pass; the matrix above independently gates extraction/types.
- name: Verify product localization catalogs, state, and coverage
run: pnpm run verify:product-localization
- name: Check styled scrollbars
run: pnpm check:styled-scrollbars
@ -191,16 +152,3 @@ jobs:
# temp copy so Node cannot mask missing package deps with repo node_modules.
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
verify:
needs: [verify-core, localization-extraction-platforms]
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
# Why: skipped required jobs can appear successful, so every dependency must complete successfully.
- name: Require core and localization verification
if: ${{ needs.verify-core.result != 'success' || needs.localization-extraction-platforms.result != 'success' }}
run: exit 1
- name: Verification complete
run: echo "Core and cross-platform localization verification passed."

View File

@ -50,52 +50,13 @@ Sync catalog keys after adding or removing `translate(...)` calls:
```sh
pnpm run sync:localization-catalog
pnpm run sync:localization-extraction
```
The sync command adds missing `en.json` entries from each call's string fallback.
It never edits target catalogs: missing values remain absent for runtime English
fallback, while placeholder mismatches fail until a localization PR fixes or
retires the target value. The legacy free-endpoint bootstrap and whole-catalog
repair scripts intentionally have no package-script entry points.
The extraction sync updates only the committed generated English template. For
an added call it adds the extracted key; for changed fallback copy it records a
fallback difference; and for a removed call it records an orphan. Existing
dispositions are preserved exactly. New differences are written as
`pending-human-classification` and deliberately keep the gate red until a
localization owner replaces each one with a supported disposition and a
key-specific reason. Neither sync command edits a target-language catalog.
When an orphan/drift resolves, manually remove its obsolete disposition; when an
allowlisted candidate count changes, manually update the reviewed count or
remove the entry if the candidate is gone. Snapshot commands preserve those
manual records intentionally and will not erase or silently refresh them.
To mark a translation reviewed in the same localization PR, set its sidecar
state to `reviewed` and add an exact-value entry to
`config/localization-reviewed-corrections.json` with reason
`localization-pr-review` and review evidence `{ provider, changeId, reviewer }`.
The verifier accepts GitHub, GitLab, or another provider and rejects the state
if the evidence is malformed or the reviewed value differs from the catalog.
Run the translation-state and extraction gates with:
```sh
pnpm run verify:localization-catalog
pnpm run verify:localization-extraction
pnpm run verify:localization-main-process
pnpm run localization:status
```
`config/localization-state/<locale>.json` is the versioned review-state source.
`config/localization-extraction/en.json` is deterministic `i18next-cli` proof
output; existing source orphans and inline-default drift have explicit committed
dispositions and any new difference fails CI.
`src/shared/localization-keys.ts` is a Phase 2 extraction/tooling evaluation
artifact. Runtime translation wrappers do not consume it yet; runtime key
typing remains a separately reviewed follow-up rather than an implied gate in
this migration.
The sync command adds missing `en.json` entries from each call's string fallback,
copies untranslated English placeholders into other locale catalogs to keep
parity, removes locale entries whose English key was deleted, and repairs
placeholder mismatches. Run the machine-translation bootstrap commands only when
refreshing real translations, not for ordinary UI copy changes.
The coverage gate compares current candidates against
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
@ -136,8 +97,8 @@ Recommended migration order:
The final gate should combine three checks:
1. Scanner coverage: no unclassified localizable candidates remain.
2. Catalog correctness: every existing target has state and matching
interpolation variables; completeness is reported rather than required.
2. Catalog coverage: every supported locale has the same keys as English, with
matching interpolation variables.
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
obvious English leftovers or layout clipping in core screens.

View File

@ -1,30 +0,0 @@
{
"version": 2,
"entries": {
"src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx#sha256:6b1650fe53d577b6b16ef06c64c33bfd0fde01414da15f1aef68961efc8f3678#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx#sha256:0dce2270cdb407907cf272bacc6038ab776056898e0d30fb2844bc77bbd0e53b#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx#sha256:f3582880b362d30ddb9f63126a0d3122b49cf8f96c7e2cdb19ab35c4aafbb2f4#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx#sha256:005143586e2be2e78e7013e8f373173729728a3100bf3e3c2b9a2cee08f69120#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx#sha256:8b8b96f6d8ab3755df1231228bccf0cd4a98f3d5b66cf36ab84cf1f781fdf3a1#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx#sha256:7047c2feccff92e27d91c918876f84c94d9277e62cef20b78e05017ba3276faa#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx#sha256:b35be4f70a3a426bf3b774528f9b62c8f862e1b56c1a5dde5ac8c9a9b7f4d8d3#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/right-sidebar/SourceControl.tsx#sha256:d9bb02bbf91f3c2e3e1e41a641f98d5a6b1f75450f816df3bd1ac30386a23873#1": "Source-control state selects from reviewed static message records.",
"src/renderer/src/components/right-sidebar/SourceControl.tsx#sha256:f938198e1f5c8b68caed410becef463efe544492348921c9fb18b55104504baf#1": "Source-control state selects from reviewed static message records.",
"src/renderer/src/components/right-sidebar/SourceControl.tsx#sha256:bb83ad7cad2d9d8a4666bd130708dc29886a2a59f43f86a1c34047bf3655d213#1": "Source-control state selects from reviewed static message records.",
"src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx#sha256:f4a3c848c06649228f63af8b507e450c3087b035e2071ffbc1179359ed7ad201#1": "Pull-policy copy selects from reviewed static message records.",
"src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx#sha256:4b72ef50be3f08d0df67639b77c4e70296d0d73ec991e6416d929821898657f4#1": "Pull-policy copy selects from reviewed static message records.",
"src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx#sha256:e6ef19b3c3ba131bf646c480f80e63d2476d274b072e3b12a9e2e6dc5867f426#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx#sha256:08c5bb998476fdbc2ad8ac74b039f24be6afdf17a3f325542b8a09973d14c304#1": "Plural choice is bounded to adjacent static catalog keys.",
"src/renderer/src/components/settings/Settings.tsx#sha256:b3e10becc26fa0dd735ceda51633377080be0b7fd15b77e976066806c652c8a7#1": "Settings metadata selects from reviewed static message records.",
"src/renderer/src/components/settings/agent-awake-copy.ts#sha256:cac53bb7b28581863bd725ee4d2047c6faff214c0adad6f9c639e1c74de6a474#1": "Copy module resolves a reviewed static message record.",
"src/renderer/src/components/settings/agent-awake-copy.ts#sha256:b7728e90f1144a2f8bdc3cb5ba9b7438b6ad99d823cfedaf97d9418e44f23fc3#1": "Copy module resolves a reviewed static message record.",
"src/renderer/src/components/settings/agent-awake-copy.ts#sha256:414370db0cd7aaf3b9f3d1bec8aaed428387b48d22992f0bed501008e8427e41#1": "Copy module resolves a reviewed static message record.",
"src/renderer/src/components/settings/agent-generated-tab-title-copy.ts#sha256:fc77163ea39ed9bf7db5572d6457a711d3e9cea3d511bdb36c1d2c6a57526e7c#1": "Copy module resolves a reviewed static message record.",
"src/renderer/src/components/settings/agent-generated-tab-title-copy.ts#sha256:37fdadae5a4d34faaf72e813382e47e2032a7883bf1a076464d9d819d36266cc#1": "Copy module resolves a reviewed static message record.",
"src/renderer/src/components/settings/agent-status-hooks-copy.ts#sha256:caa845c038172f583c0c1672c142794dfa1efc14dcac54a28922d45dadbfe922#1": "Copy module resolves a reviewed static message record.",
"src/renderer/src/components/settings/agent-status-hooks-copy.ts#sha256:c6d8107707a9b52a006a7aafe7130e4bad44a58e8d78830e97128f3a5a7a1cef#1": "Copy module resolves a reviewed static message record.",
"src/renderer/src/components/settings/settings-search-keywords.ts#sha256:54e60016a8a980c03013e929f43fedc9f8c005fd480b3dbc9ed3ac2ad3ee0b66#1": "Search metadata resolves reviewed key/value tuples.",
"src/renderer/src/i18n/i18n.ts#sha256:7b303b9312d38bfe87ff95009805d7f283b2438d10521b3c8076fcab0c2db754#1": "Runtime wrapper resolves the statically declared caller key.",
"src/main/i18n/main-i18n.ts#sha256:dc70df2296676ad3fc5c5d3afef0eebfe547dcca8e776cbc89dc87ef1123a1be#1": "Main-process wrapper resolves the statically declared caller key."
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
# Localization policy migration disposition
Phase 1 bootstraps translation state without deleting the legacy policy files.
They remain available for Phase 3 equivalence review, but their package-script
entry points are removed so they cannot overwrite reviewed catalogs accidentally.
| Legacy policy source | Phase 1 representation | Status |
| ----------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- |
| Exact key overrides | `reviewed` state for the matching locale/key | Imported; legacy rule retained for audit |
| Exact English-value overrides | `reviewed` state for every matching identifier | Imported; legacy rule retained for audit |
| Search-keyword overrides | `reviewed` state for matching search identifiers | Imported; legacy rule retained for audit |
| Native language-picker labels and reviewed main-menu literals | `reviewed` state | Imported |
| Reviewed follow-up PRs #7422, #8040, and #8219 | Enumerated in `localization-reviewed-corrections.json` | Imported with commit provenance |
| English-only prefixes, do-not-translate literals, URL/protocol literals | Sidecar-only `intentional-english` | Duplicate catalog leaves removed |
| Byte-equal English without reviewed/preservation provenance | Derived `untranslated` | Parity filler removed |
| Broad phrase, brand, and spacing rewrites | `machine` unless stronger evidence applies | Not treated as review evidence; rules retained |
| Free-endpoint bootstrap and whole-catalog repair | No normal package-script entry point | Retained only for Phase 3 disposition/equivalence audit |
No automatic suggestion service is enabled in Phases 12, so terminology policy
remains committed in the legacy data modules until the Phase 3 workflow imports
it. Existing target values that remain in catalogs are byte-for-byte unchanged;
Git history is the rollback artifact for the removed filler/intentional-English
leaves while the runtime catalog format and offline loading paths remain intact.

View File

@ -1,233 +0,0 @@
{
"version": 1,
"commits": {
"030a35a2dfa904f1c59acd80f46f455df03b362d": {
"classification": "mechanical-or-bulk",
"reason": "Feature-copy translation update, not a per-message correction review."
},
"067c88fe557709ea0aee09272ecab06deb9dca83": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"07bbd44769269d23e28294125b9e9cc056742ab4": {
"classification": "mechanical-or-bulk",
"reason": "Bulk four-locale translation update."
},
"151cc05fae1233b17409889dc88c5a11a10ccd35": {
"classification": "mechanical-or-bulk",
"reason": "Bulk multi-language feature translation update."
},
"15ed059947fe11fe220ef86310ac62c47f4a229d": {
"classification": "reviewed-correction",
"reason": "Scoped Chinese correction review."
},
"18fdcf0b053198ff70b47caefa968192b4e2e23e": {
"classification": "reviewed-correction",
"reason": "Single Korean label correction."
},
"1a7c579df0b77864ff9a5bda1689bd2231936f99": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"26a5263cb5ca2f95eb64c52f3cf4279d738893cc": {
"classification": "reviewed-correction",
"reason": "Single Japanese label correction."
},
"2aa4ae6d21cfc588d4de71c92c7e26b2dedcf7bf": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"2e03b14cc3cd8b0893d886f9e0d1a3e17a88ce63": {
"classification": "reviewed-correction",
"reason": "Chinese mistranslation correction review."
},
"37ba94a228950deee1a3bb3f1e078d2a207d15d5": {
"classification": "reviewed-correction",
"reason": "Reviewed Spanish correction PR #7422."
},
"39fb540844ecf44252e55714835f2689b294b5a7": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"404e45f0222882f7115635344d086315a8fcefe7": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"41b54c4d757feb3672e0e2e4ec82b1be01ce2617": {
"classification": "mechanical-or-bulk",
"reason": "Feature-copy translation update, not a localization correction review."
},
"47aec8066257690682c63c8e00d88be8641211cc": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"4c5c2f4df3904018fcb02f5261cef457b86df81f": {
"classification": "reviewed-correction",
"reason": "Korean mistranslation and spacing correction review."
},
"5106e339477d236d0a708dfe7fbd9082d2a08253": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"5311ab3867fe787fefc5101da273a2ea9af22d64": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"5391d1015cbe99defe63afaf56716455d151e9fb": {
"classification": "reviewed-correction",
"reason": "Scoped Chinese localization review."
},
"5d6254d769ba1eaa48235d69cd58e16a2ecba880": {
"classification": "mechanical-or-bulk",
"reason": "Feature-copy translation update, not a localization correction review."
},
"5ff6bc977845bbedef599537f7afa85c47ee14f2": {
"classification": "reviewed-correction",
"reason": "Japanese wording correction review."
},
"62fc723ea872e987e181684d4a7ff328554ce2fe": {
"classification": "reviewed-correction",
"reason": "Scoped Chinese prefix correction."
},
"66cad529a2abb66a81a51191fa57d98cb68760ee": {
"classification": "reviewed-correction",
"reason": "Reviewed Chinese correction PR #8219."
},
"68abadba8198c627fb642c41e54937c04ccddfe8": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"6d6ad8b3ec7b3054a94b657cb3622ce120ed4c6e": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"707773660246cfab5b397a22850f007317982b6e": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"719ae00009bbe1484d1d3951ac3df5e039f091e0": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"770a9d5dc3de6c6880f6a002e1e26f96c1906dd1": {
"classification": "reviewed-correction",
"reason": "Reviewed multi-locale quality PR #8040."
},
"81ed5734d6c8bef5dc82466048a9faefe4a9fac8": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"8dd52563086d5ec67a86de9498e515e2454e2804": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"903adcc2e0a4a6cb2116cdc7aefcf7cc2e425f5d": {
"classification": "mechanical-or-bulk",
"reason": "Mixed bulk translation and feature fix."
},
"9886eb507ad8ab42f75a0f728a502b288825f5e5": {
"classification": "reviewed-correction",
"reason": "Scoped Chinese and Spanish correction review."
},
"9dc1f253ad38bd641bfda2c33545fa65fbae3bf7": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"9f0770da2b770392890a8553ad54d0abcc941499": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"a300e2c7b43467b17fdd07d02a0ab00a2f03dfdc": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"a5cdb7711d5de7eb2d9a7f99e7087eaab3c4743b": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"afd9cd5e02c6c1a93c8ce3ddec0ac2fefb99af24": {
"classification": "mechanical-or-bulk",
"reason": "Bulk four-locale translation update."
},
"b0108c4a8dc37b5ee0092f12e3e3c3d684698fab": {
"classification": "mechanical-or-bulk",
"reason": "Missing feature translations, not correction review."
},
"b34671723429cb5f10efc9f40ac77421a0cedf0f": {
"classification": "reviewed-correction",
"reason": "Korean mistranslation correction review."
},
"b4c304edc8cd019fee5103b36222e9b2d03998a0": {
"classification": "reviewed-correction",
"reason": "Japanese mistranslation correction review."
},
"b93ec86fef52579ae655dde21714bedfbceb2f81": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"baa817aa5c516946309358a228f42268f912ed0d": {
"classification": "mechanical-or-bulk",
"reason": "Feature localization completion, not correction review."
},
"bb4c0e21622653307a29f3970b641b1faf824551": {
"classification": "reviewed-correction",
"reason": "Single Chinese mistranslation correction PR #6706."
},
"bed2c00df87d5890b4c90f3df88f67c428954d32": {
"classification": "mechanical-or-bulk",
"reason": "Release translation fill, not correction review."
},
"bfb778570a87834ffbc35017fdc192ac145f934a": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"c16ea9bae1e0e1a9cb35029a5d4afa22e7a4a382": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"c29e6e4a8c91ae19b368dde778453f2381f60307": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"c6146f8cd0a69b15444f95078848fd51e169a570": {
"classification": "mechanical-or-bulk",
"reason": "Missing feature translation, not correction review."
},
"cd03928e94e5f7d8a0cae6abc6b2b10b371e5c17": {
"classification": "reviewed-correction",
"reason": "Single Spanish mistranslation correction PR #6749."
},
"d452385bf7d7401926beea96530e568be755e9f4": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"d4bee7db5c0f741dccf8d8dbeec556bd979d2e35": {
"classification": "mechanical-or-bulk",
"reason": "Mechanical terminology preservation and CJK spacing pass."
},
"d7d7067ae122ed99d33ce088a1a0e4fc18f954a2": {
"classification": "mechanical-or-bulk",
"reason": "Bulk Spanish catalog rewrite."
},
"dd1133c873e5170f5b91caaa340bac03d08d6ece": {
"classification": "mechanical-or-bulk",
"reason": "Bulk Chinese catalog refinement."
},
"e58a5e9e1710bd072a1b4126daafc6916abd67e2": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"e64b27f46e760df83fe9e623ed1383f5a37d5a1f": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"f72c22532aeb7daaa812f3fec9fc8751d0def55e": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
},
"f8ae1a489ba31f128d8ceb497a4cc2332426889d": {
"classification": "mechanical-or-bulk",
"reason": "Feature translation update, not a localization correction review."
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,20 +6,9 @@ import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import {
findAllowlistCountMismatches,
findNewCandidates,
readAllowlist,
writeAllowlistSnapshot
} from './localization-coverage-allowlist-diff.mjs'
import { validateAllowlist } from './localization-coverage-allowlist-schema.mjs'
import {
collectLocalizationSourceFiles,
expressionNameText,
parseLocalizationSource
} from './localization-source-references.mjs'
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate', 'translateMain'])
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
'ariaLabel',
'aria-label',
@ -71,10 +60,48 @@ const USER_VISIBLE_OBJECT_METHODS = new Set([
'warning'
])
const USER_VISIBLE_OBJECT_NAMES = new Set(['toast'])
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectSourceFiles(root, fullPath)))
}
continue
}
if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
function hasHumanLanguageText(text) {
const trimmed = text.replace(/\s+/g, ' ').trim()
if (trimmed.length < 2) {
@ -105,6 +132,16 @@ function propertyNameText(name) {
return undefined
}
function expressionNameText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return `${expressionNameText(node.expression) ?? ''}.${node.name.text}`.replace(/^\./, '')
}
return undefined
}
function stringParts(node) {
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return [{ text: node.text, dynamic: false }]
@ -344,12 +381,16 @@ function areaForFile(relativePath) {
return `renderer/${parts[0] ?? 'root'}`
}
export function collectLocalizationCandidates(
filePath,
sourceText,
root = process.cwd(),
sourceFile = parseLocalizationSource(filePath, sourceText)
) {
export function collectLocalizationCandidates(filePath, sourceText, root = process.cwd()) {
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourceKind
)
const reports = []
const relativePath = normalizePath(root, filePath)
@ -447,7 +488,6 @@ function parseArgs(argv) {
const options = {
allowlistPath: path.join('config', 'localization-coverage-allowlist.json'),
check: false,
snapshotAllowlist: false,
format: 'summary',
outputPath: null,
sourceRoot: path.join('src', 'renderer', 'src')
@ -461,8 +501,6 @@ function parseArgs(argv) {
options.format = 'markdown'
} else if (arg === '--check') {
options.check = true
} else if (arg === '--snapshot-allowlist') {
options.snapshotAllowlist = true
} else if (arg === '--allowlist') {
options.allowlistPath = argv[index + 1] ?? options.allowlistPath
index += 1
@ -478,40 +516,71 @@ function parseArgs(argv) {
return options
}
export async function main(root = process.cwd(), argv = process.argv.slice(2), preparedSource) {
const options = parseArgs(argv)
const absoluteSourceRoot = path.resolve(root, options.sourceRoot)
const files =
preparedSource?.files ?? (await collectLocalizationSourceFiles(root, absoluteSourceRoot))
const hasPreparedReports = preparedSource?.reports !== undefined
const reports = preparedSource?.reports ? [...preparedSource.reports] : []
function candidateSignature(candidate) {
return JSON.stringify({
filePath: candidate.filePath,
kind: candidate.kind,
text: candidate.text,
dynamic: candidate.dynamic
})
}
if (!hasPreparedReports) {
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...collectLocalizationCandidates(filePath, sourceText, root))
function countBySignature(reports) {
const counts = new Map()
for (const report of reports) {
const signature = candidateSignature(report)
counts.set(signature, (counts.get(signature) ?? 0) + 1)
}
return counts
}
async function readAllowlist(root, allowlistPath) {
const absolutePath = path.resolve(root, allowlistPath)
const raw = await fs.readFile(absolutePath, 'utf8')
return JSON.parse(raw)
}
function findNewCandidates(reports, allowlist) {
const allowedCounts = new Map(
allowlist.map((entry) => [
JSON.stringify({
filePath: entry.filePath,
kind: entry.kind,
text: entry.text,
dynamic: entry.dynamic
}),
entry.count
])
)
const seenCounts = countBySignature(reports)
const newCandidates = []
for (const report of reports) {
const signature = candidateSignature(report)
const seenCount = seenCounts.get(signature) ?? 0
const allowedCount = allowedCounts.get(signature) ?? 0
if (seenCount > allowedCount) {
newCandidates.push(report)
seenCounts.set(signature, seenCount - 1)
}
}
if (options.snapshotAllowlist) {
await writeAllowlistSnapshot(root, options.allowlistPath, reports)
console.log(
`Snapshotted ${reports.length} localization candidates to ${options.allowlistPath}.`
)
return 0
return newCandidates
}
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
const options = parseArgs(argv)
const absoluteSourceRoot = path.resolve(root, options.sourceRoot)
const files = await collectSourceFiles(root, absoluteSourceRoot)
const reports = []
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...collectLocalizationCandidates(filePath, sourceText, root))
}
if (options.check) {
const allowlist = await readAllowlist(root, options.allowlistPath)
const allowlistErrors = validateAllowlist(allowlist)
if (allowlistErrors.length > 0) {
console.error('Localization coverage allowlist is invalid.')
console.error(allowlistErrors.slice(0, 40).join('\n'))
if (allowlistErrors.length > 40) {
console.error(`...and ${allowlistErrors.length - 40} more error(s)`)
}
return 1
}
const newCandidates = findNewCandidates(reports, allowlist)
if (newCandidates.length > 0) {
console.error('New unlocalized renderer strings were found.')
@ -520,17 +589,6 @@ export async function main(root = process.cwd(), argv = process.argv.slice(2), p
console.error(formatReports(root, newCandidates))
return 1
}
const countMismatches = findAllowlistCountMismatches(reports, allowlist)
if (countMismatches.length > 0) {
console.error('Localization coverage allowlist contains stale candidate counts.')
console.error('Manually update the reviewed count or remove records whose candidate is gone.')
for (const { entry, actual } of countMismatches.slice(0, 40)) {
console.error(
`${entry.filePath}: ${JSON.stringify(entry.text)} expected=${entry.count} actual=${actual}`
)
}
return 1
}
console.log(`Localization coverage check passed with ${reports.length} allowlisted candidates.`)
return 0
}

View File

@ -1,203 +0,0 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { main as auditLocalizationCoverage } from './audit-localization-coverage.mjs'
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function makeProject() {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-coverage-'))
const sourceDir = path.join(root, 'src', 'main')
const configDir = path.join(root, 'config')
mkdirSync(sourceDir, { recursive: true })
mkdirSync(configDir, { recursive: true })
writeFileSync(
path.join(sourceDir, 'example.ts'),
"export const value = { message: 'Hello from main' }\n",
'utf8'
)
return { root, allowlistPath: path.join('config', 'allowlist.json') }
}
describe('audit-localization-coverage allowlist schema', () => {
it('accepts classified entries with entry-specific reasons', async () => {
const { root, allowlistPath } = makeProject()
writeJson(path.join(root, allowlistPath), [
{
filePath: 'src/main/example.ts',
kind: 'object-property:message',
text: 'Hello from main',
dynamic: false,
count: 1,
classification: 'product-copy-pending-localization',
reason:
'Main-process product literal in src/main/example.ts is user-visible copy pending main-process localization: "Hello from main".'
}
])
await expect(
auditLocalizationCoverage(root, [
'--source-root',
'src/main',
'--allowlist',
allowlistPath,
'--check'
])
).resolves.toBe(0)
})
it('rejects the former generic unclassified reason', async () => {
const { root, allowlistPath } = makeProject()
writeJson(path.join(root, allowlistPath), [
{
filePath: 'src/main/example.ts',
kind: 'object-property:message',
text: 'Hello from main',
dynamic: false,
count: 1,
reason: 'Pre-existing main-process candidate retained for explicit follow-up disposition.'
}
])
await expect(
auditLocalizationCoverage(root, [
'--source-root',
'src/main',
'--allowlist',
allowlistPath,
'--check'
])
).resolves.toBe(1)
})
it('rejects unsupported classifications', async () => {
const { root, allowlistPath } = makeProject()
writeJson(path.join(root, allowlistPath), [
{
filePath: 'src/main/example.ts',
kind: 'object-property:message',
text: 'Hello from main',
dynamic: false,
count: 1,
classification: 'unclassified',
reason: 'Entry in src/main/example.ts has unsupported classification: "Hello from main".'
}
])
await expect(
auditLocalizationCoverage(root, [
'--source-root',
'src/main',
'--allowlist',
allowlistPath,
'--check'
])
).resolves.toBe(1)
})
it('rejects stale allowance counts in both directions', async () => {
const { root, allowlistPath } = makeProject()
writeJson(path.join(root, allowlistPath), [
{
filePath: 'src/main/example.ts',
kind: 'object-property:message',
text: 'Hello from main',
dynamic: false,
count: 2,
classification: 'product-copy-pending-localization',
reason:
'Main-process product literal in src/main/example.ts is user-visible copy pending main-process localization: "Hello from main".'
}
])
await expect(
auditLocalizationCoverage(root, [
'--source-root',
'src/main',
'--allowlist',
allowlistPath,
'--check'
])
).resolves.toBe(1)
})
it('preserves reviewed entries and makes new snapshot entries fail closed', async () => {
const { root, allowlistPath } = makeProject()
const existing = {
filePath: 'src/main/example.ts',
kind: 'object-property:message',
text: 'Hello from main',
dynamic: false,
count: 1,
classification: 'product-copy-pending-localization',
reason:
'Main-process product literal in src/main/example.ts is user-visible copy pending main-process localization: "Hello from main".'
}
writeJson(path.join(root, allowlistPath), [existing])
writeFileSync(
path.join(root, 'src', 'main', 'second.ts'),
"export const value = { message: 'Second message' }\n"
)
await auditLocalizationCoverage(root, [
'--source-root',
'src/main',
'--allowlist',
allowlistPath,
'--snapshot-allowlist'
])
const snapshot = JSON.parse(readFileSync(path.join(root, allowlistPath), 'utf8'))
expect(snapshot).toContainEqual(existing)
expect(snapshot).toContainEqual(
expect.objectContaining({ classification: 'pending-human-classification' })
)
await expect(
auditLocalizationCoverage(root, [
'--source-root',
'src/main',
'--allowlist',
allowlistPath,
'--check'
])
).resolves.toBe(1)
})
it('collects reports when callers prepare only the source file list', async () => {
const { root, allowlistPath } = makeProject()
writeJson(path.join(root, allowlistPath), [
{
filePath: 'src/main/example.ts',
kind: 'object-property:message',
text: 'Hello from main',
dynamic: false,
count: 1,
classification: 'product-copy-pending-localization',
reason:
'Main-process product literal in src/main/example.ts is user-visible copy pending main-process localization: "Hello from main".'
}
])
await expect(
auditLocalizationCoverage(
root,
['--source-root', 'src/main', '--allowlist', allowlistPath, '--check'],
{ files: [path.join(root, 'src', 'main', 'example.ts')] }
)
).resolves.toBe(0)
})
it('does not overwrite an unreadable allowlist snapshot', async () => {
const { root, allowlistPath } = makeProject()
const absoluteAllowlistPath = path.join(root, allowlistPath)
writeFileSync(absoluteAllowlistPath, '{invalid', 'utf8')
await expect(
auditLocalizationCoverage(root, [
'--source-root',
'src/main',
'--allowlist',
allowlistPath,
'--snapshot-allowlist'
])
).rejects.toThrow()
expect(readFileSync(absoluteAllowlistPath, 'utf8')).toBe('{invalid')
})
})

View File

@ -13,8 +13,6 @@ import {
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const MIGRATION_FLAG = '--migration-rewrite-locales'
const MIGRATION_ENV = 'ORCA_I18N_MIGRATION_REWRITE'
const LOCALE_CONFIG = {
zh: {
@ -127,26 +125,17 @@ function parseLocaleArg(argv) {
return argv[2]
}
export async function main(root = process.cwd(), locale, dependencies = {}) {
const argv = dependencies.argv ?? process.argv
const environment = dependencies.environment ?? process.env
if (!argv.includes(MIGRATION_FLAG) || environment[MIGRATION_ENV] !== '1') {
console.error(
`Refusing to rewrite locale catalogs. Set ${MIGRATION_ENV}=1 and pass ${MIGRATION_FLAG} for migration-only use.`
)
return 1
}
const requestedLocale = locale ?? parseLocaleArg(argv)
const config = LOCALE_CONFIG[requestedLocale]
export async function main(root = process.cwd(), locale = parseLocaleArg(process.argv)) {
const config = LOCALE_CONFIG[locale]
if (!config) {
console.error(
`Unsupported locale "${requestedLocale}". Supported: ${Object.keys(LOCALE_CONFIG).join(', ')}`
`Unsupported locale "${locale}". Supported: ${Object.keys(LOCALE_CONFIG).join(', ')}`
)
return 1
}
const enPath = path.join(root, LOCALES_DIR, 'en.json')
const localePath = path.join(root, LOCALES_DIR, `${requestedLocale}.json`)
const localePath = path.join(root, LOCALES_DIR, `${locale}.json`)
const cachePath = path.join(root, LOCALES_DIR, config.cacheFile)
const enCatalog = JSON.parse(await fs.readFile(enPath, 'utf8'))
const localeCatalog = structuredClone(enCatalog)
@ -173,12 +162,7 @@ export async function main(root = process.cwd(), locale, dependencies = {}) {
const restored = restorePlaceholders(translated, tokens)
cache.set(
value,
repairTranslatedValue({
key: '',
enValue: value,
localeValue: restored,
locale: requestedLocale
})
repairTranslatedValue({ key: '', enValue: value, localeValue: restored, locale })
)
await new Promise((resolve) => setTimeout(resolve, 200))
})
@ -200,12 +184,12 @@ export async function main(root = process.cwd(), locale, dependencies = {}) {
key: leaf.key,
enValue: leaf.value,
localeValue: cached,
locale: requestedLocale
locale
})
)
}
repairCatalog(enCatalog, localeCatalog, requestedLocale)
repairCatalog(enCatalog, localeCatalog, locale)
await fs.writeFile(localePath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
console.log(`Wrote ${localePath}`)

View File

@ -1,170 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import {
ENGLISH_ONLY_KEY_PREFIXES,
shouldPreserveEnglishValue
} from './locale-translation-policy.mjs'
import {
deleteCatalogEntry,
flattenCatalog,
jsonText,
setCatalogEntry,
sourceHash,
stateDocument
} from './localization-catalog-model.mjs'
import {
LOCALIZATION_LOCALES as LOCALES,
reviewedAuditValueMatches,
reviewedByPolicy,
validateReviewedAudit
} from './localization-reviewed-provenance.mjs'
import { collectReviewedCorrectionHistory } from './localization-git-history.mjs'
import { compareCodeUnits } from './localization-code-unit-order.mjs'
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const STATE_DIR = path.join('config', 'localization-state')
const AUDIT_PATH = path.join('config', 'localization-reviewed-corrections.json')
const COMMIT_CLASSIFICATIONS_PATH = path.join(
'config',
'localization-reviewed-commit-classifications.json'
)
async function writeReviewedAudit(root, onGitProcess) {
const commitClassifications = JSON.parse(
await fs.readFile(path.join(root, COMMIT_CLASSIFICATIONS_PATH), 'utf8')
)
const existingAudit = JSON.parse(await fs.readFile(path.join(root, AUDIT_PATH), 'utf8'))
if (commitClassifications.version !== 1) {
throw new Error('Localization provenance commit classifications must use version 1')
}
for (const [commit, entry] of Object.entries(commitClassifications.commits ?? {})) {
if (
!/^[a-f0-9]{40}$/.test(commit) ||
!['reviewed-correction', 'mechanical-or-bulk'].includes(entry?.classification) ||
typeof entry?.reason !== 'string' ||
entry.reason.trim().length < 12
) {
throw new Error(`Invalid localization provenance commit classification: ${commit}`)
}
}
const localePaths = LOCALES.map((locale) =>
path.join(LOCALES_DIR, `${locale}.json`).split(path.sep).join('/')
)
const enPath = path.join(LOCALES_DIR, 'en.json').split(path.sep).join('/')
const { messages, provenance } = await collectReviewedCorrectionHistory(
root,
LOCALES,
[enPath, ...localePaths],
commitClassifications,
onGitProcess
)
const document = {
version: 3,
algorithm: 'classified-first-parent-target-only-current-value-v2',
provenance: [...provenance.values()].sort((left, right) =>
compareCodeUnits(left.commit, right.commit)
),
messages: Object.fromEntries(
LOCALES.map((locale) => [
locale,
Object.fromEntries(
[
...messages[locale],
...Object.entries(existingAudit.messages?.[locale] ?? {}).filter(
([, entry]) => entry.reason === 'localization-pr-review'
)
].sort(([left], [right]) => compareCodeUnits(left, right))
)
])
)
}
await fs.writeFile(path.join(root, AUDIT_PATH), jsonText(document))
}
async function bootstrapLocale(root, locale, reviewedAudit) {
const enEntries = flattenCatalog(
JSON.parse(await fs.readFile(path.join(root, LOCALES_DIR, 'en.json')))
)
const localePath = path.join(root, LOCALES_DIR, `${locale}.json`)
const targetCatalog = JSON.parse(await fs.readFile(localePath))
const targetEntries = flattenCatalog(targetCatalog)
const reviewedMessages = reviewedAudit.messages[locale]
for (const [key, entry] of Object.entries(reviewedMessages)) {
if (enEntries.has(key) && !targetEntries.has(key)) {
setCatalogEntry(targetCatalog, key, entry.value)
targetEntries.set(key, entry.value)
}
}
const stateMessages = new Map()
const counts = { reviewed: 0, machine: 0, intentionalEnglish: 0, parityFiller: 0 }
for (const [key, value] of targetEntries) {
const enValue = enEntries.get(key)
if (enValue === undefined) {
continue
}
const reviewedEntry = reviewedMessages[key]
const reviewedValueMatches = reviewedAuditValueMatches(reviewedEntry, value)
if (reviewedValueMatches || reviewedByPolicy(locale, key, enValue)) {
stateMessages.set(key, { state: 'reviewed', sourceHash: sourceHash(enValue) })
counts.reviewed += 1
} else if (shouldPreserveEnglishValue(enValue, key)) {
deleteCatalogEntry(targetCatalog, key)
stateMessages.set(key, { state: 'intentional-english' })
counts.intentionalEnglish += 1
} else if (value === enValue) {
deleteCatalogEntry(targetCatalog, key)
counts.parityFiller += 1
} else {
stateMessages.set(key, { state: 'machine', sourceHash: sourceHash(enValue) })
counts.machine += 1
}
}
for (const [key, enValue] of enEntries) {
if (!stateMessages.has(key) && shouldPreserveEnglishValue(enValue, key)) {
stateMessages.set(key, { state: 'intentional-english' })
counts.intentionalEnglish += 1
}
}
await fs.writeFile(localePath, jsonText(targetCatalog))
await fs.mkdir(path.join(root, STATE_DIR), { recursive: true })
await fs.writeFile(
path.join(root, STATE_DIR, `${locale}.json`),
jsonText(stateDocument(locale, stateMessages))
)
console.log(`${locale}: ${JSON.stringify(counts)}`)
}
export async function main(root = process.cwd(), argv = process.argv.slice(2), dependencies = {}) {
const environment = dependencies.environment ?? process.env
if (
environment.ORCA_I18N_MIGRATION_REWRITE !== '1' ||
!argv.includes('--migration-rewrite-locales')
) {
console.error(
'Refusing localization-state bootstrap without ORCA_I18N_MIGRATION_REWRITE=1 and --migration-rewrite-locales.'
)
return 1
}
if (argv.includes('--audit-reviewed-commits')) {
await writeReviewedAudit(root, dependencies.onGitProcess)
}
const audit = JSON.parse(await fs.readFile(path.join(root, AUDIT_PATH)))
const errors = validateReviewedAudit(audit)
if (errors.length > 0) {
throw new Error(errors.join('\n'))
}
for (const locale of LOCALES) {
await bootstrapLocale(root, locale, audit)
}
console.log(`Preservation policy prefixes audited: ${ENGLISH_ONLY_KEY_PREFIXES.join(', ')}`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}

View File

@ -1,247 +0,0 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { main as bootstrapLocalizationState } from './bootstrap-localization-state.mjs'
import { sourceHash } from './localization-catalog-model.mjs'
import { shouldPreserveEnglishValue } from './locale-translation-policy.mjs'
const LOCALES = ['es', 'ja', 'ko', 'zh']
const MIGRATION_ARGS = ['--migration-rewrite-locales']
const MIGRATION_ENVIRONMENT = { ORCA_I18N_MIGRATION_REWRITE: '1' }
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
function runGit(root, args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8' })
}
function commitAll(root, subject) {
runGit(root, ['add', '.'])
runGit(root, [
'-c',
'user.name=Orca Test',
'-c',
'user.email=orca@example.com',
'commit',
'-m',
subject
])
}
describe('bootstrap-localization-state', () => {
it('keeps the mobile Continue action translatable despite the product-name policy', () => {
expect(
shouldPreserveEnglishValue('Continue', 'auto.components.mobile.MobileHero.a8fb43cf1c')
).toBe(false)
})
it('classifies provenance deterministically and removes only fallback leaves', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-bootstrap-'))
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
mkdirSync(localesDir, { recursive: true })
mkdirSync(path.join(root, 'config'), { recursive: true })
const enCatalog = {
auto: {
example: {
filler: 'Hello',
machine: 'Welcome',
reviewed: 'Explore Orca'
},
lib: { agent: { catalog: { codex: 'Codex' } } }
}
}
writeJson(path.join(localesDir, 'en.json'), enCatalog)
for (const locale of LOCALES) {
writeJson(path.join(localesDir, `${locale}.json`), {
auto: {
example: {
filler: 'Hello',
machine: `translated-${locale}`,
reviewed: locale === 'es' ? 'Explorar Orca' : `translated-review-${locale}`
},
lib: { agent: { catalog: { codex: 'Codex' } } }
}
})
}
writeJson(path.join(root, 'config', 'localization-reviewed-corrections.json'), {
version: 3,
algorithm: 'classified-first-parent-target-only-current-value-v2',
provenance: [],
messages: Object.fromEntries(LOCALES.map((locale) => [locale, {}]))
})
await bootstrapLocalizationState(root, MIGRATION_ARGS, {
environment: MIGRATION_ENVIRONMENT
})
const firstState = readFileSync(
path.join(root, 'config', 'localization-state', 'es.json'),
'utf8'
)
await bootstrapLocalizationState(root, MIGRATION_ARGS, {
environment: MIGRATION_ENVIRONMENT
})
expect(readFileSync(path.join(root, 'config', 'localization-state', 'es.json'), 'utf8')).toBe(
firstState
)
const state = JSON.parse(firstState)
expect(state.messages['auto.example.filler']).toBeUndefined()
expect(state.messages['auto.lib.agent.catalog.codex']).toEqual({
state: 'intentional-english'
})
expect(state.messages['auto.example.machine']).toEqual({
state: 'machine',
sourceHash: sourceHash('Welcome')
})
expect(state.messages['auto.example.reviewed']).toEqual({
state: 'reviewed',
sourceHash: sourceHash('Explore Orca')
})
const target = JSON.parse(readFileSync(path.join(localesDir, 'es.json'), 'utf8'))
expect(target.auto.example).toEqual({
machine: 'translated-es',
reviewed: 'Explorar Orca'
})
expect(target.auto.lib).toBeUndefined()
})
it('preserves reviewed target-only history for #6749 and #6706 fixtures', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-history-'))
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
mkdirSync(localesDir, { recursive: true })
mkdirSync(path.join(root, 'config'), { recursive: true })
runGit(root, ['init'])
writeJson(path.join(localesDir, 'en.json'), {
auto: {
lib: { worktree: { palette: { search: { ca40ffcbec: 'PR' } } } },
components: {
right: { sidebar: { FileExplorerRow: { '2de3b21934': 'Explorer' } } }
}
}
})
for (const locale of LOCALES) {
writeJson(path.join(localesDir, `${locale}.json`), {
auto: {
lib: {
worktree: {
palette: { search: { ca40ffcbec: locale === 'es' ? 'relaciones públicas' : 'PR' } }
}
},
components: {
right: {
sidebar: {
FileExplorerRow: {
'2de3b21934': locale === 'zh' ? '探险家' : `Explorer-${locale}`
}
}
}
}
}
})
}
commitAll(root, 'initial locale import')
const es = JSON.parse(readFileSync(path.join(localesDir, 'es.json'), 'utf8'))
es.auto.lib.worktree.palette.search.ca40ffcbec = 'PR'
writeJson(path.join(localesDir, 'es.json'), es)
commitAll(
root,
'fix(i18n): correct Spanish "PR" mistranslated as "relaciones públicas" (#6749)'
)
const zh = JSON.parse(readFileSync(path.join(localesDir, 'zh.json'), 'utf8'))
zh.auto.components.right.sidebar.FileExplorerRow['2de3b21934'] = '资源管理器'
writeJson(path.join(localesDir, 'zh.json'), zh)
commitAll(root, 'fix(i18n): correct zh "Explorer" mistranslation (探险家 → 资源管理器) (#6706)')
const zhCommit = runGit(root, ['rev-parse', 'HEAD']).trim()
const esCommit = runGit(root, ['rev-parse', 'HEAD^']).trim()
const ja = JSON.parse(readFileSync(path.join(localesDir, 'ja.json'), 'utf8'))
ja.auto.components.right.sidebar.FileExplorerRow['2de3b21934'] = 'エクスプローラー'
writeJson(path.join(localesDir, 'ja.json'), ja)
commitAll(root, 'Apply bulk spacing and translation normalization')
const bulkCommit = runGit(root, ['rev-parse', 'HEAD']).trim()
const classifications = {
version: 1,
commits: {
[esCommit]: {
classification: 'reviewed-correction',
reason: 'Single Spanish correction review.'
},
[zhCommit]: {
classification: 'reviewed-correction',
reason: 'Single Chinese correction review.'
},
[bulkCommit]: {
classification: 'mechanical-or-bulk',
reason: 'Synthetic bulk spacing pass.'
}
}
}
writeJson(
path.join(root, 'config', 'localization-reviewed-commit-classifications.json'),
classifications
)
writeJson(path.join(root, 'config', 'localization-reviewed-corrections.json'), {
version: 3,
algorithm: 'classified-first-parent-target-only-current-value-v2',
provenance: [],
messages: Object.fromEntries(LOCALES.map((locale) => [locale, {}]))
})
const initialGitProcesses = []
await bootstrapLocalizationState(root, [...MIGRATION_ARGS, '--audit-reviewed-commits'], {
environment: MIGRATION_ENVIRONMENT,
onGitProcess: (args) => initialGitProcesses.push(args)
})
expect(initialGitProcesses).toHaveLength(3)
const audit = JSON.parse(
readFileSync(path.join(root, 'config', 'localization-reviewed-corrections.json'), 'utf8')
)
expect(audit.messages.es['auto.lib.worktree.palette.search.ca40ffcbec'].value).toBe('PR')
expect(
audit.messages.zh['auto.components.right.sidebar.FileExplorerRow.2de3b21934'].value
).toBe('资源管理器')
expect(
audit.messages.ja['auto.components.right.sidebar.FileExplorerRow.2de3b21934']
).toBeUndefined()
expect(
JSON.parse(readFileSync(path.join(root, 'config', 'localization-state', 'es.json'), 'utf8'))
.messages['auto.lib.worktree.palette.search.ca40ffcbec'].state
).toBe('reviewed')
expect(
JSON.parse(readFileSync(path.join(root, 'config', 'localization-state', 'zh.json'), 'utf8'))
.messages['auto.components.right.sidebar.FileExplorerRow.2de3b21934'].state
).toBe('reviewed')
expect(
JSON.parse(readFileSync(path.join(root, 'config', 'localization-state', 'ja.json'), 'utf8'))
.messages['auto.components.right.sidebar.FileExplorerRow.2de3b21934'].state
).toBe('machine')
for (let index = 0; index < 12; index += 1) {
const growingEs = JSON.parse(readFileSync(path.join(localesDir, 'es.json'), 'utf8'))
growingEs.auto.lib.worktree.palette.search.ca40ffcbec = `PR-${index}`
writeJson(path.join(localesDir, 'es.json'), growingEs)
commitAll(root, `grow localization history ${index}`)
classifications.commits[runGit(root, ['rev-parse', 'HEAD']).trim()] = {
classification: 'mechanical-or-bulk',
reason: 'Synthetic bulk history growth.'
}
}
const grownGitProcesses = []
writeJson(
path.join(root, 'config', 'localization-reviewed-commit-classifications.json'),
classifications
)
await bootstrapLocalizationState(root, [...MIGRATION_ARGS, '--audit-reviewed-commits'], {
environment: MIGRATION_ENVIRONMENT,
onGitProcess: (args) => grownGitProcesses.push(args)
})
expect(grownGitProcesses).toHaveLength(initialGitProcesses.length)
})
})

View File

@ -1,49 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const OUTPUT_PATH = path.join('src', 'shared', 'localization-keys.ts')
function generatedSource() {
return `import type sourceMessages from '../../config/localization-extraction/en.json'
type Join<Prefix extends string, Key extends string> = Prefix extends '' ? Key : \`\${Prefix}.\${Key}\`
type CatalogKey<T, Prefix extends string = ''> = {
[Key in Extract<keyof T, string>]: T[Key] extends string
? Join<Prefix, Key>
: T[Key] extends Record<string, unknown>
? CatalogKey<T[Key], Join<Prefix, Key>>
: never
}[Extract<keyof T, string>]
// Why: deriving from the committed extractor output keeps key types generated without unchecked .d.ts files.
export type LocalizationKey = CatalogKey<typeof sourceMessages>
`
}
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
const outputPath = path.join(root, OUTPUT_PATH)
const expected = generatedSource()
if (argv.includes('--fix')) {
await fs.writeFile(outputPath, expected)
return 0
}
let current
try {
current = await fs.readFile(outputPath, 'utf8')
} catch {
console.error(`${OUTPUT_PATH} is missing. Run pnpm run sync:localization-key-types.`)
return 1
}
if (current !== expected) {
console.error(`${OUTPUT_PATH} is out of date. Run pnpm run sync:localization-key-types.`)
return 1
}
console.log(`Verified generated localization key types: ${OUTPUT_PATH}.`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}

View File

@ -5,8 +5,7 @@ import { describe, expect, it } from 'vitest'
describe('Git binary compatibility PR gate', () => {
it('runs the real-binary contract at each compatibility boundary', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
// Why: `verify` is the required aggregate; executable Linux contract steps live in `verify-core`.
const step = workflow.jobs['verify-core'].steps.find(
const step = workflow.jobs.verify.steps.find(
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
)

View File

@ -1,35 +0,0 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { main as bootstrapLocaleCatalog } from './bootstrap-locale-catalog.mjs'
import { main as bootstrapLocalizationState } from './bootstrap-localization-state.mjs'
import { main as repairLocaleCatalog } from './repair-locale-catalog.mjs'
const DENIED_MIGRATION = { argv: [], environment: {} }
function makeProject() {
const root = mkdtempSync(path.join(tmpdir(), 'orca-legacy-localization-'))
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
mkdirSync(localesDir, { recursive: true })
for (const locale of ['en', 'es', 'ja', 'ko', 'zh']) {
writeFileSync(path.join(localesDir, `${locale}.json`), '{}\n')
}
return root
}
describe('legacy localization rewrite guards', () => {
it('refuses machine-translation bootstrap without migration opt-in', async () => {
await expect(bootstrapLocaleCatalog(makeProject(), 'es', DENIED_MIGRATION)).resolves.toBe(1)
})
it('refuses whole-catalog repair without migration opt-in', async () => {
await expect(repairLocaleCatalog(makeProject(), 'es', DENIED_MIGRATION)).resolves.toBe(1)
})
it('refuses translation-state bootstrap without migration opt-in', async () => {
await expect(bootstrapLocalizationState(makeProject(), [])).resolves.toBe(1)
})
})

View File

@ -1,18 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { repairCacheMap } from './locale-translation-policy.mjs'
describe('context-sensitive translation policy', () => {
it('keeps the mobile Continue policy anchored to extracted source', () => {
const extracted = JSON.parse(readFileSync('config/localization-extraction/en.json', 'utf8'))
expect(extracted.auto.components.mobile.MobileHero.a8fb43cf1c).toBe('Continue')
})
it('preserves mobile Continue translations in the value-only migration cache', () => {
const cache = new Map([['Continue', '继续']])
expect(repairCacheMap(cache, 'zh')).toBe(0)
expect(cache.get('Continue')).toBe('继续')
})
})

View File

@ -338,23 +338,6 @@ export const NATIVE_PICKER_LABELS = {
}
const CJK_LATIN_SPACED_TERM_PATTERN = CJK_LATIN_SPACED_TERMS.join('|')
const TRANSLATABLE_VALUE_CONTEXTS = new Map([
['auto.components.mobile.MobileHero.a8fb43cf1c', 'Continue']
])
function translatableContextKeyForValue(enValue) {
// Why: the cache is value-only; any registered key for the value grants the same translatable policy.
for (const [key, value] of TRANSLATABLE_VALUE_CONTEXTS) {
if (value === enValue) {
return key
}
}
return ''
}
function isTranslatableValueContext(enValue, key) {
return TRANSLATABLE_VALUE_CONTEXTS.get(key) === enValue
}
export function isEnglishOnlyKey(key) {
return ENGLISH_ONLY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
@ -370,10 +353,6 @@ export function shouldPreserveEnglishValue(enValue, key = '') {
if (isEnglishOnlyKey(key)) {
return true
}
// Why: this CTA is an action, not a reference to the Continue AI product.
if (isTranslatableValueContext(enValue, key)) {
return false
}
return NEVER_TRANSLATE_VALUES.has(enValue)
}
@ -389,9 +368,6 @@ function includesPreservedLatinTerm(value, term) {
}
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isTranslatableValueContext(enValue, key)) {
return localeValue
}
let result = localeValue
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
@ -618,13 +594,14 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
export function repairCacheMap(cache, locale) {
let repaired = 0
for (const [enValue, translated] of cache.entries()) {
// Why: the value-only cache must retain translations used by an explicitly translatable context.
const next = repairTranslatedValue({
key: translatableContextKeyForValue(enValue),
enValue,
localeValue: translated,
locale
})
const next = shouldPreserveEnglishValue(enValue)
? enValue
: repairTranslatedValue({
key: '',
enValue,
localeValue: translated,
locale
})
if (next !== translated) {
cache.set(enValue, next)
repaired += 1

View File

@ -1,98 +0,0 @@
import { createHash } from 'node:crypto'
import { compareCodeUnits } from './localization-code-unit-order.mjs'
export function flattenCatalog(value, prefix = '', entries = new Map()) {
if (typeof value === 'string') {
entries.set(prefix, value)
return entries
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return entries
}
for (const [key, child] of Object.entries(value)) {
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
}
return entries
}
export function catalogFromEntries(entries) {
const catalog = {}
for (const [key, value] of [...entries].sort(([left], [right]) =>
compareCodeUnits(left, right)
)) {
const parts = key.split('.')
let cursor = catalog
for (const part of parts.slice(0, -1)) {
cursor[part] ??= {}
cursor = cursor[part]
}
cursor[parts.at(-1)] = value
}
return catalog
}
export function deleteCatalogEntry(catalog, key) {
const parts = key.split('.')
const parents = []
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (!cursor?.[part] || typeof cursor[part] !== 'object') {
return
}
parents.push([cursor, part])
cursor = cursor[part]
}
delete cursor[parts.at(-1)]
for (const [parent, part] of parents.toReversed()) {
if (Object.keys(parent[part]).length === 0) {
delete parent[part]
}
}
}
export function setCatalogEntry(catalog, key, value) {
const parts = key.split('.')
let cursor = catalog
for (const part of parts.slice(0, -1)) {
cursor[part] ??= {}
cursor = cursor[part]
}
cursor[parts.at(-1)] = value
}
export function getCatalogEntry(catalog, key) {
return key.split('.').reduce((cursor, part) => cursor?.[part], catalog)
}
export function sortedObject(value) {
if (Array.isArray(value)) {
return value.map(sortedObject)
}
if (!value || typeof value !== 'object') {
return value
}
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => compareCodeUnits(left, right))
.map(([key, child]) => [key, sortedObject(child)])
)
}
export function sourceHash(value) {
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`
}
export function stateDocument(locale, messages) {
return {
version: 1,
locale,
messages: Object.fromEntries(
[...messages].sort(([left], [right]) => compareCodeUnits(left, right))
)
}
}
export function jsonText(value) {
return `${JSON.stringify(value, null, 2)}\n`
}

View File

@ -1,3 +0,0 @@
export function compareCodeUnits(left, right) {
return left < right ? -1 : left > right ? 1 : 0
}

View File

@ -1,87 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { allowlistEntryForCandidate } from './localization-coverage-allowlist-schema.mjs'
import { compareCodeUnits } from './localization-code-unit-order.mjs'
function candidateSignature(candidate) {
return JSON.stringify({
filePath: candidate.filePath,
kind: candidate.kind,
text: candidate.text,
dynamic: candidate.dynamic
})
}
function countBySignature(reports) {
const counts = new Map()
for (const report of reports) {
const signature = candidateSignature(report)
counts.set(signature, (counts.get(signature) ?? 0) + 1)
}
return counts
}
export async function writeAllowlistSnapshot(root, allowlistPath, reports) {
const counts = countBySignature(reports)
let existing = []
try {
existing = await readAllowlist(root, allowlistPath)
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
}
const existingBySignature = new Map(existing.map((entry) => [candidateSignature(entry), entry]))
const currentEntries = [...counts.entries()].map(([signature, count]) => {
const prior = existingBySignature.get(signature)
return prior ?? allowlistEntryForCandidate({ ...JSON.parse(signature), count })
})
const entries = [
...currentEntries,
...existing.filter((entry) => !counts.has(candidateSignature(entry)))
].sort((left, right) => compareCodeUnits(candidateSignature(left), candidateSignature(right)))
await fs.writeFile(path.resolve(root, allowlistPath), `${JSON.stringify(entries, null, 2)}\n`)
}
export async function readAllowlist(root, allowlistPath) {
const absolutePath = path.resolve(root, allowlistPath)
const raw = await fs.readFile(absolutePath, 'utf8')
return JSON.parse(raw)
}
export function findNewCandidates(reports, allowlist) {
const allowedCounts = new Map(
allowlist.map((entry) => [
JSON.stringify({
filePath: entry.filePath,
kind: entry.kind,
text: entry.text,
dynamic: entry.dynamic
}),
entry.count
])
)
const seenCounts = countBySignature(reports)
const newCandidates = []
for (const report of reports) {
const signature = candidateSignature(report)
const seenCount = seenCounts.get(signature) ?? 0
const allowedCount = allowedCounts.get(signature) ?? 0
if (seenCount > allowedCount) {
newCandidates.push(report)
seenCounts.set(signature, seenCount - 1)
}
}
return newCandidates
}
export function findAllowlistCountMismatches(reports, allowlist) {
const seenCounts = countBySignature(reports)
return allowlist.flatMap((entry) => {
const actual = seenCounts.get(candidateSignature(entry)) ?? 0
return actual === entry.count ? [] : [{ entry, actual }]
})
}

View File

@ -1,64 +0,0 @@
const LEGACY_GENERIC_ALLOWLIST_REASON =
'Pre-existing main-process candidate retained for explicit follow-up disposition.'
const ALLOWLIST_CLASSIFICATIONS = new Set([
'agent-rpc-schema-message',
'branded-or-platform-literal',
'diagnostic-or-log',
'external-protocol-literal',
'fixture-data',
'product-copy-pending-localization',
'provider-copy-pending-localization'
])
export function allowlistEntryForCandidate(candidate) {
return {
filePath: candidate.filePath,
kind: candidate.kind,
text: candidate.text,
dynamic: candidate.dynamic,
count: candidate.count,
classification: 'pending-human-classification',
reason: `Pending explicit localization-owner classification for ${candidate.filePath}: ${JSON.stringify(candidate.text)}.`
}
}
export function validateAllowlist(allowlist) {
const errors = []
if (!Array.isArray(allowlist)) {
return ['localization allowlist must be an array']
}
for (const [index, entry] of allowlist.entries()) {
const label = entry?.filePath
? `${entry.filePath}:${entry.kind}:${JSON.stringify(entry.text)}`
: `entry ${index}`
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
errors.push(`${label} must be an object`)
continue
}
for (const field of ['filePath', 'kind', 'text', 'reason', 'classification']) {
if (typeof entry[field] !== 'string' || entry[field].trim() === '') {
errors.push(`${label} has invalid ${field}`)
}
}
if (typeof entry.dynamic !== 'boolean') {
errors.push(`${label} has invalid dynamic flag`)
}
if (!Number.isInteger(entry.count) || entry.count <= 0) {
errors.push(`${label} has invalid count`)
}
if (!ALLOWLIST_CLASSIFICATIONS.has(entry.classification)) {
errors.push(`${label} has unsupported classification ${JSON.stringify(entry.classification)}`)
}
if (entry.reason === LEGACY_GENERIC_ALLOWLIST_REASON) {
errors.push(`${label} uses the legacy generic allowlist reason`)
}
if (typeof entry.reason === 'string' && !entry.reason.includes(entry.filePath)) {
errors.push(`${label} reason must name its source file`)
}
if (typeof entry.reason === 'string' && !entry.reason.includes(JSON.stringify(entry.text))) {
errors.push(`${label} reason must name its source text`)
}
}
return errors
}

View File

@ -1,250 +0,0 @@
import { spawn } from 'node:child_process'
import { flattenCatalog } from './localization-catalog-model.mjs'
function startGit(root, args, onGitProcess) {
onGitProcess?.(args)
return spawn('git', args, { cwd: root, stdio: ['pipe', 'pipe', 'pipe'] })
}
function processFailure(child, stderr, label) {
return new Promise((resolve, reject) => {
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${label} failed (${code}): ${stderr.join('')}`))
}
})
})
}
async function writeLines(stream, lines) {
for (const line of lines) {
if (!stream.write(`${line}\n`)) {
await new Promise((resolve) => stream.once('drain', resolve))
}
}
stream.end()
}
async function readHistory(root, localePaths, onGitProcess) {
const child = startGit(
root,
['log', '--first-parent', '--reverse', '-z', '--format=%H%x00%s', 'HEAD', '--', ...localePaths],
onGitProcess
)
child.stdin.end()
const stderr = []
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => stderr.push(chunk))
const completion = processFailure(child, stderr, 'git log')
const fields = []
let pending = Buffer.alloc(0)
// Why: NUL-delimited streaming avoids shell quoting and subject/newline ambiguity across platforms.
for await (const chunk of child.stdout) {
pending = Buffer.concat([pending, chunk])
let delimiter = pending.indexOf(0)
while (delimiter >= 0) {
fields.push(pending.subarray(0, delimiter).toString('utf8'))
pending = pending.subarray(delimiter + 1)
delimiter = pending.indexOf(0)
}
}
await completion
if (pending.length > 0 || fields.length % 2 !== 0) {
throw new Error('git log returned incomplete localization history metadata')
}
const commits = []
for (let index = 0; index < fields.length; index += 2) {
commits.push({ commit: fields[index], subject: fields[index + 1] })
}
return commits
}
async function resolveBlobOids(root, specs, onGitProcess) {
if (specs.length === 0) {
return new Map()
}
const child = startGit(root, ['cat-file', '--batch-check'], onGitProcess)
const stderr = []
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => stderr.push(chunk))
const completion = processFailure(child, stderr, 'git cat-file --batch-check')
const output = []
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => output.push(chunk))
await Promise.all([writeLines(child.stdin, specs), completion])
const lines = output.join('').trimEnd().split('\n')
if (lines.length !== specs.length) {
throw new Error(`git cat-file resolved ${lines.length} blobs for ${specs.length} requests`)
}
return new Map(
specs.map((spec, index) => {
const fields = lines[index].split(' ')
return [spec, fields.at(-1) === 'missing' ? undefined : fields[0]]
})
)
}
class BatchBlobReader {
constructor(root, onGitProcess) {
this.child = startGit(root, ['cat-file', '--batch'], onGitProcess)
this.iterator = this.child.stdout[Symbol.asyncIterator]()
this.buffer = Buffer.alloc(0)
this.stderr = []
this.child.stderr.setEncoding('utf8')
this.child.stderr.on('data', (chunk) => this.stderr.push(chunk))
this.completion = processFailure(this.child, this.stderr, 'git cat-file --batch')
}
async fill() {
const { value, done } = await this.iterator.next()
if (done) {
throw new Error('git cat-file ended before returning the requested blob')
}
this.buffer = Buffer.concat([this.buffer, value])
}
async readLine() {
let newline = this.buffer.indexOf(10)
while (newline < 0) {
await this.fill()
newline = this.buffer.indexOf(10)
}
const line = this.buffer.subarray(0, newline).toString('utf8')
this.buffer = this.buffer.subarray(newline + 1)
return line
}
async readBytes(size) {
while (this.buffer.length < size + 1) {
await this.fill()
}
const value = this.buffer.subarray(0, size)
if (this.buffer[size] !== 10) {
throw new Error('git cat-file returned a malformed blob terminator')
}
this.buffer = this.buffer.subarray(size + 1)
return value
}
async read(oid) {
if (!this.child.stdin.write(`${oid}\n`)) {
await new Promise((resolve) => this.child.stdin.once('drain', resolve))
}
const header = await this.readLine()
const [returnedOid, type, sizeText] = header.split(' ')
if (returnedOid !== oid || type !== 'blob') {
throw new Error(`git cat-file returned unexpected object metadata: ${header}`)
}
return this.readBytes(Number(sizeText))
}
async close() {
this.child.stdin.end()
await this.completion
}
}
function revisionSpec(revision, relativePath) {
return `${revision}:${relativePath}`
}
export async function collectReviewedCorrectionHistory(
root,
locales,
localePaths,
commitClassifications,
onGitProcess
) {
// Why: the reviewed algorithm is scoped to commits that touched target catalogs, not English-only edits.
const commits = await readHistory(root, localePaths.slice(1), onGitProcess)
const paths = new Map(
['en', ...locales].map((locale) => [
locale,
localePaths[locale === 'en' ? 0 : locales.indexOf(locale) + 1]
])
)
const finalSpecs = locales.map((locale) => revisionSpec('HEAD', paths.get(locale)))
const commitSpecs = commits.map(({ commit }) =>
[commit, `${commit}^`].flatMap((revision) =>
['en', ...locales].map((locale) => revisionSpec(revision, paths.get(locale)))
)
)
const specs = [...new Set([...finalSpecs, ...commitSpecs.flat()])]
const oidBySpec = await resolveBlobOids(root, specs, onGitProcess)
const remainingUses = new Map()
for (const spec of [...finalSpecs, ...commitSpecs.flat()]) {
const oid = oidBySpec.get(spec)
if (oid) {
remainingUses.set(oid, (remainingUses.get(oid) ?? 0) + 1)
}
}
const reader = new BatchBlobReader(root, onGitProcess)
const cachedCatalogs = new Map()
async function catalog(spec) {
const oid = oidBySpec.get(spec)
if (!oid) {
return new Map()
}
if (!cachedCatalogs.has(oid)) {
cachedCatalogs.set(oid, flattenCatalog(JSON.parse(await reader.read(oid))))
}
const value = cachedCatalogs.get(oid)
const uses = remainingUses.get(oid) - 1
remainingUses.set(oid, uses)
// Why: each blob is parsed once, then released after its last chronological use.
if (uses === 0) {
cachedCatalogs.delete(oid)
}
return value
}
try {
const finalByLocale = {}
for (let index = 0; index < locales.length; index += 1) {
finalByLocale[locales[index]] = await catalog(finalSpecs[index])
}
const messages = Object.fromEntries(locales.map((locale) => [locale, new Map()]))
const provenance = new Map()
for (let index = 0; index < commits.length; index += 1) {
const { commit, subject } = commits[index]
const classification = commitClassifications.commits?.[commit]
const revisions = commitSpecs[index]
const beforeEn = await catalog(revisions[1 + locales.length])
const afterEn = await catalog(revisions[0])
for (let localeIndex = 0; localeIndex < locales.length; localeIndex += 1) {
const locale = locales[localeIndex]
const before = await catalog(revisions[2 + locales.length + localeIndex])
const after = await catalog(revisions[1 + localeIndex])
for (const [key, value] of after) {
const targetOnlyCorrection =
before.has(key) && before.get(key) !== value && beforeEn.get(key) === afterEn.get(key)
if (!targetOnlyCorrection || finalByLocale[locale].get(key) !== value) {
continue
}
if (!classification) {
throw new Error(`Unclassified localization provenance commit: ${commit} ${subject}`)
}
provenance.set(commit, { commit, subject, ...classification })
if (classification.classification !== 'reviewed-correction') {
continue
}
const existing = messages[locale].get(key)
messages[locale].set(key, {
value,
commits: [...(existing?.commits ?? []), commit],
reason: 'historical-target-only-correction'
})
}
}
}
return { commits, messages, provenance }
} finally {
await reader.close()
}
}

View File

@ -1,60 +0,0 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { collectReviewedCorrectionHistory } from './localization-git-history.mjs'
function git(root, args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim()
}
function commit(root, subject) {
git(root, ['add', '.'])
git(root, [
'-c',
'user.name=Orca Test',
'-c',
'user.email=orca@example.com',
'commit',
'-m',
subject
])
}
describe('localization git history', () => {
it('derives revision offsets when a fifth locale is present', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-five-locales-'))
const localeDir = path.join(root, 'locales')
const locales = ['es', 'ja', 'ko', 'zh', 'fr']
mkdirSync(localeDir, { recursive: true })
git(root, ['init'])
writeFileSync(path.join(localeDir, 'en.json'), '{"message":"Hello"}\n')
for (const locale of locales) {
writeFileSync(path.join(localeDir, `${locale}.json`), `{"message":"${locale}-old"}\n`)
}
commit(root, 'Initial locale import')
const frPath = path.join(localeDir, 'fr.json')
const fr = JSON.parse(readFileSync(frPath, 'utf8'))
fr.message = 'fr-reviewed'
writeFileSync(frPath, `${JSON.stringify(fr)}\n`)
commit(root, 'Review French translation')
const reviewedCommit = git(root, ['rev-parse', 'HEAD'])
const paths = ['en', ...locales].map((locale) => `locales/${locale}.json`)
await expect(
collectReviewedCorrectionHistory(root, locales, paths, { version: 1, commits: {} })
).rejects.toThrow('Unclassified localization provenance commit')
const result = await collectReviewedCorrectionHistory(root, locales, paths, {
version: 1,
commits: {
[reviewedCommit]: {
classification: 'reviewed-correction',
reason: 'Reviewed French correction.'
}
}
})
expect(result.messages.fr.get('message')?.value).toBe('fr-reviewed')
})
})

View File

@ -1,113 +0,0 @@
import {
LOCALE_KEY_OVERRIDES,
LOCALE_VALUE_OVERRIDES,
NATIVE_PICKER_LABELS,
SEARCH_KEYWORD_OVERRIDES
} from './locale-translation-policy.mjs'
export const LOCALIZATION_LOCALES = ['es', 'ja', 'ko', 'zh']
export function reviewedAuditValueMatches(entry, currentValue) {
if (entry?.value === currentValue) {
return true
}
// Why: this one-time reserved-token rename did not change reviewed target wording.
return (
entry?.reason === 'historical-target-only-correction' &&
entry.value.includes('{{count}}') &&
currentValue === entry.value.replaceAll('{{count}}', '{{value0}}')
)
}
export function reviewedByPolicy(locale, key, enValue) {
if (LOCALE_KEY_OVERRIDES[key]?.[locale] !== undefined) {
return true
}
if (LOCALE_VALUE_OVERRIDES[locale]?.[enValue] !== undefined) {
return true
}
if (key.includes('.search.') && SEARCH_KEYWORD_OVERRIDES[locale]?.[enValue] !== undefined) {
return true
}
const pickerKey = key.replace('settings.appearance.language.', '')
if (NATIVE_PICKER_LABELS[locale]?.[pickerKey] !== undefined) {
return true
}
return key === 'menu.exploreOrca' || key === 'menu.gettingStarted'
}
export function validateReviewedAudit(audit) {
const errors = []
const reviewedCommits = new Set()
if (Object.keys(audit).join('|') !== 'version|algorithm|provenance|messages') {
errors.push('reviewed audit fields/order invalid')
}
if (audit.version !== 3) {
errors.push('reviewed audit must use version 3')
}
if (audit.algorithm !== 'classified-first-parent-target-only-current-value-v2') {
errors.push('reviewed audit algorithm unsupported')
}
if (!Array.isArray(audit.provenance)) {
errors.push('reviewed audit provenance must be an array')
} else {
for (const entry of audit.provenance) {
if (Object.keys(entry ?? {}).join('|') !== 'commit|subject|classification|reason') {
errors.push('reviewed audit provenance entry fields invalid')
continue
}
if (!['reviewed-correction', 'mechanical-or-bulk'].includes(entry.classification)) {
errors.push(`reviewed audit provenance classification unsupported: ${entry.commit}`)
}
if (entry.classification === 'reviewed-correction') {
reviewedCommits.add(entry.commit)
}
}
}
if (!audit.messages || typeof audit.messages !== 'object' || Array.isArray(audit.messages)) {
return [...errors, 'reviewed audit messages invalid']
}
if (Object.keys(audit.messages).join('|') !== LOCALIZATION_LOCALES.join('|')) {
errors.push('reviewed audit locale set/order invalid')
}
for (const locale of LOCALIZATION_LOCALES) {
const messages = audit.messages[locale]
if (!messages || typeof messages !== 'object' || Array.isArray(messages)) {
errors.push(`reviewed audit ${locale} messages invalid`)
continue
}
for (const [key, entry] of Object.entries(messages)) {
if (entry?.reason === 'historical-target-only-correction') {
if (
Object.keys(entry ?? {}).join('|') !== 'value|commits|reason' ||
typeof entry.value !== 'string' ||
!Array.isArray(entry.commits) ||
entry.commits.length === 0
) {
errors.push(`reviewed audit ${locale}:${key} historical evidence invalid`)
}
for (const commit of entry.commits ?? []) {
if (!/^[a-f0-9]{40}$/.test(commit) || !reviewedCommits.has(commit)) {
errors.push(`reviewed audit ${locale}:${key} cites unreviewed commit ${commit}`)
}
}
} else if (entry?.reason === 'localization-pr-review') {
if (
Object.keys(entry ?? {}).join('|') !== 'value|review|reason' ||
typeof entry.value !== 'string' ||
Object.keys(entry.review ?? {}).join('|') !== 'provider|changeId|reviewer' ||
!['github', 'gitlab', 'other'].includes(entry.review?.provider) ||
typeof entry.review?.changeId !== 'string' ||
entry.review.changeId.trim().length === 0 ||
typeof entry.review?.reviewer !== 'string' ||
entry.review.reviewer.trim().length === 0
) {
errors.push(`reviewed audit ${locale}:${key} localization PR evidence invalid`)
}
} else {
errors.push(`reviewed audit ${locale}:${key} reason unsupported`)
}
}
}
return errors
}

View File

@ -1,305 +0,0 @@
import fs from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const I18NEXT_OPTION_NAMES = new Set([
'count',
'defaultValue',
'fallbackLng',
'interpolation',
'joinArrays',
'lng',
'ns',
'returnDetails',
'returnObjects'
])
const PLACEHOLDER_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
const RESERVED_PLACEHOLDER_NAMES = new Set(I18NEXT_OPTION_NAMES)
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
export async function collectLocalizationSourceFiles(root, dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectLocalizationSourceFiles(root, fullPath)))
}
continue
}
if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
export function expressionNameText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return `${expressionNameText(node.expression) ?? ''}.${node.name.text}`.replace(/^\./, '')
}
return undefined
}
function reportAt(root, filePath, sourceFile, node, key, fallback) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return {
filePath: normalizePath(root, filePath),
line: position.line + 1,
column: position.character + 1,
key,
fallback,
options: undefined
}
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text
}
return undefined
}
function staticStringBranches(node) {
if (!node) {
return []
}
if (ts.isStringLiteralLike(node)) {
return [node.text]
}
if (ts.isConditionalExpression(node)) {
return [...staticStringBranches(node.whenTrue), ...staticStringBranches(node.whenFalse)]
}
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node) ||
ts.isNonNullExpression(node)
) {
return staticStringBranches(node.expression)
}
return []
}
function objectLiteralStringBranches(node, propertyName) {
if (!node || !ts.isObjectLiteralExpression(node)) {
return []
}
for (const property of node.properties) {
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propertyName) {
return staticStringBranches(property.initializer)
}
}
return []
}
function collectOptionNames(node) {
if (!node || !ts.isObjectLiteralExpression(node)) {
return { kind: node ? 'dynamic' : 'missing', names: new Set() }
}
const names = new Set()
let dynamic = false
for (const property of node.properties) {
if (ts.isSpreadAssignment(property)) {
dynamic = true
continue
}
if (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) {
const name = propertyNameText(property.name)
if (name) {
names.add(name)
} else {
dynamic = true
}
continue
}
dynamic = true
}
return { kind: dynamic ? 'mixed' : 'object', names }
}
function localizationCallRecord(root, filePath, sourceFile, callExpression) {
const name = expressionNameText(callExpression.expression)
const functionName = name?.split('.').at(-1)
const firstArg = callExpression.arguments[0]
if (!functionName || !LOCALIZATION_FUNCTION_NAMES.has(functionName) || !firstArg) {
return undefined
}
const key = ts.isStringLiteralLike(firstArg) ? firstArg.text : undefined
let fallback
let fallbacks
let options
if (functionName === 't') {
const secondArg = callExpression.arguments[1]
fallbacks = objectLiteralStringBranches(secondArg, 'defaultValue')
fallback = fallbacks.length === 1 ? fallbacks[0] : undefined
options = collectOptionNames(secondArg)
} else {
const secondArg = callExpression.arguments[1]
fallbacks = staticStringBranches(secondArg)
fallback = fallbacks.length === 1 ? fallbacks[0] : undefined
options = collectOptionNames(callExpression.arguments[2])
}
return {
...reportAt(root, filePath, sourceFile, firstArg, key, fallback),
options,
fallbacks,
callText:
key === undefined
? ts
.createPrinter({ removeComments: true })
.printNode(ts.EmitHint.Unspecified, callExpression, sourceFile)
: undefined
}
}
export function parseLocalizationSource(filePath, sourceText) {
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, sourceKind)
}
export function collectLocalizationKeyReferences(
filePath,
sourceText,
root = process.cwd(),
sourceFile = parseLocalizationSource(filePath, sourceText)
) {
const references = []
function visit(node) {
if (ts.isCallExpression(node)) {
const record = localizationCallRecord(root, filePath, sourceFile, node)
if (record) {
references.push(record)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
const occurrences = new Map()
for (const reference of references.filter(({ key }) => key === undefined)) {
const digest = createHash('sha256').update(reference.callText).digest('hex')
const base = `${reference.filePath}#sha256:${digest}`
const occurrence = (occurrences.get(base) ?? 0) + 1
occurrences.set(base, occurrence)
reference.dynamicSignature = `${base}#${occurrence}`
delete reference.callText
}
return references
}
export function collectInterpolationVariables(value) {
if (typeof value === 'string') {
const matches = value.match(PLACEHOLDER_RE) ?? []
return [...matches].sort()
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return []
}
return Object.values(value).flatMap((child) => collectInterpolationVariables(child))
}
function interpolationVariableNames(value) {
return collectInterpolationVariables(value).map((variable) => variable.slice(2, -2))
}
export function collectCallSitePlaceholderErrors(references) {
const errors = []
for (const reference of references) {
const fallbacks = reference.fallbacks?.length
? [...new Set(reference.fallbacks)]
: typeof reference.fallback === 'string'
? [reference.fallback]
: []
if (fallbacks.length === 0) {
continue
}
const placeholders = [...new Set(fallbacks.flatMap(interpolationVariableNames))]
for (const placeholder of placeholders) {
if (!PLACEHOLDER_NAME_RE.test(placeholder)) {
errors.push({ reference, message: `invalid placeholder name {{${placeholder}}}` })
}
if (RESERVED_PLACEHOLDER_NAMES.has(placeholder)) {
errors.push({
reference,
message: `placeholder {{${placeholder}}} collides with a reserved i18next option name`
})
}
}
if (placeholders.length === 0) {
continue
}
if (!reference.options || reference.options.kind === 'missing') {
errors.push({
reference,
message: `missing interpolation options for ${placeholders
.map((name) => `{{${name}}}`)
.join(', ')}`
})
continue
}
if (reference.options.kind !== 'object') {
errors.push({ reference, message: 'interpolation options must be a static object literal' })
continue
}
const missing = placeholders.filter((placeholder) => !reference.options.names.has(placeholder))
for (const missingName of missing) {
errors.push({ reference, message: `missing interpolation value "${missingName}"` })
}
const invalidOptionNames = [...reference.options.names].filter(
(optionName) => !I18NEXT_OPTION_NAMES.has(optionName) && !PLACEHOLDER_NAME_RE.test(optionName)
)
for (const optionName of invalidOptionNames) {
errors.push({ reference, message: `invalid interpolation option name "${optionName}"` })
}
}
return errors
}
export function formatPlaceholderErrors(errors) {
return errors
.map(
({ reference, message }) =>
`${reference.filePath}:${reference.line}:${reference.column} ${reference.key}: ${message}`
)
.join('\n')
}

View File

@ -1,41 +0,0 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import {
collectCallSitePlaceholderErrors,
collectLocalizationKeyReferences
} from './localization-source-references.mjs'
const ROOT = path.join(path.parse(process.cwd()).root, 'workspace')
const FILE = path.join(ROOT, 'src', 'renderer', 'src', 'Example.tsx')
function references(source) {
return collectLocalizationKeyReferences(FILE, source, ROOT)
}
describe('localization source references', () => {
it('validates every bounded fallback branch for dynamic keys', () => {
const [reference] = references(
"translate(one ? 'auto.one' : 'auto.many', one ? '{{value0}} item' : '{{value0}} items', { count })"
)
expect(collectCallSitePlaceholderErrors([reference])).toEqual([
expect.objectContaining({ message: 'missing interpolation value "value0"' })
])
})
it('keeps semantic signatures stable across line insertions and normalizes paths', () => {
const first = references("translate(key, 'Value')")[0].dynamicSignature
const shifted = references("\n\ntranslate(key, 'Value')")[0].dynamicSignature
expect(shifted).toBe(first)
expect(first).not.toContain('\\')
})
it('changes signatures with the call and disambiguates duplicates by occurrence', () => {
const original = references("translate(key, 'Value')")[0].dynamicSignature
const changed = references("translate(otherKey, 'Value')")[0].dynamicSignature
const duplicates = references("translate(key, 'Value')\ntranslate(key, 'Value')")
expect(changed).not.toBe(original)
expect(duplicates.map(({ dynamicSignature }) => dynamicSignature.at(-1))).toEqual(['1', '2'])
})
})

View File

@ -427,8 +427,7 @@ describe('Electron runtime package contract', () => {
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
// Why: `verify` is the required aggregate; executable Linux contract steps live in `verify-core`.
const installStep = parsedWorkflow.jobs['verify-core'].steps.find(
const installStep = parsedWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
@ -438,8 +437,7 @@ describe('Electron runtime package contract', () => {
it('smokes the packaged CLI from outside the checkout in PR checks', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
// Why: `verify` is the required aggregate; executable Linux contract steps live in `verify-core`.
const smokeStep = parsedWorkflow.jobs['verify-core'].steps.find(
const smokeStep = parsedWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Smoke packaged CLI'
)

View File

@ -4,11 +4,8 @@ import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { repairCacheMap, repairCatalog } from './locale-translation-policy.mjs'
import { compareCodeUnits } from './localization-code-unit-order.mjs'
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const MIGRATION_FLAG = '--migration-rewrite-locales'
const MIGRATION_ENV = 'ORCA_I18N_MIGRATION_REWRITE'
const LOCALE_CACHE_FILES = {
ko: '.ko-catalog-cache.json',
@ -35,7 +32,7 @@ async function loadCache(cachePath) {
}
async function saveCache(cachePath, cache) {
const raw = Object.fromEntries([...cache.entries()].sort(([a], [b]) => compareCodeUnits(a, b)))
const raw = Object.fromEntries([...cache.entries()].sort(([a], [b]) => a.localeCompare(b)))
await fs.writeFile(cachePath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8')
}
@ -59,17 +56,8 @@ export async function repairLocale(root, locale) {
return { catalogRepairs, cacheRepairs }
}
export async function main(root = process.cwd(), locale, dependencies = {}) {
const argv = dependencies.argv ?? process.argv
const environment = dependencies.environment ?? process.env
if (!argv.includes(MIGRATION_FLAG) || environment[MIGRATION_ENV] !== '1') {
console.error(
`Refusing to rewrite locale catalogs. Set ${MIGRATION_ENV}=1 and pass ${MIGRATION_FLAG} for migration-only use.`
)
return 1
}
const requestedLocale = locale ?? parseLocaleArg(argv)
const locales = requestedLocale ? [requestedLocale] : ['ko', 'zh', 'ja', 'es']
export async function main(root = process.cwd(), locale = parseLocaleArg(process.argv)) {
const locales = locale ? [locale] : ['ko', 'zh', 'ja', 'es']
const unsupported = locales.filter((code) => !LOCALE_CACHE_FILES[code])
if (unsupported.length > 0) {
console.error(`Unsupported locale(s): ${unsupported.join(', ')}`)

View File

@ -1,55 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { flattenCatalog, sourceHash } from './localization-catalog-model.mjs'
import { LOCALIZATION_LOCALES } from './localization-reviewed-provenance.mjs'
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const STATE_DIR = path.join('config', 'localization-state')
export async function main(root = process.cwd()) {
const enEntries = flattenCatalog(
JSON.parse(await fs.readFile(path.join(root, LOCALES_DIR, 'en.json')))
)
console.log('locale\tuntranslated\tintentional-english\tstale\tmachine\treviewed')
for (const locale of LOCALIZATION_LOCALES) {
const targetEntries = flattenCatalog(
JSON.parse(await fs.readFile(path.join(root, LOCALES_DIR, `${locale}.json`)))
)
const state = JSON.parse(await fs.readFile(path.join(root, STATE_DIR, `${locale}.json`)))
const counts = { untranslated: 0, intentionalEnglish: 0, stale: 0, machine: 0, reviewed: 0 }
for (const [key, entry] of Object.entries(state.messages)) {
if (entry.state === 'intentional-english') {
counts.intentionalEnglish += 1
}
if (entry.state === 'machine') {
counts.machine += 1
}
if (entry.state === 'reviewed') {
counts.reviewed += 1
}
if (
entry.sourceHash &&
enEntries.has(key) &&
entry.sourceHash !== sourceHash(enEntries.get(key))
) {
counts.stale += 1
}
}
for (const key of enEntries.keys()) {
if (!targetEntries.has(key) && !state.messages[key]) {
counts.untranslated += 1
}
}
console.log(
`${locale}\t${counts.untranslated}\t${counts.intentionalEnglish}\t${counts.stale}\t${counts.machine}\t${counts.reviewed}`
)
}
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}

View File

@ -3,38 +3,131 @@ import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import {
flattenCatalog as flattenCatalogMap,
getCatalogEntry,
jsonText,
setCatalogEntry,
sourceHash,
stateDocument
} from './localization-catalog-model.mjs'
import {
LOCALIZATION_LOCALES,
reviewedAuditValueMatches,
reviewedByPolicy,
validateReviewedAudit
} from './localization-reviewed-provenance.mjs'
import {
collectCallSitePlaceholderErrors,
collectInterpolationVariables,
collectLocalizationKeyReferences,
collectLocalizationSourceFiles,
formatPlaceholderErrors
} from './localization-source-references.mjs'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const STATE_RELATIVE_DIR = path.join('config', 'localization-state')
const DYNAMIC_ALLOWLIST_PATH = path.join('config', 'localization-dynamic-call-allowlist.json')
const REVIEWED_AUDIT_PATH = path.join('config', 'localization-reviewed-corrections.json')
const SOURCE_RELATIVE_ROOTS = [path.join('src', 'renderer', 'src'), path.join('src', 'main')]
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectSourceFiles(root, fullPath)))
}
continue
}
if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
function flattenCatalogKeys(value, prefix = '') {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return prefix ? [prefix] : []
}
return Object.entries(value).flatMap(([key, child]) =>
flattenCatalogKeys(child, prefix ? `${prefix}.${key}` : key)
)
}
function expressionNameText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return `${expressionNameText(node.expression) ?? ''}.${node.name.text}`.replace(/^\./, '')
}
return undefined
}
function reportAt(root, filePath, sourceFile, node, key, fallback) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return {
filePath: normalizePath(root, filePath),
line: position.line + 1,
column: position.character + 1,
key,
fallback
}
}
export function collectLocalizationKeyReferences(filePath, sourceText, root = process.cwd()) {
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourceKind
)
const references = []
function visit(node) {
if (ts.isCallExpression(node)) {
const name = expressionNameText(node.expression)
const functionName = name?.split('.').at(-1)
const firstArg = node.arguments[0]
if (
functionName &&
LOCALIZATION_FUNCTION_NAMES.has(functionName) &&
firstArg &&
ts.isStringLiteralLike(firstArg)
) {
const secondArg = node.arguments[1]
references.push(
reportAt(
root,
filePath,
sourceFile,
firstArg,
firstArg.text,
secondArg && ts.isStringLiteralLike(secondArg) ? secondArg.text : undefined
)
)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return references
}
function formatMissingReferences(missing) {
return missing
.map(
@ -43,39 +136,6 @@ function formatMissingReferences(missing) {
.join('\n')
}
function formatDynamicReferences(references) {
return references
.map(
({ filePath, line, column, dynamicSignature }) =>
`${filePath}:${line}:${column} (${dynamicSignature})`
)
.join('\n')
}
function validateDynamicAllowlist(dynamicAllowlist) {
const errors = []
if (dynamicAllowlist.version !== 2) {
errors.push('dynamic localization allowlist must use version 2')
}
if (
!dynamicAllowlist.entries ||
typeof dynamicAllowlist.entries !== 'object' ||
Array.isArray(dynamicAllowlist.entries)
) {
errors.push('dynamic localization allowlist entries must be an object')
return errors
}
for (const [signature, reason] of Object.entries(dynamicAllowlist.entries)) {
if (!/^[^#]+#sha256:[a-f0-9]{64}#\d+$/.test(signature)) {
errors.push(`dynamic localization allowlist signature is invalid: ${signature}`)
}
if (typeof reason !== 'string' || reason.trim().length < 12) {
errors.push(`dynamic localization allowlist reason is missing for ${signature}`)
}
}
return errors
}
function formatMissingKeys(label, keys) {
return keys.map((key) => `${label}: ${key}`).join('\n')
}
@ -126,211 +186,88 @@ function collectInconsistentFallbackVariables(references) {
.filter(({ uniqueFallbackVariableCount }) => uniqueFallbackVariableCount > 1)
}
function collectInconsistentFallbackValues(references) {
const byKey = new Map()
for (const reference of references) {
if (typeof reference.fallback !== 'string') {
continue
}
const keyReferences = byKey.get(reference.key) ?? []
keyReferences.push(reference)
byKey.set(reference.key, keyReferences)
function collectInterpolationVariables(value) {
if (typeof value === 'string') {
const matches = value.match(PLACEHOLDER_RE) ?? []
return [...matches].sort()
}
return [...byKey.entries()]
.map(([key, keyReferences]) => ({
key,
references: keyReferences,
values: new Set(keyReferences.map(({ fallback }) => fallback))
}))
.filter(({ values }) => values.size > 1)
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return []
}
return Object.values(value).flatMap((child) => collectInterpolationVariables(child))
}
function reviewedAuditMatches(reviewedAudit, localeName, key, localeEntries) {
const entry = reviewedAudit?.messages?.[localeName]?.[key]
if (typeof entry?.value !== 'string') {
function flattenCatalogEntries(value, prefix = '', entries = new Map()) {
if (typeof value === 'string') {
entries.set(prefix, value)
return entries
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return entries
}
for (const [key, child] of Object.entries(value)) {
flattenCatalogEntries(child, prefix ? `${prefix}.${key}` : key, entries)
}
return entries
}
function getCatalogEntry(catalog, key) {
return key.split('.').reduce((cursor, part) => cursor?.[part], catalog)
}
function setCatalogEntry(catalog, key, value) {
const parts = key.split('.')
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (typeof cursor[part] !== 'object' || cursor[part] === null || Array.isArray(cursor[part])) {
cursor[part] = {}
}
cursor = cursor[part]
}
cursor[parts.at(-1)] = value
}
function deleteCatalogEntry(catalog, key) {
const parts = key.split('.')
const stack = []
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (
typeof cursor?.[part] !== 'object' ||
cursor[part] === null ||
Array.isArray(cursor[part])
) {
return false
}
stack.push([cursor, part])
cursor = cursor[part]
}
const leafKey = parts.at(-1)
if (!Object.hasOwn(cursor, leafKey)) {
return false
}
return reviewedAuditValueMatches(entry, localeEntries.get(key))
}
function validateStateEntry(
localeName,
key,
entry,
enEntries,
localeEntries,
reviewedAudit,
errors
) {
const label = `${localeName}:${key}`
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
errors.push(`${label} must be an object`)
return { stale: false }
}
if (!enEntries.has(key)) {
errors.push(`${label} references an unknown English key`)
}
if (entry.state === 'intentional-english') {
if (Object.keys(entry).join('|') !== 'state') {
errors.push(`${label} has invalid sidecar fields`)
}
if (localeEntries.has(key)) {
errors.push(`${label} must not have a target catalog value`)
}
return { stale: false }
}
if (entry.state !== 'machine' && entry.state !== 'reviewed') {
errors.push(`${label} has unknown state ${JSON.stringify(entry.state)}`)
return { stale: false }
}
if (Object.keys(entry).join('|') !== 'state|sourceHash') {
errors.push(`${label} has invalid fields`)
}
if (!/^sha256:[a-f0-9]{64}$/.test(entry.sourceHash ?? '')) {
errors.push(`${label} has a malformed sourceHash`)
}
if (!localeEntries.has(key)) {
errors.push(`${label} requires exactly one target catalog value`)
}
const hasReviewedProvenance =
enEntries.has(key) &&
(reviewedByPolicy(localeName, key, enEntries.get(key)) ||
reviewedAuditMatches(reviewedAudit, localeName, key, localeEntries))
if (entry.state === 'reviewed' && !hasReviewedProvenance) {
errors.push(`${label} is marked reviewed without reviewed policy or audit provenance`)
}
if (entry.state === 'machine' && hasReviewedProvenance) {
errors.push(`${label} downgrades reviewed policy/audit provenance to machine`)
}
return { stale: enEntries.has(key) && entry.sourceHash !== sourceHash(enEntries.get(key)) }
}
async function readReviewedAudit(root) {
const auditPath = path.join(root, REVIEWED_AUDIT_PATH)
let raw
try {
raw = await fs.readFile(auditPath, 'utf8')
} catch {
console.error(`Missing reviewed localization audit: ${normalizePath(root, auditPath)}`)
return null
}
let audit
try {
audit = JSON.parse(raw)
} catch (error) {
console.error('Reviewed localization audit is invalid:')
console.error(`invalid JSON (${error instanceof Error ? error.message : String(error)})`)
return null
}
const errors = validateReviewedAudit(audit)
if (errors.length > 0) {
console.error(`Reviewed localization audit is invalid:\n${errors.join('\n')}`)
return null
}
return audit
}
async function verifyTranslationState(root, localeName, enCatalog, localeCatalog, reviewedAudit) {
const statePath = path.join(root, STATE_RELATIVE_DIR, `${localeName}.json`)
let raw
try {
raw = await fs.readFile(statePath, 'utf8')
} catch {
console.error(`Missing translation state: ${normalizePath(root, statePath)}`)
return 1
}
let document
try {
document = JSON.parse(raw)
} catch (error) {
console.error(`Translation state validation failed for ${localeName}.json:`)
console.error(
`${localeName}: invalid JSON (${error instanceof Error ? error.message : String(error)})`
)
return 1
}
const errors = []
if (Object.keys(document).join('|') !== 'version|locale|messages') {
errors.push(`${localeName}: state document has invalid fields or ordering`)
}
if (document.version !== 1) {
errors.push(`${localeName}: unsupported state schema version`)
}
if (document.locale !== localeName) {
errors.push(`${localeName}: filename/locale mismatch`)
}
if (
!document.messages ||
typeof document.messages !== 'object' ||
Array.isArray(document.messages)
) {
errors.push(`${localeName}: messages must be an object`)
document.messages = {}
}
const canonicalState = stateDocument(localeName, new Map(Object.entries(document.messages)))
if (raw !== jsonText(canonicalState)) {
errors.push(`${localeName}: state file is not deterministic JSON`)
}
const enEntries = flattenCatalogMap(enCatalog)
const localeEntries = flattenCatalogMap(localeCatalog)
const counts = { untranslated: 0, intentionalEnglish: 0, stale: 0, machine: 0, reviewed: 0 }
for (const [key, entry] of Object.entries(document.messages)) {
const result = validateStateEntry(
localeName,
key,
entry,
enEntries,
localeEntries,
reviewedAudit,
errors
)
if (entry?.state === 'intentional-english') {
counts.intentionalEnglish += 1
}
if (entry?.state === 'machine') {
counts.machine += 1
}
if (entry?.state === 'reviewed') {
counts.reviewed += 1
}
if (result.stale) {
counts.stale += 1
delete cursor[leafKey]
for (let index = stack.length - 1; index >= 0; index -= 1) {
const [parent, part] = stack[index]
const child = parent[part]
if (
typeof child === 'object' &&
child !== null &&
!Array.isArray(child) &&
Object.keys(child).length === 0
) {
delete parent[part]
}
}
for (const [key, value] of localeEntries) {
const entry = document.messages[key]
if (!entry || !['machine', 'reviewed'].includes(entry.state)) {
errors.push(`${localeName}:${key} has a target value without translation state`)
}
if (value.length === 0) {
errors.push(`${localeName}:${key} has an empty target value`)
}
}
for (const key of enEntries.keys()) {
if (!localeEntries.has(key) && !document.messages[key]) {
counts.untranslated += 1
}
}
const coverage = enEntries.size === 0 ? 100 : (localeEntries.size / enEntries.size) * 100
console.log(
`${localeName}: ${coverage.toFixed(1)}% translated; ${Object.entries(counts)
.map(([name, count]) => `${name}=${count}`)
.join(' ')}`
)
if (errors.length > 0) {
console.error(`Translation state validation failed for ${localeName}.json:`)
console.error(errors.slice(0, 40).join('\n'))
if (errors.length > 40) {
console.error(`...and ${errors.length - 40} more error(s)`)
}
return 1
}
return 0
return true
}
function collectLocaleParityIssues(enCatalog, localeCatalog) {
const enEntries = flattenCatalogMap(enCatalog)
const localeEntries = flattenCatalogMap(localeCatalog)
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
const missingInLocale = [...enEntries.keys()].filter((key) => !localeEntries.has(key))
const extraInLocale = [...localeEntries.keys()].filter((key) => !enEntries.has(key))
const interpolationMismatches = []
@ -349,6 +286,30 @@ function collectLocaleParityIssues(enCatalog, localeCatalog) {
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
}
function repairLocaleParity(enCatalog, localeCatalog) {
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
let changed = 0
for (const key of missingInLocale) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
for (const key of extraInLocale) {
if (deleteCatalogEntry(localeCatalog, key)) {
changed += 1
}
}
for (const key of interpolationMismatches) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
return changed
}
function referencesMissingFallbacks(missing) {
return missing.filter((reference) => typeof reference.fallback !== 'string')
}
@ -387,8 +348,19 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
console.error(`Locale catalog validation failed for ${localeName}.json.`)
if (
missingInLocale.length > 0 ||
extraInLocale.length > 0 ||
interpolationMismatches.length > 0
) {
console.error(`Locale catalog parity failed for ${localeName}.json.`)
if (missingInLocale.length > 0) {
console.error('')
console.error(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
if (missingInLocale.length > 20) {
console.error(`...and ${missingInLocale.length - 20} more missing keys`)
}
}
if (extraInLocale.length > 0) {
console.error('')
console.error(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
@ -408,9 +380,7 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
return 1
}
console.log(
`Verified ${localeName}.json structure (${localeEntries.size} translated, ${missingInLocale.length} fallback).`
)
console.log(`Verified locale parity for ${localeName}.json (${localeEntries.size} keys).`)
return 0
}
@ -424,75 +394,26 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
const catalogPath = path.join(localesDir, 'en.json')
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
let catalogKeys = new Set(flattenCatalogMap(catalog).keys())
let catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
const references = options.references ?? []
const references = []
if (!options.references) {
for (const sourceRoot of sourceRoots) {
const files = await collectLocalizationSourceFiles(root, sourceRoot)
for (const filePath of files) {
references.push(
...collectLocalizationKeyReferences(filePath, await fs.readFile(filePath, 'utf8'), root)
)
}
for (const sourceRoot of sourceRoots) {
const files = await collectSourceFiles(root, sourceRoot)
for (const filePath of files) {
references.push(
...collectLocalizationKeyReferences(filePath, await fs.readFile(filePath, 'utf8'), root)
)
}
}
const dynamicAllowlist = JSON.parse(await fs.readFile(path.join(root, DYNAMIC_ALLOWLIST_PATH)))
const dynamicAllowlistErrors = validateDynamicAllowlist(dynamicAllowlist)
if (dynamicAllowlistErrors.length > 0) {
console.error('Dynamic localization allowlist is invalid.')
console.error('')
console.error(dynamicAllowlistErrors.join('\n'))
return 1
}
const dynamicLocations = new Set(
references
.filter(({ key }) => key === undefined)
.map(({ dynamicSignature }) => dynamicSignature)
)
const staleAllowlistEntries = Object.keys(dynamicAllowlist.entries).filter(
(location) => !dynamicLocations.has(location)
)
if (staleAllowlistEntries.length > 0) {
console.error('Dynamic localization allowlist contains stale entries.')
console.error('')
console.error(staleAllowlistEntries.join('\n'))
return 1
}
const dynamicReferences = references.filter(({ key, dynamicSignature }) => {
if (key !== undefined) {
return false
}
return !dynamicAllowlist.entries[dynamicSignature]
})
if (dynamicReferences.length > 0) {
console.error('Localization calls must use statically extractable string identifiers.')
console.error('')
console.error(formatDynamicReferences(dynamicReferences))
return 1
}
const staticReferences = references.filter(({ key }) => key !== undefined)
// Why: bounded dynamic-key calls still carry statically provable fallback contracts.
const placeholderErrors = collectCallSitePlaceholderErrors(references)
if (placeholderErrors.length > 0) {
console.error('Localization interpolation options are invalid.')
console.error('')
console.error(formatPlaceholderErrors(placeholderErrors.slice(0, 40)))
if (placeholderErrors.length > 40) {
console.error(`...and ${placeholderErrors.length - 40} more error(s)`)
}
return 1
}
const missing = staticReferences.filter((reference) => !catalogKeys.has(reference.key))
const missing = references.filter((reference) => !catalogKeys.has(reference.key))
if (missing.length > 0) {
const missingFallbacks = referencesMissingFallbacks(missing)
if (options.fix && missingFallbacks.length === 0) {
const added = applyMissingEnglishEntries(catalog, missing)
await fs.writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8')
catalogKeys = new Set(flattenCatalogMap(catalog).keys())
catalogKeys = new Set(flattenCatalogKeys(catalog))
console.log(`Added ${added} missing localization key(s) to en.json.`)
} else {
if (options.fix && missingFallbacks.length > 0) {
@ -510,7 +431,7 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
}
}
const remainingMissing = staticReferences.filter((reference) => !catalogKeys.has(reference.key))
const remainingMissing = references.filter((reference) => !catalogKeys.has(reference.key))
if (remainingMissing.length > 0) {
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
console.error('')
@ -518,7 +439,7 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
return 1
}
const inconsistentFallbackVariables = collectInconsistentFallbackVariables(staticReferences)
const inconsistentFallbackVariables = collectInconsistentFallbackVariables(references)
if (inconsistentFallbackVariables.length > 0) {
console.error('Localization keys are used with inconsistent interpolation placeholders.')
console.error('')
@ -526,26 +447,7 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
return 1
}
const inconsistentFallbackValues = collectInconsistentFallbackValues(staticReferences)
if (inconsistentFallbackValues.length > 0) {
console.error('Localization identifiers are used with conflicting English fallbacks.')
console.error('')
console.error(formatInconsistentFallbackVariables(inconsistentFallbackValues))
return 1
}
console.log(`Verified ${staticReferences.length} localization key references against en.json.`)
const reviewedAudit = await readReviewedAudit(root)
if (!reviewedAudit) {
return 1
}
for (const locale of LOCALIZATION_LOCALES) {
if (!reviewedAudit.messages[locale]) {
console.error(`Reviewed audit is missing locale ${locale}.`)
return 1
}
}
console.log(`Verified ${references.length} localization key references against en.json.`)
const localeFiles = (await fs.readdir(localesDir))
.filter(
@ -561,19 +463,21 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
const localeName = fileName.replace(/\.json$/, '')
const localeCatalogPath = path.join(localesDir, fileName)
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
if (options.fix) {
const repaired = repairLocaleParity(catalog, localeCatalog)
if (repaired > 0) {
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
}
}
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
if (exitCode !== 0) {
if (!options.fix) {
console.error('')
console.error('Fix the reported target value in a localization PR.')
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
}
return exitCode
}
if (
(await verifyTranslationState(root, localeName, catalog, localeCatalog, reviewedAudit)) !== 0
) {
return 1
}
}
return 0

View File

@ -4,269 +4,79 @@ import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { sourceHash } from './localization-catalog-model.mjs'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function makeProject({ sourceText, enCatalog = {}, esCatalog = {}, messages = {} }) {
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf8'))
}
function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-catalog-'))
const rendererDir = path.join(root, 'src', 'renderer', 'src', 'components')
const mainDir = path.join(root, 'src', 'main')
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
const stateDir = path.join(root, 'config', 'localization-state')
mkdirSync(rendererDir, { recursive: true })
mkdirSync(mainDir, { recursive: true })
mkdirSync(localesDir, { recursive: true })
mkdirSync(stateDir, { recursive: true })
writeFileSync(path.join(rendererDir, 'Example.tsx'), sourceText, 'utf8')
writeFileSync(path.join(mainDir, 'empty.ts'), 'export {}\n', 'utf8')
writeJson(path.join(localesDir, 'en.json'), enCatalog)
writeJson(path.join(localesDir, 'es.json'), esCatalog)
writeJson(path.join(stateDir, 'es.json'), { version: 1, locale: 'es', messages })
writeJson(path.join(root, 'config', 'localization-dynamic-call-allowlist.json'), {
version: 2,
entries: {}
})
writeJson(path.join(root, 'config', 'localization-reviewed-corrections.json'), {
version: 3,
algorithm: 'classified-first-parent-target-only-current-value-v2',
provenance: [],
messages: { es: {}, ja: {}, ko: {}, zh: {} }
})
return { root, localesDir }
}
describe('verify-localization-catalog', () => {
it('adds only the English fallback and leaves a missing target absent', async () => {
it('bootstraps missing catalog entries from string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
expect(JSON.parse(readFileSync(path.join(localesDir, 'en.json'), 'utf8'))).toEqual({
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(JSON.parse(readFileSync(path.join(localesDir, 'es.json'), 'utf8'))).toEqual({})
})
it('rejects but never overwrites an existing placeholder mismatch', async () => {
const enValue = 'Hello {{name}}'
it('repairs stale locale keys and interpolation mismatches', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
enCatalog: { auto: { example: { greeting: enValue } } },
esCatalog: { auto: { example: { greeting: 'Hola' } } },
messages: {
'auto.example.greeting': { state: 'machine', sourceHash: sourceHash(enValue) }
enCatalog: { auto: { example: { greeting: 'Hello {{name}}' } } },
esCatalog: {
auto: {
example: { greeting: 'Hola' },
stale: { removed: 'Viejo' }
}
}
})
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(JSON.parse(readFileSync(path.join(localesDir, 'es.json'), 'utf8'))).toEqual({
auto: { example: { greeting: 'Hola' } }
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
})
it('enforces sidecar-only intentional English and exact catalog/state bijection', async () => {
const { root } = makeProject({
sourceText: "export const label = translate('auto.example.product', 'Orca')\n",
enCatalog: { auto: { example: { product: 'Orca' } } },
messages: { 'auto.example.product': { state: 'intentional-english' } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
})
it('rejects conflicting defaults for one identifier', async () => {
const { root } = makeProject({
sourceText:
"translate('auto.example.conflict', 'First')\ntranslate('auto.example.conflict', 'Second')\n",
enCatalog: { auto: { example: { conflict: 'First' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('rejects named placeholders without matching static option values', async () => {
const { root } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}')\n",
enCatalog: { auto: { example: { greeting: 'Hello {{name}}' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('validates t object-form defaultValue placeholders', async () => {
const { root } = makeProject({
sourceText:
"const label = t('auto.example.greeting', { defaultValue: 'Hello {{name}}', name })\n",
enCatalog: { auto: { example: { greeting: 'Hello {{name}}' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
})
it('rejects reserved i18next option names as translate placeholders', async () => {
const { root } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\ntranslate('auto.example.locale', 'Locale {{lng}}', { lng: 'es' })\n",
enCatalog: { auto: { example: { locale: 'Locale {{lng}}' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('rejects reserved i18next option names as translateMain placeholders', async () => {
const { root } = makeProject({
sourceText:
"import { translateMain } from './i18n/main-i18n'\ntranslateMain('auto.example.count', 'Count {{count}}', { count: 1 })\n",
enCatalog: { auto: { example: { count: 'Count {{count}}' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('rejects reserved i18next option names in t object-form defaults', async () => {
const { root } = makeProject({
sourceText: "t('auto.example.namespace', { defaultValue: 'Namespace {{ns}}', ns: 'main' })\n",
enCatalog: { auto: { example: { namespace: 'Namespace {{ns}}' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('rejects unsupported reviewed upgrades', async () => {
const { root } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\ntranslate('auto.example.fake', 'Fake')\n",
enCatalog: { auto: { example: { fake: 'Fake' } } },
esCatalog: { auto: { example: { fake: 'Falso' } } },
messages: {
'auto.example.fake': { state: 'reviewed', sourceHash: sourceHash('Fake') }
}
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('rejects reviewed provenance downgrades', async () => {
const reviewedValue = 'PR'
it('does not invent values for keys without string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\ntranslate('auto.example.reviewed', 'PR')\n",
enCatalog: { auto: { example: { reviewed: 'PR' } } },
esCatalog: { auto: { example: { reviewed: reviewedValue } } },
messages: {
'auto.example.reviewed': { state: 'machine', sourceHash: sourceHash('PR') }
}
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.noFallback')\n"
})
writeJson(path.join(root, 'config', 'localization-reviewed-corrections.json'), {
version: 3,
algorithm: 'classified-first-parent-target-only-current-value-v2',
provenance: [
{
commit: 'cd03928e94e5f7d8a0cae6abc6b2b10b371e5c17',
subject: 'fix(i18n): correct Spanish "PR" mistranslated as "relaciones públicas" (#6749)',
classification: 'reviewed-correction',
reason: 'Single Spanish correction review.'
}
],
messages: {
es: {
'auto.example.reviewed': {
value: reviewedValue,
commits: ['cd03928e94e5f7d8a0cae6abc6b2b10b371e5c17'],
reason: 'historical-target-only-correction'
}
},
ja: {},
ko: {},
zh: {}
}
})
expect(readFileSync(path.join(localesDir, 'es.json'), 'utf8')).toContain(reviewedValue)
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('rejects malformed reviewed-audit JSON without throwing', async () => {
const { root } = makeProject({ sourceText: '', enCatalog: {} })
writeFileSync(path.join(root, 'config', 'localization-reviewed-corrections.json'), '{invalid')
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('rejects malformed translation-state JSON without throwing', async () => {
const { root } = makeProject({ sourceText: '', enCatalog: {} })
writeFileSync(path.join(root, 'config', 'localization-state', 'es.json'), '{invalid')
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
})
it('accepts reviewed target values after the reserved count placeholder migration', async () => {
const enValue = 'Selected {{value0}}'
const { root } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\ntranslate('auto.example.selected', 'Selected {{value0}}', { value0: 2 })\n",
enCatalog: { auto: { example: { selected: enValue } } },
esCatalog: { auto: { example: { selected: 'Seleccionados {{value0}}' } } },
messages: {
'auto.example.selected': { state: 'reviewed', sourceHash: sourceHash(enValue) }
}
})
writeJson(path.join(root, 'config', 'localization-reviewed-corrections.json'), {
version: 3,
algorithm: 'classified-first-parent-target-only-current-value-v2',
provenance: [
{
commit: '770a9d5dc3de6c6880f6a002e1e26f96c1906dd1',
subject: 'Improve translation quality across es/ja/ko/zh locale files (#8040)',
classification: 'reviewed-correction',
reason: 'Reviewed multi-locale correction PR.'
}
],
messages: {
es: {
'auto.example.selected': {
value: 'Seleccionados {{count}}',
commits: ['770a9d5dc3de6c6880f6a002e1e26f96c1906dd1'],
reason: 'historical-target-only-correction'
}
},
ja: {},
ko: {},
zh: {}
}
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
})
it('accepts exact-value localization PR evidence for confirmations and improvements', async () => {
const { root } = makeProject({
sourceText:
"translate('auto.example.confirmed', 'Confirmed')\ntranslate('auto.example.improved', 'Improved')\n",
enCatalog: { auto: { example: { confirmed: 'Confirmed', improved: 'Improved' } } },
esCatalog: { auto: { example: { confirmed: 'Confirmado', improved: 'Mejorado' } } },
messages: {
'auto.example.confirmed': { state: 'reviewed', sourceHash: sourceHash('Confirmed') },
'auto.example.improved': { state: 'reviewed', sourceHash: sourceHash('Improved') }
}
})
const review = { provider: 'gitlab', changeId: 'orca/orca!123', reviewer: 'locale-owner' }
writeJson(path.join(root, 'config', 'localization-reviewed-corrections.json'), {
version: 3,
algorithm: 'classified-first-parent-target-only-current-value-v2',
provenance: [],
messages: {
es: {
'auto.example.confirmed': {
value: 'Confirmado',
review,
reason: 'localization-pr-review'
},
'auto.example.improved': {
value: 'Mejorado',
review,
reason: 'localization-pr-review'
}
},
ja: {},
ko: {},
zh: {}
}
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
})
})

View File

@ -1,244 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { pathToFileURL } from 'node:url'
import { catalogFromEntries, flattenCatalog, jsonText } from './localization-catalog-model.mjs'
import { compareCodeUnits } from './localization-code-unit-order.mjs'
const TEMPLATE_PATH = path.join('config', 'localization-extraction', 'en.json')
const CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
const DISPOSITIONS_PATH = path.join('config', 'localization-extraction-dispositions.json')
const require = createRequire(import.meta.url)
const LEGACY_ORPHAN_REASON =
'Phase 2 keeps en.json authoritative until catalog ownership switches; this key has no current static source declaration.'
const LEGACY_DRIFT_REASON =
'Phase 2 reports fallback drift while runtime fallback remains the committed en.json value.'
const BULK_REASONS = {
phase2CatalogRetention: {
disposition: 'removed-source-retained-for-phase-2',
reason:
'Reviewed bulk rationale: Phase 2 keeps existing en.json catalog keys as runtime fallback source until catalog ownership switches, so removed-source keys are retained but tracked explicitly.'
}
}
const ORPHAN_DISPOSITIONS = new Set([
'removed-source-retained-for-phase-2',
'active-dynamic-extracted-elsewhere',
'migration-defect'
])
const FALLBACK_DRIFT_DISPOSITIONS = new Set([
'fallback-wording-drift-catalog-authoritative',
'source-default-update-pending-catalog-adoption',
'migration-defect'
])
function sorted(values) {
return [...values].sort(compareCodeUnits)
}
function sameValues(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index])
}
async function updateDispositions(root, orphans, fallbackDrift) {
let existing = { version: 2, bulkReasons: BULK_REASONS, orphans: {}, fallbackDrift: {} }
try {
existing = JSON.parse(await fs.readFile(path.join(root, DISPOSITIONS_PATH), 'utf8'))
} catch {}
const document = {
version: 2,
bulkReasons: existing.bulkReasons ?? BULK_REASONS,
orphans: Object.fromEntries(
sorted(new Set([...orphans, ...Object.keys(existing.orphans ?? {})])).map((key) => [
key,
existing.orphans?.[key] ?? {
disposition: 'pending-human-classification',
reason: `Pending explicit localization-owner disposition for orphan ${key}.`
}
])
),
fallbackDrift: Object.fromEntries(
sorted(new Set([...fallbackDrift, ...Object.keys(existing.fallbackDrift ?? {})])).map(
(key) => [
key,
existing.fallbackDrift?.[key] ?? {
disposition: 'pending-human-classification',
reason: `Pending explicit localization-owner disposition for fallback drift ${key}.`
}
]
)
)
}
await fs.writeFile(path.join(root, DISPOSITIONS_PATH), jsonText(document))
}
function validateBulkReasons(dispositions) {
const errors = []
if (dispositions.version !== 2) {
errors.push('extraction dispositions must use version 2')
}
if (
!dispositions.bulkReasons ||
typeof dispositions.bulkReasons !== 'object' ||
Array.isArray(dispositions.bulkReasons)
) {
errors.push('extraction dispositions bulkReasons must be an object')
return errors
}
for (const [name, entry] of Object.entries(dispositions.bulkReasons)) {
if (Object.keys(entry ?? {}).join('|') !== 'disposition|reason') {
errors.push(`bulk reason ${name} has invalid fields`)
continue
}
if (entry.disposition !== BULK_REASONS[name]?.disposition) {
errors.push(`bulk reason ${name} has unsupported disposition ${entry.disposition}`)
}
if (entry.reason !== BULK_REASONS[name]?.reason) {
errors.push(`bulk reason ${name} has unreviewed rationale text`)
}
}
return errors
}
function validateDispositionRecords(records, allowedDispositions, label, bulkReasons) {
const errors = []
if (!records || typeof records !== 'object' || Array.isArray(records)) {
return [`${label} dispositions must be an object keyed by localization key`]
}
for (const [key, entry] of Object.entries(records)) {
const fields = Object.keys(entry ?? {}).join('|')
if (fields !== 'disposition|reason' && fields !== 'disposition|reasonRef') {
errors.push(`${label}:${key} has invalid fields`)
continue
}
if (!allowedDispositions.has(entry.disposition)) {
errors.push(`${label}:${key} has unsupported disposition ${entry.disposition}`)
}
if (entry.reasonRef) {
const bulkReason = bulkReasons[entry.reasonRef]
if (!bulkReason) {
errors.push(`${label}:${key} references unknown bulk reason ${entry.reasonRef}`)
} else if (bulkReason.disposition !== entry.disposition) {
errors.push(`${label}:${key} bulk reason disposition mismatch`)
}
continue
}
if (
typeof entry.reason !== 'string' ||
entry.reason.trim().length < 24 ||
!entry.reason.includes(key)
) {
errors.push(`${label}:${key} is missing an explicit key-specific reason`)
}
if (entry.reason === LEGACY_ORPHAN_REASON || entry.reason === LEGACY_DRIFT_REASON) {
errors.push(`${label}:${key} uses a rejected generated blanket reason`)
}
}
return errors
}
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
if (!argv.includes('--skip-extractor')) {
const templatePath = path.join(root, TEMPLATE_PATH)
const originalTemplate = await fs.readFile(templatePath, 'utf8')
const packageDir = path.dirname(require.resolve('i18next-cli/package.json'))
const cliPath = path.join(packageDir, 'dist', 'esm', 'cli.js')
const result = spawnSync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
cwd: root,
env: process.env,
stdio: 'inherit'
})
if (result.status !== 0) {
await fs.writeFile(templatePath, originalTemplate)
console.error(
'Source extraction is stale. Run `pnpm run sync:localization-extraction`; target catalogs are never edited.'
)
return result.status ?? 1
}
const extracted = JSON.parse(await fs.readFile(templatePath, 'utf8'))
const normalized = jsonText(catalogFromEntries(flattenCatalog(extracted)))
await fs.writeFile(templatePath, originalTemplate)
if (normalized !== originalTemplate) {
console.error(
'Source extraction is stale. Run `pnpm run sync:localization-extraction`; target catalogs are never edited.'
)
return 1
}
} else if (argv.includes('--normalize-template')) {
const templatePath = path.join(root, TEMPLATE_PATH)
const extracted = JSON.parse(await fs.readFile(templatePath, 'utf8'))
await fs.writeFile(templatePath, jsonText(catalogFromEntries(flattenCatalog(extracted))))
}
const template = flattenCatalog(JSON.parse(await fs.readFile(path.join(root, TEMPLATE_PATH))))
const catalog = flattenCatalog(JSON.parse(await fs.readFile(path.join(root, CATALOG_PATH))))
const missing = sorted([...template.keys()].filter((key) => !catalog.has(key)))
const orphans = sorted([...catalog.keys()].filter((key) => !template.has(key)))
const fallbackDrift = sorted(
[...template]
.filter(([key, value]) => catalog.has(key) && catalog.get(key) !== value)
.map(([key]) => key)
)
if (missing.length > 0) {
console.error(`Extracted keys missing from en.json:\n${missing.join('\n')}`)
return 1
}
if (argv.includes('--update-dispositions')) {
await updateDispositions(root, orphans, fallbackDrift)
}
const dispositions = JSON.parse(await fs.readFile(path.join(root, DISPOSITIONS_PATH)))
const dispositionErrors = [
...validateBulkReasons(dispositions),
...validateDispositionRecords(
dispositions.orphans,
ORPHAN_DISPOSITIONS,
'orphan',
dispositions.bulkReasons ?? {}
),
...validateDispositionRecords(
dispositions.fallbackDrift,
FALLBACK_DRIFT_DISPOSITIONS,
'fallback drift',
dispositions.bulkReasons ?? {}
)
]
if (dispositionErrors.length > 0) {
console.error('Extraction dispositions are invalid.')
console.error(dispositionErrors.slice(0, 40).join('\n'))
if (dispositionErrors.length > 40) {
console.error(`...and ${dispositionErrors.length - 40} more error(s)`)
}
console.error(
'Replace pending entries with explicit reviewed dispositions, then rerun `pnpm run verify:localization-extraction`.'
)
return 1
}
const expectedOrphans = sorted(Object.keys(dispositions.orphans))
const expectedDrift = sorted(Object.keys(dispositions.fallbackDrift))
if (!sameValues(orphans, expectedOrphans) || !sameValues(fallbackDrift, expectedDrift)) {
console.error(
'Extraction drift changed without an explicit orphan/fallback disposition update. Run `pnpm run sync:localization-extraction`, classify new pending entries, and manually remove records whose difference has resolved.'
)
return 1
}
const surfaceCounts = {
renderer: [...template.keys()].filter((key) => key.startsWith('auto.')).length,
web: [...template.keys()].filter((key) => key.startsWith('auto.web.')).length,
main: [...template.keys()].filter((key) => key.startsWith('menu.')).length
}
if (Object.values(surfaceCounts).some((count) => count === 0)) {
console.error(`Cross-process extraction proof failed: ${JSON.stringify(surfaceCounts)}`)
return 1
}
console.log(
`Verified deterministic source template: ${template.size} keys, ${orphans.length} disposed orphans, ` +
`${fallbackDrift.length} disposed fallback drifts; ${JSON.stringify(surfaceCounts)}.`
)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}

View File

@ -1,122 +0,0 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { main as verifyLocalizationExtraction } from './verify-localization-extraction.mjs'
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function makeProject() {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-extraction-'))
const extractionDir = path.join(root, 'config', 'localization-extraction')
const catalogDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
mkdirSync(extractionDir, { recursive: true })
mkdirSync(catalogDir, { recursive: true })
writeJson(path.join(extractionDir, 'en.json'), {
auto: { active: 'Active', drift: 'Inline fallback', web: { example: 'Web' } },
menu: { file: 'File' }
})
writeJson(path.join(catalogDir, 'en.json'), {
auto: {
active: 'Active',
drift: 'Catalog fallback',
orphan: 'Old copy',
web: { example: 'Web' }
},
menu: { file: 'File' }
})
return root
}
describe('verify-localization-extraction dispositions', () => {
it('writes pending dispositions without fabricating reviewed classifications', async () => {
const root = makeProject()
await expect(
verifyLocalizationExtraction(root, ['--skip-extractor', '--update-dispositions'])
).resolves.toBe(1)
})
it('rejects legacy generated blanket reasons', async () => {
const root = makeProject()
writeJson(path.join(root, 'config', 'localization-extraction-dispositions.json'), {
version: 1,
orphans: {
'auto.orphan': {
disposition: 'retained-catalog-source-not-statically-referenced',
reason:
'Phase 2 keeps en.json authoritative until catalog ownership switches; this key has no current static source declaration.'
}
},
fallbackDrift: {
'auto.drift': {
disposition: 'catalog-authoritative-through-phase-2',
reason:
'Phase 2 reports fallback drift while runtime fallback remains the committed en.json value.'
}
}
})
await expect(verifyLocalizationExtraction(root, ['--skip-extractor'])).resolves.toBe(1)
})
it('rejects unsupported dispositions and non-key-specific direct reasons', async () => {
const root = makeProject()
writeJson(path.join(root, 'config', 'localization-extraction-dispositions.json'), {
version: 2,
bulkReasons: {
phase2CatalogRetention: {
disposition: 'removed-source-retained-for-phase-2',
reason:
'Reviewed bulk rationale: Phase 2 keeps existing en.json catalog keys as runtime fallback source until catalog ownership switches, so removed-source keys are retained but tracked explicitly.'
}
},
orphans: {
'auto.orphan': {
disposition: 'unclassified',
reason: 'This orphan has a generic reason without the key.'
}
},
fallbackDrift: {
'auto.drift': {
disposition: 'fallback-wording-drift-catalog-authoritative',
reason: 'This drift has a generic reason without the key.'
}
}
})
await expect(verifyLocalizationExtraction(root, ['--skip-extractor'])).resolves.toBe(1)
})
it('preserves existing dispositions and leaves new differences pending', async () => {
const root = makeProject()
const existing = {
disposition: 'migration-defect',
reason: 'The auto.orphan extraction gap is a reviewed migration defect.'
}
writeJson(path.join(root, 'config', 'localization-extraction-dispositions.json'), {
version: 2,
bulkReasons: {},
orphans: { 'auto.orphan': existing },
fallbackDrift: {
'auto.drift': {
disposition: 'migration-defect',
reason: 'The auto.drift fallback mismatch is a reviewed migration defect.'
}
}
})
const catalogPath = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'))
catalog.auto.secondOrphan = 'Second orphan'
writeJson(catalogPath, catalog)
await expect(
verifyLocalizationExtraction(root, ['--skip-extractor', '--update-dispositions'])
).resolves.toBe(1)
const updated = JSON.parse(
readFileSync(path.join(root, 'config', 'localization-extraction-dispositions.json'), 'utf8')
)
expect(updated.orphans['auto.orphan']).toEqual(existing)
expect(updated.orphans['auto.secondOrphan'].disposition).toBe('pending-human-classification')
})
})

View File

@ -1,63 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import {
collectLocalizationCandidates,
main as verifyCoverage
} from './audit-localization-coverage.mjs'
import {
collectLocalizationKeyReferences,
collectLocalizationSourceFiles,
parseLocalizationSource
} from './localization-source-references.mjs'
import { main as verifyCatalog } from './verify-localization-catalog.mjs'
const SOURCE_AREAS = [
{
root: path.join('src', 'renderer', 'src'),
allowlist: path.join('config', 'localization-coverage-allowlist.json')
},
{
root: path.join('src', 'main'),
allowlist: path.join('config', 'localization-main-process-allowlist.json')
}
]
export async function main(root = process.cwd()) {
const references = []
const preparedAreas = []
// Why: product-owned checks share one read and AST per file; i18next-cli remains independent.
for (const area of SOURCE_AREAS) {
const files = await collectLocalizationSourceFiles(root, path.join(root, area.root))
const reports = []
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
const sourceFile = parseLocalizationSource(filePath, sourceText)
references.push(collectLocalizationKeyReferences(filePath, sourceText, root, sourceFile))
reports.push(...collectLocalizationCandidates(filePath, sourceText, root, sourceFile))
}
preparedAreas.push({ ...area, files, reports })
}
if ((await verifyCatalog(root, { fix: false, references: references.flat() })) !== 0) {
return 1
}
for (const area of preparedAreas) {
const result = await verifyCoverage(
root,
['--source-root', area.root, '--allowlist', area.allowlist, '--check'],
area
)
if (result !== 0) {
return result
}
}
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}

View File

@ -1,22 +0,0 @@
import { defineConfig } from 'i18next-cli'
export default defineConfig({
locales: ['en'],
extract: {
input: ['src/**/*.{js,jsx,ts,tsx,mts,cts}'],
ignore: [
'**/*.test.*',
'**/*.spec.*',
'**/__tests__/**',
'**/__snapshots__/**',
'**/assets/**'
],
output: 'config/localization-extraction/{{language}}.json',
defaultNS: false,
functions: ['t', '*.t', 'translate', 'translateMain'],
useTranslationNames: ['useTranslation'],
sort: true,
disablePlurals: true,
removeUnusedKeys: true
}
})

View File

@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:product-localization && pnpm run verify:localization-extraction && pnpm run verify:localization-key-types",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
@ -48,16 +48,15 @@
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
"verify:cli-bin": "node config/scripts/verify-cli-bin.mjs",
"verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs",
"verify:product-localization": "node config/scripts/verify-product-localization.mjs",
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
"verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs",
"sync:localization-extraction": "i18next-cli extract --sync-primary && node config/scripts/verify-localization-extraction.mjs --skip-extractor --normalize-template --update-dispositions",
"verify:localization-key-types": "node config/scripts/generate-localization-key-types.mjs",
"sync:localization-key-types": "node config/scripts/generate-localization-key-types.mjs --fix",
"verify:localization-main-process": "node config/scripts/audit-localization-coverage.mjs --source-root src/main --allowlist config/localization-main-process-allowlist.json --check",
"bootstrap:locale-catalog": "node config/scripts/bootstrap-locale-catalog.mjs",
"bootstrap:zh-catalog": "node config/scripts/bootstrap-zh-catalog.mjs",
"bootstrap:ko-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ko",
"bootstrap:ja-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ja",
"bootstrap:es-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale es",
"repair:locale-catalog": "node config/scripts/repair-locale-catalog.mjs",
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check",
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
"localization:status": "node config/scripts/report-localization-status.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs",
@ -176,7 +175,6 @@
"happy-dom": "^20.9.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"i18next-cli": "1.65.0",
"katex": "^0.16.45",
"lint-staged": "^16.4.0",
"lowlight": "^3.3.0",

View File

@ -238,7 +238,7 @@ importers:
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
electron-vite:
specifier: ^5.0.0
version: 5.0.0(@swc/core@1.15.43)(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
version: 5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
emoji-picker-react:
specifier: ^4.19.1
version: 4.19.1(react@19.2.5)
@ -251,9 +251,6 @@ importers:
husky:
specifier: ^9.1.7
version: 9.1.7
i18next-cli:
specifier: 1.65.0
version: 1.65.0(@types/node@25.6.0)(react-dom@19.2.5(react@19.2.5))(typescript@7.0.2)
katex:
specifier: ^0.16.45
version: 0.16.45
@ -562,12 +559,6 @@ packages:
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
'@croct/json5-parser@0.2.2':
resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==}
'@croct/json@2.1.0':
resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==}
'@develar/schema-utils@2.6.5':
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'}
@ -1011,19 +1002,6 @@ packages:
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/ansi@2.0.7':
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/checkbox@5.2.1':
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/confirm@6.0.12':
resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@ -1033,15 +1011,6 @@ packages:
'@types/node':
optional: true
'@inquirer/confirm@6.1.1':
resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@11.1.9':
resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@ -1051,113 +1020,10 @@ packages:
'@types/node':
optional: true
'@inquirer/core@11.2.1':
resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/editor@5.2.2':
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/expand@5.1.1':
resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/external-editor@3.0.3':
resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@2.0.5':
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/figures@2.0.7':
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/input@5.1.2':
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/number@4.1.1':
resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/password@5.1.1':
resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/prompts@8.5.2':
resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/rawlist@5.3.1':
resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/search@4.2.1':
resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/select@5.2.1':
resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/type@4.0.5':
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@ -1167,15 +1033,6 @@ packages:
'@types/node':
optional: true
'@inquirer/type@4.0.7':
resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
@ -2610,99 +2467,6 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/core-darwin-arm64@1.15.43':
resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.43':
resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.43':
resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.43':
resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.43':
resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-ppc64-gnu@1.15.43':
resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@swc/core-linux-s390x-gnu@1.15.43':
resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-gnu@1.15.43':
resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.15.43':
resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.43':
resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.43':
resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.43':
resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.43':
resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/types@0.1.27':
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@ -3749,13 +3513,6 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
chardet@2.2.0:
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@ -4632,10 +4389,6 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
@ -4793,15 +4546,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
i18next-cli@1.65.0:
resolution: {integrity: sha512-sak+2Ry4P7wtl7xMAZg2sWG2vup1lRHFBKA7h5IeEqFUog51QEgeYUhHxd2x85+MvS4BhVOZZs833clnd1WgYA==}
engines: {node: '>=22'}
hasBin: true
i18next-resources-for-ts@2.1.0:
resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==}
hasBin: true
i18next@26.3.1:
resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==}
peerDependencies:
@ -4852,15 +4596,6 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
inquirer@14.0.2:
resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
inspect-webkit@0.0.5:
resolution: {integrity: sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw==}
hasBin: true
@ -5738,10 +5473,6 @@ packages:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@ -6032,18 +5763,10 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
read-binary-file-arch@1.0.6:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
hasBin: true
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@ -6166,10 +5889,6 @@ packages:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
run-async@4.0.6:
resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==}
engines: {node: '>=0.12.0'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -6899,11 +6618,6 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
@ -7182,12 +6896,6 @@ snapshots:
'@chevrotain/types@11.1.2': {}
'@croct/json5-parser@0.2.2':
dependencies:
'@croct/json': 2.1.0
'@croct/json@2.1.0': {}
'@develar/schema-utils@2.6.5':
dependencies:
ajv: 6.15.0
@ -7532,17 +7240,6 @@ snapshots:
'@inquirer/ansi@2.0.5': {}
'@inquirer/ansi@2.0.7': {}
'@inquirer/checkbox@5.2.1(@types/node@25.6.0)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/confirm@6.0.12(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.1.9(@types/node@25.6.0)
@ -7550,13 +7247,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/confirm@6.1.1(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/core@11.1.9(@types/node@25.6.0)':
dependencies:
'@inquirer/ansi': 2.0.5
@ -7569,113 +7259,12 @@ snapshots:
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/core@11.2.1(@types/node@25.6.0)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@25.6.0)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/editor@5.2.2(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/external-editor': 3.0.3(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/expand@5.1.1(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/external-editor@3.0.3(@types/node@25.6.0)':
dependencies:
chardet: 2.2.0
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/figures@2.0.5': {}
'@inquirer/figures@2.0.7': {}
'@inquirer/input@5.1.2(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/number@4.1.1(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/password@5.1.1(@types/node@25.6.0)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/prompts@8.5.2(@types/node@25.6.0)':
dependencies:
'@inquirer/checkbox': 5.2.1(@types/node@25.6.0)
'@inquirer/confirm': 6.1.1(@types/node@25.6.0)
'@inquirer/editor': 5.2.2(@types/node@25.6.0)
'@inquirer/expand': 5.1.1(@types/node@25.6.0)
'@inquirer/input': 5.1.2(@types/node@25.6.0)
'@inquirer/number': 4.1.1(@types/node@25.6.0)
'@inquirer/password': 5.1.1(@types/node@25.6.0)
'@inquirer/rawlist': 5.3.1(@types/node@25.6.0)
'@inquirer/search': 4.2.1(@types/node@25.6.0)
'@inquirer/select': 5.2.1(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/rawlist@5.3.1(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/search@4.2.1(@types/node@25.6.0)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/select@5.2.1(@types/node@25.6.0)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@25.6.0)
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/type@4.0.5(@types/node@25.6.0)':
optionalDependencies:
'@types/node': 25.6.0
'@inquirer/type@4.0.7(@types/node@25.6.0)':
optionalDependencies:
'@types/node': 25.6.0
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
@ -8929,66 +8518,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@swc/core-darwin-arm64@1.15.43':
optional: true
'@swc/core-darwin-x64@1.15.43':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.43':
optional: true
'@swc/core-linux-arm64-gnu@1.15.43':
optional: true
'@swc/core-linux-arm64-musl@1.15.43':
optional: true
'@swc/core-linux-ppc64-gnu@1.15.43':
optional: true
'@swc/core-linux-s390x-gnu@1.15.43':
optional: true
'@swc/core-linux-x64-gnu@1.15.43':
optional: true
'@swc/core-linux-x64-musl@1.15.43':
optional: true
'@swc/core-win32-arm64-msvc@1.15.43':
optional: true
'@swc/core-win32-ia32-msvc@1.15.43':
optional: true
'@swc/core-win32-x64-msvc@1.15.43':
optional: true
'@swc/core@1.15.43':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.27
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.43
'@swc/core-darwin-x64': 1.15.43
'@swc/core-linux-arm-gnueabihf': 1.15.43
'@swc/core-linux-arm64-gnu': 1.15.43
'@swc/core-linux-arm64-musl': 1.15.43
'@swc/core-linux-ppc64-gnu': 1.15.43
'@swc/core-linux-s390x-gnu': 1.15.43
'@swc/core-linux-x64-gnu': 1.15.43
'@swc/core-linux-x64-musl': 1.15.43
'@swc/core-win32-arm64-msvc': 1.15.43
'@swc/core-win32-ia32-msvc': 1.15.43
'@swc/core-win32-x64-msvc': 1.15.43
'@swc/counter@0.1.3': {}
'@swc/types@0.1.27':
dependencies:
'@swc/counter': 0.1.3
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
@ -10044,12 +9573,6 @@ snapshots:
character-reference-invalid@2.0.1: {}
chardet@2.2.0: {}
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chownr@3.0.0: {}
chromium-pickle-js@0.2.0: {}
@ -10587,7 +10110,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-vite@5.0.0(@swc/core@1.15.43)(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
electron-vite@5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7)
@ -10596,8 +10119,6 @@ snapshots:
magic-string: 0.30.21
picocolors: 1.1.1
vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
optionalDependencies:
'@swc/core': 1.15.43
transitivePeerDependencies:
- supports-color
@ -11032,12 +10553,6 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@13.0.6:
dependencies:
minimatch: 10.2.5
minipass: 7.1.3
path-scurry: 2.0.2
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@ -11292,41 +10807,6 @@ snapshots:
husky@9.1.7: {}
i18next-cli@1.65.0(@types/node@25.6.0)(react-dom@19.2.5(react@19.2.5))(typescript@7.0.2):
dependencies:
'@croct/json5-parser': 0.2.2
'@swc/core': 1.15.43
chokidar: 5.0.0
commander: 14.0.3
execa: 9.6.1
glob: 13.0.6
i18next: 26.3.1(typescript@7.0.2)
i18next-resources-for-ts: 2.1.0
inquirer: 14.0.2(@types/node@25.6.0)
jiti: 2.7.0
jsonc-parser: 3.3.1
magic-string: 0.30.21
minimatch: 10.2.5
ora: 9.4.0
react: 19.2.7
react-i18next: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.7)(typescript@7.0.2)
yaml: 2.9.0
transitivePeerDependencies:
- '@swc/helpers'
- '@types/node'
- react-dom
- react-native
- typescript
i18next-resources-for-ts@2.1.0:
dependencies:
'@babel/runtime': 7.29.7
'@swc/core': 1.15.43
chokidar: 5.0.0
yaml: 2.9.0
transitivePeerDependencies:
- '@swc/helpers'
i18next@26.3.1(typescript@7.0.2):
optionalDependencies:
typescript: 7.0.2
@ -11368,17 +10848,6 @@ snapshots:
inline-style-parser@0.2.7: {}
inquirer@14.0.2(@types/node@25.6.0):
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.6.0)
'@inquirer/prompts': 8.5.2(@types/node@25.6.0)
'@inquirer/type': 4.0.7(@types/node@25.6.0)
mute-stream: 3.0.0
run-async: 4.0.6
optionalDependencies:
'@types/node': 25.6.0
inspect-webkit@0.0.5(typescript@7.0.2):
dependencies:
typescript: 7.0.2
@ -12445,11 +11914,6 @@ snapshots:
path-key@4.0.0: {}
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.1
minipass: 7.1.3
path-to-regexp@6.3.0: {}
path-to-regexp@8.4.2: {}
@ -12756,17 +12220,6 @@ snapshots:
react-dom: 19.2.5(react@19.2.5)
typescript: 7.0.2
react-i18next@17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.7)(typescript@7.0.2):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1
i18next: 26.3.1(typescript@7.0.2)
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
typescript: 7.0.2
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
@ -12818,16 +12271,12 @@ snapshots:
react@19.2.5: {}
react@19.2.7: {}
read-binary-file-arch@1.0.6:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
readdirp@5.0.0: {}
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@ -13037,8 +12486,6 @@ snapshots:
run-applescript@7.1.0: {}
run-async@4.0.6: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@ -13578,10 +13025,6 @@ snapshots:
dependencies:
react: 19.2.5
use-sync-external-store@1.6.0(react@19.2.7):
dependencies:
react: 19.2.7
utf8-byte-length@1.0.5: {}
util-deprecate@1.0.2: {}
@ -13735,8 +13178,6 @@ snapshots:
yaml@2.8.4: {}
yaml@2.9.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1

View File

@ -51,16 +51,6 @@ describe('main-i18n lazy locale loading', () => {
expect(translateMain('menu.file', 'File')).not.toBe('File')
})
it('uses caller English when a target catalog intentionally omits a key', async () => {
await setMainUiLanguage(UI_LANGUAGE_SPANISH)
expect(
translateMain(
'auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder',
'/home/user/projects'
)
).toBe('/home/user/projects')
})
it('returns to English from a lazily-loaded locale', async () => {
await setMainUiLanguage(UI_LANGUAGE_SPANISH)
expect(translateMain('menu.file', 'File')).toBe('Archivo')

View File

@ -2942,7 +2942,7 @@ function CommentCodeContext({
)
}
aria-label={translate(
'auto.components.GitHubItemDialog.4e65770e91',
'auto.components.GitHubItemDialog.307c98e8e3',
'Show {{value0}} more lines below',
{ value0: CODE_CONTEXT_EXPAND_STEP }
)}

View File

@ -3110,7 +3110,7 @@ function CommentCodeContext({
)
}
aria-label={translate(
'auto.components.PullRequestPage.5ea92b8f34',
'auto.components.PullRequestPage.e295a78c11',
'Show {{value0}} more lines below',
{ value0: CODE_CONTEXT_EXPAND_STEP }
)}

View File

@ -120,8 +120,8 @@ export function LinearIssueFilterSectionMenu({
value.stateIds.length > 0
? translate(
'auto.components.linear-issue-attribute-filter-sections.countSelected',
'{{value0}} selected',
{ value0: value.stateIds.length }
'{{count}} selected',
{ count: value.stateIds.length }
)
: ''
},
@ -135,8 +135,8 @@ export function LinearIssueFilterSectionMenu({
value.priorities.length > 0
? translate(
'auto.components.linear-issue-attribute-filter-sections.countSelected',
'{{value0}} selected',
{ value0: value.priorities.length }
'{{count}} selected',
{ count: value.priorities.length }
)
: ''
},
@ -162,8 +162,8 @@ export function LinearIssueFilterSectionMenu({
value.labelIds.length > 0
? translate(
'auto.components.linear-issue-attribute-filter-sections.countSelected',
'{{value0}} selected',
{ value0: value.labelIds.length }
'{{count}} selected',
{ count: value.labelIds.length }
)
: ''
}

View File

@ -399,7 +399,7 @@ export function LinearProjectTable({
onOpenProject(project)
}}
aria-label={translate(
'auto.components.linear.project.view.surfaces.1e63a1b413',
'auto.components.linear.project.view.surfaces.7616c986c6',
'Open {{value0}} in Linear',
{ value0: project.name }
)}
@ -549,7 +549,7 @@ export function LinearCustomViewTable({
onOpenView(view)
}}
aria-label={translate(
'auto.components.linear.project.view.surfaces.1e63a1b413',
'auto.components.linear.project.view.surfaces.7616c986c6',
'Open {{value0}} in Linear',
{ value0: view.name }
)}

View File

@ -107,12 +107,11 @@ export function NativeChatToolRun({
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
const fallbackLabel =
callCount === 1
? translate('components.native-chat.tool.countOne', '1 tool call')
: translate('components.native-chat.tool.countN', '{{value0}} tool calls', {
value0: callCount
})
const fallbackLabel = translate(
callCount === 1 ? 'components.native-chat.tool.countOne' : 'components.native-chat.tool.countN',
callCount === 1 ? '1 tool call' : `${callCount} tool calls`,
{ count: callCount }
)
return (
<div className="mt-1">

View File

@ -208,8 +208,8 @@ export function OrcaProfileManagementDialog({
repos.length === 1
? 'auto.components.orca.profiles.management.projectCountSingular'
: 'auto.components.orca.profiles.management.projectCountPlural',
repos.length === 1 ? '{{value0}} project' : '{{value0}} projects',
{ value0: repos.length }
repos.length === 1 ? '{{count}} project' : '{{count}} projects',
{ count: repos.length }
)}
</div>
</div>

View File

@ -34,9 +34,9 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
? 'auto.components.orca.profiles.project.transfer.confirm.terminalSingular'
: 'auto.components.orca.profiles.project.transfer.confirm.terminalPlural',
summary.liveTerminalTabCount === 1
? '{{value0}} live terminal tab'
: '{{value0}} live terminal tabs',
{ value0: summary.liveTerminalTabCount }
? '{{count}} live terminal tab'
: '{{count}} live terminal tabs',
{ count: summary.liveTerminalTabCount }
)
)
}
@ -46,8 +46,8 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
summary.liveAgentCount === 1
? 'auto.components.orca.profiles.project.transfer.confirm.agentSingular'
: 'auto.components.orca.profiles.project.transfer.confirm.agentPlural',
summary.liveAgentCount === 1 ? '{{value0}} active agent' : '{{value0}} active agents',
{ value0: summary.liveAgentCount }
summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents',
{ count: summary.liveAgentCount }
)
)
}
@ -58,9 +58,9 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
? 'auto.components.orca.profiles.project.transfer.confirm.browserSingular'
: 'auto.components.orca.profiles.project.transfer.confirm.browserPlural',
summary.browserWorkspaceCount === 1
? '{{value0}} browser workspace'
: '{{value0}} browser workspaces',
{ value0: summary.browserWorkspaceCount }
? '{{count}} browser workspace'
: '{{count}} browser workspaces',
{ count: summary.browserWorkspaceCount }
)
)
}

View File

@ -21,9 +21,9 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
? 'auto.components.orca.profiles.switch.confirm.terminalSingular'
: 'auto.components.orca.profiles.switch.confirm.terminalPlural',
summary.liveTerminalTabCount === 1
? '{{value0}} live terminal tab'
: '{{value0}} live terminal tabs',
{ value0: summary.liveTerminalTabCount }
? '{{count}} live terminal tab'
: '{{count}} live terminal tabs',
{ count: summary.liveTerminalTabCount }
)
)
}
@ -33,8 +33,8 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
summary.liveAgentCount === 1
? 'auto.components.orca.profiles.switch.confirm.agentSingular'
: 'auto.components.orca.profiles.switch.confirm.agentPlural',
summary.liveAgentCount === 1 ? '{{value0}} active agent' : '{{value0}} active agents',
{ value0: summary.liveAgentCount }
summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents',
{ count: summary.liveAgentCount }
)
)
}
@ -45,9 +45,9 @@ function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] {
? 'auto.components.orca.profiles.switch.confirm.browserSingular'
: 'auto.components.orca.profiles.switch.confirm.browserPlural',
summary.browserWorkspaceCount === 1
? '{{value0}} browser workspace'
: '{{value0}} browser workspaces',
{ value0: summary.browserWorkspaceCount }
? '{{count}} browser workspace'
: '{{count}} browser workspaces',
{ count: summary.browserWorkspaceCount }
)
)
}

View File

@ -355,8 +355,8 @@ export default function AiVaultPanel(): React.JSX.Element {
<div className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped',
'{{value0}} transcript skipped',
{ value0: scanResult.issues.length }
'{{count}} transcript skipped',
{ count: scanResult.issues.length }
)}
</div>
) : null}

View File

@ -513,19 +513,23 @@ const PRIMARY_ICONS: Partial<
create_pr: GitPullRequestArrow
}
function getSourceControlSectionLabel(area: SourceControlSectionArea): string {
switch (area) {
case 'staged':
return translate('auto.components.right.sidebar.SourceControl.48a003c1b1', 'Staged Changes')
case 'unstaged':
return translate('auto.components.right.sidebar.SourceControl.d4ef4bafc5', 'Changes')
case 'untracked':
return translate('auto.components.right.sidebar.SourceControl.522f44dce5', 'Untracked Files')
const SECTION_LABELS: Record<SourceControlSectionArea, { key: string; fallback: string }> = {
staged: {
key: 'auto.components.right.sidebar.SourceControl.48a003c1b1',
fallback: 'Staged Changes'
},
unstaged: {
key: 'auto.components.right.sidebar.SourceControl.d4ef4bafc5',
fallback: 'Changes'
},
untracked: {
key: 'auto.components.right.sidebar.SourceControl.522f44dce5',
fallback: 'Untracked Files'
}
}
function getConflictsSectionLabel(): string {
return translate('auto.components.right.sidebar.SourceControl.conflictsSection', 'Conflicts')
const CONFLICTS_SECTION_LABEL = {
key: 'auto.components.right.sidebar.SourceControl.conflictsSection',
fallback: 'Conflicts'
}
// Why: 5s branch compare polling churned git subprocesses in large repos.
@ -5944,14 +5948,12 @@ function SourceControlInner(): React.JSX.Element {
const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0
const canRevertAll = !normalizedFilter && discardAllPaths.length > 0
const sectionLabel =
id === 'conflicts'
? getConflictsSectionLabel()
: getSourceControlSectionLabel(area)
id === 'conflicts' ? CONFLICTS_SECTION_LABEL : SECTION_LABELS[area]
const sectionViewAction = getSourceControlSectionViewAction(actionSection)
return (
<div key={id}>
<SectionHeader
label={sectionLabel}
label={translate(sectionLabel.key, sectionLabel.fallback)}
count={items.length}
conflictCount={
items.filter((entry) => entry.conflictStatus === 'unresolved').length

View File

@ -287,13 +287,13 @@ export function useFileDeletion({
title: hasLocalDelete
? translate(
'auto.components.right.sidebar.useFileDeletion.77fdc36183',
'Delete {{value0}} items?',
{ value0: nodes.length }
'Delete {{count}} items?',
{ count: nodes.length }
)
: translate(
'auto.components.right.sidebar.useFileDeletion.af1270b90d',
'Permanently delete {{value0}} items?',
{ value0: nodes.length }
'Permanently delete {{count}} items?',
{ count: nodes.length }
),
description: hasLocalDelete
? translate(

View File

@ -15,30 +15,28 @@ export { getComputerUsePaneSearchEntries } from './computer-use-search'
type PermissionDefinition = {
id: ComputerUsePermissionId
label: () => string
description: () => string
labelKey: string
labelDefault: string
descriptionKey: string
descriptionDefault: string
icon: ReactNode
}
const PERMISSIONS: PermissionDefinition[] = [
{
id: 'accessibility',
label: () => translate('auto.components.settings.ComputerUsePane.6b5a2cd3a5', 'Accessibility'),
description: () =>
translate(
'auto.components.settings.ComputerUsePane.4d03dec2d0',
'Read app interface trees and perform requested actions.'
),
labelKey: 'auto.components.settings.ComputerUsePane.6b5a2cd3a5',
labelDefault: 'Accessibility',
descriptionKey: 'auto.components.settings.ComputerUsePane.4d03dec2d0',
descriptionDefault: 'Read app interface trees and perform requested actions.',
icon: <Accessibility className="size-4" />
},
{
id: 'screenshots',
label: () => translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots'),
description: () =>
translate(
'auto.components.settings.ComputerUsePane.0c9a33f468',
'Capture app windows so agents can inspect visual state.'
),
labelKey: 'auto.components.settings.ComputerUsePane.07bbe4c4cb',
labelDefault: 'Screenshots',
descriptionKey: 'auto.components.settings.ComputerUsePane.0c9a33f468',
descriptionDefault: 'Capture app windows so agents can inspect visual state.',
icon: <Camera className="size-4" />
}
]
@ -303,7 +301,9 @@ export function ComputerUsePane(): React.JSX.Element {
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{permission.label()}</span>
<span className="text-sm font-medium">
{translate(permission.labelKey, permission.labelDefault)}
</span>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusClass(
status
@ -312,7 +312,9 @@ export function ComputerUsePane(): React.JSX.Element {
{statusLabel(status)}
</span>
</div>
<p className="text-xs text-muted-foreground">{permission.description()}</p>
<p className="text-xs text-muted-foreground">
{translate(permission.descriptionKey, permission.descriptionDefault)}
</p>
</div>
</div>
<div className="flex w-28 shrink-0 justify-end">

View File

@ -248,8 +248,8 @@ function getLiveTerminalCountLabel(count: number): string {
count === 1
? 'auto.components.settings.ProjectWindowsRuntimeSetting.liveTerminalSingular'
: 'auto.components.settings.ProjectWindowsRuntimeSetting.liveTerminalPlural',
count === 1 ? '{{value0}} live terminal' : '{{value0}} live terminals',
{ value0: count }
count === 1 ? '{{count}} live terminal' : '{{count}} live terminals',
{ count }
)
}
@ -258,8 +258,8 @@ function getActiveTaskCountLabel(count: number): string {
count === 1
? 'auto.components.settings.ProjectWindowsRuntimeSetting.activeTaskSingular'
: 'auto.components.settings.ProjectWindowsRuntimeSetting.activeTaskPlural',
count === 1 ? '{{value0}} active task' : '{{value0}} active tasks',
{ value0: count }
count === 1 ? '{{count}} active task' : '{{count}} active tasks',
{ count }
)
}

View File

@ -33,8 +33,8 @@ function formatForkSyncResult(result: GitForkSyncResult): { title: string; descr
)
: translate(
'auto.components.settings.RepositoryForkSyncSection.syncedDescriptionPlural',
'Fast-forwarded {{branch}} by {{value0}} commits.',
{ branch, value0: result.behind }
'Fast-forwarded {{branch}} by {{count}} commits.',
{ branch, count: result.behind }
)
}
}

View File

@ -164,7 +164,7 @@ export function RepositoryIconTabs({
className="size-8"
onClick={() => onSetIcon({ type: 'lucide', name: option.name })}
aria-label={translate(
'auto.components.settings.RepositoryIconPicker.8fbd0654c1',
'auto.components.settings.RepositoryIconPicker.2b7d27b93c',
'Use {{value0}} repo icon',
{ value0: option.label }
)}
@ -190,7 +190,7 @@ export function RepositoryIconTabs({
className="size-8 text-base"
onClick={() => onSetIcon({ type: 'emoji', emoji })}
aria-label={translate(
'auto.components.settings.RepositoryIconPicker.8fbd0654c1',
'auto.components.settings.RepositoryIconPicker.2b7d27b93c',
'Use {{value0}} repo icon',
{ value0: emoji }
)}

View File

@ -69,8 +69,8 @@ export function SourceControlActionRepoOverrideNote({
)
: translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.plural',
"Global saves won't change {{value0}} repositories with their own recipes.",
{ value0: summary.count }
"Global saves won't change {{count}} repositories with their own recipes.",
{ count: summary.count }
)
return (
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded-md bg-muted/40 px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
@ -103,8 +103,8 @@ export function SourceControlActionRepoOverrideNote({
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SourceControlActionRepoOverrideNote.more',
'+{{value0}} more',
{ value0: hiddenOverrideCount }
'+{{count}} more',
{ count: hiddenOverrideCount }
)}
</p>
) : null}

View File

@ -137,17 +137,12 @@ export function CloneStep({
value={cloneDestination}
onChange={(e) => onDestChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
placeholder={translate(
isRemoteClone
? translate(
'auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder',
'/home/user/projects'
)
: translate(
'auto.components.sidebar.AddRepoSteps.2ce3f6edf8',
'/path/to/destination'
)
}
? 'auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder'
: 'auto.components.sidebar.AddRepoSteps.2ce3f6edf8',
isRemoteClone ? '/home/user/projects' : '/path/to/destination'
)}
className="h-8 text-xs flex-1"
disabled={isCloning}
/>

View File

@ -117,8 +117,8 @@ export function HostRemoveDialog({
toast.error(
translate(
'auto.components.sidebar.HostRemoveDialog.workspacesFailed',
'Could not remove {{value0}} of this hosts workspaces. The host was kept so you can retry.',
{ value0: failedIds.length }
'Could not remove {{count}} of this hosts workspaces. The host was kept so you can retry.',
{ count: failedIds.length }
)
)
return
@ -154,8 +154,10 @@ export function HostRemoveDialog({
? translate('auto.components.sidebar.HostRemoveDialog.oneWorkspace', '1 workspace')
: translate(
'auto.components.sidebar.HostRemoveDialog.manyWorkspaces',
'{{value0}} workspaces',
{ value0: workspaceCount }
'{{count}} workspaces',
{
count: workspaceCount
}
)
const description =

View File

@ -107,7 +107,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
aria-label={
hasAnyFilter
? translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.21083fb457',
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041',
'Workspace options ({{value0}} active)',
{ value0: activeFilterLabel }
)

View File

@ -164,7 +164,7 @@ export default function WorkspaceKanbanSettingsMenu({
disabled={index === workspaceStatuses.length - 1}
onClick={() => onMoveStatus(status.id, 1)}
aria-label={translate(
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.833d8a506e',
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.b45b350eb0',
'Move {{value0}} right',
{ value0: status.label }
)}

View File

@ -108,7 +108,7 @@ export default function WorkspaceStatusAppearancePopover({
className="size-8"
onClick={() => onChangeIcon(status.id, icon.id)}
aria-label={translate(
'auto.components.sidebar.WorkspaceStatusAppearancePopover.2270c830c4',
'auto.components.sidebar.WorkspaceStatusAppearancePopover.514be2f569',
'Set {{value0}} icon to {{value1}}',
{ value0: status.label, value1: icon.label }
)}

View File

@ -26,10 +26,12 @@ export function UsageBreakdownSection({
rows,
eventsOrTurns
}: UsageBreakdownSectionProps): React.JSX.Element {
const eventsOrTurnsLabel =
const eventsOrTurnsKey =
eventsOrTurns === 'turns'
? translate('auto.components.stats.UsageBreakdownSection.32176e1d44', 'turns')
: translate('auto.components.stats.UsageBreakdownSection.79a69522a5', 'events')
? 'auto.components.stats.UsageBreakdownSection.32176e1d44'
: 'auto.components.stats.UsageBreakdownSection.79a69522a5'
const eventsOrTurnsLabel = eventsOrTurns === 'turns' ? 'turns' : 'events'
const sessionsKey = 'auto.components.stats.UsageBreakdownSection.02a046792e'
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
@ -48,9 +50,8 @@ export function UsageBreakdownSection({
<span className="shrink-0 text-muted-foreground">{formatTokens(row.tokens)}</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions}{' '}
{translate('auto.components.stats.UsageBreakdownSection.02a046792e', 'sessions •')}{' '}
{row.eventsOrTurns} {eventsOrTurnsLabel}
{row.sessions} {translate(sessionsKey, 'sessions •')} {row.eventsOrTurns}{' '}
{translate(eventsOrTurnsKey, eventsOrTurnsLabel)}
{row.hasInferredPricing
? ` ${translate('auto.components.stats.UsageBreakdownSection.247c93ca92', '• inferred pricing')}`
: ''}

View File

@ -71,7 +71,7 @@ export function WorkspaceSpaceCompactPanel({
}
)
: translate(
'auto.components.status.bar.WorkspaceSpaceCompactPanel.28f967d2fb',
'auto.components.status.bar.WorkspaceSpaceCompactPanel.bef4dc0457',
'{{value0}} reclaimable · {{value1}} workspaces',
{
value0: formatBytes(analysis.reclaimableBytes),

View File

@ -231,7 +231,7 @@ async function uploadRemoteDropPaths(
): Promise<void> {
const pending = toast.loading(
translate(
'auto.components.terminal.pane.terminal.drop.handler.e040850c0d',
'auto.components.terminal.pane.terminal.drop.handler.29c031b49a',
'Uploading {{value0}} file{{value1}} to remote…',
{ value0: args.dataPaths.length, value1: args.dataPaths.length === 1 ? '' : 's' }
)

View File

@ -31,8 +31,8 @@ async function renderProbe(): Promise<void> {
})
}
function appearanceTitle(): string | undefined {
return latest?.find((section) => section.id === 'appearance')?.title
function agentsTitle(): string | undefined {
return latest?.find((section) => section.id === 'agents')?.title
}
afterEach(async () => {
@ -54,13 +54,13 @@ it('retranslates the settings nav labels when the UI language changes live', asy
await i18n.changeLanguage('en')
})
await renderProbe()
expect(appearanceTitle()).toBe('Appearance')
expect(agentsTitle()).toBe('Agents')
// Same path as the Settings → Appearance language switch.
await act(async () => {
await i18n.changeLanguage('es')
})
// "Agents" is intentionally English under current terminology policy, so use translated copy here.
expect(appearanceTitle()).toBe('Apariencia')
// Without the active-locale memo dep this stays 'Agents' (stale cache).
expect(agentsTitle()).toBe('Agentes')
})

View File

@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
UI_LANGUAGE_CHINESE,
UI_LANGUAGE_ENGLISH,
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_SPANISH
} from '../../../shared/ui-language'
import { i18n, setRendererUiLanguage } from './i18n'
@ -37,23 +35,6 @@ describe('renderer i18n lazy locale loading', () => {
expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File')
})
it('loads every supported target locale and falls back for an absent target key', async () => {
for (const locale of [
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_CHINESE
] as const) {
await setRendererUiLanguage(locale)
expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File')
expect(
i18n.t('auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder', {
defaultValue: '/home/user/projects'
})
).toBe('/home/user/projects')
}
})
it('returns to English from a lazily-loaded locale', async () => {
await setRendererUiLanguage(UI_LANGUAGE_SPANISH)
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('Archivo')

View File

@ -252,7 +252,7 @@
"3be0f7df04": "Cannot open folder on selected runtime",
"15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder.",
"2dcd706774": "Project also exists in another profile",
"presenceProfileOverflow": "{{names}} +{{value0}} more"
"presenceProfileOverflow": "{{names}} +{{count}} more"
},
"settings": {
"e12dab333b": "Failed to switch servers",
@ -922,8 +922,7 @@
"activity": "Activity",
"noActivity": "No activity yet."
},
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.",
"4e65770e91": "Show {{value0}} more lines below"
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
},
"GitLabItemDialog": {
"65e784c1f1": "Reopen",
@ -1397,8 +1396,7 @@
"8ff5ae8866": "Assignees",
"82c87eceb9": "Edit assignees",
"1ff5d979df": "No one assigned",
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.",
"5ea92b8f34": "Show {{value0}} more lines below"
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
},
"QuickOpen": {
"1dbd3f59ff": "Move",
@ -2236,8 +2234,7 @@
"df4bd63c1d": "Unassigned",
"30402d2c6e": "Try search or refresh.",
"a2f31c4cd6": "No Linear projects found",
"c9b6e9f90d": "Unable to load Linear projects",
"1e63a1b413": "Open {{value0}} in Linear"
"c9b6e9f90d": "Unable to load Linear projects"
}
}
},
@ -2633,8 +2630,7 @@
"internalPathsTooLarge": "Drop path list is too large for a safe terminal paste.",
"b4cf68e889": "Skipped {{value0}} {{value1}}.",
"writeTimeout": "File drop cancelled: terminal did not accept the path before the safety timeout.",
"writeRejected": "File drop cancelled: terminal could not accept the path.",
"e040850c0d": "Uploading {{value0}} file{{value1}} to remote…"
"writeRejected": "File drop cancelled: terminal could not accept the path."
}
}
},
@ -3094,8 +3090,7 @@
"0583c806ac": "Workspace disk usage is not scanned.",
"39786e3b73": "Scanning workspace sizes.",
"bef4dc0457": "{{value0}} reclaimable · {{value1}} unavailable",
"3d8d47ce77": "{{value0}} · last result kept",
"28f967d2fb": "{{value0}} reclaimable · {{value1}} workspaces"
"3d8d47ce77": "{{value0}} · last result kept"
},
"WorkspaceSpaceManagerPanel": {
"2965415393": "Force delete failed",
@ -3514,10 +3509,7 @@
},
"UsageBreakdownSection": {
"7765a4c3e1": "n/a",
"247c93ca92": "• inferred pricing",
"32176e1d44": "turns",
"79a69522a5": "events",
"02a046792e": "sessions •"
"247c93ca92": "• inferred pricing"
},
"UsageSessionsTable": {
"1afc25eb06": "Turns",
@ -3747,8 +3739,7 @@
"32a7256d85": "Clone",
"69f5b5380d": "Cloning...",
"cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.",
"cloneParentFolder": "Parent folder",
"remoteCloneParentPlaceholder": "/home/user/projects"
"cloneParentFolder": "Parent folder"
},
"AutoRenameFailedDialog": {
"aed1623b1e": "Close",
@ -4068,8 +4059,7 @@
"2d74665a56": "Ports",
"65a9820bd1": "Agent statuses",
"219ebf1961": "Branch name",
"folderPathIdentity": "Branch / folder path",
"21083fb457": "Workspace options ({{value0}} active)"
"folderPathIdentity": "Branch / folder path"
},
"SshDisconnectedDialog": {
"ca4a7892af": "Connecting...",
@ -4110,8 +4100,7 @@
"34f03eb0de": "Board settings",
"26cbc92150": "Workspace board settings",
"87d24a0c2f": "Sync board and issue status",
"4c2eaa78cc": "Moving a linked workspace updates its Linear issue status when a matching workflow state exists.",
"833d8a506e": "Move {{value0}} right"
"4c2eaa78cc": "Moving a linked workspace updates its Linear issue status when a matching workflow state exists."
},
"WorkspaceKanbanStatusLane": {
"8ad104642b": "Empty",
@ -4122,8 +4111,7 @@
"8be427206b": "Icon",
"2ac106f6b2": "Color",
"74b1413279": "Appearance",
"ccbd1e2c69": "Customize {{value0}} appearance",
"2270c830c4": "Set {{value0}} icon to {{value1}}"
"ccbd1e2c69": "Customize {{value0}} appearance"
},
"WorktreeCard": {
"a88c92d0e3": "{{value0}}/{{value1}} already exists.",
@ -4506,9 +4494,9 @@
"6f7a8b9c0d": "Cancel",
"7a8b9c0d1e": "Open settings",
"8b9c0d1e2f": "Remove host",
"workspacesFailed": "Could not remove {{value0}} of this hosts workspaces. The host was kept so you can retry.",
"workspacesFailed": "Could not remove {{count}} of this hosts workspaces. The host was kept so you can retry.",
"oneWorkspace": "1 workspace",
"manyWorkspaces": "{{value0}} workspaces",
"manyWorkspaces": "{{count}} workspaces",
"hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.",
"alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}",
"alsoForgetLocal": "Also remove these {{value0}} from Orca",
@ -6122,8 +6110,7 @@
"f79972271a": "No GitHub remote found for this repo.",
"4d039317f4": "Website favicon",
"acf31559a0": "Enter a valid website URL.",
"868c5c9b56": "Failed to import repo icon",
"8fbd0654c1": "Use {{value0}} repo icon"
"868c5c9b56": "Failed to import repo icon"
},
"RepositoryPane": {
"15a99d9b9f": "Relative paths resolve from this project root.",
@ -8775,7 +8762,7 @@
"defaultBranch": "default branch",
"synced": "Fork updated",
"syncedDescriptionSingular": "Fast-forwarded {{branch}} by 1 commit.",
"syncedDescriptionPlural": "Fast-forwarded {{branch}} by {{value0}} commits.",
"syncedDescriptionPlural": "Fast-forwarded {{branch}} by {{count}} commits.",
"upToDate": "Fork already up to date",
"upToDateDescription": "{{branch}} already matches upstream.",
"missingOrigin": "origin remote is missing.",
@ -8822,10 +8809,10 @@
"projectWsl": "This project runs in {{value0}} via WSL.",
"inheritedWindows": "No project override. General settings select Windows.",
"projectWindows": "This project runs on Windows.",
"liveTerminalSingular": "{{value0}} live terminal",
"liveTerminalPlural": "{{value0}} live terminals",
"activeTaskSingular": "{{value0}} active task",
"activeTaskPlural": "{{value0}} active tasks",
"liveTerminalSingular": "{{count}} live terminal",
"liveTerminalPlural": "{{count}} live terminals",
"activeTaskSingular": "{{count}} active task",
"activeTaskPlural": "{{count}} active tasks",
"runtimeSessionJoin": "{{value0}} and {{value1}}",
"runtimeSessionWarning": "{{value0}} will keep running in the current runtime. Let tasks finish or restart terminals before continuing.",
"pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.",
@ -8995,8 +8982,8 @@
"agent": "Agent",
"agentArgs": "CLI arguments",
"commandTemplate": "Command template",
"more": "+{{value0}} more",
"plural": "Global saves won't change {{value0}} repositories with their own recipes.",
"more": "+{{count}} more",
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
"recipe": "Recipe",
"review": "Review",
"reviewFirst": "Review first",
@ -9915,9 +9902,9 @@
"7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.",
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.",
"8b8ee9d22f": "Couldn't determine which host owns this file. Check the workspace connection and try again.",
"af1270b90d": "Permanently delete {{value0}} items?",
"af1270b90d": "Permanently delete {{count}} items?",
"dd029aa5cd": "This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone.",
"77fdc36183": "Delete {{value0}} items?",
"77fdc36183": "Delete {{count}} items?",
"fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}."
},
"useFileExplorerHandlers": {
@ -9960,7 +9947,7 @@
"searchSessions": "Search sessions",
"clearSearch": "Clear search",
"remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.",
"transcriptsSkipped": "{{value0}} transcript skipped",
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
@ -12835,8 +12822,8 @@
"2db945e4a0": "Copy or move projects from the active profile to another local profile.",
"8668cb2946": "No projects in this profile.",
"93034915ab": "Create another profile before copying projects.",
"projectCountSingular": "{{value0}} project",
"projectCountPlural": "{{value0}} projects"
"projectCountSingular": "{{count}} project",
"projectCountPlural": "{{count}} projects"
},
"project": {
"transfer": {
@ -12855,12 +12842,12 @@
"work": "Live work in this project"
},
"cancel": "Cancel",
"terminalSingular": "{{value0}} live terminal tab",
"terminalPlural": "{{value0}} live terminal tabs",
"agentSingular": "{{value0}} active agent",
"agentPlural": "{{value0}} active agents",
"browserSingular": "{{value0}} browser workspace",
"browserPlural": "{{value0}} browser workspaces"
"terminalSingular": "{{count}} live terminal tab",
"terminalPlural": "{{count}} live terminal tabs",
"agentSingular": "{{count}} active agent",
"agentPlural": "{{count}} active agents",
"browserSingular": "{{count}} browser workspace",
"browserPlural": "{{count}} browser workspaces"
}
}
},
@ -12882,12 +12869,12 @@
},
"cancel": "Cancel",
"switch": "Switch profile",
"terminalSingular": "{{value0}} live terminal tab",
"terminalPlural": "{{value0}} live terminal tabs",
"agentSingular": "{{value0}} active agent",
"agentPlural": "{{value0}} active agents",
"browserSingular": "{{value0}} browser workspace",
"browserPlural": "{{value0}} browser workspaces"
"terminalSingular": "{{count}} live terminal tab",
"terminalPlural": "{{count}} live terminal tabs",
"agentSingular": "{{count}} active agent",
"agentPlural": "{{count}} active agents",
"browserSingular": "{{count}} browser workspace",
"browserPlural": "{{count}} browser workspaces"
}
},
"org": {
@ -12962,7 +12949,7 @@
"assignee": "Assignee",
"unassigned": "Unassigned",
"labels": "Labels",
"countSelected": "{{value0}} selected",
"countSelected": "{{count}} selected",
"selected": "selected",
"searchPriority": "Filter priority…",
"searchStatus": "Filter status…",
@ -13008,9 +12995,7 @@
},
"tool": {
"running": "Running…",
"result": "Result",
"countOne": "1 tool call",
"countN": "{{value0}} tool calls"
"result": "Result"
},
"status": {
"responding": "Agent is responding"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -24,8 +24,7 @@ describe('native chat locale copy', () => {
expect(localized.trim()).not.toBe('')
expect(localized).not.toBe(english)
}
// Why: intentional English lives only in translation state so current English always wins by fallback.
expect('grok' in search).toBe(false)
expect(search.grok).toBe('grok')
}
)
})

View File

@ -276,14 +276,10 @@ function formatProjectPresenceProfileNames(profileNames: readonly string[]): str
return names.join(', ')
}
// Why: the "+N more" overflow suffix is user-visible toast copy and must localize.
return translate(
'auto.store.slices.repos.presenceProfileOverflow',
'{{names}} +{{value0}} more',
{
names: names.slice(0, 3).join(', '),
value0: names.length - 3
}
)
return translate('auto.store.slices.repos.presenceProfileOverflow', '{{names}} +{{count}} more', {
names: names.slice(0, 3).join(', '),
count: names.length - 3
})
}
async function warnIfProjectKnownInAnotherProfile(

View File

@ -1,13 +0,0 @@
import type sourceMessages from '../../config/localization-extraction/en.json'
type Join<Prefix extends string, Key extends string> = Prefix extends '' ? Key : `${Prefix}.${Key}`
type CatalogKey<T, Prefix extends string = ''> = {
[Key in Extract<keyof T, string>]: T[Key] extends string
? Join<Prefix, Key>
: T[Key] extends Record<string, unknown>
? CatalogKey<T[Key], Join<Prefix, Key>>
: never
}[Extract<keyof T, string>]
// Why: deriving from the committed extractor output keeps key types generated without unchecked .d.ts files.
export type LocalizationKey = CatalogKey<typeof sourceMessages>