@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 +51 -4
- package/package.json +4 -1
- package/src/checks.mjs +21 -9
- package/src/cli.mjs +5 -1
- package/src/duplicates.mjs +475 -0
- package/src/i18n.mjs +14 -3
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,
|
|
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
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
-
|
|
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: `
|
|
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({
|