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.
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dev-diagnosis — one offline CLI that diagnoses a repo the way four small
4
+ * tools do: context noise for AI agents, missing CLI commands, build-cache
5
+ * value, and flaky tests.
6
+ *
7
+ * Standard-library Node.js only, no network, no account. Works on Linux,
8
+ * macOS and Windows: all paths go through node:path, JUnit input is
9
+ * normalised from CRLF before parsing, and build commands run without a
10
+ * shell (see lib/run-command.mjs for the one Windows-only retry).
11
+ *
12
+ * Exit codes: 0 = every check that ran passed; 1 = at least one check
13
+ * found something to fix, or a check could not run; 2 = usage error.
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { scanDirectory, formatBytes } from '../lib/context.mjs';
19
+ import {
20
+ buildContexts, findCommandCopies, fixForDirectory, lookupCommand,
21
+ searchToolchainBins,
22
+ } from '../lib/pathcheck.mjs';
23
+ import { detectCacheDir, runCacheCheck } from '../lib/cache.mjs';
24
+ import { formatFlakesTable, scanRuns } from '../lib/flakes.mjs';
25
+ import { formatMs, section, verdictLine } from '../lib/report.mjs';
26
+
27
+ const VERSION = JSON.parse(
28
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
29
+ ).version;
30
+
31
+ const USAGE = `dev-diagnosis: one diagnosis for your repo, your commands, your cache and your tests.
32
+
33
+ Usage:
34
+ dev-diagnosis [all] [path] [--command <name>] [--flakes <dir>] [-- <build-command...>]
35
+ dev-diagnosis context [path] [--json]
36
+ dev-diagnosis path <command> [--json]
37
+ dev-diagnosis cache [--cache-dir <dir>] [--cwd <dir>] -- <build-command...>
38
+ dev-diagnosis flakes <runs-dir> [--json] [--top N]
39
+ dev-diagnosis --help
40
+ dev-diagnosis --version
41
+
42
+ Commands:
43
+ all Run every check that has what it needs and print one unified
44
+ report. context always runs on [path] (default: current
45
+ directory). path runs when --command is given, flakes when
46
+ --flakes is given, cache when a build command follows --.
47
+ Checks without their input are listed as skipped, with the
48
+ command that would run them.
49
+ context Find the files an AI coding agent should not read: build
50
+ output, lockfiles, logs and oversized files. Prints a 0-100
51
+ score and the .aiignore-style suggestions that would fix it.
52
+ path Diagnose why a CLI command is missing: one row per shell
53
+ context (current, login, non-login, daemon-like), shadowed
54
+ copies, toolchain installs, and a copy-paste fix.
55
+ cache Time one build command cold (cache moved aside) and warm
56
+ (cache present) and say whether the cache pays. The cache
57
+ directory is restored to its original state afterwards.
58
+ flakes Rank flaky tests from JUnit XML across CI runs, including
59
+ passed-on-retry. Each subdirectory of <runs-dir> is one run;
60
+ each *.xml file directly under it is a run of its own.
61
+
62
+ Options:
63
+ --json machine-readable output (context, path, flakes, all)
64
+ --top N show only the N highest-ranked flaky tests
65
+ -h, --help show this help
66
+ -v, --version print the version
67
+
68
+ Exit codes: 0 = every check that ran passed; 1 = a check found something
69
+ to fix or could not run; 2 = usage error.
70
+ `;
71
+
72
+ class UsageError extends Error {
73
+ constructor(message) {
74
+ super(message);
75
+ this.name = 'UsageError';
76
+ }
77
+ }
78
+
79
+ function parseArgs(argv) {
80
+ const parsed = {
81
+ command: null, positional: [], json: false, help: false, version: false,
82
+ commandName: null, flakesDir: null, cacheDir: null, cwd: null, top: null,
83
+ buildCommand: null, error: null,
84
+ };
85
+ let i = 0;
86
+ while (i < argv.length) {
87
+ const arg = argv[i];
88
+ if (arg === '--') {
89
+ parsed.buildCommand = argv.slice(i + 1);
90
+ break;
91
+ }
92
+ if (arg === '--json' || arg === '-j') parsed.json = true;
93
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
94
+ else if (arg === '--version' || arg === '-v') parsed.version = true;
95
+ else if (arg === '--command') {
96
+ parsed.commandName = argv[i + 1];
97
+ if (parsed.commandName === undefined) parsed.error = '--command needs a value';
98
+ i += 1;
99
+ } else if (arg.startsWith('--command=')) parsed.commandName = arg.slice('--command='.length);
100
+ else if (arg === '--flakes') {
101
+ parsed.flakesDir = argv[i + 1];
102
+ if (parsed.flakesDir === undefined) parsed.error = '--flakes needs a value';
103
+ i += 1;
104
+ } else if (arg.startsWith('--flakes=')) parsed.flakesDir = arg.slice('--flakes='.length);
105
+ else if (arg === '--cache-dir') {
106
+ parsed.cacheDir = argv[i + 1];
107
+ if (parsed.cacheDir === undefined) parsed.error = '--cache-dir needs a value';
108
+ i += 1;
109
+ } else if (arg.startsWith('--cache-dir=')) parsed.cacheDir = arg.slice('--cache-dir='.length);
110
+ else if (arg === '--cwd') {
111
+ parsed.cwd = argv[i + 1];
112
+ if (parsed.cwd === undefined) parsed.error = '--cwd needs a value';
113
+ i += 1;
114
+ } else if (arg.startsWith('--cwd=')) parsed.cwd = arg.slice('--cwd='.length);
115
+ else if (arg === '--top') {
116
+ const value = Number(argv[i + 1]);
117
+ if (!Number.isInteger(value) || value < 1) parsed.error = '--top needs a positive integer';
118
+ else parsed.top = value;
119
+ i += 1;
120
+ } else if (arg.startsWith('--top=')) {
121
+ const value = Number(arg.slice('--top='.length));
122
+ if (!Number.isInteger(value) || value < 1) parsed.error = '--top needs a positive integer';
123
+ else parsed.top = value;
124
+ } else if (arg.startsWith('-')) {
125
+ parsed.error = `unknown option: ${arg}`;
126
+ } else if (parsed.command === null) parsed.command = arg;
127
+ else parsed.positional.push(arg);
128
+ i += 1;
129
+ }
130
+ return parsed;
131
+ }
132
+
133
+ function printContext(result, json) {
134
+ if (json) {
135
+ console.log(JSON.stringify(result, null, 2));
136
+ return;
137
+ }
138
+ console.log(`Repo: ${result.root}`);
139
+ console.log(`Files: ${result.files} Size: ${formatBytes(result.bytes)} Score: ${result.score}/100 — ${result.status}`);
140
+ if (result.truncated) console.log('Note: scan stopped at the file limit; totals are a lower bound.');
141
+ for (const item of result.breakdown) {
142
+ console.log(` ${item.label}: ${formatBytes(item.bytes)} (${item.share}%) — lost ${item.lost} of ${item.weight}`);
143
+ }
144
+ if (result.noise.length > 0) {
145
+ console.log('Noise to keep away from an AI agent:');
146
+ for (const item of result.noise.slice(0, 20)) {
147
+ console.log(` ${item.label} — ${item.reason} (${formatBytes(item.bytes)})`);
148
+ }
149
+ if (result.noise.length > 20) console.log(` ... and ${result.noise.length - 20} more`);
150
+ }
151
+ if (result.review.length > 0) {
152
+ console.log('Worth a look before sharing:');
153
+ for (const item of result.review.slice(0, 10)) {
154
+ console.log(` ${item.label} — ${item.reason}`);
155
+ }
156
+ }
157
+ if (result.suggestions.length > 0) {
158
+ console.log('Fix: add these to .aiignore (or the agent\'s ignore list):');
159
+ for (const suggestion of result.suggestions) console.log(` ${suggestion}`);
160
+ }
161
+ }
162
+
163
+ function runContext(target) {
164
+ const result = scanDirectory(target || process.cwd());
165
+ const passed = result.score >= 85;
166
+ return { result, passed, verdict: passed ? 'context is clean' : `${result.status.toLowerCase()} (score ${result.score}/100)` };
167
+ }
168
+
169
+ function printPath(result, json) {
170
+ if (json) {
171
+ console.log(JSON.stringify(result, null, 2));
172
+ return;
173
+ }
174
+ for (const row of result.contexts) {
175
+ const status = row.resolved ? row.path : `MISS — ${row.reason}`;
176
+ console.log(`${row.name.padEnd(12)} ${status}`);
177
+ }
178
+ if (result.copies.shadowed.length > 0) {
179
+ console.log(`Shadowed by the winning copy: ${result.copies.shadowed.map((c) => c.path).join(', ')}`);
180
+ }
181
+ if (result.toolchainHits.length > 0) {
182
+ console.log('Installed, but not on PATH:');
183
+ for (const hit of result.toolchainHits) console.log(` ${hit.path}`);
184
+ }
185
+ if (result.fix) console.log(`Fix: ${result.fix}`);
186
+ const unreliable = result.contexts.filter((row) => !row.reliable);
187
+ for (const row of unreliable) console.log(`Note: ${row.name} context fell back to the current PATH (${row.note}).`);
188
+ }
189
+
190
+ function runPath(commandName) {
191
+ if (!commandName) throw new UsageError('path needs a command, for example: dev-diagnosis path node');
192
+ const contexts = buildContexts();
193
+ const rows = contexts.map((ctx) => {
194
+ const found = lookupCommand(commandName, ctx.pathValue);
195
+ return { ...ctx, resolved: found.resolved, path: found.path, reason: found.reason || null };
196
+ });
197
+ const copies = findCommandCopies(commandName, process.env.PATH || '');
198
+ const toolchainHits = rows[0].resolved ? [] : searchToolchainBins(commandName);
199
+ let fix = null;
200
+ if (!rows[0].resolved && toolchainHits.length > 0) fix = fixForDirectory(toolchainHits[0].directory);
201
+ else if (!rows[0].resolved && rows.some((row) => row.resolved)) {
202
+ fix = 'it resolves in another shell context — check which startup file sets your PATH';
203
+ }
204
+ const passed = rows[0].resolved;
205
+ return {
206
+ result: { command: commandName, contexts: rows, copies, toolchainHits, fix },
207
+ passed,
208
+ verdict: passed ? `${commandName} resolves to ${rows[0].path}` : `${commandName} is missing in the current shell`,
209
+ };
210
+ }
211
+
212
+ function printFlakes(result, top, json) {
213
+ if (json) {
214
+ console.log(JSON.stringify(result, null, 2));
215
+ return;
216
+ }
217
+ console.log(formatFlakesTable(result, top));
218
+ }
219
+
220
+ function runFlakes(dir) {
221
+ if (!dir) throw new UsageError('flakes needs a runs directory, for example: dev-diagnosis flakes examples/runs');
222
+ const result = scanRuns(dir);
223
+ const passed = result.totals.flaky === 0 && result.totals.alwaysFailing === 0;
224
+ return {
225
+ result,
226
+ passed,
227
+ verdict: passed
228
+ ? `no flaky tests across ${result.totals.runs} runs`
229
+ : `${result.totals.flaky} flaky, ${result.totals.alwaysFailing} always-failing across ${result.totals.runs} runs`,
230
+ };
231
+ }
232
+
233
+ function runCache(options) {
234
+ if (!options.buildCommand || options.buildCommand.length === 0) {
235
+ throw new UsageError('cache needs a build command after --, for example: dev-diagnosis cache -- node build.mjs');
236
+ }
237
+ const result = runCacheCheck({
238
+ root: options.cwd || process.cwd(),
239
+ cacheDir: options.cacheDir,
240
+ command: options.buildCommand,
241
+ });
242
+ const passed = result.verdict !== 'cache costs';
243
+ return {
244
+ result,
245
+ passed,
246
+ verdict: `${result.verdict}: cold ${formatMs(result.coldMs)}, warm ${formatMs(result.warmMs)} (${result.verdictDetail})`,
247
+ };
248
+ }
249
+
250
+ function printCache(result, json) {
251
+ if (json) {
252
+ const { coldStdout, warmStdout, ...rest } = result;
253
+ console.log(JSON.stringify(rest, null, 2));
254
+ return;
255
+ }
256
+ console.log(`Cache dir: ${result.cacheDir}`);
257
+ console.log(`Command: ${result.command.join(' ')}`);
258
+ console.log(`Cold: ${formatMs(result.coldMs)} Warm: ${formatMs(result.warmMs)} Saved: ${formatMs(Math.max(0, result.savedMs))} (${result.savedPct}%)`);
259
+ console.log(`Verdict: ${result.verdict} — ${result.verdictDetail}`);
260
+ }
261
+
262
+ function main(argv) {
263
+ const args = parseArgs(argv);
264
+ if (args.error) {
265
+ console.error(`Error: ${args.error}`);
266
+ console.error('Run with --help to see the available options.');
267
+ return 2;
268
+ }
269
+ if (args.help) {
270
+ console.log(USAGE);
271
+ return 0;
272
+ }
273
+ if (args.version) {
274
+ console.log(VERSION);
275
+ return 0;
276
+ }
277
+
278
+ const command = args.command || 'all';
279
+ try {
280
+ if (command === 'context') {
281
+ const target = args.positional[0];
282
+ const check = runContext(target);
283
+ printContext(check.result, args.json);
284
+ if (!args.json) console.log(verdictLine(check.passed, check.verdict));
285
+ return check.passed ? 0 : 1;
286
+ }
287
+ if (command === 'path') {
288
+ const name = args.positional[0];
289
+ const check = runPath(name);
290
+ printPath(check.result, args.json);
291
+ if (!args.json) console.log(verdictLine(check.passed, check.verdict));
292
+ return check.passed ? 0 : 1;
293
+ }
294
+ if (command === 'flakes') {
295
+ const dir = args.positional[0];
296
+ const check = runFlakes(dir);
297
+ printFlakes(check.result, args.top, args.json);
298
+ if (!args.json) console.log(verdictLine(check.passed, check.verdict));
299
+ return check.passed ? 0 : 1;
300
+ }
301
+ if (command === 'cache') {
302
+ const check = runCache({ buildCommand: args.buildCommand, cacheDir: args.cacheDir, cwd: args.cwd });
303
+ printCache(check.result, args.json);
304
+ if (!args.json) console.log(verdictLine(check.passed, check.verdict));
305
+ return check.passed ? 0 : 1;
306
+ }
307
+ if (command === 'all') {
308
+ const target = args.positional[0];
309
+ const root = target ? path.resolve(target) : process.cwd();
310
+ const checks = {};
311
+ const skipped = [];
312
+
313
+ const contextCheck = runContext(root);
314
+ checks.context = contextCheck;
315
+ const pathCheck = args.commandName ? runPath(args.commandName) : null;
316
+ if (pathCheck) checks.path = pathCheck;
317
+ else skipped.push('path — pass --command <name>, for example: dev-diagnosis all --command node');
318
+ let flakesCheck = null;
319
+ if (args.flakesDir) {
320
+ try {
321
+ flakesCheck = runFlakes(args.flakesDir);
322
+ checks.flakes = flakesCheck;
323
+ } catch (error) {
324
+ checks.flakes = { result: null, passed: false, verdict: `could not run: ${error.message}`, error: error.message };
325
+ flakesCheck = checks.flakes;
326
+ }
327
+ } else {
328
+ skipped.push('flakes — pass --flakes <runs-dir>, for example: dev-diagnosis all --flakes examples/runs');
329
+ }
330
+ let cacheCheck = null;
331
+ if (args.buildCommand && args.buildCommand.length > 0) {
332
+ try {
333
+ cacheCheck = runCache({ buildCommand: args.buildCommand, cacheDir: args.cacheDir, cwd: root });
334
+ checks.cache = cacheCheck;
335
+ } catch (error) {
336
+ checks.cache = { result: null, passed: false, verdict: `could not run: ${error.message}`, error: error.message };
337
+ cacheCheck = checks.cache;
338
+ }
339
+ } else {
340
+ skipped.push('cache — add -- <build-command>, for example: dev-diagnosis all -- node build.mjs');
341
+ }
342
+
343
+ const overall = Object.values(checks).every((check) => check.passed);
344
+ if (args.json) {
345
+ const out = {
346
+ command: 'all', root,
347
+ checks: Object.fromEntries(Object.entries(checks).map(([key, check]) => [key, check.result])),
348
+ skipped, overall: overall ? 'PASS' : 'FAIL',
349
+ };
350
+ console.log(JSON.stringify(out, null, 2));
351
+ return overall ? 0 : 1;
352
+ }
353
+
354
+ console.log(`dev-diagnosis — ${root}`);
355
+ console.log(section('context'));
356
+ printContext(contextCheck.result, false);
357
+ console.log(verdictLine(contextCheck.passed, contextCheck.verdict));
358
+ if (pathCheck) {
359
+ console.log(section('path'));
360
+ printPath(pathCheck.result, false);
361
+ console.log(verdictLine(pathCheck.passed, pathCheck.verdict));
362
+ }
363
+ if (flakesCheck) {
364
+ console.log(section('flakes'));
365
+ if (flakesCheck.result) printFlakes(flakesCheck.result, args.top, false);
366
+ console.log(verdictLine(flakesCheck.passed, flakesCheck.verdict));
367
+ }
368
+ if (cacheCheck) {
369
+ console.log(section('cache'));
370
+ if (cacheCheck.result) printCache(cacheCheck.result, false);
371
+ console.log(verdictLine(cacheCheck.passed, cacheCheck.verdict));
372
+ }
373
+ if (skipped.length > 0) {
374
+ console.log(section('skipped'));
375
+ for (const line of skipped) console.log(` ${line}`);
376
+ }
377
+ console.log('');
378
+ console.log(overall ? 'Overall: PASS — nothing found to fix' : 'Overall: FAIL — see the checks above');
379
+ return overall ? 0 : 1;
380
+ }
381
+ console.error(`Error: unknown command: ${command}`);
382
+ console.error('Run with --help to see the available options.');
383
+ return 2;
384
+ } catch (error) {
385
+ if (error instanceof UsageError) {
386
+ console.error(`Error: ${error.message}`);
387
+ console.error('Run with --help to see the available options.');
388
+ return 2;
389
+ }
390
+ console.error(`Error: ${error.message}`);
391
+ return 1;
392
+ }
393
+ }
394
+
395
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1 @@
1
+ built
@@ -0,0 +1 @@
1
+ API_KEY=your-key-here
@@ -0,0 +1,17 @@
1
+ // Demo build: slow on a cold cache, fast on a warm one.
2
+ // If .cache/out.txt exists the work is already done; otherwise this
3
+ // script simulates 150 ms of real work and writes the cache file.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const cacheDir = path.join(process.cwd(), '.cache');
8
+ const out = path.join(cacheDir, 'out.txt');
9
+ if (fs.existsSync(out)) {
10
+ console.log('cache hit');
11
+ } else {
12
+ const until = Date.now() + 150;
13
+ while (Date.now() < until) { /* simulate work the cache would skip */ }
14
+ fs.mkdirSync(cacheDir, { recursive: true });
15
+ fs.writeFileSync(out, 'built\n');
16
+ console.log('cache miss');
17
+ }