mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* chore(i18n): add a detector for catalog keys no source references
A key can leave the code without leaving the catalog: a feature is reverted, a
setting is rewritten in place, a component is deleted. Nothing fails, so the
entries stay. Every locale carries them, translators spend effort on strings no
screen renders, and coverage numbers understate how much is actually translated.
`verify:localization-extraction` already reports an unreferenced set, but it
cannot be deleted as-is: i18next-cli does not see a key held in a data structure
(`{ key: '…' }`) or passed as a variable, and 18 files do exactly that. So this
requires two independent conditions — extraction misses the key AND the exact
string appears in no source literal under src/ or mobile/.
Two properties matter and both were found the hard way:
- Plural variants are judged by their base key. i18next appends the CLDR
category at runtime, so `…_one` never appears in a source file even though the
base does. Without this the scan eats every plural in the catalog.
- The scan is iterative. Removing a base key can strand the variants that
survived on its account, so it must be re-run until it reports none.
Registered as `verify:localization-orphans` but deliberately left out of the
lint chain: it reports 1,401 keys on current main, so gating now would fail CI.
The prune and the gate come together in a follow-up.
* test(i18n): cover the orphan detector's two non-obvious rules
Both rules decide whether a key is live, and both are easy to break without
noticing: the plural rule keeps `…count_one` when only `…count` appears in
source, and the literal scan is the sole evidence for keys extraction cannot
see — held in a data structure or passed as a variable.
Extracting them as pure functions changes no behaviour: the detector still
reports 14,459 keys, 12,887 referenced, 1,401 orphans, and --check still
exits 1.
* chore(i18n): rename the detector's key pattern constant
main gained the anti-slop rule no-shape-in-symbol-names after this branch
was cut, and KEY_SHAPED trips it once the branch is merged with main.
Renamed to CATALOG_KEY_PATTERN; no behaviour change.
* chore(i18n): name the orphan detector like the other localization verifiers
CODEOWNERS routes /config/scripts/*localization*.mjs to the localization owner, and find-orphaned-catalog-keys.mjs matched neither that pattern nor *locale*, so a change to it asked nobody for review. The file now carries the name of the npm script that runs it, verify:localization-orphans.
* fix(i18n): report stranded plural variants in the same pass as their base key
isKeyReferenced kept a plural variant alive whenever en.json still carried its base key, even when no source reached that base key. The base key was reported and the variants were not, so a prune needed a second run to find them. Only a base key that extraction or a source literal references keeps its variants now; the --check message drops the re-run advice and names the override tables, which sit outside the scan roots.
* fix(i18n): let the literal scan accept every key-shaped literal
The scan captured only literals of seven characters or more and then applied CATALOG_KEY_PATTERN, which has no such floor. A live key shorter than that, held in a data structure, would have been reported as an orphan. en.json has no key that short today, so the result is unchanged; the capture now leaves the shape decision to the one pattern.
* fix(i18n): stop the orphan scan when a source root cannot be read
collectSourceFiles swallowed every readdir error, so a directory it could not read contributed no literals and a key named only there would have been reported as safe to delete. Only a missing mobile/ root is skipped now, since a checkout without the mobile app is still a complete desktop scan; any other error stops the scan.
* style(i18n): wrap one line of the orphan detector to the formatter's width
* chore(i18n): fold the orphan-key detector into the extraction check and retire 1,644 unreferenced keys
verify-localization-extraction.mjs now filters its own unreferenced set by
source literals (src/ and mobile/), plural base keys and dynamic prefixes
composed inside translate calls, and fails when any key is named nowhere.
That replaces the standalone verify-localization-orphans.mjs script and its
second extraction pass.
The prune is recomputed on current main: 1,644 keys leave all seven locale
catalogs, 285 stale entries leave the key-override tables, and
en-runtime-required.json is regenerated.
Supersedes #19801.
Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com>
* chore(i18n): scope the unused-key scan to src/ and require a dotted dynamic prefix
mobile/ edits do not trigger the extraction check, so literals there must not
decide its verdict; a dotless runtime-built prefix would keep unrelated keys alive.
Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com>
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com>
84 lines
2.6 KiB
JavaScript
84 lines
2.6 KiB
JavaScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
collectKeyLiterals,
|
|
compareExtraction,
|
|
findUnreferencedKeys
|
|
} from './verify-localization-extraction.mjs'
|
|
|
|
function sourceEvidence(sourceText) {
|
|
return collectKeyLiterals(sourceText, { literals: new Set(), dynamicPrefixes: new Set() })
|
|
}
|
|
|
|
describe('verify-localization-extraction', () => {
|
|
it('reports legacy drift without requiring a committed disposition database', () => {
|
|
const result = compareExtraction(
|
|
{ menu: { open: 'Open now {{name}}' } },
|
|
{ menu: { open: 'Open {{name}}', legacy: 'Legacy copy' } }
|
|
)
|
|
|
|
expect(result.orphans).toEqual(['menu.legacy'])
|
|
expect(result.fallbackDrift).toEqual(['menu.open'])
|
|
expect(result.placeholderMismatches).toEqual([])
|
|
})
|
|
|
|
it('reports dynamic defaults without treating empty extractor values as catalog copy', () => {
|
|
const result = compareExtraction(
|
|
{ menu: { dynamic: '' } },
|
|
{ menu: { dynamic: '{{count}} items' } }
|
|
)
|
|
|
|
expect(result.dynamicDefaults).toEqual(['menu.dynamic'])
|
|
expect(result.fallbackDrift).toEqual([])
|
|
expect(result.placeholderMismatches).toEqual([])
|
|
})
|
|
|
|
it('rejects undeclared keys even when their defaults are dynamic', () => {
|
|
const result = compareExtraction({ menu: { dynamic: '' } }, {})
|
|
|
|
expect(result.missingFromEnglish).toEqual(['menu.dynamic'])
|
|
})
|
|
|
|
it('rejects missing English declarations and incompatible placeholders', () => {
|
|
const result = compareExtraction(
|
|
{
|
|
menu: {
|
|
missing: 'Missing',
|
|
open: 'Open {{name}}'
|
|
}
|
|
},
|
|
{ menu: { open: 'Open {{path}}' } }
|
|
)
|
|
|
|
expect(result.missingFromEnglish).toEqual(['menu.missing'])
|
|
expect(result.placeholderMismatches).toEqual(['menu.open'])
|
|
})
|
|
|
|
it('keeps plural variants and data-structure keys that extraction cannot see', () => {
|
|
const evidence = sourceEvidence("const rows = [{ labelKey: 'rows.held' }]")
|
|
const extracted = new Map([['menu.count', '']])
|
|
|
|
expect(
|
|
findUnreferencedKeys(
|
|
['menu.count_one', 'menu.count_other', 'rows.held', 'rows.dead_one', 'menu.stale'],
|
|
extracted,
|
|
evidence
|
|
)
|
|
).toEqual(['rows.dead_one', 'menu.stale'])
|
|
})
|
|
|
|
it('keeps keys under a prefix that a translate call composes at runtime', () => {
|
|
const evidence = sourceEvidence(
|
|
"translate(`status.label.${state}`)\nt('menu.item.' + id)\nconst id = `rpc.${method}`"
|
|
)
|
|
|
|
expect(
|
|
findUnreferencedKeys(
|
|
['status.label.ready', 'menu.item.open', 'rpc.ping'],
|
|
new Map(),
|
|
evidence
|
|
)
|
|
).toEqual(['rpc.ping'])
|
|
})
|
|
})
|