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 CHANGED
@@ -1,6 +1,32 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.6 — unreleased
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. Each check is also available on its own:
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 — 62 tests
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
 
@@ -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 runs when --command is given, flakes when
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
- console.log(JSON.stringify(result, null, 2));
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
- // A file that could not be parsed means the verdict is based on
252
- // incomplete data: "no flaky tests" on a half-read run set is not a
253
- // pass, so the check fails and the verdict says how many files were
254
- // unreadable. The warnings themselves are printed with the table.
255
- const passed = result.totals.flaky === 0 && result.totals.alwaysFailing === 0 && unparsable === 0;
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: unparsable > 0
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
- if (args.fix && command !== 'context' && command !== 'all') {
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 (command === 'context') {
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 (command === 'path') {
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 (command === 'flakes') {
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 (command === 'cache') {
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 (command === 'all') {
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]) => [key, check.result])),
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
- {"author":"WJDdidi-1","bin":{"dev-diagnosis":"bin/dev-diagnosis.js"},"bugs":{"url":"https://github.com/WJDdidi-1/dev-doctor/issues"},"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.","engines":{"node":">=18"},"files":["bin/","lib/","examples/","README.md","LICENSE","CHANGELOG.md"],"homepage":"https://github.com/WJDdidi-1/dev-doctor#readme","keywords":["cli","diagnostics","ai-coding","context","build-cache","flaky-tests","path","developer-tools","offline"],"license":"Apache-2.0","name":"dev-diagnosis-cli","repository":{"type":"git","url":"git+https://github.com/WJDdidi-1/dev-doctor.git"},"scripts":{"example":"node examples/demo.mjs","test":"node --test"},"type":"module","version":"0.1.6"}
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
+ }