@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.
Files changed (3) hide show
  1. package/package.json +10 -2
  2. package/src/checks.mjs +79 -27
  3. 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.0",
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": ["src", "README.md"],
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) => FIRST_PARTY_SCOPES.some((scope) => name.startsWith(scope));
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) => value.replace(/^v/, '').split(/[.-]/).slice(0, 3).map((part) => Number(part) || 0);
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 comparison >= 0 && candidateMajor === major && (major > 0 || candidateMinor === minor);
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') manifests.push(join(directory, entry.name));
76
- if (entry.isDirectory() && !ignored.has(entry.name)) manifests.push(...await collectManifests(join(directory, entry.name), depth + 1));
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(root, fetchLatest, { graceDays = DEFAULT_GRACE_DAYS, now = new Date() } = {}) {
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 ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
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) findings.push({ severity: 'error', code: 'missing-lockfile', message: 'bun.lock is missing' });
104
- await Promise.all([...usages].map(async ([name, rows]) => {
105
- const { version: latest, publishedAt } = await fetchLatest(name);
106
- const published = new Date(publishedAt);
107
- if (Number.isNaN(published.getTime())) throw new Error(`Registry returned no publish time for ${name}@${latest}`);
108
- const deadline = new Date(published.getTime() + graceDays * DAY_MS);
109
- const overdue = now >= deadline;
110
- const installed = lockfile ? [...new Set(lockfileVersions(lockfile, name))] : [];
111
- if (installed.length > 1) findings.push({ severity: 'error', code: 'duplicate-version', package: name, installed, message: `${name} has multiple locked versions: ${installed.join(', ')}` });
112
- for (const row of rows) {
113
- if (rangeIncludesVersion(row.range, latest)) continue;
114
- const due = deadline.toISOString().slice(0, 10);
115
- findings.push(overdue
116
- ? { severity: 'error', code: 'outdated', package: name, latest, publishedAt, ...row, message: `${name} ${row.range} does not include ${latest} in ${row.manifest} (published ${publishedAt.slice(0, 10)}; the ${graceDays}-day upgrade window closed ${due})` }
117
- : { severity: 'warning', code: 'outdated', package: name, latest, publishedAt, ...row, message: `${name} ${row.range} does not include ${latest} in ${row.manifest} (upgrade by ${due})` });
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)}`, { headers: { accept: 'application/json' } });
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') throw new Error(`Registry returned no latest version for ${name}`);
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') throw new Error(`Registry returned no publish time for ${name}@${version}`);
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 { DEFAULT_GRACE_DAYS, fetchLatestRelease, findRepositoryRoot, inspectRepository } from './checks.mjs';
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(`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.`);
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 = graceArg === undefined ? DEFAULT_GRACE_DAYS : Number(graceArg.slice('--grace-days='.length));
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, { graceDays });
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(`Oxy Doctor: ${report.packages} first-party packages across ${report.manifests} manifests`);
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) console.log(`${finding.severity === 'error' ? '✗' : '!'} ${finding.message}`);
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')) process.exitCode = 1;
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;