@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 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
- versions in `bun.lock`, and fails
20
- clearly when the lockfile is missing.
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.1.0",
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": ["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);
@@ -66,16 +74,85 @@ export async function findRepositoryRoot(start = process.cwd()) {
66
74
  }
67
75
  }
68
76
 
69
- async function collectManifests(directory, depth = 0) {
70
- if (depth > 4) return [];
71
- const ignored = new Set(['.git', '.expo', '.turbo', '.worktrees', 'dist', 'lib', 'node_modules']);
72
- const entries = await readdir(directory, { withFileTypes: true });
73
- const manifests = [];
74
- 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));
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 manifests;
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(root, fetchLatest, { graceDays = DEFAULT_GRACE_DAYS, now = new Date() } = {}) {
87
- const manifestPaths = await collectManifests(root);
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 ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
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 (!isFirstPartyPackage(name) || range === 'workspace:*' || range === 'catalog:') continue;
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) 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
- }));
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)}`, { headers: { accept: 'application/json' } });
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') throw new Error(`Registry returned no latest version for ${name}`);
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') throw new Error(`Registry returned no publish time for ${name}@${version}`);
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 { 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;