@oxy.so/doctor 0.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/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # Oxy Doctor
2
+
3
+ Read-only health checks for JavaScript/TypeScript repositories in the Oxy
4
+ ecosystem. Doctor never edits manifests, installs packages, or bypasses a
5
+ lockfile; dependency changes remain reviewable commits produced by Renovate or
6
+ a developer.
7
+
8
+ ## Usage
9
+
10
+ ```bash
11
+ bunx @oxy.so/doctor
12
+ bunx @oxy.so/doctor --ci
13
+ bunx @oxy.so/doctor --json
14
+ ```
15
+
16
+ It checks direct dependencies under canonical `@oxy.so/*`, transitional
17
+ `@oxyhq/*`, `@clarity.surf/*`, and `@alia.onl/*` against npm, reports duplicate
18
+ versions in `bun.lock`, and fails
19
+ clearly when the lockfile is missing. `--ci` exits 1 for findings; operational
20
+ failures use status 2. Default mode is informational.
21
+
22
+ Use Renovate with a shared organisation preset for grouped, reviewable updates.
23
+ Require normal tests and typecheck before merging; never use `latest` ranges or
24
+ mutate dependencies during application startup or CI. Renovate proposes
25
+ changes; Doctor detects drift and duplicate installations.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@oxy.so/doctor",
3
+ "version": "0.1.1",
4
+ "description": "Read-only health checks for Oxy ecosystem repositories",
5
+ "type": "module",
6
+ "bin": {
7
+ "oxy-doctor": "./src/cli.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/checks.mjs"
11
+ },
12
+ "files": ["src", "README.md"],
13
+ "scripts": {
14
+ "test": "bun test tests",
15
+ "prepublishOnly": "bun run test"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "license": "SEE LICENSE IN LICENSE"
24
+ }
package/src/checks.mjs ADDED
@@ -0,0 +1,107 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+
5
+ export const FIRST_PARTY_SCOPES = ['@oxy.so/', '@oxyhq/', '@clarity.surf/', '@alia.onl/'];
6
+ export const isFirstPartyPackage = (name) => FIRST_PARTY_SCOPES.some((scope) => name.startsWith(scope));
7
+
8
+ export function compareVersions(left, right) {
9
+ const parse = (value) => value.replace(/^v/, '').split(/[.-]/).slice(0, 3).map((part) => Number(part) || 0);
10
+ const a = parse(left);
11
+ const b = parse(right);
12
+ for (let index = 0; index < 3; index += 1) {
13
+ if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
14
+ }
15
+ return 0;
16
+ }
17
+
18
+ export function rangeIncludesVersion(range, version) {
19
+ return range.split('||').some((alternative) => {
20
+ const terms = alternative.trim().split(/\s+/).filter(Boolean);
21
+ return terms.every((term) => {
22
+ const target = term.match(/\d+\.\d+\.\d+/)?.[0];
23
+ if (!target) return true;
24
+ const comparison = compareVersions(version, target);
25
+ if (term.startsWith('>=')) return comparison >= 0;
26
+ if (term.startsWith('<=')) return comparison <= 0;
27
+ if (term.startsWith('>')) return comparison > 0;
28
+ if (term.startsWith('<')) return comparison < 0;
29
+ if (term.startsWith('^')) {
30
+ const [major, minor] = target.split('.').map(Number);
31
+ const [candidateMajor, candidateMinor] = version.split('.').map(Number);
32
+ return comparison >= 0 && candidateMajor === major && (major > 0 || candidateMinor === minor);
33
+ }
34
+ if (term.startsWith('~')) {
35
+ const [major, minor] = target.split('.').map(Number);
36
+ const [candidateMajor, candidateMinor] = version.split('.').map(Number);
37
+ return comparison >= 0 && candidateMajor === major && candidateMinor === minor;
38
+ }
39
+ return comparison === 0;
40
+ });
41
+ });
42
+ }
43
+
44
+ export function lockfileVersions(lockfile, packageName) {
45
+ const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+ const matcher = new RegExp(`"(?:[^"\\n]+/)?${escaped}"\\s*:\\s*\\["${escaped}@([^"\\s]+)"`, 'g');
47
+ return [...lockfile.matchAll(matcher)].map((match) => match[1]);
48
+ }
49
+
50
+ export async function findRepositoryRoot(start = process.cwd()) {
51
+ let current = resolve(start);
52
+ while (true) {
53
+ if (existsSync(join(current, 'package.json'))) return current;
54
+ const parent = dirname(current);
55
+ if (parent === current) throw new Error(`No package.json found above ${start}`);
56
+ current = parent;
57
+ }
58
+ }
59
+
60
+ async function collectManifests(directory, depth = 0) {
61
+ if (depth > 4) return [];
62
+ const ignored = new Set(['.git', '.expo', '.turbo', '.worktrees', 'dist', 'lib', 'node_modules']);
63
+ const entries = await readdir(directory, { withFileTypes: true });
64
+ const manifests = [];
65
+ for (const entry of entries) {
66
+ if (entry.isFile() && entry.name === 'package.json') manifests.push(join(directory, entry.name));
67
+ if (entry.isDirectory() && !ignored.has(entry.name)) manifests.push(...await collectManifests(join(directory, entry.name), depth + 1));
68
+ }
69
+ return manifests;
70
+ }
71
+
72
+ export async function inspectRepository(root, fetchLatest) {
73
+ const manifestPaths = await collectManifests(root);
74
+ const usages = new Map();
75
+ for (const path of manifestPaths) {
76
+ const manifest = JSON.parse(await readFile(path, 'utf8'));
77
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
78
+ for (const [name, range] of Object.entries(manifest[section] ?? {})) {
79
+ if (!isFirstPartyPackage(name) || range === 'workspace:*' || range === 'catalog:') continue;
80
+ const rows = usages.get(name) ?? [];
81
+ rows.push({ manifest: path.slice(root.length + 1), section, range });
82
+ usages.set(name, rows);
83
+ }
84
+ }
85
+ }
86
+ const lockPath = join(root, 'bun.lock');
87
+ const lockfile = existsSync(lockPath) ? await readFile(lockPath, 'utf8') : null;
88
+ const findings = [];
89
+ if (!lockfile) findings.push({ severity: 'error', code: 'missing-lockfile', message: 'bun.lock is missing' });
90
+ await Promise.all([...usages].map(async ([name, rows]) => {
91
+ const latest = await fetchLatest(name);
92
+ const installed = lockfile ? [...new Set(lockfileVersions(lockfile, name))] : [];
93
+ if (installed.length > 1) findings.push({ severity: 'error', code: 'duplicate-version', package: name, installed, message: `${name} has multiple locked versions: ${installed.join(', ')}` });
94
+ for (const row of rows) {
95
+ if (!rangeIncludesVersion(row.range, latest)) findings.push({ severity: 'warning', code: 'outdated', package: name, latest, ...row, message: `${name} ${row.range} does not include ${latest} in ${row.manifest}` });
96
+ }
97
+ }));
98
+ return { root, manifests: manifestPaths.length, packages: usages.size, findings };
99
+ }
100
+
101
+ export async function fetchLatestVersion(name) {
102
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, { headers: { accept: 'application/json' } });
103
+ if (!response.ok) throw new Error(`Registry returned ${response.status} for ${name}`);
104
+ const metadata = await response.json();
105
+ if (typeof metadata.version !== 'string') throw new Error(`Registry returned no version for ${name}`);
106
+ return metadata.version;
107
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { fetchLatestVersion, findRepositoryRoot, inspectRepository } from './checks.mjs';
3
+
4
+ const args = new Set(process.argv.slice(2));
5
+ if (args.has('--help') || args.has('-h')) {
6
+ console.log('oxy-doctor [--ci] [--json]\n\nRead-only checks for Oxy ecosystem dependency health.');
7
+ process.exit(0);
8
+ }
9
+ try {
10
+ const report = await inspectRepository(await findRepositoryRoot(), fetchLatestVersion);
11
+ if (args.has('--json')) console.log(JSON.stringify(report, null, 2));
12
+ else {
13
+ console.log(`Oxy Doctor: ${report.packages} first-party packages across ${report.manifests} manifests`);
14
+ if (report.findings.length === 0) console.log('✓ No dependency-health findings');
15
+ for (const finding of report.findings) console.log(`${finding.severity === 'error' ? '✗' : '!'} ${finding.message}`);
16
+ }
17
+ if (args.has('--ci') && report.findings.length > 0) process.exitCode = 1;
18
+ } catch (error) {
19
+ console.error(`Oxy Doctor failed: ${error instanceof Error ? error.message : String(error)}`);
20
+ process.exitCode = 2;
21
+ }