@oxy.so/doctor 1.6.0 → 1.7.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
@@ -15,8 +15,8 @@ bunx @oxy.so/doctor --ci --grace-days=7
15
15
  ```
16
16
 
17
17
  It checks direct dependencies under the canonical `@oxy.so/*` scope against
18
- npm, reports duplicate versions in `bun.lock`, and fails clearly when the
19
- lockfile is missing.
18
+ npm, fails on duplicate copies of a package in `bun.lock` or `node_modules`
19
+ (below), and fails clearly when the lockfile is missing.
20
20
 
21
21
  It reads the manifests Bun installs from: the root `package.json` and the
22
22
  workspaces its `workspaces` patterns match. A `catalog:` dependency is checked
@@ -29,8 +29,55 @@ A range that doesn't include the newest published `@oxy.so/*` release is a
29
29
  changes the window), and an **error** after that. `--ci` exits 1 on errors
30
30
  only, so publishing a new major never turns every app's CI, and every deploy
31
31
  gated on it, red at once; each app still has to upgrade inside the window.
32
- Duplicate locked versions and a missing lockfile are always errors.
33
- Operational failures use status 2. Default mode is informational.
32
+ A missing lockfile is always an error. Operational failures use status 2.
33
+ Default mode is informational.
34
+
35
+ ## Duplicate copies
36
+
37
+ Each copy of a module is its own module instance: two `@oxy.so/contracts` are
38
+ two schema sets, two `@oxy.so/bloom` two theme contexts (a provider from one
39
+ copy is invisible to a consumer of the other), two `react` an invalid-hook
40
+ crash, two native modules a build that links one and runs the other. OxyOS's
41
+ packager refuses a duplicate module outright.
42
+
43
+ Doctor reads every copy `bun.lock` installs and who pulls each one (the
44
+ workspaces and packages whose dependency resolves to it), and reports:
45
+
46
+ - **`duplicate-copy`** (error, any package): the newest locked copy satisfies
47
+ every range that pulls a copy, so one would do. Bun reuses an existing copy
48
+ only when it satisfies a range, so after an SDK release moves the hoisted copy
49
+ forward, a dependent's older nested copy stays locked: Dropsea once held
50
+ `@oxy.so/protocol/@oxy.so/contracts@6.8.0` beside the hoisted 6.9.0, though
51
+ protocol's `^6.5.0` accepts 6.9.0. Fix: `bun update -r <pkg>` (`-r` so a
52
+ workspace's own declaration moves too) and commit `bun.lock`.
53
+ - **`duplicate-pinned`**: every range accepts one older locked version, but a
54
+ dependent pins below the newest, and Bun resolves the wider ranges to the
55
+ newest, so `bun update` keeps both. An **error** for a single-copy package
56
+ (fix: a release of the pinning dependent, or a root `overrides` entry); a
57
+ **warning** for any other.
58
+ - **`duplicate-hoisted`** (error, zod): the Oxy packages agree on one
59
+ version, but each holds its own nested copy because the hoisted copy is a
60
+ version they reject (a workspace or tool still on zod 3). Fix: declare their
61
+ version in the root `package.json`, or move the hoisted copy's users to it.
62
+ - **`duplicate-conflict`** (error, single-copy packages): no one version
63
+ satisfies every range. No update can fix it; it names the dependents whose
64
+ ranges reject the version the rest use, and they need a release (or, for a
65
+ workspace, a wider range in its `package.json`).
66
+ - **`installed-duplicate`** (error): a single-copy package installed more than
67
+ once under `node_modules` (hoisted, nested, or Bun's isolated `.bun` store,
68
+ by real path) although `bun.lock` names one copy: a stale install, fixed by
69
+ `bun install --frozen-lockfile`. Without `node_modules` only `bun.lock` is
70
+ checked, and the CLI says so.
71
+
72
+ Single-copy packages: every `@oxy.so/*`, `react`, `react-dom`, `react-native`,
73
+ `expo`, `expo-modules-core`, `react-native-reanimated`,
74
+ `react-native-gesture-handler`, `react-native-safe-area-context`,
75
+ `react-native-screens`, `socket.io-client`, and `zod` among the copies
76
+ `@oxy.so/*` packages use (`@oxy.so/contracts` and an app's own
77
+ `@oxy.so/*-contracts` build schemas with it; a build tool's private zod may
78
+ differ). Copies of an ordinary package forced by incompatible ranges (two
79
+ majors of a utility) are normal and not reported. A copy bundled inside its
80
+ dependent's tarball is that dependent's own unless the package is single-copy.
34
81
 
35
82
  It also checks that every app (a manifest on `@oxy.so/services` and on `expo`
36
83
  or `react-native`) declares each REQUIRED native peer of the services it has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/doctor",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Read-only health checks for Oxy ecosystem repositories",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,9 @@
25
25
  "scripts": {
26
26
  "test": "bun test tests"
27
27
  },
28
+ "dependencies": {
29
+ "semver": "^7.7.4"
30
+ },
28
31
  "publishConfig": {
29
32
  "access": "public"
30
33
  },
package/src/checks.mjs CHANGED
@@ -1,6 +1,18 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
2
  import { existsSync, readdirSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
+ import { duplicateFindings, installedDuplicateFindings, parseBunLock } from './duplicates.mjs';
5
+
6
+ export {
7
+ duplicateFindings,
8
+ installedCopies,
9
+ installedDuplicateFindings,
10
+ lockfileCopies,
11
+ mustBeSingle,
12
+ parseBunLock,
13
+ SHARED_WITH_OXY_PACKAGES,
14
+ SINGLE_COPY_PACKAGES,
15
+ } from './duplicates.mjs';
4
16
 
5
17
  export const FIRST_PARTY_SCOPES = ['@oxy.so/'];
6
18
 
@@ -277,6 +289,14 @@ export async function inspectRepository(
277
289
  const lockfile = existsSync(lockPath) ? await readFile(lockPath, 'utf8') : null;
278
290
  if (!lockfile)
279
291
  findings.push({ severity: 'error', code: 'missing-lockfile', message: 'bun.lock is missing' });
292
+ const duplicates = lockfile ? duplicateFindings(parseBunLock(lockfile)) : [];
293
+ findings.push(...duplicates);
294
+ const installed = installedDuplicateFindings(
295
+ root,
296
+ manifestPaths.map((path) => dirname(path)),
297
+ new Set(duplicates.map((finding) => finding.package)),
298
+ );
299
+ findings.push(...installed.findings);
280
300
  await Promise.all(
281
301
  [...usages].map(async ([name, rows]) => {
282
302
  const { version: latest, publishedAt } = await fetchLatest(name);
@@ -285,15 +305,6 @@ export async function inspectRepository(
285
305
  throw new Error(`Registry returned no publish time for ${name}@${latest}`);
286
306
  const deadline = new Date(published.getTime() + graceDays * DAY_MS);
287
307
  const overdue = now >= deadline;
288
- const installed = lockfile ? [...new Set(lockfileVersions(lockfile, name))] : [];
289
- if (installed.length > 1)
290
- findings.push({
291
- severity: 'error',
292
- code: 'duplicate-version',
293
- package: name,
294
- installed,
295
- message: `${name} has multiple locked versions: ${installed.join(', ')}`,
296
- });
297
308
  for (const row of rows) {
298
309
  if (rangeIncludesVersion(row.range, latest)) continue;
299
310
  const due = deadline.toISOString().slice(0, 10);
@@ -327,6 +338,7 @@ export async function inspectRepository(
327
338
  packages: usages.size,
328
339
  findings,
329
340
  nativePeersUnverified: unverified,
341
+ installedTreeChecked: installed.checked,
330
342
  };
331
343
  }
332
344
 
package/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ const argv = process.argv.slice(2);
10
10
  const args = new Set(argv);
11
11
  if (args.has('--help') || args.has('-h')) {
12
12
  console.log(
13
- `oxy-doctor [--ci] [--json] [--grace-days=N]\n\nRead-only checks for Oxy ecosystem dependency health.\nA newer @oxy.so release is a warning for N days after it is published (default ${DEFAULT_GRACE_DAYS}), then an error.\n--ci exits 1 on errors only.`,
13
+ `oxy-doctor [--ci] [--json] [--grace-days=N]\n\nRead-only checks for Oxy ecosystem dependency health.\nA newer @oxy.so release is a warning for N days after it is published (default ${DEFAULT_GRACE_DAYS}), then an error.\nMore than one copy of a package bun could serve with one (\`bun update <pkg>\`), or of a package that must be single (@oxy.so/*, react, expo, ...), is an error.\n--ci exits 1 on errors only.`,
14
14
  );
15
15
  process.exit(0);
16
16
  }
@@ -35,6 +35,10 @@ try {
35
35
  console.log(
36
36
  `? ${manifest}: @oxy.so/services is not installed, so its native peers were not checked (run bun install first)`,
37
37
  );
38
+ if (!report.installedTreeChecked)
39
+ console.log(
40
+ '? node_modules is not installed, so only bun.lock was checked for duplicate copies (run bun install first)',
41
+ );
38
42
  for (const finding of report.findings)
39
43
  console.log(`${finding.severity === 'error' ? '✗' : '!'} ${finding.message}`);
40
44
  }
@@ -0,0 +1,475 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import semver from 'semver';
4
+
5
+ /**
6
+ * Packages an app must hold exactly one copy of. Each copy of a module is its
7
+ * own module instance: two `@oxy.so/contracts` are two schema sets, two
8
+ * `@oxy.so/bloom` two theme contexts (a provider from one copy is invisible to
9
+ * a consumer of the other), two `react` an invalid-hook crash, two native
10
+ * modules a build that links one and runs the other. OxyOS's packager refuses
11
+ * a duplicate module outright. Every `@oxy.so/*` package is on this list too.
12
+ */
13
+ export const SINGLE_COPY_PACKAGES = [
14
+ 'expo',
15
+ 'expo-modules-core',
16
+ 'react',
17
+ 'react-dom',
18
+ 'react-native',
19
+ 'react-native-gesture-handler',
20
+ 'react-native-reanimated',
21
+ 'react-native-safe-area-context',
22
+ 'react-native-screens',
23
+ 'socket.io-client',
24
+ ];
25
+
26
+ /**
27
+ * Single only among the copies Oxy packages use: `@oxy.so/contracts` and an
28
+ * app's own `@oxy.so/*-contracts` build schemas with zod, and a schema from one
29
+ * zod copy cannot be composed into, or parsed by, another. A build tool
30
+ * carrying its own zod for its own config is not shared and may differ.
31
+ */
32
+ export const SHARED_WITH_OXY_PACKAGES = ['zod'];
33
+
34
+ const isOxyPackage = (name) => name.startsWith('@oxy.so/');
35
+ export const mustBeSingle = (name) => isOxyPackage(name) || SINGLE_COPY_PACKAGES.includes(name);
36
+
37
+ /**
38
+ * `bun.lock` is JSON with trailing commas. Drop every comma that closes an
39
+ * object or array (outside strings) and parse the rest as JSON.
40
+ */
41
+ export function parseBunLock(text) {
42
+ let out = '';
43
+ let inString = false;
44
+ for (let index = 0; index < text.length; index += 1) {
45
+ const char = text[index];
46
+ if (inString) {
47
+ out += char;
48
+ if (char === '\\') {
49
+ out += text[index + 1] ?? '';
50
+ index += 1;
51
+ } else if (char === '"') inString = false;
52
+ continue;
53
+ }
54
+ if (char === '"') inString = true;
55
+ else if (char === ',') {
56
+ let next = index + 1;
57
+ while (/\s/.test(text[next] ?? '')) next += 1;
58
+ if (text[next] === '}' || text[next] === ']') continue;
59
+ }
60
+ out += char;
61
+ }
62
+ return JSON.parse(out);
63
+ }
64
+
65
+ /** `@scope/name@1.2.3` → { name, spec }. */
66
+ function splitIdent(ident) {
67
+ const at = ident.indexOf('@', ident.startsWith('@') ? 1 : 0);
68
+ return at === -1
69
+ ? { name: ident, spec: '' }
70
+ : { name: ident.slice(0, at), spec: ident.slice(at + 1) };
71
+ }
72
+
73
+ /** The package names a lockfile key nests: `a/@s/b/c` → ['a', '@s/b', 'c']. */
74
+ function keyNames(key) {
75
+ const segments = key.split('/');
76
+ const names = [];
77
+ for (let index = 0; index < segments.length; index += 1) {
78
+ if (segments[index].startsWith('@')) {
79
+ names.push(`${segments[index]}/${segments[index + 1]}`);
80
+ index += 1;
81
+ } else names.push(segments[index]);
82
+ }
83
+ return names;
84
+ }
85
+
86
+ const DEPENDENCY_SECTIONS = ['dependencies', 'optionalDependencies', 'devDependencies'];
87
+
88
+ /**
89
+ * Every copy of every package `bun.lock` installs, and who pulls each copy:
90
+ * the workspaces and packages whose dependency on it resolves to that copy
91
+ * (Node's walk: the dependent's own nested entry first, then each ancestor's,
92
+ * then the hoisted one).
93
+ *
94
+ * @returns {Map<string, Array<{ key: string, version: string, pulledBy: Array<{ by: string, range: string, peer: boolean, oxy: boolean, workspace: boolean }> }>>}
95
+ */
96
+ export function lockfileCopies(lock) {
97
+ const packages = lock.packages ?? {};
98
+ const workspaces = lock.workspaces ?? {};
99
+ const catalog = lock.catalog ?? {};
100
+ const catalogs = lock.catalogs ?? {};
101
+ const copies = new Map();
102
+ const byKey = new Map();
103
+ for (const [key, entry] of Object.entries(packages)) {
104
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue;
105
+ const { name, spec } = splitIdent(entry[0]);
106
+ let version = spec;
107
+ if (spec.startsWith('workspace:')) {
108
+ const path = spec.slice('workspace:'.length);
109
+ version = workspaces[path]?.version ?? spec;
110
+ }
111
+ const meta =
112
+ entry.find((part) => part && typeof part === 'object' && !Array.isArray(part)) ?? {};
113
+ const copy = {
114
+ key,
115
+ name,
116
+ version,
117
+ meta,
118
+ workspace: spec.startsWith('workspace:'),
119
+ bundled: meta.bundled === true,
120
+ pulledBy: [],
121
+ };
122
+ byKey.set(key, copy);
123
+ const list = copies.get(name) ?? [];
124
+ list.push(copy);
125
+ copies.set(name, list);
126
+ }
127
+ const resolveFrom = (chain, dependency) => {
128
+ for (let depth = chain.length; depth >= 0; depth -= 1) {
129
+ const key = [...chain.slice(0, depth), dependency].join('/');
130
+ if (byKey.has(key)) return byKey.get(key);
131
+ }
132
+ return null;
133
+ };
134
+ const record = (chain, dependency, range, edge) => {
135
+ const target = resolveFrom(chain, dependency);
136
+ if (target) target.pulledBy.push({ range, ...edge });
137
+ };
138
+ for (const [path, workspace] of Object.entries(workspaces)) {
139
+ const chain = path === '' || !workspace.name ? [] : [workspace.name];
140
+ const by = path === '' ? 'package.json' : `${path}/package.json`;
141
+ for (const section of [...DEPENDENCY_SECTIONS, 'peerDependencies'])
142
+ for (const [dependency, range] of Object.entries(workspace[section] ?? {})) {
143
+ let resolved = range;
144
+ if (range.startsWith('catalog:')) {
145
+ const named = range.slice('catalog:'.length);
146
+ resolved =
147
+ (named && named !== 'default' ? catalogs[named] : catalog)?.[dependency] ?? range;
148
+ }
149
+ record(chain, dependency, resolved, {
150
+ by,
151
+ peer: section === 'peerDependencies',
152
+ oxy: typeof workspace.name === 'string' && isOxyPackage(workspace.name),
153
+ workspace: true,
154
+ });
155
+ }
156
+ }
157
+ for (const copy of byKey.values()) {
158
+ if (copy.workspace) continue;
159
+ const chain = keyNames(copy.key);
160
+ const by = `${copy.name}@${copy.version}`;
161
+ for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies'])
162
+ for (const [dependency, range] of Object.entries(copy.meta[section] ?? {})) {
163
+ record(chain, dependency, range, {
164
+ by,
165
+ peer: section === 'peerDependencies',
166
+ oxy: isOxyPackage(copy.name),
167
+ workspace: false,
168
+ });
169
+ }
170
+ }
171
+ return copies;
172
+ }
173
+
174
+ /** The package at one lockfile key, for naming it. */
175
+ function lockCopy(lock, key) {
176
+ const entry = lock.packages?.[key];
177
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string') return null;
178
+ const { name, spec } = splitIdent(entry[0]);
179
+ return { name, version: spec };
180
+ }
181
+
182
+ /** The semver range in a dependency spec, or null when it is not one (git, file, link, tag). */
183
+ function semverRange(range) {
184
+ const spec = range.startsWith('npm:') ? splitIdent(range.slice(4)).spec : range;
185
+ return semver.validRange(spec) === null ? null : spec;
186
+ }
187
+
188
+ /** Workspaces install what they declare; packages install their dependencies, never their peers. */
189
+ function pullers(copy) {
190
+ const installing = copy.pulledBy.filter((edge) => !edge.peer || edge.workspace);
191
+ return installing.length > 0 ? installing : copy.pulledBy;
192
+ }
193
+
194
+ const LIST_LIMIT = 4;
195
+ function describeEdges(edges) {
196
+ const unique = [...new Map(edges.map((edge) => [`${edge.by} ${edge.range}`, edge])).values()];
197
+ const shown = unique
198
+ .slice(0, LIST_LIMIT)
199
+ .map((edge) => `${edge.by} (${edge.range})`)
200
+ .join(', ');
201
+ return unique.length > LIST_LIMIT ? `${shown} and ${unique.length - LIST_LIMIT} more` : shown;
202
+ }
203
+
204
+ /** "6.8.0 (pulled by …); 6.9.0 (3 copies, pulled by …)": one clause per version. */
205
+ function describeCopies(copies) {
206
+ const byVersion = new Map();
207
+ for (const copy of copies) {
208
+ const rows = byVersion.get(copy.version) ?? [];
209
+ rows.push(copy);
210
+ byVersion.set(copy.version, rows);
211
+ }
212
+ return [...byVersion]
213
+ .map(([version, rows]) => {
214
+ const count = rows.length === 1 ? '' : `${rows.length} copies, `;
215
+ const edges = describeEdges(rows.flatMap(pullers));
216
+ return `${version} (${count}pulled by ${edges || 'nothing that declares it'})`;
217
+ })
218
+ .join('; ');
219
+ }
220
+
221
+ /**
222
+ * Duplicate copies in a parsed `bun.lock`. Bun resolves each range to the
223
+ * newest version it accepts and reuses an existing copy only when that copy
224
+ * satisfies the range, so after an SDK bump moves the hoisted copy forward a
225
+ * dependent's older nested copy can stay locked although the new one would do.
226
+ *
227
+ * - `duplicate-copy` (error, any package): the newest locked copy satisfies
228
+ * every range that pulls a copy. `bun update -r <name>` re-resolves them all
229
+ * (`-r`: a workspace's own declaration too) to
230
+ * it.
231
+ * - `duplicate-pinned`: every range accepts one OLDER locked version, but a
232
+ * dependent pins below the newest, so the resolver's newest-first choice
233
+ * splits them and `bun update` cannot. An error for a single-copy package
234
+ * (fix: a release of the pinning dependent, or a root `overrides` entry); a
235
+ * warning for any other package.
236
+ * - `duplicate-hoisted` (error, zod): the Oxy packages agree on one version
237
+ * but each holds its own nested copy, because the hoisted copy is a version
238
+ * they reject (another workspace's or tool's). Fix: declare their version at
239
+ * the root, or move the hoisted copy's users to it.
240
+ * - `duplicate-conflict` (error, single-copy packages): no one version
241
+ * satisfies every range. No update or override is correct; the dependent
242
+ * whose range is too narrow needs a release.
243
+ *
244
+ * Copies of an ordinary package forced by incompatible ranges (two majors of
245
+ * a utility) are normal and not reported.
246
+ */
247
+ export function duplicateFindings(lock) {
248
+ const findings = [];
249
+ const overrides = lock.overrides ?? {};
250
+ for (const [name, all] of lockfileCopies(lock)) {
251
+ if (all.length < 2) continue;
252
+ const shared = SHARED_WITH_OXY_PACKAGES.includes(name);
253
+ const single = mustBeSingle(name) || shared;
254
+ // A bundled copy ships inside its dependent's tarball: no resolver can
255
+ // collapse it, so it counts only where a second copy is never acceptable.
256
+ const copies = all.filter(
257
+ (copy) => (single || !copy.bundled) && (!shared || copy.pulledBy.some((edge) => edge.oxy)),
258
+ );
259
+ if (copies.length < 2) continue;
260
+ const edges = copies.flatMap(pullers);
261
+ const ranges = edges.map((edge) => edge.range);
262
+ if (overrides[name]) ranges.push(overrides[name]);
263
+ const checkable = ranges.map(semverRange).filter((range) => range !== null);
264
+ const acceptsAll = (version) => checkable.every((range) => semver.satisfies(version, range));
265
+ const versions = [...new Set(copies.map((copy) => copy.version))]
266
+ .filter((version) => semver.valid(version) !== null)
267
+ .sort(semver.rcompare);
268
+ const newest = versions[0];
269
+ const bundled = copies.some((copy) => copy.bundled);
270
+ const common = bundled ? undefined : versions.find(acceptsAll);
271
+ const base = {
272
+ package: name,
273
+ installed: [...new Set(copies.map((copy) => copy.version))],
274
+ copies: copies.map((copy) => ({
275
+ key: copy.key,
276
+ version: copy.version,
277
+ pulledBy: pullers(copy).map(({ by, range }) => ({ by, range })),
278
+ })),
279
+ };
280
+ const listing = describeCopies(copies);
281
+ const rejecting = (version) =>
282
+ edges.filter((edge) => {
283
+ const range = semverRange(edge.range);
284
+ return range !== null && !semver.satisfies(version, range);
285
+ });
286
+ const remedyFor = (version) => {
287
+ const blockers = rejecting(version);
288
+ const releases = [
289
+ ...new Set(blockers.filter((edge) => !edge.workspace).map((edge) => edge.by)),
290
+ ];
291
+ const manifests = [
292
+ ...new Set(blockers.filter((edge) => edge.workspace).map((edge) => edge.by)),
293
+ ];
294
+ const bundlers = copies
295
+ .filter((copy) => copy.bundled)
296
+ .map((copy) => {
297
+ const owner = lockCopy(lock, keyNames(copy.key).slice(0, -1).join('/'));
298
+ return owner ? `${owner.name}@${owner.version}` : copy.key;
299
+ });
300
+ const plural = (list, word) => `${word}${list.length === 1 ? 's' : ''}`;
301
+ return {
302
+ blockers: blockers.map(({ by, range }) => ({ by, range })),
303
+ text:
304
+ [
305
+ bundlers.length > 0 &&
306
+ `${bundlers.join(', ')} ${plural(bundlers, 'bundle')} a private copy and ${plural(bundlers, 'need')} a release that does not`,
307
+ releases.length > 0 &&
308
+ `${releases.join(', ')} ${plural(releases, 'need')} a release whose range accepts ${version}`,
309
+ manifests.length > 0 &&
310
+ `widen the range in ${manifests.join(', ')} to accept ${version}`,
311
+ ]
312
+ .filter(Boolean)
313
+ .join('; ') || 'the ranges that pull these copies must be made compatible',
314
+ };
315
+ };
316
+ // The hoisted copy can be left out of `copies` only for a shared package:
317
+ // the Oxy packages' copies are then all nested, one per dependent, because
318
+ // the hoisted slot holds a version their ranges reject. No update moves it.
319
+ const hoisted = all.find((copy) => copy.key === name);
320
+ const hoistedRejects =
321
+ hoisted !== undefined &&
322
+ !copies.includes(hoisted) &&
323
+ common !== undefined &&
324
+ pullers(hoisted).some((edge) => {
325
+ const range = semverRange(edge.range);
326
+ return range !== null && !semver.satisfies(common, range);
327
+ });
328
+ if (hoistedRejects) {
329
+ const holders = pullers(hoisted).filter((edge) => {
330
+ const range = semverRange(edge.range);
331
+ return range !== null && !semver.satisfies(common, range);
332
+ });
333
+ findings.push({
334
+ severity: 'error',
335
+ code: 'duplicate-hoisted',
336
+ ...base,
337
+ target: common,
338
+ hoisted: {
339
+ version: hoisted.version,
340
+ pulledBy: holders.map(({ by, range }) => ({ by, range })),
341
+ },
342
+ message: `${name}: the Oxy packages hold ${copies.length} separate copies (${listing}) because the hoisted ${name} is ${hoisted.version}, pulled by ${describeEdges(holders)}, which rejects ${common}. \`bun update\` cannot move it: declare \`"${name}": "^${common}"\` in the root package.json so they share one hoisted copy (the ${hoisted.version} users then get their own nested one), or move ${describeEdges(holders)} to ${common}`,
343
+ });
344
+ } else if (common !== undefined && common === newest) {
345
+ findings.push({
346
+ severity: 'error',
347
+ code: 'duplicate-copy',
348
+ ...base,
349
+ target: common,
350
+ message: `${name}: ${copies.length} copies in bun.lock where one would do: ${listing}. Every range accepts ${common}: run \`bun update -r ${name}\` and commit bun.lock`,
351
+ });
352
+ } else if (common !== undefined) {
353
+ if (!single && newest === undefined) continue;
354
+ const remedy = remedyFor(newest);
355
+ findings.push({
356
+ severity: single ? 'error' : 'warning',
357
+ code: 'duplicate-pinned',
358
+ ...base,
359
+ target: common,
360
+ newest,
361
+ blockers: remedy.blockers,
362
+ message: `${name}: ${copies.length} copies in bun.lock: ${listing}. Every range accepts ${common}, but Bun resolves the wider ranges to ${newest}, so \`bun update\` keeps both: ${remedy.text}, or pin it with \`"overrides": { "${name}": "${common}" }\` in the root package.json`,
363
+ });
364
+ } else if (single) {
365
+ const target =
366
+ [...versions].sort((left, right) => rejecting(left).length - rejecting(right).length)[0] ??
367
+ newest;
368
+ const remedy = remedyFor(target);
369
+ findings.push({
370
+ severity: 'error',
371
+ code: 'duplicate-conflict',
372
+ ...base,
373
+ target,
374
+ blockers: remedy.blockers,
375
+ message: `${name} must be a single copy, but bun.lock has ${copies.length} and no one version satisfies every range: ${listing}. A real conflict that \`bun update\` cannot fix: ${remedy.text}`,
376
+ });
377
+ }
378
+ }
379
+ return findings.sort((left, right) => left.package.localeCompare(right.package));
380
+ }
381
+
382
+ /**
383
+ * Every installed copy of a single-copy package under the repository's
384
+ * node_modules trees (hoisted or Bun's isolated `.bun` store), by real path, so
385
+ * a symlink is one copy and a stale nested directory the lockfile no longer
386
+ * names is another.
387
+ *
388
+ * @param {string} root
389
+ * @param {string[]} workspaceDirs
390
+ * @returns {Map<string, Array<{ path: string, version: string }>> | null} null when nothing is installed
391
+ */
392
+ export function installedCopies(root, workspaceDirs = []) {
393
+ const pending = [root, ...workspaceDirs]
394
+ .map((directory) => join(directory, 'node_modules'))
395
+ .filter((directory) => existsSync(directory));
396
+ if (pending.length === 0) return null;
397
+ const seen = new Set();
398
+ const found = new Map();
399
+ const real = (path) => {
400
+ try {
401
+ return realpathSync(path);
402
+ } catch {
403
+ return null;
404
+ }
405
+ };
406
+ const list = (directory) => {
407
+ try {
408
+ return readdirSync(directory);
409
+ } catch {
410
+ return [];
411
+ }
412
+ };
413
+ const visitPackage = (directory) => {
414
+ const resolved = real(directory);
415
+ if (!resolved || seen.has(resolved)) return;
416
+ seen.add(resolved);
417
+ const manifestPath = join(resolved, 'package.json');
418
+ if (existsSync(manifestPath)) {
419
+ try {
420
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
421
+ if (typeof manifest.name === 'string' && mustBeSingle(manifest.name)) {
422
+ const rows = found.get(manifest.name) ?? [];
423
+ rows.push({ path: relative(root, resolved) || '.', version: String(manifest.version) });
424
+ found.set(manifest.name, rows);
425
+ }
426
+ } catch {
427
+ // A package.json that does not parse is not a package this check can name.
428
+ }
429
+ }
430
+ const nested = join(resolved, 'node_modules');
431
+ if (existsSync(nested)) pending.push(nested);
432
+ };
433
+ while (pending.length > 0) {
434
+ const modules = pending.pop();
435
+ const resolved = real(modules);
436
+ if (!resolved || seen.has(`modules:${resolved}`)) continue;
437
+ seen.add(`modules:${resolved}`);
438
+ for (const entry of list(resolved)) {
439
+ if (entry === '.bin' || entry === '.cache') continue;
440
+ if (entry === '.bun') {
441
+ for (const store of list(join(resolved, entry))) {
442
+ const storeModules = join(resolved, entry, store, 'node_modules');
443
+ if (existsSync(storeModules)) pending.push(storeModules);
444
+ }
445
+ continue;
446
+ }
447
+ if (entry.startsWith('.')) continue;
448
+ if (entry.startsWith('@')) {
449
+ for (const scoped of list(join(resolved, entry)))
450
+ visitPackage(join(resolved, entry, scoped));
451
+ } else visitPackage(join(resolved, entry));
452
+ }
453
+ }
454
+ return found;
455
+ }
456
+
457
+ /** `installed-duplicate` errors for single-copy packages installed more than once. */
458
+ export function installedDuplicateFindings(root, workspaceDirs, alreadyReported = new Set()) {
459
+ const copies = installedCopies(root, workspaceDirs);
460
+ if (copies === null) return { checked: false, findings: [] };
461
+ const findings = [];
462
+ for (const [name, rows] of [...copies].sort(([left], [right]) => left.localeCompare(right))) {
463
+ if (rows.length < 2 || alreadyReported.has(name)) continue;
464
+ rows.sort((left, right) => left.path.localeCompare(right.path));
465
+ findings.push({
466
+ severity: 'error',
467
+ code: 'installed-duplicate',
468
+ package: name,
469
+ installed: [...new Set(rows.map((row) => row.version))],
470
+ paths: rows,
471
+ message: `${name} is installed ${rows.length} times: ${rows.map((row) => `${row.path} (${row.version})`).join(', ')}. bun.lock names one copy, so the install is stale: run \`bun install --frozen-lockfile\` (after a hoisted-linker churn, \`rm -rf node_modules packages/*/node_modules\` first)`,
472
+ });
473
+ }
474
+ return { checked: true, findings };
475
+ }
package/src/i18n.mjs CHANGED
@@ -110,14 +110,25 @@ const isLeaf = (value) =>
110
110
  const PLURAL_FORM = /_(?:zero|one|two|few|many|other)$/;
