|
|
@@ -9,83 +9,144 @@
|
|
|
* only one side breaks that: a reader of the other language sees a bare key
|
|
|
* such as `list.aria` instead of text. This gate fails on the asymmetry rather
|
|
|
* than waiting for the bare key to reach a UI.
|
|
|
+ *
|
|
|
+ * Discovery is deliberately broad, because a gate that silently narrows is
|
|
|
+ * worse than no gate. It sweeps every workspace package (not just
|
|
|
+ * `packages/client`), reads dictionaries wherever they are declared —
|
|
|
+ * `locales.ts`, a `locales/` directory, or inline in the plugin body — and
|
|
|
+ * pairs `zh`/`en` across sibling files as well as within one module. A `zh`
|
|
|
+ * dictionary whose `en` counterpart cannot be found anywhere is an error, not
|
|
|
+ * a skip.
|
|
|
*/
|
|
|
|
|
|
import type { Dirent } from 'node:fs'
|
|
|
-import { readdirSync, readFileSync } from 'node:fs'
|
|
|
-import { resolve } from 'node:path'
|
|
|
+import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
|
+import { dirname, resolve } from 'node:path'
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
import ts from 'typescript'
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
|
|
const root = fileURLToPath(new URL('..', import.meta.url))
|
|
|
|
|
|
-/** Every `locales*.ts` module under a client package's `src/`. */
|
|
|
-function dictionaryModules(): string[] {
|
|
|
+/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */
|
|
|
+function relative(file: string): string {
|
|
|
+ return file.slice(root.length).replaceAll('\\', '/')
|
|
|
+}
|
|
|
+
|
|
|
+/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */
|
|
|
+function sourceFiles(): string[] {
|
|
|
const files: string[] = []
|
|
|
- for (const group of ['client', 'extensions']) {
|
|
|
- const groupRoot = resolve(root, 'packages', group)
|
|
|
- let packages: string[]
|
|
|
- try {
|
|
|
- packages = readdirSync(groupRoot, { withFileTypes: true })
|
|
|
- .filter(entry => entry.isDirectory())
|
|
|
- .map(entry => entry.name)
|
|
|
- } catch {
|
|
|
- continue
|
|
|
- }
|
|
|
- for (const pkg of packages) {
|
|
|
- const srcRoot = resolve(groupRoot, pkg, 'src')
|
|
|
- walk(srcRoot, files)
|
|
|
+ const packagesRoot = resolve(root, 'packages')
|
|
|
+ for (const group of directories(packagesRoot)) {
|
|
|
+ for (const pkg of directories(resolve(packagesRoot, group))) {
|
|
|
+ walk(resolve(packagesRoot, group, pkg, 'src'), files)
|
|
|
}
|
|
|
}
|
|
|
return files.sort()
|
|
|
}
|
|
|
|
|
|
+/** Immediate subdirectory names, or none when the path is not a directory. */
|
|
|
+function directories(dir: string): string[] {
|
|
|
+ if (!existsSync(dir)) return []
|
|
|
+ let entries: Dirent[]
|
|
|
+ try {
|
|
|
+ entries = readdirSync(dir, { withFileTypes: true })
|
|
|
+ } catch {
|
|
|
+ // Swallows only the race between existsSync and readdirSync (a package
|
|
|
+ // directory removed mid-sweep); readdirSync is the sole statement in the
|
|
|
+ // try, so no other failure can reach here.
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ return entries.filter(entry => entry.isDirectory()).map(entry => entry.name)
|
|
|
+}
|
|
|
+
|
|
|
function walk(dir: string, out: string[]): void {
|
|
|
+ if (!existsSync(dir)) return
|
|
|
let entries: Dirent[]
|
|
|
try {
|
|
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
|
} catch {
|
|
|
+ // Same narrow race as `directories`: readdirSync is the only statement
|
|
|
+ // guarded, so this cannot mask a parse or assertion failure.
|
|
|
return
|
|
|
}
|
|
|
for (const entry of entries) {
|
|
|
const full = resolve(dir, entry.name)
|
|
|
- if (entry.isDirectory()) {
|
|
|
- walk(full, out)
|
|
|
- } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
|
|
- if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full)
|
|
|
- }
|
|
|
+ if (entry.isDirectory()) walk(full, out)
|
|
|
+ else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/** One discovered dictionary: which file and export name declared it. */
|
|
|
+interface Dictionary {
|
|
|
+ /** Repo-relative declaring file. */
|
|
|
+ file: string
|
|
|
+ /** Export name, or the registration site for an inline literal. */
|
|
|
+ name: string
|
|
|
+ /** Declared keys, sorted. */
|
|
|
+ keys: string[]
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
- * Keys of every top-level `export const <zh|en>...= { ... }` object literal,
|
|
|
- * read from the AST so the gate never executes package code.
|
|
|
- * @param file - absolute path of the dictionary module.
|
|
|
- * @returns exported dictionary name mapped to its declared keys.
|
|
|
+ * Keys of every top-level `export const <name> = { ... }` object literal whose
|
|
|
+ * name identifies a locale dictionary, plus inline `register(ns, locale, {...})`
|
|
|
+ * literals. Read from the AST so the gate never executes package code.
|
|
|
+ * @param file - absolute path of a candidate module.
|
|
|
+ * @returns discovered dictionaries, keyed by locale-bearing name.
|
|
|
*/
|
|
|
-function exportedDictionaries(file: string): Map<string, string[]> {
|
|
|
- const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true)
|
|
|
- const found = new Map<string, string[]>()
|
|
|
+function dictionariesIn(file: string): Dictionary[] {
|
|
|
+ const text = readFileSync(file, 'utf8')
|
|
|
+ // Cheap pre-filter: parsing every package source is wasteful, and a file
|
|
|
+ // with no locale token cannot declare a dictionary under any shape below.
|
|
|
+ if (!/\b(zh|en)\b/.test(text)) return []
|
|
|
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
|
|
|
+ const found: Dictionary[] = []
|
|
|
+ const rel = relative(file)
|
|
|
+
|
|
|
for (const statement of source.statements) {
|
|
|
if (!ts.isVariableStatement(statement)) continue
|
|
|
- const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true
|
|
|
- if (!exported) continue
|
|
|
+ if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue
|
|
|
for (const decl of statement.declarationList.declarations) {
|
|
|
if (!ts.isIdentifier(decl.name)) continue
|
|
|
- const initializer = unwrap(decl.initializer)
|
|
|
- if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue
|
|
|
- const keys: string[] = []
|
|
|
- for (const prop of initializer.properties) {
|
|
|
- if (!ts.isPropertyAssignment(prop)) continue
|
|
|
- if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text)
|
|
|
+ const literal = unwrap(decl.initializer)
|
|
|
+ if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
|
|
|
+ if (localeOf(decl.name.text) === undefined) continue
|
|
|
+ found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a
|
|
|
+ // registration loop in the plugin body. Both halves key off the enclosing
|
|
|
+ // array's line so they pair with each other and not across sites.
|
|
|
+ const visit = (node: ts.Node): void => {
|
|
|
+ if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) {
|
|
|
+ const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
|
|
|
+ for (const element of node.elements) {
|
|
|
+ if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue
|
|
|
+ const [tag, dict] = element.elements
|
|
|
+ const literal = unwrap(dict)
|
|
|
+ if (tag === undefined || !ts.isStringLiteral(tag)) continue
|
|
|
+ if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
|
|
|
+ if (tag.text !== 'zh' && tag.text !== 'en') continue
|
|
|
+ found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) })
|
|
|
}
|
|
|
- found.set(decl.name.text, keys.sort())
|
|
|
}
|
|
|
+ ts.forEachChild(node, visit)
|
|
|
}
|
|
|
+ visit(source)
|
|
|
return found
|
|
|
}
|
|
|
|
|
|
+/** Declared property names of an object literal, sorted. */
|
|
|
+function keysOf(literal: ts.ObjectLiteralExpression): string[] {
|
|
|
+ const keys: string[] = []
|
|
|
+ for (const prop of literal.properties) {
|
|
|
+ if (!ts.isPropertyAssignment(prop)) continue
|
|
|
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text)
|
|
|
+ }
|
|
|
+ return keys.sort()
|
|
|
+}
|
|
|
+
|
|
|
/** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */
|
|
|
function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
|
|
|
let current = node
|
|
|
@@ -98,40 +159,90 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
|
|
|
return current
|
|
|
}
|
|
|
|
|
|
-/** Pair a `zh` export with the `en` export covering the same namespace. */
|
|
|
-function counterpart(name: string): string | undefined {
|
|
|
- if (name === 'zh') return 'en'
|
|
|
- if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}`
|
|
|
- if (name.endsWith('Zh')) return `${name.slice(0, -2)}En`
|
|
|
+/**
|
|
|
+ * The locale a dictionary name declares, and the namespace-ish remainder that
|
|
|
+ * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`,
|
|
|
+ * and `settingsZh`/`settingsEn` are the shapes this repo uses.
|
|
|
+ * @param name - export name or synthetic inline name.
|
|
|
+ * @returns locale plus pair key, or undefined when the name names no locale.
|
|
|
+ */
|
|
|
+function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined {
|
|
|
+ for (const locale of ['zh', 'en'] as const) {
|
|
|
+ const other = locale === 'zh' ? 'Zh' : 'En'
|
|
|
+ if (name === locale) return { locale, pair: '' }
|
|
|
+ if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) }
|
|
|
+ if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) {
|
|
|
+ return { locale, pair: name.slice(2) }
|
|
|
+ }
|
|
|
+ if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) }
|
|
|
+ }
|
|
|
return undefined
|
|
|
}
|
|
|
|
|
|
describe('shipped locale dictionaries', () => {
|
|
|
it('declares the same keys in zh and en, so the single fallback locale always resolves', () => {
|
|
|
- const modules = dictionaryModules()
|
|
|
- // Guard the discovery itself: an empty sweep would pass every assertion
|
|
|
- // below while checking nothing.
|
|
|
- expect(modules.length).toBeGreaterThan(20)
|
|
|
+ const files = sourceFiles()
|
|
|
+ // Guard the discovery itself: an empty or narrowed sweep would pass every
|
|
|
+ // assertion below while checking nothing.
|
|
|
+ expect(files.length).toBeGreaterThan(500)
|
|
|
+
|
|
|
+ // Pair within a file first; a dictionary whose counterpart is not in the
|
|
|
+ // same module then pairs with a sibling in the same directory. Both shapes
|
|
|
+ // ship here: `locales/settings.ts` exports zh+en together, while
|
|
|
+ // `locales/zh.ts` + `locales/en.ts` split the common pair across files.
|
|
|
+ const perFile = new Map<string, Dictionary[]>()
|
|
|
+ for (const file of files) {
|
|
|
+ const dicts = dictionariesIn(file)
|
|
|
+ if (dicts.length > 0) perFile.set(relative(file), dicts)
|
|
|
+ }
|
|
|
+
|
|
|
+ const groups = new Map<string, Map<'zh' | 'en', Dictionary>>()
|
|
|
+ const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => {
|
|
|
+ const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>()
|
|
|
+ if (slot.has(locale)) {
|
|
|
+ throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`)
|
|
|
+ }
|
|
|
+ slot.set(locale, dict)
|
|
|
+ groups.set(key, slot)
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const [rel, dicts] of perFile) {
|
|
|
+ for (const dict of dicts) {
|
|
|
+ const parsed = localeOf(dict.name)
|
|
|
+ if (parsed === undefined) continue
|
|
|
+ const sameFileCounterpart = dicts.some((other) => {
|
|
|
+ const otherParsed = localeOf(other.name)
|
|
|
+ return otherParsed !== undefined
|
|
|
+ && otherParsed.pair === parsed.pair
|
|
|
+ && otherParsed.locale !== parsed.locale
|
|
|
+ })
|
|
|
+ // Same-file pairs key by file so two pairs in one directory stay
|
|
|
+ // distinct; split pairs key by directory so siblings meet.
|
|
|
+ const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}`
|
|
|
+ place(key, parsed.locale, dict)
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- const mismatches: string[] = []
|
|
|
+ const problems: string[] = []
|
|
|
let comparedPairs = 0
|
|
|
- for (const file of modules) {
|
|
|
- const dicts = exportedDictionaries(file)
|
|
|
- for (const [name, zhKeys] of dicts) {
|
|
|
- const enName = counterpart(name)
|
|
|
- if (enName === undefined) continue
|
|
|
- const enKeys = dicts.get(enName)
|
|
|
- if (enKeys === undefined) continue
|
|
|
- comparedPairs++
|
|
|
- const rel = file.slice(root.length)
|
|
|
- const zhOnly = zhKeys.filter(key => !enKeys.includes(key))
|
|
|
- const enOnly = enKeys.filter(key => !zhKeys.includes(key))
|
|
|
- if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`)
|
|
|
- if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`)
|
|
|
+ for (const [key, slot] of [...groups].sort()) {
|
|
|
+ const zh = slot.get('zh')
|
|
|
+ const en = slot.get('en')
|
|
|
+ if (zh === undefined || en === undefined) {
|
|
|
+ const present = zh ?? en
|
|
|
+ problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`)
|
|
|
+ continue
|
|
|
}
|
|
|
+ comparedPairs++
|
|
|
+ const zhOnly = zh.keys.filter(k => !en.keys.includes(k))
|
|
|
+ const enOnly = en.keys.filter(k => !zh.keys.includes(k))
|
|
|
+ if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`)
|
|
|
+ if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`)
|
|
|
}
|
|
|
|
|
|
- expect(comparedPairs).toBeGreaterThan(20)
|
|
|
- expect(mismatches).toEqual([])
|
|
|
+ // The shipped dictionary count only grows; a collapse means discovery or
|
|
|
+ // pairing broke, which would hide real asymmetry.
|
|
|
+ expect(comparedPairs).toBeGreaterThan(25)
|
|
|
+ expect(problems).toEqual([])
|
|
|
})
|
|
|
})
|