@oxy.so/doctor 1.1.0 → 1.2.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 +8 -3
- package/package.json +10 -2
- package/src/checks.mjs +167 -36
- package/src/cli.mjs +21 -7
package/README.md
CHANGED
|
@@ -15,9 +15,14 @@ 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
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
npm, reports duplicate versions in `bun.lock`, and fails clearly when the
|
|
19
|
+
lockfile is missing.
|
|
20
|
+
|
|
21
|
+
It reads the manifests Bun installs from: the root `package.json` and the
|
|
22
|
+
workspaces its `workspaces` patterns match. A `catalog:` dependency is checked
|
|
23
|
+
where its range lives, the root's `workspaces.catalog` (or `catalogs.<name>`).
|
|
24
|
+
A `package.json` outside the workspaces, such as a frozen copy kept as audit
|
|
25
|
+
evidence, is not this repository's dependency state and is not read.
|
|
21
26
|
|
|
22
27
|
A range that doesn't include the newest published `@oxy.so/*` release is a
|
|
23
28
|
**warning** for 14 days after that release was published (`--grace-days=N`
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/doctor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Read-only health checks for Oxy ecosystem repositories",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/OxyHQ/oxy.git",
|
|
8
|
+
"directory": "packages/doctor"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"bin": {
|
|
7
12
|
"oxy-doctor": "./src/cli.mjs"
|
|
@@ -9,7 +14,10 @@
|
|
|
9
14
|
"exports": {
|
|
10
15
|
".": "./src/checks.mjs"
|
|
11
16
|
},
|
|
12
|
-
"files": [
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
13
21
|
"scripts": {
|
|
14
22
|
"test": "bun test tests",
|
|
15
23
|
"prepublishOnly": "bun run test"
|
package/src/checks.mjs
CHANGED
|
@@ -12,10 +12,16 @@ export const FIRST_PARTY_SCOPES = ['@oxy.so/'];
|
|
|
12
12
|
*/
|
|
13
13
|
export const DEFAULT_GRACE_DAYS = 14;
|
|
14
14
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
15
|
-
export const isFirstPartyPackage = (name) =>
|
|
15
|
+
export const isFirstPartyPackage = (name) =>
|
|
16
|
+
FIRST_PARTY_SCOPES.some((scope) => name.startsWith(scope));
|
|
16
17
|
|
|
17
18
|
export function compareVersions(left, right) {
|
|
18
|
-
const parse = (value) =>
|
|
19
|
+
const parse = (value) =>
|
|
20
|
+
value
|
|
21
|
+
.replace(/^v/, '')
|
|
22
|
+
.split(/[.-]/)
|
|
23
|
+
.slice(0, 3)
|
|
24
|
+
.map((part) => Number(part) || 0);
|
|
19
25
|
const a = parse(left);
|
|
20
26
|
const b = parse(right);
|
|
21
27
|
for (let index = 0; index < 3; index += 1) {
|
|
@@ -38,7 +44,9 @@ export function rangeIncludesVersion(range, version) {
|
|
|
38
44
|
if (term.startsWith('^')) {
|
|
39
45
|
const [major, minor] = target.split('.').map(Number);
|
|
40
46
|
const [candidateMajor, candidateMinor] = version.split('.').map(Number);
|
|
41
|
-
return
|
|
47
|
+
return (
|
|
48
|
+
comparison >= 0 && candidateMajor === major && (major > 0 || candidateMinor === minor)
|
|
49
|
+
);
|
|
42
50
|
}
|
|
43
51
|
if (term.startsWith('~')) {
|
|
44
52
|
const [major, minor] = target.split('.').map(Number);
|
|
@@ -66,16 +74,85 @@ export async function findRepositoryRoot(start = process.cwd()) {
|
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
78
|
+
'.git',
|
|
79
|
+
'.expo',
|
|
80
|
+
'.turbo',
|
|
81
|
+
'.worktrees',
|
|
82
|
+
'dist',
|
|
83
|
+
'lib',
|
|
84
|
+
'node_modules',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
async function childDirectories(directory) {
|
|
88
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
89
|
+
return entries
|
|
90
|
+
.filter((entry) => entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name))
|
|
91
|
+
.map((entry) => join(directory, entry.name));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Directories matching one Bun workspace pattern (`dir`, `dir/*`, `dir/**`). */
|
|
95
|
+
async function expandWorkspacePattern(directory, segments) {
|
|
96
|
+
if (segments.length === 0) return [directory];
|
|
97
|
+
const [segment, ...rest] = segments;
|
|
98
|
+
if (segment === '**') {
|
|
99
|
+
const here = await expandWorkspacePattern(directory, rest);
|
|
100
|
+
const deeper = await Promise.all(
|
|
101
|
+
(await childDirectories(directory)).map((child) => expandWorkspacePattern(child, segments)),
|
|
102
|
+
);
|
|
103
|
+
return [...here, ...deeper.flat()];
|
|
104
|
+
}
|
|
105
|
+
if (segment.includes('*')) {
|
|
106
|
+
const matcher = new RegExp(
|
|
107
|
+
`^${segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`,
|
|
108
|
+
);
|
|
109
|
+
const children = (await childDirectories(directory)).filter((child) =>
|
|
110
|
+
matcher.test(child.slice(directory.length + 1)),
|
|
111
|
+
);
|
|
112
|
+
return (await Promise.all(children.map((child) => expandWorkspacePattern(child, rest)))).flat();
|
|
77
113
|
}
|
|
78
|
-
return
|
|
114
|
+
return expandWorkspacePattern(join(directory, segment), rest);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The manifests Bun installs from: the root and its workspaces. A package.json
|
|
119
|
+
* outside them (a frozen copy kept as audit evidence, a fixture) is not
|
|
120
|
+
* installed from this lockfile, so it is not this repository's dependency state.
|
|
121
|
+
*/
|
|
122
|
+
async function collectManifests(root, rootManifest) {
|
|
123
|
+
const workspaces = rootManifest.workspaces;
|
|
124
|
+
const patterns = Array.isArray(workspaces) ? workspaces : (workspaces?.packages ?? []);
|
|
125
|
+
const directories = new Set([root]);
|
|
126
|
+
const excluded = new Set();
|
|
127
|
+
for (const pattern of patterns) {
|
|
128
|
+
const negated = pattern.startsWith('!');
|
|
129
|
+
const segments = (negated ? pattern.slice(1) : pattern)
|
|
130
|
+
.replace(/^\.\//, '')
|
|
131
|
+
.split('/')
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
for (const directory of await expandWorkspacePattern(root, segments))
|
|
134
|
+
(negated ? excluded : directories).add(directory);
|
|
135
|
+
}
|
|
136
|
+
return [...directories]
|
|
137
|
+
.filter((directory) => !excluded.has(directory))
|
|
138
|
+
.map((directory) => join(directory, 'package.json'))
|
|
139
|
+
.filter((path) => existsSync(path))
|
|
140
|
+
.sort();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `catalog:` / `catalog:<name>` resolved against the root manifest's catalogs. */
|
|
144
|
+
function catalogEntries(rootManifest) {
|
|
145
|
+
const workspaces = Array.isArray(rootManifest.workspaces) ? {} : (rootManifest.workspaces ?? {});
|
|
146
|
+
const rows = [];
|
|
147
|
+
const add = (section, catalog) => {
|
|
148
|
+
for (const [name, range] of Object.entries(catalog ?? {})) rows.push({ section, name, range });
|
|
149
|
+
};
|
|
150
|
+
add('catalog', workspaces.catalog ?? rootManifest.catalog);
|
|
151
|
+
for (const [catalogName, catalog] of Object.entries(
|
|
152
|
+
workspaces.catalogs ?? rootManifest.catalogs ?? {},
|
|
153
|
+
))
|
|
154
|
+
add(`catalogs.${catalogName}`, catalog);
|
|
155
|
+
return rows;
|
|
79
156
|
}
|
|
80
157
|
|
|
81
158
|
/**
|
|
@@ -83,14 +160,35 @@ async function collectManifests(directory, depth = 0) {
|
|
|
83
160
|
* @param {(name: string) => Promise<{ version: string, publishedAt: string }>} fetchLatest
|
|
84
161
|
* @param {{ graceDays?: number, now?: Date }} [options]
|
|
85
162
|
*/
|
|
86
|
-
export async function inspectRepository(
|
|
87
|
-
|
|
163
|
+
export async function inspectRepository(
|
|
164
|
+
root,
|
|
165
|
+
fetchLatest,
|
|
166
|
+
{ graceDays = DEFAULT_GRACE_DAYS, now = new Date() } = {},
|
|
167
|
+
) {
|
|
168
|
+
const rootManifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
169
|
+
const manifestPaths = await collectManifests(root, rootManifest);
|
|
88
170
|
const usages = new Map();
|
|
171
|
+
for (const { section, name, range } of catalogEntries(rootManifest)) {
|
|
172
|
+
if (!isFirstPartyPackage(name)) continue;
|
|
173
|
+
const rows = usages.get(name) ?? [];
|
|
174
|
+
rows.push({ manifest: 'package.json', section, range });
|
|
175
|
+
usages.set(name, rows);
|
|
176
|
+
}
|
|
89
177
|
for (const path of manifestPaths) {
|
|
90
178
|
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
91
|
-
for (const section of [
|
|
179
|
+
for (const section of [
|
|
180
|
+
'dependencies',
|
|
181
|
+
'devDependencies',
|
|
182
|
+
'peerDependencies',
|
|
183
|
+
'optionalDependencies',
|
|
184
|
+
]) {
|
|
92
185
|
for (const [name, range] of Object.entries(manifest[section] ?? {})) {
|
|
93
|
-
if (
|
|
186
|
+
if (
|
|
187
|
+
!isFirstPartyPackage(name) ||
|
|
188
|
+
range.startsWith('workspace:') ||
|
|
189
|
+
range.startsWith('catalog:')
|
|
190
|
+
)
|
|
191
|
+
continue;
|
|
94
192
|
const rows = usages.get(name) ?? [];
|
|
95
193
|
rows.push({ manifest: path.slice(root.length + 1), section, range });
|
|
96
194
|
usages.set(name, rows);
|
|
@@ -100,34 +198,67 @@ export async function inspectRepository(root, fetchLatest, { graceDays = DEFAULT
|
|
|
100
198
|
const lockPath = join(root, 'bun.lock');
|
|
101
199
|
const lockfile = existsSync(lockPath) ? await readFile(lockPath, 'utf8') : null;
|
|
102
200
|
const findings = [];
|
|
103
|
-
if (!lockfile)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
201
|
+
if (!lockfile)
|
|
202
|
+
findings.push({ severity: 'error', code: 'missing-lockfile', message: 'bun.lock is missing' });
|
|
203
|
+
await Promise.all(
|
|
204
|
+
[...usages].map(async ([name, rows]) => {
|
|
205
|
+
const { version: latest, publishedAt } = await fetchLatest(name);
|
|
206
|
+
const published = new Date(publishedAt);
|
|
207
|
+
if (Number.isNaN(published.getTime()))
|
|
208
|
+
throw new Error(`Registry returned no publish time for ${name}@${latest}`);
|
|
209
|
+
const deadline = new Date(published.getTime() + graceDays * DAY_MS);
|
|
210
|
+
const overdue = now >= deadline;
|
|
211
|
+
const installed = lockfile ? [...new Set(lockfileVersions(lockfile, name))] : [];
|
|
212
|
+
if (installed.length > 1)
|
|
213
|
+
findings.push({
|
|
214
|
+
severity: 'error',
|
|
215
|
+
code: 'duplicate-version',
|
|
216
|
+
package: name,
|
|
217
|
+
installed,
|
|
218
|
+
message: `${name} has multiple locked versions: ${installed.join(', ')}`,
|
|
219
|
+
});
|
|
220
|
+
for (const row of rows) {
|
|
221
|
+
if (rangeIncludesVersion(row.range, latest)) continue;
|
|
222
|
+
const due = deadline.toISOString().slice(0, 10);
|
|
223
|
+
findings.push(
|
|
224
|
+
overdue
|
|
225
|
+
? {
|
|
226
|
+
severity: 'error',
|
|
227
|
+
code: 'outdated',
|
|
228
|
+
package: name,
|
|
229
|
+
latest,
|
|
230
|
+
publishedAt,
|
|
231
|
+
...row,
|
|
232
|
+
message: `${name} ${row.range} does not include ${latest} in ${row.manifest} (published ${publishedAt.slice(0, 10)}; the ${graceDays}-day upgrade window closed ${due})`,
|
|
233
|
+
}
|
|
234
|
+
: {
|
|
235
|
+
severity: 'warning',
|
|
236
|
+
code: 'outdated',
|
|
237
|
+
package: name,
|
|
238
|
+
latest,
|
|
239
|
+
publishedAt,
|
|
240
|
+
...row,
|
|
241
|
+
message: `${name} ${row.range} does not include ${latest} in ${row.manifest} (upgrade by ${due})`,
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
120
247
|
return { root, manifests: manifestPaths.length, packages: usages.size, findings };
|
|
121
248
|
}
|
|
122
249
|
|
|
123
250
|
/** The `latest` dist-tag of `name` and when that version was published. */
|
|
124
251
|
export async function fetchLatestRelease(name) {
|
|
125
|
-
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
|
|
252
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
|
|
253
|
+
headers: { accept: 'application/json' },
|
|
254
|
+
});
|
|
126
255
|
if (!response.ok) throw new Error(`Registry returned ${response.status} for ${name}`);
|
|
127
256
|
const metadata = await response.json();
|
|
128
257
|
const version = metadata['dist-tags']?.latest;
|
|
129
|
-
if (typeof version !== 'string')
|
|
258
|
+
if (typeof version !== 'string')
|
|
259
|
+
throw new Error(`Registry returned no latest version for ${name}`);
|
|
130
260
|
const publishedAt = metadata.time?.[version];
|
|
131
|
-
if (typeof publishedAt !== 'string')
|
|
261
|
+
if (typeof publishedAt !== 'string')
|
|
262
|
+
throw new Error(`Registry returned no publish time for ${name}@${version}`);
|
|
132
263
|
return { version, publishedAt };
|
|
133
264
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_GRACE_DAYS,
|
|
4
|
+
fetchLatestRelease,
|
|
5
|
+
findRepositoryRoot,
|
|
6
|
+
inspectRepository,
|
|
7
|
+
} from './checks.mjs';
|
|
3
8
|
|
|
4
9
|
const argv = process.argv.slice(2);
|
|
5
10
|
const args = new Set(argv);
|
|
6
11
|
if (args.has('--help') || args.has('-h')) {
|
|
7
|
-
console.log(
|
|
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.`,
|
|
14
|
+
);
|
|
8
15
|
process.exit(0);
|
|
9
16
|
}
|
|
10
17
|
const graceArg = argv.find((value) => value.startsWith('--grace-days='));
|
|
11
|
-
const graceDays =
|
|
18
|
+
const graceDays =
|
|
19
|
+
graceArg === undefined ? DEFAULT_GRACE_DAYS : Number(graceArg.slice('--grace-days='.length));
|
|
12
20
|
if (!Number.isInteger(graceDays) || graceDays < 0) {
|
|
13
21
|
console.error('Oxy Doctor failed: --grace-days must be a non-negative integer');
|
|
14
22
|
process.exit(2);
|
|
15
23
|
}
|
|
16
24
|
try {
|
|
17
|
-
const report = await inspectRepository(await findRepositoryRoot(), fetchLatestRelease, {
|
|
25
|
+
const report = await inspectRepository(await findRepositoryRoot(), fetchLatestRelease, {
|
|
26
|
+
graceDays,
|
|
27
|
+
});
|
|
18
28
|
if (args.has('--json')) console.log(JSON.stringify(report, null, 2));
|
|
19
29
|
else {
|
|
20
|
-
console.log(
|
|
30
|
+
console.log(
|
|
31
|
+
`Oxy Doctor: ${report.packages} first-party packages across ${report.manifests} manifests`,
|
|
32
|
+
);
|
|
21
33
|
if (report.findings.length === 0) console.log('✓ No dependency-health findings');
|
|
22
|
-
for (const finding of report.findings)
|
|
34
|
+
for (const finding of report.findings)
|
|
35
|
+
console.log(`${finding.severity === 'error' ? '✗' : '!'} ${finding.message}`);
|
|
23
36
|
}
|
|
24
|
-
if (args.has('--ci') && report.findings.some((finding) => finding.severity === 'error'))
|
|
37
|
+
if (args.has('--ci') && report.findings.some((finding) => finding.severity === 'error'))
|
|
38
|
+
process.exitCode = 1;
|
|
25
39
|
} catch (error) {
|
|
26
40
|
console.error(`Oxy Doctor failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
41
|
process.exitCode = 2;
|