@oxy.so/doctor 1.4.0 → 1.6.0

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,56 @@ 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
+
72
+ ## Expo Android device-session autolinking
73
+
74
+ After a complete frozen install, run the separate check against the Expo app
75
+ root (the current directory by default):
76
+
77
+ ```bash
78
+ oxy-doctor-expo-android --project-root=packages/frontend \
79
+ --require-source=SharedDeviceSessionPublish.kt \
80
+ --require-source=SharedDeviceSessionSweep.kt \
81
+ --require-source=OxyDeviceSessionStore.kt
82
+ ```
83
+
84
+ The command invokes the app's installed `expo-modules-autolinking` resolver and
85
+ requires exactly one Oxy Services entry matching the version resolved by Node
86
+ from the app root, and one device-session module from that package's Android
87
+ directory. It requires
88
+ `OxyDeviceSessionModule.kt` and any repeated `--require-source` filenames.
89
+ Choose additional sources according to the Services version and guarantees the
90
+ app expects; older releases need not contain newer files. Missing or ambiguous
91
+ results and resolver failures exit nonzero. This does not change the existing
92
+ Doctor or emulator preflight commands.
93
+
94
+ The check verifies discovery and source presence in the installed dependency
95
+ graph, not source contents, Android compilation, native binary distribution,
96
+ or device runtime behavior. It cannot exclude additional installed copies or
97
+ prove which copy Metro resolves from nested importers. Run it in a complete
98
+ install; a partial install cannot establish that the full app graph is correct.
99
+
50
100
  ## Android emulator preflight
51
101
 
52
102
  `oxy-emulator-preflight --serial emulator-5554` checks that the explicitly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/doctor",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Read-only health checks for Oxy ecosystem repositories",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,10 +10,13 @@
10
10
  "type": "module",
11
11
  "bin": {
12
12
  "oxy-doctor": "./src/cli.mjs",
13
+ "oxy-doctor-expo-android": "./src/expo-android.mjs",
14
+ "oxy-doctor-i18n": "./src/i18n-cli.mjs",
13
15
  "oxy-emulator-preflight": "./src/emulator-preflight.mjs"
14
16
  },
15
17
  "exports": {
16
- ".": "./src/checks.mjs"
18
+ ".": "./src/checks.mjs",
19
+ "./i18n": "./src/i18n.mjs"
17
20
  },
