dev-diagnosis-cli 0.1.6 → 0.1.7
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/CHANGELOG.md +27 -1
- package/README.md +4 -4
- package/bin/dev-diagnosis.js +50 -17
- package/lib/flakes.mjs +18 -0
- package/package.json +45 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.7 — unreleased
|
|
4
|
+
|
|
5
|
+
- Fix: `dev-diagnosis <path>` no longer errors with "unknown command" —
|
|
6
|
+
the usage line promises `[all] [path]`, but a bare path was treated as a
|
|
7
|
+
command name. A bare positional that is an existing directory now means
|
|
8
|
+
`all` on that path (options like `--command` still apply); a word that
|
|
9
|
+
is neither a known command nor a directory is still a usage error, so a
|
|
10
|
+
typo stays a typo. The help text and README say so.
|
|
11
|
+
- Fix: `--top N` was silently ignored in `--json` output — `flakes
|
|
12
|
+
<dir> --json --top 1` returned all five demo tests instead of one.
|
|
13
|
+
The JSON `tests` array is now limited to the N highest-ranked tests,
|
|
14
|
+
in standalone `flakes` and in `all --json` alike; `totals` still
|
|
15
|
+
describes the whole run set, the same split the text table makes.
|
|
16
|
+
- Fix: `all --json` dropped the reason a check could not run — a missing
|
|
17
|
+
runs directory or a failing build came out as `"flakes": null` /
|
|
18
|
+
`"cache": null` next to `overall: FAIL`, with nothing saying why.
|
|
19
|
+
A check with no result now reports `{ "error": "<reason>" }` instead,
|
|
20
|
+
so a script consuming the JSON can tell which check failed and why.
|
|
21
|
+
- Fix: a run directory with no JUnit XML files was silently dropped —
|
|
22
|
+
`flakes` on two run directories, one of them empty, reported
|
|
23
|
+
"runs: 1" with no mention of the second run. An empty run directory
|
|
24
|
+
is now named in the output (and in `--json` under `skippedRuns`) and
|
|
25
|
+
fails the check: a CI run that produced no reports is a broken upload,
|
|
26
|
+
not a run with zero tests.
|
|
27
|
+
(72 tests.)
|
|
28
|
+
|
|
29
|
+
## 0.1.6 — 2026-11-12
|
|
4
30
|
|
|
5
31
|
- Fix: an empty JUnit file (0 bytes, whitespace only, or a declaration/comment
|
|
6
32
|
and nothing else) no longer parses as a clean run of zero tests — the XML
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ npm install -g dev-diagnosis-cli
|
|
|
20
20
|
dev-diagnosis all
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
`dev-diagnosis` with no arguments is the same as `dev-diagnosis all` on the current directory
|
|
23
|
+
`dev-diagnosis` with no arguments is the same as `dev-diagnosis all` on the current directory, and `dev-diagnosis <path>` is the same as `dev-diagnosis all <path>`. Each check is also available on its own:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
26
|
dev-diagnosis context [path] # files an AI agent should not read
|
|
@@ -30,7 +30,7 @@ dev-diagnosis flakes <junit-dir> # flaky tests from JUnit XML
|
|
|
30
30
|
dev-diagnosis all [path] --command <name> --flakes <dir> -- <build-command...>
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
`all` always runs `context` on `[path]` (default: the current directory) and runs `path`, `flakes` and `cache` when their input is given. Checks without their input are listed as skipped, with the command that would run them, instead of silently pretending they ran. A build command on a repo with no cache directory to measure is also listed as skipped rather than failing: nothing to measure is not a problem. `--json` gives machine-readable output for `context`, `path`, `flakes` and `all`. Add `--fix` to `context` (or `all`) to write the suggestions into `<path>/.aiignore` instead of only printing them: entries already there are left alone, nothing else in the file is touched, and a later scan skips everything `.aiignore` lists — so `context --fix` followed by `context` comes back clean.
|
|
33
|
+
`all` always runs `context` on `[path]` (default: the current directory) and runs `path`, `flakes` and `cache` when their input is given. Checks without their input are listed as skipped, with the command that would run them, instead of silently pretending they ran. A build command on a repo with no cache directory to measure is also listed as skipped rather than failing: nothing to measure is not a problem. `--json` gives machine-readable output for `context`, `path`, `flakes` and `all`. In `all --json`, a check that could not run (a missing runs directory, a failing build) reports `{ "error": "<reason>" }` instead of `null`, so a script can tell why the overall result failed. Add `--fix` to `context` (or `all`) to write the suggestions into `<path>/.aiignore` instead of only printing them: entries already there are left alone, nothing else in the file is touched, and a later scan skips everything `.aiignore` lists — so `context --fix` followed by `context` comes back clean.
|
|
34
34
|
|
|
35
35
|
Exit codes: `0` every check that ran passed; `1` a check found something to fix or could not run; `2` usage error.
|
|
36
36
|
|
|
@@ -107,12 +107,12 @@ Runs the build command twice: cold, with the cache directory moved aside, and wa
|
|
|
107
107
|
|
|
108
108
|
### `flakes` — which tests are flaky
|
|
109
109
|
|
|
110
|
-
Reads JUnit XML across CI runs — each subdirectory of `<junit-dir>` is one run, and each `*.xml` file directly under it is a run of its own — and ranks tests by instability, using a Wilson 95% lower bound so a test that failed twice in two runs outranks one that failed twice in twenty. It distinguishes *passed-on-retry* (failed then passed in the same run, the classic flake signature) from *always-failing* and plain cross-run flakes, and labels each test `new`, `chronic` or `recovered` from its history. CRLF line endings are normalised before parsing, so Windows-produced XML parses the same as Unix-produced XML. A file that cannot be parsed — broken XML, or a file with no root element at all (empty, whitespace-only, a lone declaration) — is printed as a warning and fails the check, so the verdict is never based on half-read data; a genuinely empty report (`<testsuites></testsuites>`) still counts as zero tests.
|
|
110
|
+
Reads JUnit XML across CI runs — each subdirectory of `<junit-dir>` is one run, and each `*.xml` file directly under it is a run of its own — and ranks tests by instability, using a Wilson 95% lower bound so a test that failed twice in two runs outranks one that failed twice in twenty. It distinguishes *passed-on-retry* (failed then passed in the same run, the classic flake signature) from *always-failing* and plain cross-run flakes, and labels each test `new`, `chronic` or `recovered` from its history. CRLF line endings are normalised before parsing, so Windows-produced XML parses the same as Unix-produced XML. A file that cannot be parsed — broken XML, or a file with no root element at all (empty, whitespace-only, a lone declaration) — is printed as a warning and fails the check, so the verdict is never based on half-read data; a genuinely empty report (`<testsuites></testsuites>`) still counts as zero tests. `--top N` limits the listing to the N highest-ranked flaky tests, in the text table and in `--json` output alike; the totals still describe the whole run set. A run directory that contains no JUnit XML files at all is named and skipped, and also fails the check — a CI run that produced no reports is a broken upload, not a run with zero tests, and the report must not pretend it never existed. `--json` lists such runs under `skippedRuns`.
|
|
111
111
|
|
|
112
112
|
## Development
|
|
113
113
|
|
|
114
114
|
```sh
|
|
115
|
-
npm test # node:test, no extra dependencies —
|
|
115
|
+
npm test # node:test, no extra dependencies — 72 tests
|
|
116
116
|
npm run example # run the CLI against the demo fixture
|
|
117
117
|
```
|
|
118
118
|
|
package/bin/dev-diagnosis.js
CHANGED
|
@@ -42,7 +42,8 @@ Usage:
|
|
|
42
42
|
Commands:
|
|
43
43
|
all Run every check that has what it needs and print one unified
|
|
44
44
|
report. context always runs on [path] (default: current
|
|
45
|
-
directory). path
|
|
45
|
+
directory). A bare path is the same as all <path>.
|
|
46
|
+
path runs when --command is given, flakes when
|
|
46
47
|
--flakes is given, cache when a build command follows --.
|
|
47
48
|
Checks without their input are listed as skipped, with the
|
|
48
49
|
command that would run them.
|
|
@@ -238,7 +239,11 @@ function runPath(commandName) {
|
|
|
238
239
|
|
|
239
240
|
function printFlakes(result, top, json) {
|
|
240
241
|
if (json) {
|
|
241
|
-
|
|
242
|
+
// --top limits the listing, not the scan: the tests array holds only
|
|
243
|
+
// the N highest-ranked tests, while totals still describe the whole
|
|
244
|
+
// run set — the same split the text table already makes.
|
|
245
|
+
const out = top == null ? result : { ...result, tests: result.tests.slice(0, top) };
|
|
246
|
+
console.log(JSON.stringify(out, null, 2));
|
|
242
247
|
return;
|
|
243
248
|
}
|
|
244
249
|
console.log(formatFlakesTable(result, top));
|
|
@@ -248,20 +253,23 @@ function runFlakes(dir) {
|
|
|
248
253
|
if (!dir) throw new UsageError('flakes needs a runs directory, for example: dev-diagnosis flakes examples/runs');
|
|
249
254
|
const result = scanRuns(dir);
|
|
250
255
|
const unparsable = result.warnings.length;
|
|
251
|
-
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
+
const skippedRuns = result.skippedRuns ? result.skippedRuns.length : 0;
|
|
257
|
+
// A file that could not be parsed, or a run directory that produced no
|
|
258
|
+
// reports, means the verdict is based on incomplete data: "no flaky
|
|
259
|
+
// tests" on a half-read run set is not a pass, so the check fails and
|
|
260
|
+
// the verdict says what was unreadable. The warnings themselves are
|
|
261
|
+
// printed with the table.
|
|
262
|
+
const passed = result.totals.flaky === 0 && result.totals.alwaysFailing === 0 && unparsable === 0 && skippedRuns === 0;
|
|
256
263
|
const base = result.totals.flaky === 0 && result.totals.alwaysFailing === 0
|
|
257
264
|
? `no flaky tests across ${result.totals.runs} runs`
|
|
258
265
|
: `${result.totals.flaky} flaky, ${result.totals.alwaysFailing} always-failing across ${result.totals.runs} runs`;
|
|
266
|
+
const problems = [];
|
|
267
|
+
if (unparsable > 0) problems.push(`${unparsable} file${unparsable === 1 ? '' : 's'} could not be parsed`);
|
|
268
|
+
if (skippedRuns > 0) problems.push(`${skippedRuns} run director${skippedRuns === 1 ? 'y' : 'ies'} had no JUnit XML files`);
|
|
259
269
|
return {
|
|
260
270
|
result,
|
|
261
271
|
passed,
|
|
262
|
-
verdict:
|
|
263
|
-
? `${base}; ${unparsable} file${unparsable === 1 ? '' : 's'} could not be parsed`
|
|
264
|
-
: base,
|
|
272
|
+
verdict: problems.length > 0 ? `${base}; ${problems.join('; ')}` : base,
|
|
265
273
|
};
|
|
266
274
|
}
|
|
267
275
|
|
|
@@ -311,13 +319,26 @@ function main(argv) {
|
|
|
311
319
|
}
|
|
312
320
|
|
|
313
321
|
const command = args.command || 'all';
|
|
314
|
-
|
|
322
|
+
// `dev-diagnosis <path>` means `dev-diagnosis all <path>`: the usage line
|
|
323
|
+
// promises `[all] [path]`, so a bare positional that is an existing
|
|
324
|
+
// directory is a path, not an unknown command. Anything else that is not
|
|
325
|
+
// a known command still errors below, so a typo stays a typo.
|
|
326
|
+
let resolvedCommand = command;
|
|
327
|
+
if (!['all', 'context', 'path', 'cache', 'flakes'].includes(command)) {
|
|
328
|
+
let isDir = false;
|
|
329
|
+
try { isDir = fs.statSync(path.resolve(command)).isDirectory(); } catch { isDir = false; }
|
|
330
|
+
if (isDir) {
|
|
331
|
+
args.positional.unshift(command);
|
|
332
|
+
resolvedCommand = 'all';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (args.fix && resolvedCommand !== 'context' && resolvedCommand !== 'all') {
|
|
315
336
|
console.error('Error: --fix only applies to context (and all).');
|
|
316
337
|
console.error('Run with --help to see the available options.');
|
|
317
338
|
return 2;
|
|
318
339
|
}
|
|
319
340
|
try {
|
|
320
|
-
if (
|
|
341
|
+
if (resolvedCommand === 'context') {
|
|
321
342
|
const target = args.positional[0];
|
|
322
343
|
const check = runContext(target);
|
|
323
344
|
// In --json mode the fix must be part of the printed object, so it
|
|
@@ -331,27 +352,27 @@ function main(argv) {
|
|
|
331
352
|
if (!args.json) console.log(verdictLine(check.passed, check.verdict));
|
|
332
353
|
return check.passed ? 0 : 1;
|
|
333
354
|
}
|
|
334
|
-
if (
|
|
355
|
+
if (resolvedCommand === 'path') {
|
|
335
356
|
const name = args.positional[0];
|
|
336
357
|
const check = runPath(name);
|
|
337
358
|
printPath(check.result, args.json);
|
|
338
359
|
if (!args.json) console.log(verdictLine(check.passed, check.verdict));
|
|
339
360
|
return check.passed ? 0 : 1;
|
|
340
361
|
}
|
|
341
|
-
if (
|
|
362
|
+
if (resolvedCommand === 'flakes') {
|
|
342
363
|
const dir = args.positional[0];
|
|
343
364
|
const check = runFlakes(dir);
|
|
344
365
|
printFlakes(check.result, args.top, args.json);
|
|
345
366
|
if (!args.json) console.log(verdictLine(check.passed, check.verdict));
|
|
346
367
|
return check.passed ? 0 : 1;
|
|
347
368
|
}
|
|
348
|
-
if (
|
|
369
|
+
if (resolvedCommand === 'cache') {
|
|
349
370
|
const check = runCache({ buildCommand: args.buildCommand, cacheDir: args.cacheDir, cwd: args.cwd });
|
|
350
371
|
printCache(check.result, args.json);
|
|
351
372
|
if (!args.json) console.log(verdictLine(check.passed, check.verdict));
|
|
352
373
|
return check.passed ? 0 : 1;
|
|
353
374
|
}
|
|
354
|
-
if (
|
|
375
|
+
if (resolvedCommand === 'all') {
|
|
355
376
|
const target = args.positional[0];
|
|
356
377
|
const root = target ? path.resolve(target) : process.cwd();
|
|
357
378
|
const checks = {};
|
|
@@ -403,7 +424,19 @@ function main(argv) {
|
|
|
403
424
|
if (args.json) {
|
|
404
425
|
const out = {
|
|
405
426
|
command: 'all', root,
|
|
406
|
-
checks: Object.fromEntries(Object.entries(checks).map(([key, check]) =>
|
|
427
|
+
checks: Object.fromEntries(Object.entries(checks).map(([key, check]) => {
|
|
428
|
+
let result = check.result;
|
|
429
|
+
// Same --top split as the standalone flakes output: the
|
|
430
|
+
// embedded tests array is limited, totals stay whole-scan.
|
|
431
|
+
if (key === 'flakes' && result && args.top != null) {
|
|
432
|
+
result = { ...result, tests: result.tests.slice(0, args.top) };
|
|
433
|
+
}
|
|
434
|
+
// A check that could not run has no result: keep its reason
|
|
435
|
+
// in the JSON instead of emitting null, or a script consumer
|
|
436
|
+
// sees overall FAIL with no way to tell why.
|
|
437
|
+
if (result == null) result = { error: check.error || check.verdict };
|
|
438
|
+
return [key, result];
|
|
439
|
+
})),
|
|
407
440
|
skipped, overall: overall ? 'PASS' : 'FAIL',
|
|
408
441
|
};
|
|
409
442
|
console.log(JSON.stringify(out, null, 2));
|
package/lib/flakes.mjs
CHANGED
|
@@ -146,11 +146,20 @@ export function scanRuns(root, options = {}) {
|
|
|
146
146
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
147
147
|
|
|
148
148
|
const runGroups = [];
|
|
149
|
+
const skippedRuns = [];
|
|
149
150
|
for (const entry of entries) {
|
|
150
151
|
const full = path.join(absolute, entry.name);
|
|
151
152
|
if (entry.isDirectory()) {
|
|
152
153
|
const files = listXmlFiles(full);
|
|
153
154
|
if (files.length > 0) runGroups.push({ id: entry.name, files });
|
|
155
|
+
else {
|
|
156
|
+
// A run directory with no XML is a run that produced no reports —
|
|
157
|
+
// a broken CI upload, not a run with zero tests. Dropping it
|
|
158
|
+
// silently would report "runs: 1" while a second run existed, so
|
|
159
|
+
// it is recorded and the check fails on it, like an unparsable
|
|
160
|
+
// file: the verdict must not rest on half-read data.
|
|
161
|
+
skippedRuns.push({ id: entry.name, reason: 'no JUnit XML files found' });
|
|
162
|
+
}
|
|
154
163
|
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xml')) {
|
|
155
164
|
runGroups.push({ id: entry.name.replace(/\.xml$/i, ''), files: [full] });
|
|
156
165
|
}
|
|
@@ -240,6 +249,7 @@ export function scanRuns(root, options = {}) {
|
|
|
240
249
|
runs: runs.map((run) => ({ id: run.id, files: run.files, testCount: run.tests.length })),
|
|
241
250
|
tests,
|
|
242
251
|
warnings,
|
|
252
|
+
skippedRuns,
|
|
243
253
|
totals: {
|
|
244
254
|
runs: runs.length,
|
|
245
255
|
tests: tests.length,
|
|
@@ -271,5 +281,13 @@ export function formatFlakesTable(result, top = null) {
|
|
|
271
281
|
lines.push(`Warning: ${result.warnings.length} ${noun} could not be parsed — results are incomplete:`);
|
|
272
282
|
for (const warning of result.warnings) lines.push(` ${warning}`);
|
|
273
283
|
}
|
|
284
|
+
// A run directory that produced no reports is not "no flaky tests":
|
|
285
|
+
// say which runs were skipped, so a missing CI upload is visible.
|
|
286
|
+
if (result.skippedRuns && result.skippedRuns.length > 0) {
|
|
287
|
+
const noun = result.skippedRuns.length === 1 ? 'run directory' : 'run directories';
|
|
288
|
+
const verb = result.skippedRuns.length === 1 ? 'was' : 'were';
|
|
289
|
+
lines.push(`Note: ${result.skippedRuns.length} ${noun} had no JUnit XML files and ${verb} skipped:`);
|
|
290
|
+
for (const run of result.skippedRuns) lines.push(` ${run.id} — ${run.reason}`);
|
|
291
|
+
}
|
|
274
292
|
return lines.join('\n');
|
|
275
293
|
}
|
package/package.json
CHANGED
|
@@ -1 +1,45 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"author": "WJDdidi-1",
|
|
3
|
+
"bin": {
|
|
4
|
+
"dev-diagnosis": "bin/dev-diagnosis.js"
|
|
5
|
+
},
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/WJDdidi-1/dev-doctor/issues"
|
|
8
|
+
},
|
|
9
|
+
"description": "One offline CLI that diagnoses a repo the way four small tools do: context noise for AI agents, missing CLI commands, build-cache value, and flaky tests.",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"lib/",
|
|
16
|
+
"examples/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/WJDdidi-1/dev-doctor#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"cli",
|
|
24
|
+
"diagnostics",
|
|
25
|
+
"ai-coding",
|
|
26
|
+
"context",
|
|
27
|
+
"build-cache",
|
|
28
|
+
"flaky-tests",
|
|
29
|
+
"path",
|
|
30
|
+
"developer-tools",
|
|
31
|
+
"offline"
|
|
32
|
+
],
|
|
33
|
+
"license": "Apache-2.0",
|
|
34
|
+
"name": "dev-diagnosis-cli",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/WJDdidi-1/dev-doctor.git"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"example": "node examples/demo.mjs",
|
|
41
|
+
"test": "node --test"
|
|
42
|
+
},
|
|
43
|
+
"type": "module",
|
|
44
|
+
"version": "0.1.7"
|
|
45
|
+
}
|