dev-diagnosis-cli 0.0.0-stage → 0.1.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/lib/flakes.mjs ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Flaky-test ranking from JUnit XML.
3
+ *
4
+ * Adapted from flake-ledger (WJDdidi-1/flake-ledger, Apache-2.0): the same
5
+ * outcome model — failure/error, flakyFailure/flakyError/rerunFailure
6
+ * tags, retry attributes, repeated testcase entries collapsing into one
7
+ * record — and the same 95% Wilson lower bound for ranking. Reduced to
8
+ * what this CLI needs: no sharding rules, no GitHub annotations, no
9
+ * quarantine file. Run layout is simple and documented in the help:
10
+ * each subdirectory of <runs-dir> is one run, and each *.xml file
11
+ * directly under <runs-dir> is one run of its own.
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { elementChildren, getAttribute, isElement, localName, parseXml, textContent } from './xml.mjs';
16
+
17
+ const FAILURE_TAGS = new Set(['failure', 'error']);
18
+ const FLAKY_TAGS = new Set(['flakyfailure', 'flakyerror']);
19
+ const RERUN_TAGS = new Set(['rerunfailure', 'rerunerror']);
20
+ const SKIP_TAGS = new Set(['skipped', 'disabled']);
21
+
22
+ const RETRY_ATTRIBUTE_NAMES = [
23
+ 'retry', 'retries', 'rerun', 'reruns', 'retryCount', 'rerunCount', 'retry_count', 'rerun_count',
24
+ ];
25
+
26
+ function lower(value) {
27
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
28
+ }
29
+
30
+ function retryEvidence(node) {
31
+ for (const name of RETRY_ATTRIBUTE_NAMES) {
32
+ const raw = getAttribute(node, name);
33
+ if (raw === undefined || raw === '') continue;
34
+ const trimmed = String(raw).trim();
35
+ const numeric = Number(trimmed);
36
+ if (Number.isFinite(numeric)) {
37
+ if (numeric > 0) return true;
38
+ continue;
39
+ }
40
+ if (/^(true|yes|y)$/i.test(trimmed)) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ function collectTestcases(node, out) {
46
+ if (!node || !Array.isArray(node.children)) return;
47
+ for (const child of node.children) {
48
+ if (!isElement(child)) continue;
49
+ if (localName(child.name).toLowerCase() === 'testcase') out.push(child);
50
+ else collectTestcases(child, out);
51
+ }
52
+ }
53
+
54
+ function attemptFrom(node) {
55
+ const children = elementChildren(node);
56
+ let status = 'passed';
57
+ let message = null;
58
+ let retried = retryEvidence(node);
59
+ let file = getAttribute(node, 'file') || null;
60
+ const lineRaw = getAttribute(node, 'line');
61
+ const line = lineRaw !== undefined && /^\d+$/.test(String(lineRaw).trim()) ? Number(lineRaw) : null;
62
+
63
+ for (const child of children) {
64
+ const tag = lower(localName(child.name));
65
+ if (FLAKY_TAGS.has(tag) || RERUN_TAGS.has(tag)) {
66
+ status = 'passed-on-retry';
67
+ retried = true;
68
+ message = getAttribute(child, 'message') || message;
69
+ } else if (FAILURE_TAGS.has(tag)) {
70
+ if (status !== 'passed-on-retry') status = tag === 'error' ? 'error' : 'failed';
71
+ message = getAttribute(child, 'message') || message;
72
+ } else if (SKIP_TAGS.has(tag)) {
73
+ status = 'skipped';
74
+ message = getAttribute(child, 'message') || message;
75
+ }
76
+ if (!file) {
77
+ const childFile = getAttribute(child, 'file');
78
+ if (childFile) file = childFile;
79
+ }
80
+ }
81
+ if (status === 'passed' && retried) status = 'passed-on-retry';
82
+ return { status, message, retried, file, line };
83
+ }
84
+
85
+ export function parseJunitText(text, sourceName = '<xml>') {
86
+ const doc = parseXml(text, { sourceName });
87
+ const cases = [];
88
+ collectTestcases(doc, cases);
89
+ return cases.map((node) => {
90
+ const classname = getAttribute(node, 'classname') || '';
91
+ const name = getAttribute(node, 'name') || '';
92
+ return {
93
+ id: `${classname}::${name}`,
94
+ classname,
95
+ name,
96
+ ...attemptFrom(node),
97
+ };
98
+ });
99
+ }
100
+
101
+ export function parseJunitFile(filePath) {
102
+ const text = fs.readFileSync(filePath, 'utf8');
103
+ return parseJunitText(text, filePath);
104
+ }
105
+
106
+ /** 95% Wilson score interval, lower bound — the ranking flake-ledger uses. */
107
+ export function wilsonLowerBound(unstable, total) {
108
+ if (total <= 0) return 0;
109
+ const z = 1.96;
110
+ const p = unstable / total;
111
+ const denom = 1 + (z * z) / total;
112
+ const centre = p + (z * z) / (2 * total);
113
+ const spread = z * Math.sqrt((p * (1 - p)) / total + (z * z) / (4 * total * total));
114
+ return Math.max(0, (centre - spread) / denom);
115
+ }
116
+
117
+ function listXmlFiles(dir) {
118
+ const out = [];
119
+ const walk = (current) => {
120
+ let entries;
121
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
122
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
123
+ for (const entry of entries) {
124
+ const full = path.join(current, entry.name);
125
+ if (entry.isDirectory()) walk(full);
126
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xml')) out.push(full);
127
+ }
128
+ };
129
+ walk(dir);
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Scan a runs directory. Each immediate subdirectory is one run (all XML
135
+ * files inside it, recursively, belong to that run); each *.xml file
136
+ * directly under the directory is a run of its own. Runs are ordered by
137
+ * name so the timeline is stable on every platform.
138
+ */
139
+ export function scanRuns(root, options = {}) {
140
+ const absolute = path.resolve(root);
141
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isDirectory()) {
142
+ throw new Error(`runs directory not found: ${root}`);
143
+ }
144
+ const newWindow = options.newWindow == null ? 2 : options.newWindow;
145
+ const entries = fs.readdirSync(absolute, { withFileTypes: true })
146
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
147
+
148
+ const runGroups = [];
149
+ for (const entry of entries) {
150
+ const full = path.join(absolute, entry.name);
151
+ if (entry.isDirectory()) {
152
+ const files = listXmlFiles(full);
153
+ if (files.length > 0) runGroups.push({ id: entry.name, files });
154
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xml')) {
155
+ runGroups.push({ id: entry.name.replace(/\.xml$/i, ''), files: [full] });
156
+ }
157
+ }
158
+ if (runGroups.length === 0) throw new Error(`no JUnit XML files found under ${root}`);
159
+
160
+ const runs = [];
161
+ const warnings = [];
162
+ for (const group of runGroups) {
163
+ const tests = new Map();
164
+ for (const file of group.files) {
165
+ let parsed;
166
+ try {
167
+ parsed = parseJunitFile(file);
168
+ } catch (error) {
169
+ warnings.push(`${file}: ${error.message}`);
170
+ continue;
171
+ }
172
+ for (const test of parsed) {
173
+ const existing = tests.get(test.id);
174
+ if (!existing) {
175
+ tests.set(test.id, { ...test, attempts: [test] });
176
+ } else {
177
+ existing.attempts.push(test);
178
+ // A test that failed once and passed once in the same run passed
179
+ // on retry — that is the flake signal, not two separate results.
180
+ const statuses = new Set(existing.attempts.map((a) => a.status));
181
+ const bad = [...statuses].some((s) => s === 'failed' || s === 'error');
182
+ const good = statuses.has('passed') || statuses.has('passed-on-retry');
183
+ if (bad && good) existing.status = 'passed-on-retry';
184
+ else if (bad) existing.status = statuses.has('error') ? 'error' : 'failed';
185
+ else if (statuses.has('skipped')) existing.status = 'skipped';
186
+ if (!existing.message && test.message) existing.message = test.message;
187
+ if (!existing.file && test.file) existing.file = test.file;
188
+ }
189
+ }
190
+ }
191
+ runs.push({ id: group.id, files: group.files, tests: [...tests.values()] });
192
+ }
193
+
194
+ const ledger = new Map();
195
+ for (const run of runs) {
196
+ for (const test of run.tests) {
197
+ let record = ledger.get(test.id);
198
+ if (!record) {
199
+ record = {
200
+ id: test.id, classname: test.classname, name: test.name,
201
+ file: test.file || null, timeline: [],
202
+ unstableRuns: 0, eligibleRuns: 0, failedRuns: 0, passedOnRetryRuns: 0, skippedRuns: 0,
203
+ };
204
+ ledger.set(test.id, record);
205
+ }
206
+ const outcome = test.status === 'passed-on-retry' ? 'passed-on-retry'
207
+ : test.status === 'failed' || test.status === 'error' ? 'failed'
208
+ : test.status === 'skipped' ? 'skipped' : 'passed';
209
+ record.timeline.push({ runId: run.id, outcome, status: test.status, message: test.message || null });
210
+ if (outcome === 'passed-on-retry') { record.passedOnRetryRuns += 1; record.unstableRuns += 1; record.eligibleRuns += 1; }
211
+ else if (outcome === 'failed') { record.failedRuns += 1; record.unstableRuns += 1; record.eligibleRuns += 1; }
212
+ else if (outcome === 'skipped') { record.skippedRuns += 1; }
213
+ else { record.eligibleRuns += 1; }
214
+ if (!record.file && test.file) record.file = test.file;
215
+ }
216
+ }
217
+
218
+ const tests = [...ledger.values()];
219
+ for (const test of tests) {
220
+ test.unstableRate = test.eligibleRuns > 0 ? test.unstableRuns / test.eligibleRuns : 0;
221
+ test.score = wilsonLowerBound(test.unstableRuns, test.eligibleRuns);
222
+ const lastUnstable = test.timeline.map((entry, i) => ({ entry, i }))
223
+ .filter(({ entry }) => entry.outcome === 'failed' || entry.outcome === 'passed-on-retry');
224
+ const newestIndex = lastUnstable.length > 0 ? lastUnstable[lastUnstable.length - 1].i : -1;
225
+ const isNew = newestIndex !== -1 && newestIndex >= test.timeline.length - newWindow
226
+ && test.timeline.slice(0, newestIndex).every((e) => e.outcome === 'passed');
227
+ test.trend = isNew ? 'new' : (test.unstableRuns > 0 ? 'chronic' : 'stable');
228
+ if (test.failedRuns === test.eligibleRuns && test.eligibleRuns > 0) test.classification = 'always-failing';
229
+ else if (test.unstableRuns > 0 && test.passedOnRetryRuns > 0) test.classification = 'passed-on-retry';
230
+ else if (test.unstableRuns > 0) test.classification = 'flaky';
231
+ else test.classification = 'stable';
232
+ }
233
+ tests.sort((a, b) => (b.score - a.score) || (b.unstableRuns - a.unstableRuns) || (a.id < b.id ? -1 : 1));
234
+
235
+ return {
236
+ runsDir: absolute,
237
+ runs: runs.map((run) => ({ id: run.id, files: run.files, testCount: run.tests.length })),
238
+ tests,
239
+ warnings,
240
+ totals: {
241
+ runs: runs.length,
242
+ tests: tests.length,
243
+ flaky: tests.filter((t) => t.classification === 'flaky' || t.classification === 'passed-on-retry').length,
244
+ alwaysFailing: tests.filter((t) => t.classification === 'always-failing').length,
245
+ },
246
+ };
247
+ }
248
+
249
+ export function formatFlakesTable(result, top = null) {
250
+ const rows = top == null ? result.tests : result.tests.slice(0, top);
251
+ const unstable = rows.filter((t) => t.unstableRuns > 0);
252
+ const lines = [];
253
+ lines.push(`runs: ${result.totals.runs} tests: ${result.totals.tests} flaky: ${result.totals.flaky} always-failing: ${result.totals.alwaysFailing}`);
254
+ if (unstable.length === 0) {
255
+ lines.push('No flaky tests: every test passed in every run it appeared in.');
256
+ return lines.join('\n');
257
+ }
258
+ const header = `${'test'.padEnd(46)} ${'unstable'.padStart(8)} ${'runs'.padStart(5)} ${'trend'.padStart(8)} classification`;
259
+ lines.push(header);
260
+ for (const test of unstable) {
261
+ const label = test.id.length > 46 ? `${test.id.slice(0, 43)}...` : test.id;
262
+ lines.push(`${label.padEnd(46)} ${`${test.unstableRuns}/${test.eligibleRuns}`.padStart(8)} ${String(test.eligibleRuns).padStart(5)} ${test.trend.padStart(8)} ${test.classification}`);
263
+ }
264
+ return lines.join('\n');
265
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Missing-command diagnosis.
3
+ *
4
+ * Core lookup logic adapted from cli-path-doctor (WJDdidi-1/cli-path-doctor,
5
+ * Apache-2.0): PATHEXT handling on Windows, broken-symlink reporting,
6
+ * shadowed copies, and the four PATH contexts (current, login, non-login,
7
+ * daemon-like). The npm-metadata and toolchain-metadata detail of the
8
+ * original is reduced here to a toolchain-bin search: the check names the
9
+ * directory a command was installed into and the fix that adds it to PATH.
10
+ */
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { spawnSync } from 'node:child_process';
15
+
16
+ const DEFAULT_PATHEXT = [
17
+ '.COM', '.EXE', '.BAT', '.CMD', '.VBS', '.VBE', '.JS', '.JSE', '.WSF', '.WSH', '.MSC', '.PS1',
18
+ ];
19
+
20
+ export function pathDelimiter(platform = process.platform) {
21
+ return platform === 'win32' ? ';' : ':';
22
+ }
23
+
24
+ export function pathApi(platform = process.platform) {
25
+ return platform === 'win32' ? path.win32 : path.posix;
26
+ }
27
+
28
+ export function splitPathValue(pathValue, platform = process.platform) {
29
+ return String(pathValue == null ? '' : pathValue).split(pathDelimiter(platform));
30
+ }
31
+
32
+ export function windowsExtensions(env = process.env) {
33
+ const raw = env && env.PATHEXT ? String(env.PATHEXT) : '';
34
+ const extensions = raw
35
+ .split(';')
36
+ .map((ext) => ext.trim())
37
+ .filter(Boolean)
38
+ .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`));
39
+ return extensions.length > 0 ? extensions : DEFAULT_PATHEXT;
40
+ }
41
+
42
+ export function commandNames(command, platform = process.platform, env = process.env) {
43
+ if (platform !== 'win32') return [command];
44
+ const api = pathApi(platform);
45
+ if (api.extname(command) !== '') return [command];
46
+ return [command, ...windowsExtensions(env).map((ext) => `${command}${ext}`)];
47
+ }
48
+
49
+ function isWindowsExecutableExtension(candidate, env) {
50
+ const extension = path.win32.extname(candidate).toLowerCase();
51
+ if (!extension) return false;
52
+ return windowsExtensions(env).some((entry) => entry.toLowerCase() === extension);
53
+ }
54
+
55
+ function executableOnPlatform(candidate, stat, platform, env) {
56
+ if (platform === 'win32') return stat.isFile() && isWindowsExecutableExtension(candidate, env);
57
+ if (!stat.isFile()) return false;
58
+ try {
59
+ fs.accessSync(candidate, fs.constants.X_OK);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ export function inspectFile(candidate, options = {}) {
67
+ const platform = options.platform || process.platform;
68
+ const env = options.env || process.env;
69
+ let linkStat;
70
+ try {
71
+ linkStat = fs.lstatSync(candidate);
72
+ } catch (error) {
73
+ return { path: candidate, exists: false, broken: false, error: error.code === 'ENOENT' ? undefined : error.message };
74
+ }
75
+ const symlink = linkStat.isSymbolicLink();
76
+ let linkTarget = null;
77
+ if (symlink) {
78
+ try { linkTarget = fs.readlinkSync(candidate); } catch { linkTarget = null; }
79
+ }
80
+ let stat;
81
+ try {
82
+ stat = fs.statSync(candidate);
83
+ } catch (error) {
84
+ return {
85
+ path: candidate, exists: true, broken: Boolean(symlink && error.code === 'ENOENT'),
86
+ symlink, linkTarget, executable: false, isFile: false, error: error.message,
87
+ };
88
+ }
89
+ // Always resolve the real path, not only for symlinked files: the
90
+ // symlink can be an intermediate directory (/bin -> usr/bin), in
91
+ // which case the file itself is not a symlink but its real path still
92
+ // differs from the spelling. Copies are deduplicated on real paths.
93
+ let realPath = null;
94
+ try { realPath = fs.realpathSync(candidate); } catch { realPath = null; }
95
+ return {
96
+ path: candidate, exists: true, broken: false, symlink, linkTarget, realPath,
97
+ isFile: stat.isFile(), isDirectory: stat.isDirectory(),
98
+ executable: executableOnPlatform(candidate, stat, platform, env), mode: stat.mode,
99
+ };
100
+ }
101
+
102
+ function lookupInDirectories(command, directories, options) {
103
+ const platform = options.platform;
104
+ const env = options.env;
105
+ const api = pathApi(platform);
106
+ const names = commandNames(command, platform, env);
107
+ let foundNonExecutable = null;
108
+ let foundBroken = null;
109
+ let checked = 0;
110
+ for (const rawDirectory of directories) {
111
+ const directory = rawDirectory === '' ? '.' : rawDirectory;
112
+ checked += 1;
113
+ for (const name of names) {
114
+ const candidate = api.join(directory, name);
115
+ const inspected = inspectFile(candidate, { platform, env });
116
+ if (inspected.broken) {
117
+ if (!foundBroken) foundBroken = inspected;
118
+ continue;
119
+ }
120
+ if (!inspected.exists || !inspected.isFile) continue;
121
+ if (inspected.executable) {
122
+ return {
123
+ resolved: true, path: candidate, realPath: inspected.realPath,
124
+ symlink: inspected.symlink, linkTarget: inspected.linkTarget, mode: inspected.mode, checked,
125
+ };
126
+ }
127
+ if (!foundNonExecutable) foundNonExecutable = inspected;
128
+ }
129
+ }
130
+ if (foundNonExecutable) {
131
+ return {
132
+ resolved: false, path: null,
133
+ reason: `found at ${foundNonExecutable.path} but it is not executable`,
134
+ foundPath: foundNonExecutable.path, nonExecutable: foundNonExecutable, checked,
135
+ };
136
+ }
137
+ if (foundBroken) {
138
+ return {
139
+ resolved: false, path: null,
140
+ reason: `broken symlink ${foundBroken.path} -> ${foundBroken.linkTarget || '?'}`,
141
+ foundPath: foundBroken.path, brokenLink: foundBroken, checked,
142
+ };
143
+ }
144
+ return { resolved: false, path: null, reason: 'not found on PATH', checked };
145
+ }
146
+
147
+ export function lookupCommand(command, pathValue, options = {}) {
148
+ const platform = options.platform || process.platform;
149
+ const env = options.env || process.env;
150
+ const cwd = options.cwd || process.cwd();
151
+ const api = pathApi(platform);
152
+ if (typeof command !== 'string' || command.length === 0) {
153
+ return { resolved: false, path: null, reason: 'no command was given', checked: 0 };
154
+ }
155
+ const looksLikePath = command.includes('/')
156
+ || (platform === 'win32' && (command.includes('\\') || /^[A-Za-z]:/.test(command)));
157
+ if (looksLikePath) {
158
+ const absolute = api.isAbsolute(command) ? command : api.resolve(cwd, command);
159
+ const names = api.extname(absolute) === '' && platform === 'win32'
160
+ ? commandNames(absolute, platform, env) : [absolute];
161
+ for (const candidate of names) {
162
+ const inspected = inspectFile(candidate, { platform, env });
163
+ if (inspected.broken) {
164
+ return { resolved: false, path: null, reason: `broken symlink ${candidate} -> ${inspected.linkTarget || '?'}`, foundPath: candidate, brokenLink: inspected, checked: 1 };
165
+ }
166
+ if (inspected.exists && inspected.isFile && inspected.executable) {
167
+ return { resolved: true, path: candidate, realPath: inspected.realPath, symlink: inspected.symlink, linkTarget: inspected.linkTarget, mode: inspected.mode, checked: 1 };
168
+ }
169
+ if (inspected.exists && inspected.isFile && !inspected.executable) {
170
+ return { resolved: false, path: null, reason: `found at ${candidate} but it is not executable`, foundPath: candidate, nonExecutable: inspected, checked: 1 };
171
+ }
172
+ }
173
+ return { resolved: false, path: null, reason: `${command} does not exist`, checked: names.length };
174
+ }
175
+ return lookupInDirectories(command, splitPathValue(pathValue, platform), { platform, env });
176
+ }
177
+
178
+ export function findCommandCopies(command, pathValue, options = {}) {
179
+ const platform = options.platform || process.platform;
180
+ const env = options.env || process.env;
181
+ const api = pathApi(platform);
182
+ if (typeof command !== 'string' || command.length === 0) {
183
+ return { command: null, copies: [], winner: null, shadowed: [] };
184
+ }
185
+ if (command.includes('/') || (platform === 'win32' && (command.includes('\\') || /^[A-Za-z]:/.test(command)))) {
186
+ return { command, copies: [], winner: null, shadowed: [] };
187
+ }
188
+ const directories = splitPathValue(pathValue, platform);
189
+ const names = commandNames(command, platform, env);
190
+ const seen = new Set();
191
+ const copies = [];
192
+ for (const rawDirectory of directories) {
193
+ const directory = rawDirectory === '' ? '.' : rawDirectory;
194
+ for (const name of names) {
195
+ const candidate = api.join(directory, name);
196
+ const inspected = inspectFile(candidate, { platform, env });
197
+ if (!inspected.exists || inspected.broken || !inspected.isFile || !inspected.executable) continue;
198
+ // Two PATH entries can point at the same file (on Ubuntu /bin is a
199
+ // symlink to /usr/bin, so /bin/node and /usr/bin/node are one copy).
200
+ // Deduplicate on the real path, not the spelling.
201
+ const key = platform === 'win32'
202
+ ? (inspected.realPath || candidate).toLowerCase()
203
+ : (inspected.realPath || candidate);
204
+ if (seen.has(key)) break;
205
+ seen.add(key);
206
+ copies.push({
207
+ path: candidate, directory, realPath: inspected.realPath || null,
208
+ symlink: Boolean(inspected.symlink), linkTarget: inspected.linkTarget || null, executable: true,
209
+ });
210
+ break;
211
+ }
212
+ }
213
+ const marked = copies.map((copy, index) => ({ ...copy, winner: index === 0, shadowed: index > 0 }));
214
+ return { command, copies: marked, winner: marked[0] || null, shadowed: marked.slice(1) };
215
+ }
216
+
217
+ // --- PATH contexts -------------------------------------------------------
218
+
219
+ const PATH_START = '__DEV_DOCTOR_PATH__';
220
+ const PATH_END = '__DEV_DOCTOR_END__';
221
+
222
+ export function defaultDaemonPath(platform = process.platform, env = process.env) {
223
+ if (platform === 'win32') {
224
+ const root = env.SystemRoot || env.windir || 'C:\\Windows';
225
+ const api = path.win32;
226
+ return [
227
+ api.join(root, 'System32'), root,
228
+ api.join(root, 'System32', 'Wbem'),
229
+ api.join(root, 'System32', 'WindowsPowerShell', 'v1.0'),
230
+ ].join(';');
231
+ }
232
+ return ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'].join(':');
233
+ }
234
+
235
+ function isUsableShell(candidate) {
236
+ if (!candidate || typeof candidate !== 'string') return false;
237
+ try {
238
+ fs.accessSync(candidate, fs.constants.X_OK);
239
+ return fs.statSync(candidate).isFile();
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ export function resolveShell(env = process.env, platform = process.platform) {
246
+ if (platform === 'win32') return env.ComSpec || env.COMSPEC || null;
247
+ const configured = env.DEV_DOCTOR_SHELL || env.SHELL;
248
+ if (isUsableShell(configured)) return configured;
249
+ for (const fallback of ['/bin/sh', '/usr/bin/sh', '/bin/bash']) {
250
+ if (isUsableShell(fallback)) return fallback;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ function probeShellPath(shell, login, env, timeout) {
256
+ if (!shell) return { ok: false, reason: 'no usable shell found' };
257
+ // The sentinel lets us ignore anything a noisy shell profile prints.
258
+ const script = `printf '%s%s%s' '${PATH_START}' "$PATH" '${PATH_END}'`;
259
+ const variants = login ? [['-l', '-c', script], ['-c', script]] : [['-c', script]];
260
+ const perVariantTimeout = Math.max(500, Math.floor(Math.max(1, timeout) / variants.length));
261
+ const failures = [];
262
+ for (const args of variants) {
263
+ let result;
264
+ try {
265
+ result = spawnSync(shell, args, {
266
+ encoding: 'utf8', env, timeout: perVariantTimeout, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
267
+ });
268
+ } catch (error) {
269
+ failures.push(error && error.message ? error.message : 'could not start shell');
270
+ continue;
271
+ }
272
+ const stdout = result && result.stdout ? String(result.stdout) : '';
273
+ const start = stdout.lastIndexOf(PATH_START);
274
+ if (start !== -1) {
275
+ const end = stdout.indexOf(PATH_END, start + PATH_START.length);
276
+ if (end !== -1) {
277
+ return { ok: true, pathValue: stdout.slice(start + PATH_START.length, end), args };
278
+ }
279
+ }
280
+ if (result && result.error) failures.push(result.error.message);
281
+ else if (result && typeof result.status === 'number' && result.status !== 0) failures.push(`shell exited with status ${result.status}`);
282
+ else failures.push('shell did not report PATH');
283
+ }
284
+ return { ok: false, reason: failures[failures.length - 1] || 'shell probe failed' };
285
+ }
286
+
287
+ function contextRow(name, pathValue, source, note = null, reliable = true) {
288
+ return { name, pathValue, source, note, reliable: Boolean(reliable) };
289
+ }
290
+
291
+ /**
292
+ * The four PATH environments a command can be missing in. Shell probing is
293
+ * best-effort: a failed probe keeps the row with the current PATH and a
294
+ * note, rather than hiding the context.
295
+ */
296
+ export function buildContexts(options = {}) {
297
+ const env = options.env || process.env;
298
+ const platform = options.platform || process.platform;
299
+ const timeout = options.timeout == null ? 5000 : options.timeout;
300
+ const currentPath = env.PATH == null ? '' : String(env.PATH);
301
+ const contexts = [contextRow('current', currentPath, 'this process (process.env.PATH)')];
302
+
303
+ if (platform === 'win32') {
304
+ const loginOverride = env.DEV_DOCTOR_LOGIN_PATH;
305
+ const nonLoginOverride = env.DEV_DOCTOR_NON_LOGIN_PATH;
306
+ contexts.push(contextRow('login', loginOverride == null ? currentPath : String(loginOverride),
307
+ loginOverride == null ? 'Windows process PATH (no portable login shell)' : 'DEV_DOCTOR_LOGIN_PATH', null, loginOverride != null));
308
+ contexts.push(contextRow('non-login', nonLoginOverride == null ? currentPath : String(nonLoginOverride),
309
+ nonLoginOverride == null ? 'Windows process PATH (no portable non-login shell)' : 'DEV_DOCTOR_NON_LOGIN_PATH', null, nonLoginOverride != null));
310
+ } else {
311
+ const shell = resolveShell(env, platform);
312
+ const loginOverride = env.DEV_DOCTOR_LOGIN_PATH;
313
+ const nonLoginOverride = env.DEV_DOCTOR_NON_LOGIN_PATH;
314
+ if (loginOverride != null) {
315
+ contexts.push(contextRow('login', String(loginOverride), 'DEV_DOCTOR_LOGIN_PATH'));
316
+ } else {
317
+ const login = probeShellPath(shell, true, env, timeout);
318
+ contexts.push(login.ok
319
+ ? contextRow('login', login.pathValue, `${shell} ${login.args.slice(0, -1).join(' ')}`)
320
+ : contextRow('login', currentPath, 'this process (login shell probe failed)', login.reason, false));
321
+ }
322
+ if (nonLoginOverride != null) {
323
+ contexts.push(contextRow('non-login', String(nonLoginOverride), 'DEV_DOCTOR_NON_LOGIN_PATH'));
324
+ } else {
325
+ const nonLogin = probeShellPath(shell, false, env, timeout);
326
+ contexts.push(nonLogin.ok
327
+ ? contextRow('non-login', nonLogin.pathValue, `${shell} ${nonLogin.args.slice(0, -1).join(' ')}`)
328
+ : contextRow('non-login', currentPath, 'this process (non-login shell probe failed)', nonLogin.reason, false));
329
+ }
330
+ }
331
+
332
+ const daemonOverride = env.DEV_DOCTOR_DAEMON_PATH;
333
+ contexts.push(contextRow('daemon-like',
334
+ daemonOverride == null ? defaultDaemonPath(platform, env) : String(daemonOverride),
335
+ daemonOverride == null ? 'minimal system PATH (daemon/CI-like)' : 'DEV_DOCTOR_DAEMON_PATH'));
336
+ return contexts;
337
+ }
338
+
339
+ // --- Toolchain bins ------------------------------------------------------
340
+
341
+ export function toolchainBinDirs(options = {}) {
342
+ const platform = options.platform || process.platform;
343
+ const home = os.homedir();
344
+ const api = pathApi(platform);
345
+ const candidates = platform === 'win32'
346
+ ? [
347
+ api.join(home, '.cargo', 'bin'),
348
+ api.join(home, '.bun', 'bin'),
349
+ api.join(home, 'go', 'bin'),
350
+ api.join(home, 'AppData', 'Local', 'Programs', 'Python'),
351
+ api.join(home, '.local', 'bin'),
352
+ ]
353
+ : [
354
+ api.join(home, '.cargo', 'bin'),
355
+ api.join(home, '.bun', 'bin'),
356
+ api.join(home, '.volta', 'bin'),
357
+ api.join(home, 'go', 'bin'),
358
+ api.join(home, '.deno', 'bin'),
359
+ api.join(home, '.local', 'bin'),
360
+ '/opt/homebrew/bin',
361
+ '/usr/local/bin',
362
+ ];
363
+ return candidates.filter((dir) => {
364
+ try { return fs.statSync(dir).isDirectory(); } catch { return false; }
365
+ });
366
+ }
367
+
368
+ export function searchToolchainBins(command, options = {}) {
369
+ const platform = options.platform || process.platform;
370
+ const env = options.env || process.env;
371
+ const hits = [];
372
+ for (const dir of toolchainBinDirs({ platform })) {
373
+ const result = lookupCommand(command, dir, { platform, env });
374
+ if (result.resolved) hits.push({ directory: dir, path: result.path });
375
+ }
376
+ return hits;
377
+ }
378
+
379
+ export function rcFileForShell(env = process.env) {
380
+ const shell = String(env.SHELL || '');
381
+ if (shell.endsWith('zsh')) return '~/.zshrc';
382
+ if (shell.endsWith('fish')) return '~/.config/fish/config.fish';
383
+ if (shell.endsWith('bash')) return '~/.bashrc';
384
+ return '~/.zshrc or ~/.bashrc';
385
+ }
386
+
387
+ /** A copy-paste fix for a command found in a directory that is not on PATH. */
388
+ export function fixForDirectory(directory, platform = process.platform) {
389
+ if (platform === 'win32') {
390
+ return `setx PATH "%PATH%;${directory}" (then open a new terminal)`;
391
+ }
392
+ return `export PATH="${directory}:$PATH" # add to ${rcFileForShell()} to keep it`;
393
+ }
package/lib/report.mjs ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared output format: one section per check, a one-line verdict, and a
3
+ * copy-paste fix where one exists. Text goes to stdout; nothing here
4
+ * writes to stderr, so `dev-diagnosis ... > report.txt` captures the report.
5
+ */
6
+
7
+ export function section(title) {
8
+ return `\n== ${title} ==`;
9
+ }
10
+
11
+ export function verdictLine(ok, text) {
12
+ return `${ok ? 'PASS' : 'FAIL'} ${text}`;
13
+ }
14
+
15
+ export function formatMs(ms) {
16
+ if (ms < 1000) return `${Math.round(ms)} ms`;
17
+ return `${(ms / 1000).toFixed(2)} s`;
18
+ }