111
111
  const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'];
112
112
 
113
- /** Whether `node` holds another plural form of the same base as `segment`. */
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
+ */
114
124
  function hasPluralSibling(node, segment) {
125
+ if (isPluralCategory(segment)) return isPluralObject(node);
115
126
  if (!PLURAL_FORM.test(segment)) return false;
116
127
  const base = segment.replace(PLURAL_FORM, '');
117
128
  return PLURAL_CATEGORIES.some((category) => `${base}_${category}` in node);
118
129
  }
119
130
 
120
- const SINGULAR_PLURAL = /_(?:zero|one|two)$/;
131
+ const SINGULAR_PLURAL = /[._](?:zero|one|two)$/;
121
132
 
122
133
  /**
123
134
  * The placeholders a string interpolates. A singular plural form may spell its
@@ -198,7 +209,7 @@ export function checkCatalogs({ locales }, sourceLocale = DEFAULT_SOURCE_LOCALE)
198
209
  for (const [segment, value] of Object.entries(node)) {
199
210
  const key = path ? `${path}.${segment}` : segment;
200
211
  if (!(segment in sourceNode)) {
201
- // Another CLDR plural form of a source key: `_few` / `_many` exist in
212
+ // Another CLDR plural form of a source key: `few` / `many` exist in
202
213
  // languages English has no form for.
203
214
  if (hasPluralSibling(sourceNode, segment)) continue;
204
215
  errors.push({