@oxy.so/doctor 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,28 @@ Require normal tests and typecheck before merging; never use `latest` ranges or
47
47
  mutate dependencies during application startup or CI. Renovate proposes
48
48
  changes; Doctor detects drift and duplicate installations.
49
49
 
50
+ ## Translation keys
51
+
52
+ Every translation catalog follows one key convention (oxy
53
+ `docs/adr/0055-translation-keys.md`): nested objects of identifier segments
54
+ (`compose.toolbar.addImage`), the first segment a namespace, never the English
55
+ text and never a flat dotted string, and English as the source.
56
+
57
+ ```bash
58
+ bunx oxy-doctor-i18n --ci # every locales/ directory with an en catalog
59
+ bunx oxy-doctor-i18n --root=packages/frontend --json
60
+ ```
61
+
62
+ It finds `locales/<lang>.json` (single file) and `locales/<lang>/<namespace>.json`
63
+ (per namespace) catalogs. **Errors** (`--ci` exits 1): a key segment that is
64
+ text (`text-key`), a dotted key stored flat (`flat-key`), a top-level string in
65
+ a single-file catalog (`top-level-string`), a leaf that is neither a string
66
+ nor a list of strings (`non-string`), a translation key the source lacks
67
+ (`stray-key`), a string where the source has a namespace or the reverse
68
+ (`shape-mismatch`). **Warnings**: a missing translation (`missing-translation`,
69
+ rendered in English) and a placeholder set that differs from the source
70
+ (`placeholder-mismatch`).
71
+
50
72
  ## Expo Android device-session autolinking
51
73
 
52
74
  After a complete frozen install, run the separate check against the Expo app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/doctor",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Read-only health checks for Oxy ecosystem repositories",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,10 +11,12 @@
11
11
  "bin": {
12
12
  "oxy-doctor": "./src/cli.mjs",
13
13
  "oxy-doctor-expo-android": "./src/expo-android.mjs",
14
+ "oxy-doctor-i18n": "./src/i18n-cli.mjs",
14
15
  "oxy-emulator-preflight": "./src/emulator-preflight.mjs"
15
16
  },
16
17
  "exports": {
17
- ".": "./src/checks.mjs"
18
+ ".": "./src/checks.mjs",
19
+ "./i18n": "./src/i18n.mjs"
18
20
  },
