locale-dictionary-parity.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. /**
  2. * Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary
  3. * declares the same keys in `zh` and `en`.
  4. *
  5. * The locale runtime resolves a key through the active locale, then through
  6. * the single fallback locale (`en`), then surfaces the key itself. With
  7. * symmetric dictionaries that middle step always resolves, so one constant can
  8. * serve as both the opening locale and the dictionary fallback. A key added to
  9. * only one side breaks that: a reader of the other language sees a bare key
  10. * such as `list.aria` instead of text. This gate fails on the asymmetry rather
  11. * than waiting for the bare key to reach a UI.
  12. *
  13. * Discovery is deliberately broad, because a gate that silently narrows is
  14. * worse than no gate. It sweeps every workspace package (not just
  15. * `packages/client`), reads dictionaries wherever they are declared —
  16. * `locales.ts`, a `locales/` directory, or inline in the plugin body — and
  17. * pairs `zh`/`en` across sibling files as well as within one module. A `zh`
  18. * dictionary whose `en` counterpart cannot be found anywhere is an error, not
  19. * a skip.
  20. */
  21. import type { Dirent } from 'node:fs'
  22. import { readdirSync, readFileSync } from 'node:fs'
  23. import { dirname, resolve } from 'node:path'
  24. import { fileURLToPath } from 'node:url'
  25. import ts from 'typescript'
  26. import { describe, expect, it } from 'vitest'
  27. const root = fileURLToPath(new URL('..', import.meta.url))
  28. /** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */
  29. function relative(file: string): string {
  30. return file.slice(root.length).replaceAll('\\', '/')
  31. }
  32. /** Every `.ts` source file under each workspace package's `src`, excluding declarations. */
  33. function sourceFiles(): string[] {
  34. const files: string[] = []
  35. const packagesRoot = resolve(root, 'packages')
  36. for (const group of directories(packagesRoot)) {
  37. for (const pkg of directories(resolve(packagesRoot, group))) {
  38. walk(resolve(packagesRoot, group, pkg, 'src'), files)
  39. }
  40. }
  41. return files.sort()
  42. }
  43. /** Immediate subdirectory names, or none when the path is not a directory. */
  44. function directories(dir: string): string[] {
  45. return readEntries(dir).filter(entry => entry.isDirectory()).map(entry => entry.name)
  46. }
  47. /**
  48. * Directory entries, treating only a genuinely absent directory as empty.
  49. * Any other failure (`EACCES`, I/O) rethrows: silently reading it as "absent"
  50. * would narrow the sweep and let the gate pass while checking less.
  51. * @param dir - absolute directory path.
  52. * @returns entries, or none when the directory does not exist.
  53. */
  54. function readEntries(dir: string): Dirent[] {
  55. try {
  56. return readdirSync(dir, { withFileTypes: true })
  57. } catch (error) {
  58. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
  59. throw error
  60. }
  61. }
  62. function walk(dir: string, out: string[]): void {
  63. for (const entry of readEntries(dir)) {
  64. const full = resolve(dir, entry.name)
  65. if (entry.isDirectory()) walk(full, out)
  66. else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full)
  67. }
  68. }
  69. /** One discovered dictionary: which file and export name declared it. */
  70. interface Dictionary {
  71. /** Repo-relative declaring file. */
  72. file: string
  73. /** Export name, or the registration site for an inline literal. */
  74. name: string
  75. /** Declared keys, sorted. */
  76. keys: string[]
  77. }
  78. /**
  79. * Keys of every top-level `export const <name> = { ... }` object literal whose
  80. * name identifies a locale dictionary, plus inline `register(ns, locale, {...})`
  81. * literals. Read from the AST so the gate never executes package code.
  82. * @param file - absolute path of a candidate module.
  83. * @returns discovered dictionaries, keyed by locale-bearing name.
  84. */
  85. function dictionariesIn(file: string): Dictionary[] {
  86. const text = readFileSync(file, 'utf8')
  87. // Cheap pre-filter: parsing every package source is wasteful. The pattern
  88. // must admit every shape `localeOf` accepts, or a file would be skipped
  89. // before parsing — the silent narrowing this gate exists to prevent. A bare
  90. // `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold
  91. // between `h` and an uppercase letter.
  92. if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return []
  93. const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
  94. const found: Dictionary[] = []
  95. const rel = relative(file)
  96. // Module-scope variable declarations, keyed by name. A 3-arg
  97. // `register(NS, 'zh'|'en', dict)` whose third argument is an identifier —
  98. // e.g. a local dictionary variable rather than an inline literal — resolves
  99. // through here so the gate still verifies its symmetry.
  100. const moduleConsts = new Map<string, ts.Expression>()
  101. for (const statement of source.statements) {
  102. if (!ts.isVariableStatement(statement)) continue
  103. for (const decl of statement.declarationList.declarations) {
  104. if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) {
  105. moduleConsts.set(decl.name.text, decl.initializer)
  106. }
  107. }
  108. }
  109. for (const statement of source.statements) {
  110. if (!ts.isVariableStatement(statement)) continue
  111. if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue
  112. for (const decl of statement.declarationList.declarations) {
  113. if (!ts.isIdentifier(decl.name)) continue
  114. const literal = unwrap(decl.initializer)
  115. if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
  116. if (localeOf(decl.name.text) === undefined) continue
  117. found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) })
  118. }
  119. }
  120. // A 3-arg `register(ns, 'zh'|'en', dict)` call whose dictionary argument we
  121. // cannot turn into an object literal. We refuse instead of skipping: a
  122. // registration we cannot measure is exactly the silent narrowing this gate
  123. // exists to catch.
  124. const refuse = (ns: string, tag: string, why: string): never => {
  125. throw new Error(`cannot verify register('${ns}', '${tag}', ...) in ${rel}: ${why}`)
  126. }
  127. // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair
  128. // handed to a registration loop keys off the enclosing array; separate
  129. // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the
  130. // namespace argument, so the two calls pair with each other.
  131. const visit = (node: ts.Node): void => {
  132. if (ts.isCallExpression(node)) {
  133. const callee = node.expression
  134. const name = ts.isPropertyAccessExpression(callee)
  135. ? callee.name.text
  136. : ts.isIdentifier(callee) && callee.text === 'register' ? 'register' : undefined
  137. if (name === 'register' && node.arguments.length >= 3) {
  138. const [ns, tag, dict] = node.arguments
  139. if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return
  140. if (tag.text !== 'zh' && tag.text !== 'en') return
  141. const raw = unwrap(dict)
  142. const literal = raw !== undefined && ts.isIdentifier(raw)
  143. ? (() => {
  144. const resolved = moduleConsts.get(raw.text)
  145. return resolved === undefined ? undefined : unwrap(resolved)
  146. })()
  147. : raw
  148. const why = raw !== undefined && ts.isIdentifier(raw)
  149. ? `third argument ${raw.text} does not resolve to an inline or module-scope object literal`
  150. : 'third argument is neither an object literal nor a resolvable dictionary variable'
  151. if (literal === undefined || !ts.isObjectLiteralExpression(literal)) {
  152. // The dictionary argument must resolve to an object literal; the
  153. // gate refuses rather than skips, so the symmetry it verifies never
  154. // silently narrows.
  155. refuse(ns.getText(source), tag.text, why)
  156. }
  157. const dictionary: ts.ObjectLiteralExpression = literal as ts.ObjectLiteralExpression
  158. // The namespace expression's source text identifies the pair, so the
  159. // zh and en calls for one namespace meet and calls for different
  160. // namespaces stay apart.
  161. found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) })
  162. }
  163. }
  164. if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) {
  165. const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
  166. for (const element of node.elements) {
  167. if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue
  168. const [tag, dict] = element.elements
  169. const literal = unwrap(dict)
  170. if (tag === undefined || !ts.isStringLiteral(tag)) continue
  171. if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
  172. if (tag.text !== 'zh' && tag.text !== 'en') continue
  173. found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) })
  174. }
  175. }
  176. ts.forEachChild(node, visit)
  177. }
  178. visit(source)
  179. return found
  180. }
  181. /** Declared property names of an object literal, sorted. */
  182. function keysOf(literal: ts.ObjectLiteralExpression): string[] {
  183. const keys: string[] = []
  184. for (const prop of literal.properties) {
  185. if (!ts.isPropertyAssignment(prop)) continue
  186. if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text)
  187. }
  188. return keys.sort()
  189. }
  190. /** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */
  191. function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
  192. let current = node
  193. while (
  194. current !== undefined
  195. && (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current))
  196. ) {
  197. current = current.expression
  198. }
  199. return current
  200. }
  201. /**
  202. * The locale a dictionary name declares, and the namespace-ish remainder that
  203. * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`,
  204. * and `settingsZh`/`settingsEn` are the shapes this repo uses. A name-prefix
  205. * shape requires an uppercase ASCII letter at the third position (`[A-Z]`),
  206. * matching the admission of the cheap pre-filter, so `zh2Foo`/`zh_probe`
  207. * cannot be treated as dictionaries in one place and skipped in another.
  208. * @param name - export name or synthetic inline name.
  209. * @returns locale plus pair key, or undefined when the name names no locale.
  210. */
  211. function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined {
  212. for (const locale of ['zh', 'en'] as const) {
  213. const other = locale === 'zh' ? 'Zh' : 'En'
  214. if (name === locale) return { locale, pair: '' }
  215. // Synthetic names for inline shapes carry their own pair key after the
  216. // first ':' (the enclosing array's line, or the namespace expression).
  217. if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) }
  218. if (name.startsWith(locale) && name.length > 2 && /[A-Z]/.test(name[2] ?? '')) {
  219. return { locale, pair: name.slice(2) }
  220. }
  221. if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) }
  222. }
  223. return undefined
  224. }
  225. describe('shipped locale dictionaries', () => {
  226. it('declares the same keys in zh and en, so the single fallback locale always resolves', () => {
  227. const files = sourceFiles()
  228. // Guard the discovery itself: an empty or narrowed sweep would pass every
  229. // assertion below while checking nothing.
  230. expect(files.length).toBeGreaterThan(500)
  231. // Pair within a file first; a dictionary whose counterpart is not in the
  232. // same module then pairs with a sibling in the same directory. Both shapes
  233. // ship here: `locales/settings.ts` exports zh+en together, while
  234. // `locales/zh.ts` + `locales/en.ts` split the common pair across files.
  235. const perFile = new Map<string, Dictionary[]>()
  236. for (const file of files) {
  237. const dicts = dictionariesIn(file)
  238. if (dicts.length > 0) perFile.set(relative(file), dicts)
  239. }
  240. const groups = new Map<string, Map<'zh' | 'en', Dictionary>>()
  241. const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => {
  242. const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>()
  243. if (slot.has(locale)) {
  244. throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`)
  245. }
  246. slot.set(locale, dict)
  247. groups.set(key, slot)
  248. }
  249. for (const [rel, dicts] of perFile) {
  250. for (const dict of dicts) {
  251. const parsed = localeOf(dict.name)
  252. if (parsed === undefined) continue
  253. const sameFileCounterpart = dicts.some((other) => {
  254. const otherParsed = localeOf(other.name)
  255. return otherParsed !== undefined
  256. && otherParsed.pair === parsed.pair
  257. && otherParsed.locale !== parsed.locale
  258. })
  259. // Same-file pairs key by file so two pairs in one directory stay
  260. // distinct; split pairs key by directory so siblings meet.
  261. const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}`
  262. place(key, parsed.locale, dict)
  263. }
  264. }
  265. const problems: string[] = []
  266. let comparedPairs = 0
  267. for (const [key, slot] of [...groups].sort()) {
  268. const zh = slot.get('zh')
  269. const en = slot.get('en')
  270. if (zh === undefined || en === undefined) {
  271. const present = zh ?? en
  272. problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`)
  273. continue
  274. }
  275. comparedPairs++
  276. const zhOnly = zh.keys.filter(k => !en.keys.includes(k))
  277. const enOnly = en.keys.filter(k => !zh.keys.includes(k))
  278. if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`)
  279. if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`)
  280. }
  281. // The shipped dictionary count only grows; a collapse means discovery or
  282. // pairing broke, which would hide real asymmetry.
  283. expect(comparedPairs).toBeGreaterThan(25)
  284. expect(problems).toEqual([])
  285. })
  286. })