18
21
  "files": [
19
22
  "src",
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { dirname, join, resolve } from 'node:path';
6
+
7
+ const usage = 'oxy-doctor-expo-android [--project-root=PATH] [--require-source=NAME.kt ...]';
8
+ const args = process.argv.slice(2);
9
+ if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
10
+ console.log(
11
+ `${usage}\n\nCheck Expo Android autolinking for the installed Oxy device-session module.`,
12
+ );
13
+ process.exit(0);
14
+ }
15
+ try {
16
+ let root;
17
+ const requiredSources = new Set(['OxyDeviceSessionModule.kt']);
18
+ for (const arg of args) {
19
+ if (arg.startsWith('--project-root=') && root === undefined) {
20
+ root = arg.slice('--project-root='.length);
21
+ if (!root) throw new Error('project root must not be empty');
22
+ } else if (arg.startsWith('--require-source=')) {
23
+ const source = arg.slice('--require-source='.length);
24
+ if (!/^[A-Za-z][A-Za-z0-9_]*\.kt$/.test(source)) {
25
+ throw new Error('required source must be a Kotlin filename');
26
+ }
27
+ requiredSources.add(source);
28
+ } else {
29
+ throw new Error(`invalid argument: ${arg}`);
30
+ }
31
+ }
32
+ const projectRoot = resolve(root ?? process.cwd());
33
+ const projectRequire = createRequire(join(projectRoot, 'package.json'));
34
+ const manifestPath = projectRequire.resolve('@oxy.so/services/package.json');
35
+ const services = JSON.parse(readFileSync(manifestPath, 'utf8'));
36
+ if (
37
+ services.name !== '@oxy.so/services' ||
38
+ typeof services.version !== 'string' ||
39
+ !services.version
40
+ ) {
41
+ throw new Error('installed Oxy Services manifest is invalid');
42
+ }
43
+ const autolinking = projectRequire.resolve('expo-modules-autolinking/package.json');
44
+ const cli = join(dirname(autolinking), 'bin/expo-modules-autolinking.js');
45
+ let output;
46
+ try {
47
+ output = execFileSync(
48
+ process.execPath,
49
+ [cli, 'resolve', '--platform', 'android', '--project-root', projectRoot, '--json'],
50
+ {
51
+ encoding: 'utf8',
52
+ env: { ...process.env, NO_COLOR: '1' },
53
+ stdio: ['ignore', 'pipe', 'pipe'],
54
+ },
55
+ );
56
+ } catch {
57
+ throw new Error('Expo Android autolinking failed');
58
+ }
59
+ let result;
60
+ try {
61
+ result = JSON.parse(output);
62
+ } catch {
63
+ throw new Error('invalid Expo autolinking result');
64
+ }
65
+ if (!Array.isArray(result?.modules)) throw new Error('invalid Expo autolinking result');
66
+ const matches = result.modules.filter((entry) => entry?.packageName === '@oxy.so/services');
67
+ if (matches.length !== 1 || matches[0].packageVersion !== services.version) {
68
+ throw new Error('Android autolinking must select one Oxy Services entry matching the app root');
69
+ }
70
+ if (!Array.isArray(matches[0].projects)) throw new Error('invalid Oxy Services project list');
71
+ const projects = matches[0].projects.filter(
72
+ (project) =>
73
+ Array.isArray(project?.modules) &&
74
+ project.modules.some(
75
+ (module) => module?.classifier === 'so.oxy.devicesession.OxyDeviceSessionModule',
76
+ ),
77
+ );
78
+ if (
79
+ projects.length !== 1 ||
80
+ projects[0].modules.filter(
81
+ (module) => module?.classifier === 'so.oxy.devicesession.OxyDeviceSessionModule',
82
+ ).length !== 1
83
+ )
84
+ throw new Error('Android autolinking must include exactly one OxyDeviceSessionModule');
85
+ const nativeDir = join(dirname(manifestPath), 'android');
86
+ if (realpathSync(projects[0].sourceDir) !== realpathSync(nativeDir)) {
87
+ throw new Error('OxyDeviceSessionModule must come from the installed Oxy Services package');
88
+ }
89
+ for (const source of requiredSources) {
90
+ if (!existsSync(join(nativeDir, 'src/main/java/so/oxy/devicesession', source))) {
91
+ throw new Error(`installed Oxy Services is missing Android source ${source}`);
92
+ }
93
+ }
94
+ console.log(
95
+ `Android autolinking includes OxyDeviceSessionModule from Oxy Services ${services.version}.`,
96
+ );
97
+ } catch (error) {
98
+ console.error(
99
+ `Oxy Android check failed: ${error instanceof Error ? error.message : String(error)}`,
100
+ );
101
+ process.exitCode = 1;
102
+ }
@@ -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,262 @@
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
+ /** Whether `node` holds another plural form of the same base as `segment`. */
114
+ function hasPluralSibling(node, segment) {
115
+ if (!PLURAL_FORM.test(segment)) return false;
116
+ const base = segment.replace(PLURAL_FORM, '');
117
+ return PLURAL_CATEGORIES.some((category) => `${base}_${category}` in node);
118
+ }
119
+
120
+ const SINGULAR_PLURAL = /_(?:zero|one|two)$/;
121
+
122
+ /**
123
+ * The placeholders a string interpolates. A singular plural form may spell its
124
+ * number out ("un elemento"), so `count` is not required there.
125
+ */
126
+ function placeholders(text, key) {
127
+ const names = [...text.matchAll(PLACEHOLDER)].map((match) => match[1]);
128
+ return [...new Set(SINGULAR_PLURAL.test(key) ? names.filter((name) => name !== 'count') : names)]
129
+ .sort()
130
+ .join(',');
131
+ }
132
+
133
+ /**
134
+ * Check one loaded catalog directory. Returns `{ errors, warnings }`, each a
135
+ * list of `{ rule, locale, key, message }`.
136
+ */
137
+ export function checkCatalogs({ locales }, sourceLocale = DEFAULT_SOURCE_LOCALE) {
138
+ const errors = [];
139
+ const warnings = [];
140
+ const source = locales.get(sourceLocale);
141
+ if (!isTree(source)) {
142
+ errors.push({
143
+ rule: 'missing-source',
144
+ locale: sourceLocale,
145
+ key: '',
146
+ message: `no ${sourceLocale} catalog`,
147
+ });
148
+ return { errors, warnings };
149
+ }
150
+
151
+ // Key shape, on every locale: a translation can introduce a bad key too.
152
+ for (const [locale, tree] of locales) {
153
+ for (const [key, value] of Object.entries(isTree(tree) ? tree : {})) {
154
+ if (!isTree(value) && !key.includes('.')) {
155
+ errors.push({
156
+ rule: 'top-level-string',
157
+ locale,
158
+ key,
159
+ message: 'a top-level entry must be a namespace object',
160
+ });
161
+ }
162
+ }
163
+ (function walk(node, path) {
164
+ for (const [segment, value] of Object.entries(node)) {
165
+ const key = path ? `${path}.${segment}` : segment;
166
+ if (segment.includes('.')) {
167
+ errors.push({
168
+ rule: 'flat-key',
169
+ locale,
170
+ key,
171
+ message: 'a dotted key must be nested objects, not one flat string',
172
+ });
173
+ } else if (!KEY_SEGMENT.test(segment)) {
174
+ errors.push({
175
+ rule: 'text-key',
176
+ locale,
177
+ key,
178
+ message: 'a key segment is an identifier, never the text itself',
179
+ });
180
+ }
181
+ if (isTree(value)) walk(value, key);
182
+ else if (!isLeaf(value)) {
183
+ errors.push({
184
+ rule: 'non-string',
185
+ locale,
186
+ key,
187
+ message: 'a leaf must be a string or a list of strings',
188
+ });
189
+ }
190
+ }
191
+ })(isTree(tree) ? tree : {}, '');
192
+ }
193
+
194
+ // Translations against the source.
195
+ for (const [locale, tree] of locales) {
196
+ if (locale === sourceLocale) continue;
197
+ (function compare(sourceNode, node, path) {
198
+ for (const [segment, value] of Object.entries(node)) {
199
+ const key = path ? `${path}.${segment}` : segment;
200
+ if (!(segment in sourceNode)) {
201
+ // Another CLDR plural form of a source key: `_few` / `_many` exist in
202
+ // languages English has no form for.
203
+ if (hasPluralSibling(sourceNode, segment)) continue;
204
+ errors.push({
205
+ rule: 'stray-key',
206
+ locale,
207
+ key,
208
+ message: `not in ${sourceLocale}; delete it or add it to ${sourceLocale}`,
209
+ });
210
+ } else if (isTree(value) !== isTree(sourceNode[segment])) {
211
+ errors.push({
212
+ rule: 'shape-mismatch',
213
+ locale,
214
+ key,
215
+ message: `a ${isTree(value) ? 'namespace' : 'string'} here, the other in ${sourceLocale}`,
216
+ });
217
+ } else if (isTree(value)) {
218
+ compare(sourceNode[segment], value, key);
219
+ } else if (typeof value === 'string' && typeof sourceNode[segment] === 'string') {
220
+ const expected = placeholders(sourceNode[segment], key);
221
+ if (placeholders(value, key) !== expected) {
222
+ warnings.push({
223
+ rule: 'placeholder-mismatch',
224
+ locale,
225
+ key,
226
+ message: `placeholders differ from ${sourceLocale} ({{${expected}}})`,
227
+ });
228
+ }
229
+ }
230
+ }
231
+ for (const segment of Object.keys(sourceNode)) {
232
+ // A language without this plural category (`_one` in Japanese) is not missing it.
233
+ if (!(segment in node) && !hasPluralSibling(node, segment)) {
234
+ const key = path ? `${path}.${segment}` : segment;
235
+ warnings.push({
236
+ rule: 'missing-translation',
237
+ locale,
238
+ key,
239
+ message: `renders the ${sourceLocale} text`,
240
+ });
241
+ }
242
+ }
243
+ })(source, isTree(tree) ? tree : {}, '');
244
+ }
245
+
246
+ return { errors, warnings };
247
+ }
248
+
249
+ /** Find and check every catalog under `root`. */
250
+ export async function inspectTranslations(root, sourceLocale = DEFAULT_SOURCE_LOCALE) {
251
+ const catalogs = [];
252
+ for (const directory of await findCatalogDirectories(root, sourceLocale)) {
253
+ const loaded = await loadCatalogs(directory, sourceLocale);
254
+ catalogs.push({
255
+ directory: relative(root, directory) || '.',
256
+ layout: loaded.layout,
257
+ locales: [...loaded.locales.keys()].sort(),
258
+ ...checkCatalogs(loaded, sourceLocale),
259
+ });
260
+ }
261
+ return catalogs;
262
+ }