19
21
  "files": [
20
22
  "src",
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { DEFAULT_SOURCE_LOCALE, inspectTranslations } from './i18n.mjs';
4
+
5
+ const usage = 'oxy-doctor-i18n [--root=PATH] [--source=LOCALE] [--ci] [--json]';
6
+ const SHOWN_PER_RULE = 10;
7
+
8
+ /** `Map.groupBy`, which Node 20 (the engines floor) lacks. */
9
+ function groupBy(items, keyOf) {
10
+ const groups = new Map();
11
+ for (const item of items) {
12
+ const key = keyOf(item);
13
+ groups.set(key, [...(groups.get(key) ?? []), item]);
14
+ }
15
+ return groups;
16
+ }
17
+
18
+ let root = process.cwd();
19
+ let source = DEFAULT_SOURCE_LOCALE;
20
+ let ci = false;
21
+ let json = false;
22
+ for (const arg of process.argv.slice(2)) {
23
+ if (arg === '--help' || arg === '-h') {
24
+ console.log(
25
+ `${usage}\n\nCheck every translation catalog (locales/<lang>.json or locales/<lang>/*.json)\nagainst the Oxy key convention. --ci exits 1 on errors; warnings never fail.`,
26
+ );
27
+ process.exit(0);
28
+ } else if (arg.startsWith('--root=')) root = arg.slice('--root='.length);
29
+ else if (arg.startsWith('--source=')) source = arg.slice('--source='.length);
30
+ else if (arg === '--ci') ci = true;
31
+ else if (arg === '--json') json = true;
32
+ else {
33
+ console.error(`invalid argument: ${arg}\n${usage}`);
34
+ process.exit(2);
35
+ }
36
+ }
37
+
38
+ let catalogs;
39
+ try {
40
+ catalogs = await inspectTranslations(resolve(root), source);
41
+ } catch (error) {
42
+ console.error(`oxy-doctor-i18n: ${error instanceof Error ? error.message : String(error)}`);
43
+ process.exit(2);
44
+ }
45
+
46
+ const errorCount = catalogs.reduce((sum, catalog) => sum + catalog.errors.length, 0);
47
+ if (json) {
48
+ console.log(JSON.stringify({ catalogs, errorCount }, null, 2));
49
+ } else if (catalogs.length === 0) {
50
+ console.log(`No translation catalogs with a ${source} source found.`);
51
+ } else {
52
+ for (const catalog of catalogs) {
53
+ console.log(
54
+ `${catalog.directory} (${catalog.layout}, ${catalog.locales.length} locales): ${catalog.errors.length} errors, ${catalog.warnings.length} warnings`,
55
+ );
56
+ const byRule = groupBy(catalog.errors, (issue) => issue.rule);
57
+ for (const [rule, issues] of byRule) {
58
+ console.log(` error ${rule} (${issues.length})`);
59
+ for (const issue of issues.slice(0, SHOWN_PER_RULE)) {
60
+ console.log(` ${issue.locale}: ${JSON.stringify(issue.key)} — ${issue.message}`);
61
+ }
62
+ if (issues.length > SHOWN_PER_RULE) {
63
+ console.log(` … ${issues.length - SHOWN_PER_RULE} more`);
64
+ }
65
+ }
66
+ const warnings = groupBy(catalog.warnings, (issue) => issue.rule);
67
+ for (const [rule, issues] of warnings) {
68
+ const perLocale = groupBy(issues, (issue) => issue.locale);
69
+ console.log(
70
+ ` warning ${rule}: ${[...perLocale].map(([locale, list]) => `${locale} ${list.length}`).join(', ')}`,
71
+ );
72
+ }
73
+ }
74
+ }
75
+ process.exit(ci && errorCount > 0 ? 1 : 0);
package/src/i18n.mjs ADDED
@@ -0,0 +1,273 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { basename, join, relative } from 'node:path';
3
+
4
+ /**
5
+ * Translation catalogs across the Oxy ecosystem follow ONE key convention
6
+ * (oxy `docs/adr/0055-translation-keys.md`):
7
+ *
8
+ * - A key is a PATH of short identifier segments (`compose.toolbar.addImage`),
9
+ * stored as NESTED JSON objects, never as a flat dotted string and never as
10
+ * the English sentence itself.
11
+ * - In a single-file catalog (`locales/en.json`) every top-level entry is a
12
+ * namespace object; a per-namespace catalog (`locales/en/compose.json`) is
13
+ * that namespace's file.
14
+ * - A translation holds only keys the source locale has, with the same shape.
15
+ *
16
+ * Missing translations are warnings: i18next renders the source text, and a
17
+ * translation pass is ordinary product work, not a broken build.
18
+ */
19
+
20
+ export const DEFAULT_SOURCE_LOCALE = 'en';
21
+
22
+ /** One key segment: an identifier, or a data value such as `picked_up` or `us-east-1`. */
23
+ const KEY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
24
+ const LOCALE_NAME = /^[a-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/;
25
+ const PLACEHOLDER = /\{\{\s*([^}\s,]+)[^}]*\}\}/g;
26
+
27
+ /** Never walked, with every dot-directory (`.git`, `.worktrees`, `.claude`, …). */
28
+ const IGNORED_DIRECTORIES = new Set(['build', 'coverage', 'dist', 'node_modules']);
29
+
30
+ /**
31
+ * Every `locales` directory under `root` that holds a catalog for
32
+ * `sourceLocale`: either `locales/<source>.json` (single file) or
33
+ * `locales/<source>/*.json` (one file per namespace).
34
+ */
35
+ export async function findCatalogDirectories(root, sourceLocale = DEFAULT_SOURCE_LOCALE) {
36
+ const found = [];
37
+ async function walk(directory) {
38
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
39
+ if (basename(directory) === 'locales') {
40
+ const hasSource = entries.some(
41
+ (entry) =>
42
+ (entry.isFile() && entry.name === `${sourceLocale}.json`) ||
43
+ (entry.isDirectory() && entry.name === sourceLocale),
44
+ );
45
+ if (hasSource) {
46
+ found.push(directory);
47
+ return;
48
+ }
49
+ }
50
+ for (const entry of entries) {
51
+ if (
52
+ entry.isDirectory() &&
53
+ !entry.name.startsWith('.') &&
54
+ !IGNORED_DIRECTORIES.has(entry.name)
55
+ ) {
56
+ await walk(join(directory, entry.name));
57
+ }
58
+ }
59
+ }
60
+ await walk(root);
61
+ return found.sort();
62
+ }
63
+
64
+ async function readJson(path) {
65
+ try {
66
+ return JSON.parse(await readFile(path, 'utf8'));
67
+ } catch (error) {
68
+ throw new Error(`${path}: ${error instanceof Error ? error.message : String(error)}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Load a catalog directory as `{ layout, locales: Map<locale, tree> }`, where a
74
+ * per-namespace catalog's tree is `{ <namespace>: <file contents> }` so both
75
+ * layouts are checked as one nested tree.
76
+ */
77
+ export async function loadCatalogs(directory, sourceLocale = DEFAULT_SOURCE_LOCALE) {
78
+ const entries = await readdir(directory, { withFileTypes: true });
79
+ const perNamespace = entries.some((entry) => entry.isDirectory() && entry.name === sourceLocale);
80
+ const locales = new Map();
81
+ for (const entry of entries) {
82
+ if (perNamespace && entry.isDirectory() && LOCALE_NAME.test(entry.name)) {
83
+ const tree = {};
84
+ const files = await readdir(join(directory, entry.name), { withFileTypes: true });
85
+ for (const file of files) {
86
+ if (file.isFile() && file.name.endsWith('.json')) {
87
+ tree[file.name.slice(0, -'.json'.length)] = await readJson(
88
+ join(directory, entry.name, file.name),
89
+ );
90
+ }
91
+ }
92
+ locales.set(entry.name, tree);
93
+ } else if (!perNamespace && entry.isFile() && entry.name.endsWith('.json')) {
94
+ const locale = entry.name.slice(0, -'.json'.length);
95
+ if (LOCALE_NAME.test(locale)) {
96
+ locales.set(locale, await readJson(join(directory, entry.name)));
97
+ }
98
+ }
99
+ }
100
+ return { layout: perNamespace ? 'per-namespace' : 'single-file', locales };
101
+ }
102
+
103
+ const isTree = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
104
+
105
+ /** A string, or a list of strings (read with i18next's `returnObjects`). */
106
+ const isLeaf = (value) =>
107
+ typeof value === 'string' ||
108
+ (Array.isArray(value) && value.every((item) => typeof item === 'string'));
109
+
110
+ const PLURAL_FORM = /_(?:zero|one|two|few|many|other)$/;
111
+ const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'];
112
+
113
+ const isPluralCategory = (segment) => PLURAL_CATEGORIES.includes(segment);
114
+
115
+ /** An i18n-js plural object, `{ one, other }`: every key a CLDR category, `other` among them. */
116
+ const isPluralObject = (node) =>
117
+ 'other' in node && Object.keys(node).every((segment) => isPluralCategory(segment));
118
+
119
+ /**
120
+ * Whether `node` holds another plural form of the same base as `segment`:
121
+ * i18next's suffixed siblings (`count_one` / `count_many`) or i18n-js's nested
122
+ * plural object (`count.one` / `count.many`).
123
+ */
124
+ function hasPluralSibling(node, segment) {
125
+ if (isPluralCategory(segment)) return isPluralObject(node);
126
+ if (!PLURAL_FORM.test(segment)) return false;
127
+ const base = segment.replace(PLURAL_FORM, '');
128
+ return PLURAL_CATEGORIES.some((category) => `${base}_${category}` in node);
129
+ }
130
+
131
+ const SINGULAR_PLURAL = /[._](?:zero|one|two)$/;
132
+
133
+ /**
134
+ * The placeholders a string interpolates. A singular plural form may spell its
135
+ * number out ("un elemento"), so `count` is not required there.
136
+ */
137
+ function placeholders(text, key) {
138
+ const names = [...text.matchAll(PLACEHOLDER)].map((match) => match[1]);
139
+ return [...new Set(SINGULAR_PLURAL.test(key) ? names.filter((name) => name !== 'count') : names)]
140
+ .sort()
141
+ .join(',');
142
+ }
143
+
144
+ /**
145
+ * Check one loaded catalog directory. Returns `{ errors, warnings }`, each a
146
+ * list of `{ rule, locale, key, message }`.
147
+ */
148
+ export function checkCatalogs({ locales }, sourceLocale = DEFAULT_SOURCE_LOCALE) {
149
+ const errors = [];
150
+ const warnings = [];
151
+ const source = locales.get(sourceLocale);
152
+ if (!isTree(source)) {
153
+ errors.push({
154
+ rule: 'missing-source',
155
+ locale: sourceLocale,
156
+ key: '',
157
+ message: `no ${sourceLocale} catalog`,
158
+ });
159
+ return { errors, warnings };
160
+ }
161
+
162
+ // Key shape, on every locale: a translation can introduce a bad key too.
163
+ for (const [locale, tree] of locales) {
164
+ for (const [key, value] of Object.entries(isTree(tree) ? tree : {})) {
165
+ if (!isTree(value) && !key.includes('.')) {
166
+ errors.push({
167
+ rule: 'top-level-string',
168
+ locale,
169
+ key,
170
+ message: 'a top-level entry must be a namespace object',
171
+ });
172
+ }
173
+ }
174
+ (function walk(node, path) {
175
+ for (const [segment, value] of Object.entries(node)) {
176
+ const key = path ? `${path}.${segment}` : segment;
177
+ if (segment.includes('.')) {
178
+ errors.push({
179
+ rule: 'flat-key',
180
+ locale,
181
+ key,
182
+ message: 'a dotted key must be nested objects, not one flat string',
183
+ });
184
+ } else if (!KEY_SEGMENT.test(segment)) {
185
+ errors.push({
186
+ rule: 'text-key',
187
+ locale,
188
+ key,
189
+ message: 'a key segment is an identifier, never the text itself',
190
+ });
191
+ }
192
+ if (isTree(value)) walk(value, key);
193
+ else if (!isLeaf(value)) {
194
+ errors.push({
195
+ rule: 'non-string',
196
+ locale,
197
+ key,
198
+ message: 'a leaf must be a string or a list of strings',
199
+ });
200
+ }
201
+ }
202
+ })(isTree(tree) ? tree : {}, '');
203
+ }
204
+
205
+ // Translations against the source.
206
+ for (const [locale, tree] of locales) {
207
+ if (locale === sourceLocale) continue;
208
+ (function compare(sourceNode, node, path) {
209
+ for (const [segment, value] of Object.entries(node)) {
210
+ const key = path ? `${path}.${segment}` : segment;
211
+ if (!(segment in sourceNode)) {
212
+ // Another CLDR plural form of a source key: `few` / `many` exist in
213
+ // languages English has no form for.
214
+ if (hasPluralSibling(sourceNode, segment)) continue;
215
+ errors.push({
216
+ rule: 'stray-key',
217
+ locale,
218
+ key,
219
+ message: `not in ${sourceLocale}; delete it or add it to ${sourceLocale}`,
220
+ });
221
+ } else if (isTree(value) !== isTree(sourceNode[segment])) {
222
+ errors.push({
223
+ rule: 'shape-mismatch',
224
+ locale,
225
+ key,
226
+ message: `a ${isTree(value) ? 'namespace' : 'string'} here, the other in ${sourceLocale}`,
227
+ });
228
+ } else if (isTree(value)) {
229
+ compare(sourceNode[segment], value, key);
230
+ } else if (typeof value === 'string' && typeof sourceNode[segment] === 'string') {
231
+ const expected = placeholders(sourceNode[segment], key);
232
+ if (placeholders(value, key) !== expected) {
233
+ warnings.push({
234
+ rule: 'placeholder-mismatch',
235
+ locale,
236
+ key,
237
+ message: `placeholders differ from ${sourceLocale} ({{${expected}}})`,
238
+ });
239
+ }
240
+ }
241
+ }
242
+ for (const segment of Object.keys(sourceNode)) {
243
+ // A language without this plural category (`_one` in Japanese) is not missing it.
244
+ if (!(segment in node) && !hasPluralSibling(node, segment)) {
245
+ const key = path ? `${path}.${segment}` : segment;
246
+ warnings.push({
247
+ rule: 'missing-translation',
248
+ locale,
249
+ key,
250
+ message: `renders the ${sourceLocale} text`,
251
+ });
252
+ }
253
+ }
254
+ })(source, isTree(tree) ? tree : {}, '');
255
+ }
256
+
257
+ return { errors, warnings };
258
+ }
259
+
260
+ /** Find and check every catalog under `root`. */
261
+ export async function inspectTranslations(root, sourceLocale = DEFAULT_SOURCE_LOCALE) {
262
+ const catalogs = [];
263
+ for (const directory of await findCatalogDirectories(root, sourceLocale)) {
264
+ const loaded = await loadCatalogs(directory, sourceLocale);
265
+ catalogs.push({
266
+ directory: relative(root, directory) || '.',
267
+ layout: loaded.layout,
268
+ locales: [...loaded.locales.keys()].sort(),
269
+ ...checkCatalogs(loaded, sourceLocale),
270
+ });
271
+ }
272
+ return catalogs;
273
+ }