@oxy.so/doctor 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -2
- package/src/checks.mjs +79 -27
- package/src/cli.mjs +21 -7
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/doctor",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
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);
|
|
@@ -72,8 +80,10 @@ async function collectManifests(directory, depth = 0) {
|
|
|
72
80
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
73
81
|
const manifests = [];
|
|
74
82
|
for (const entry of entries) {
|
|
75
|
-
if (entry.isFile() && entry.name === 'package.json')
|
|
76
|
-
|
|
83
|
+
if (entry.isFile() && entry.name === 'package.json')
|
|
84
|
+
manifests.push(join(directory, entry.name));
|
|
85
|
+
if (entry.isDirectory() && !ignored.has(entry.name))
|
|
86
|
+
manifests.push(...(await collectManifests(join(directory, entry.name), depth + 1)));
|
|
77
87
|
}
|
|
78
88
|
return manifests;
|
|
79
89
|
}
|
|
@@ -83,12 +93,21 @@ async function collectManifests(directory, depth = 0) {
|
|
|
83
93
|
* @param {(name: string) => Promise<{ version: string, publishedAt: string }>} fetchLatest
|
|
84
94
|
* @param {{ graceDays?: number, now?: Date }} [options]
|
|
85
95
|
*/
|
|
86
|
-
export async function inspectRepository(
|
|
96
|
+
export async function inspectRepository(
|
|
97
|
+
root,
|
|
98
|
+
fetchLatest,
|
|
99
|
+
{ graceDays = DEFAULT_GRACE_DAYS, now = new Date() } = {},
|
|
100
|
+
) {
|
|
87
101
|
const manifestPaths = await collectManifests(root);
|
|
88
102
|
const usages = new Map();
|
|
89
103
|
for (const path of manifestPaths) {
|
|
90
104
|
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
91
|
-
for (const section of [
|
|
105
|
+
for (const section of [
|
|
106
|
+
'dependencies',
|
|
107
|
+
'devDependencies',
|
|
108
|
+
'peerDependencies',
|
|
109
|
+
'optionalDependencies',
|
|
110
|
+
]) {
|
|
92
111
|
for (const [name, range] of Object.entries(manifest[section] ?? {})) {
|
|
93
112
|
if (!isFirstPartyPackage(name) || range === 'workspace:*' || range === 'catalog:') continue;
|
|
94
113
|
const rows = usages.get(name) ?? [];
|
|
@@ -100,34 +119,67 @@ export async function inspectRepository(root, fetchLatest, { graceDays = DEFAULT
|
|
|
100
119
|
const lockPath = join(root, 'bun.lock');
|
|
101
120
|
const lockfile = existsSync(lockPath) ? await readFile(lockPath, 'utf8') : null;
|
|
102
121
|
const findings = [];
|
|
103
|
-
if (!lockfile)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
if (!lockfile)
|
|
123
|
+
findings.push({ severity: 'error', code: 'missing-lockfile', message: 'bun.lock is missing' });
|
|
124
|
+
await Promise.all(
|
|
125
|
+
[...usages].map(async ([name, rows]) => {
|
|
126
|
+
const { version: latest, publishedAt } = await fetchLatest(name);
|
|
127
|
+
const published = new Date(publishedAt);
|
|
128
|
+
if (Number.isNaN(published.getTime()))
|
|
129
|
+
throw new Error(`Registry returned no publish time for ${name}@${latest}`);
|
|
130
|
+
const deadline = new Date(published.getTime() + graceDays * DAY_MS);
|
|
131
|
+
const overdue = now >= deadline;
|
|
132
|
+
const installed = lockfile ? [...new Set(lockfileVersions(lockfile, name))] : [];
|
|
133
|
+
if (installed.length > 1)
|
|
134
|
+
findings.push({
|
|
135
|
+
severity: 'error',
|
|
136
|
+
code: 'duplicate-version',
|
|
137
|
+
package: name,
|
|
138
|
+
installed,
|
|
139
|
+
message: `${name} has multiple locked versions: ${installed.join(', ')}`,
|
|
140
|
+
});
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
if (rangeIncludesVersion(row.range, latest)) continue;
|
|
143
|
+
const due = deadline.toISOString().slice(0, 10);
|
|
144
|
+
findings.push(
|
|
145
|
+
overdue
|
|
146
|
+
? {
|
|
147
|
+
severity: 'error',
|
|
148
|
+
code: 'outdated',
|
|
149
|
+
package: name,
|
|
150
|
+
latest,
|
|
151
|
+
publishedAt,
|
|
152
|
+
...row,
|
|
153
|
+
message: `${name} ${row.range} does not include ${latest} in ${row.manifest} (published ${publishedAt.slice(0, 10)}; the ${graceDays}-day upgrade window closed ${due})`,
|
|
154
|
+
}
|
|
155
|
+
: {
|
|
156
|
+
severity: 'warning',
|
|
157
|
+
code: 'outdated',
|
|
158
|
+
package: name,
|
|
159
|
+
latest,
|
|
160
|
+
publishedAt,
|
|
161
|
+
...row,
|
|
162
|
+
message: `${name} ${row.range} does not include ${latest} in ${row.manifest} (upgrade by ${due})`,
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
120
168
|
return { root, manifests: manifestPaths.length, packages: usages.size, findings };
|
|
121
169
|
}
|
|
122
170
|
|
|
123
171
|
/** The `latest` dist-tag of `name` and when that version was published. */
|
|
124
172
|
export async function fetchLatestRelease(name) {
|
|
125
|
-
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
|
|
173
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
|
|
174
|
+
headers: { accept: 'application/json' },
|
|
175
|
+
});
|
|
126
176
|
if (!response.ok) throw new Error(`Registry returned ${response.status} for ${name}`);
|
|
127
177
|
const metadata = await response.json();
|
|
128
178
|
const version = metadata['dist-tags']?.latest;
|
|
129
|
-
if (typeof version !== 'string')
|
|
179
|
+
if (typeof version !== 'string')
|
|
180
|
+
throw new Error(`Registry returned no latest version for ${name}`);
|
|
130
181
|
const publishedAt = metadata.time?.[version];
|
|
131
|
-
if (typeof publishedAt !== 'string')
|
|
182
|
+
if (typeof publishedAt !== 'string')
|
|
183
|
+
throw new Error(`Registry returned no publish time for ${name}@${version}`);
|
|
132
184
|
return { version, publishedAt };
|
|
133
185
|
}
|
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;
|