dev-diagnosis-cli 0.1.6 → 0.1.8

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,55 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.6 — unreleased
3
+ ## 0.1.8 — 2026-11-12
4
+
5
+ - Fix: `all --json` embedded the build's full stdout (`coldStdout` /
6
+ `warmStdout`) in the cache check, while standalone `cache --json`
7
+ already left it out — a chatty build bloated the machine-readable
8
+ output. Both shapes are now the same: timings and verdict only. The
9
+ `--json` option also now documents `cache` (the usage line and help
10
+ text listed only `context`, `path`, `flakes` and `all`, although
11
+ standalone `cache --json` already worked).
12
+ - New: `context` now also honours `.gitignore` — files and directories
13
+ it lists are excluded from the scan, at the repo root and in nested
14
+ `.gitignore` files alike, so a repo whose build output, logs or data
15
+ dumps are already git-ignored no longer gets flagged for them, and
16
+ `--fix` no longer duplicates those entries into `.aiignore`. The
17
+ common pattern forms work (`*`, `?`, `[...]`, `**`, `/`-anchored
18
+ paths, directory-only `dir/`, `!` negation with last-match-wins);
19
+ `.git/info/exclude` and the global gitignore are not read. (82 tests.)
20
+ - Dogfood: the repo now ships a `.aiignore` excluding the demo fixture
21
+ (`examples/demo-repo/`), so `dev-diagnosis all` on a checkout passes
22
+ its own `context` check (was 77/100 FAIL, now 100/100 PASS). The
23
+ fixture is intentionally noisy test data and stays noisy when scanned
24
+ directly — `npm run example` output is unchanged.
25
+
26
+ ## 0.1.7 — 2026-11-12
27
+
28
+ - Fix: `dev-diagnosis <path>` no longer errors with "unknown command" —
29
+ the usage line promises `[all] [path]`, but a bare path was treated as a
30
+ command name. A bare positional that is an existing directory now means
31
+ `all` on that path (options like `--command` still apply); a word that
32
+ is neither a known command nor a directory is still a usage error, so a
33
+ typo stays a typo. The help text and README say so.
34
+ - Fix: `--top N` was silently ignored in `--json` output — `flakes
35
+ <dir> --json --top 1` returned all five demo tests instead of one.
36
+ The JSON `tests` array is now limited to the N highest-ranked tests,
37
+ in standalone `flakes` and in `all --json` alike; `totals` still
38
+ describes the whole run set, the same split the text table makes.
39
+ - Fix: `all --json` dropped the reason a check could not run — a missing
40
+ runs directory or a failing build came out as `"flakes": null` /
41
+ `"cache": null` next to `overall: FAIL`, with nothing saying why.
42
+ A check with no result now reports `{ "error": "<reason>" }` instead,
43
+ so a script consuming the JSON can tell which check failed and why.
44
+ - Fix: a run directory with no JUnit XML files was silently dropped —
45
+ `flakes` on two run directories, one of them empty, reported
46
+ "runs: 1" with no mention of the second run. An empty run directory
47
+ is now named in the output (and in `--json` under `skippedRuns`) and
48
+ fails the check: a CI run that produced no reports is a broken upload,
49
+ not a run with zero tests.
50
+ (72 tests.)
51
+
52
+ ## 0.1.6 — 2026-11-12
4
53
 
5
54
  - Fix: an empty JUnit file (0 bytes, whitespace only, or a declaration/comment
6
55
  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`, `cache`, `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
 
@@ -93,7 +93,7 @@ The demo repo is intentionally noisy, so `all` exits 1: the context and flakes c
93
93
 
94
94
  ### `context` — what an AI agent should not read
95
95
 
96
- Walks the repo (skipping `.git`, `node_modules` and other hidden directories) and classifies every file: build output, large files over 1 MB, lockfiles, logs, binary payloads, secret-like files and environment templates. It prints a 0–100 score, the bytes lost per category, the noisy files, the files worth a look before sharing, and the `.aiignore` entries that would fix it. Build-output directories are reported by their full path from the repo root (`packages/foo/dist/`, not a bare `dist/`) and rolled up into one row per directory, so the entry `--fix` writes matches nested build output too. A large file is fixed by its exact path from the repo root — no glob can single one file out — so `--fix` followed by a re-scan comes back clean even when the noise is a single oversized file. Files the repo's `.aiignore` already lists are excluded from the scan entirely — they are what the agent will not be handed, so they are not noise any more. `context --fix` writes the suggestions into `.aiignore` for you; `--json` includes the fix outcome in the output.
96
+ Walks the repo (skipping `.git`, `node_modules` and other hidden directories) and classifies every file: build output, large files over 1 MB, lockfiles, logs, binary payloads, secret-like files and environment templates. It prints a 0–100 score, the bytes lost per category, the noisy files, the files worth a look before sharing, and the `.aiignore` entries that would fix it. Build-output directories are reported by their full path from the repo root (`packages/foo/dist/`, not a bare `dist/`) and rolled up into one row per directory, so the entry `--fix` writes matches nested build output too. A large file is fixed by its exact path from the repo root — no glob can single one file out — so `--fix` followed by a re-scan comes back clean even when the noise is a single oversized file. Files the repo's `.aiignore` already lists are excluded from the scan entirely — they are what the agent will not be handed, so they are not noise any more. Files and directories listed in `.gitignore` — at the repo root or in a subdirectory — are excluded the same way: they are already invisible to most agents, so flagging them would be noise about noise, and `--fix` will not duplicate them into `.aiignore`. The common pattern forms work (`*`, `?`, `[...]`, `**`, `/`-anchored paths, directory-only `dir/`, and `!` negation with last-match-wins); `.git/info/exclude` and the global gitignore are not read. `context --fix` writes the suggestions into `.aiignore` for you; `--json` includes the fix outcome in the output.
97
97
 
98
98
  The score is the *noise-only* part of ai-context-doctor's score: 100 minus the category-weighted share of noise bytes (build 40, large 25, lockfile 20, log 15). The full ai-context-doctor score also weighs context volume, one file dominating, file sprawl and binary payloads, so its composite number reads lower on the same repo. The file classification itself is the same.
99
99
 
@@ -103,23 +103,27 @@ Runs the lookup in four shell contexts — current, login, non-login and daemon-
103
103
 
104
104
  ### `cache` — does the cache pay
105
105
 
106
- Runs the build command twice: cold, with the cache directory moved aside, and warm, with it present, then reports both timings and the saving. The original cache is restored to its original state afterwards, and anything the cold run produced is thrown away. A cache directory that is the project root, contains it, or lies outside it is refused rather than moved. No network is involved; the check only runs your own build command.
106
+ Runs the build command twice: cold, with the cache directory moved aside, and warm, with it present, then reports both timings and the saving. The original cache is restored to its original state afterwards, and anything the cold run produced is thrown away. A cache directory that is the project root, contains it, or lies outside it is refused rather than moved. No network is involved; the check only runs your own build command. `--json` reports the timings and the verdict without the build's stdout, so a chatty build does not bloat the machine-readable output — in `all --json` the embedded cache check is the same shape.
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 — 82 tests
116
116
  npm run example # run the CLI against the demo fixture
117
117
  ```
118
118
 
119
119
  There is nothing to install: the CLI has no runtime dependencies, so
120
120
  `npm test` works straight after cloning. Run the checkout as
121
121
  `node bin/dev-diagnosis.js …` — see Troubleshooting if you reach for
122
- `npx` inside the checkout.
122
+ `npx` inside the checkout. The checkout ships a `.aiignore` that
123
+ excludes the demo fixture (`examples/demo-repo/`), so `dev-diagnosis
124
+ all` on the repo itself passes its own `context` check — the fixture
125
+ is intentionally noisy test data, and it stays noisy when scanned
126
+ directly (`npm run example`).
123
127
 
124
128
  Requires Node.js 18 or later. CI runs the tests and the demo on Node 18, 20, 22 and 24 across Ubuntu, macOS and Windows, and smoke-tests the packed tarball by installing it in a clean directory. Windows is covered by CI only — the Windows-only code paths (the `cmd.exe` retry for `.cmd`/`.bat` shims, `PATHEXT` lookup) have not been run on a real Windows machine.
125
129
 
@@ -34,7 +34,7 @@ Usage:
34
34
  dev-diagnosis [all] [path] [--command <name>] [--flakes <dir>] [--fix] [-- <build-command...>]
35
35
  dev-diagnosis context [path] [--json] [--fix]
36
36
  dev-diagnosis path <command> [--json]
37
- dev-diagnosis cache [--cache-dir <dir>] [--cwd <dir>] -- <build-command...>
37
+ dev-diagnosis cache [--json] [--cache-dir <dir>] [--cwd <dir>] -- <build-command...>
38
38
  dev-diagnosis flakes <runs-dir> [--json] [--top N]
39
39
  dev-diagnosis --help
40
40
  dev-diagnosis --version
@@ -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.
@@ -60,7 +61,7 @@ Commands:
60
61
  each *.xml file directly under it is a run of its own.
61
62
 
62
63
  Options:
63
- --json machine-readable output (context, path, flakes, all)
64
+ --json machine-readable output (context, path, cache, flakes, all)
64
65
  --fix write the context check's .aiignore suggestions into
65
66
  <path>/.aiignore (context and all only). Entries that
66
67
  are already there are left alone; nothing else in the
@@ -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
 
@@ -284,8 +292,7 @@ function runCache(options) {
284
292
 
285
293
  function printCache(result, json) {
286
294
  if (json) {
287
- const { coldStdout, warmStdout, ...rest } = result;
288
- console.log(JSON.stringify(rest, null, 2));
295
+ console.log(JSON.stringify(stripCacheStdout(result), null, 2));
289
296
  return;
290
297
  }
291
298
  console.log(`Cache dir: ${result.cacheDir}`);
@@ -294,6 +301,15 @@ function printCache(result, json) {
294
301
  console.log(`Verdict: ${result.verdict} — ${result.verdictDetail}`);
295
302
  }
296
303
 
304
+ // The raw cache result carries the build's full stdout from both runs;
305
+ // JSON output drops it, so a chatty build does not bloat the machine-
306
+ // readable output. Used by printCache and by the `all --json` builder
307
+ // below, so both shapes stay identical.
308
+ function stripCacheStdout(result) {
309
+ const { coldStdout, warmStdout, ...rest } = result;
310
+ return rest;
311
+ }
312
+
297
313
  function main(argv) {
298
314
  const args = parseArgs(argv);
299
315
  if (args.error) {
@@ -311,13 +327,26 @@ function main(argv) {
311
327
  }
312
328
 
313
329
  const command = args.command || 'all';
314
- if (args.fix && command !== 'context' && command !== 'all') {
330
+ // `dev-diagnosis <path>` means `dev-diagnosis all <path>`: the usage line
331
+ // promises `[all] [path]`, so a bare positional that is an existing
332
+ // directory is a path, not an unknown command. Anything else that is not
333
+ // a known command still errors below, so a typo stays a typo.
334
+ let resolvedCommand = command;
335
+ if (!['all', 'context', 'path', 'cache', 'flakes'].includes(command)) {
336
+ let isDir = false;
337
+ try { isDir = fs.statSync(path.resolve(command)).isDirectory(); } catch { isDir = false; }
338
+ if (isDir) {
339
+ args.positional.unshift(command);
340
+ resolvedCommand = 'all';
341
+ }
342
+ }
343
+ if (args.fix && resolvedCommand !== 'context' && resolvedCommand !== 'all') {
315
344
  console.error('Error: --fix only applies to context (and all).');
316
345
  console.error('Run with --help to see the available options.');
317
346
  return 2;
318
347
  }
319
348
  try {
320
- if (command === 'context') {
349
+ if (resolvedCommand === 'context') {
321
350
  const target = args.positional[0];
322
351
  const check = runContext(target);
323
352
  // In --json mode the fix must be part of the printed object, so it
@@ -331,27 +360,27 @@ function main(argv) {
331
360
  if (!args.json) console.log(verdictLine(check.passed, check.verdict));
332
361
  return check.passed ? 0 : 1;
333
362
  }
334
- if (command === 'path') {
363
+ if (resolvedCommand === 'path') {
335
364
  const name = args.positional[0];
336
365
  const check = runPath(name);
337
366
  printPath(check.result, args.json);
338
367
  if (!args.json) console.log(verdictLine(check.passed, check.verdict));
339
368
  return check.passed ? 0 : 1;
340
369
  }
341
- if (command === 'flakes') {
370
+ if (resolvedCommand === 'flakes') {
342
371
  const dir = args.positional[0];
343
372
  const check = runFlakes(dir);
344
373
  printFlakes(check.result, args.top, args.json);
345
374
  if (!args.json) console.log(verdictLine(check.passed, check.verdict));
346
375
  return check.passed ? 0 : 1;
347
376
  }
348
- if (command === 'cache') {
377
+ if (resolvedCommand === 'cache') {
349
378
  const check = runCache({ buildCommand: args.buildCommand, cacheDir: args.cacheDir, cwd: args.cwd });
350
379
  printCache(check.result, args.json);
351
380
  if (!args.json) console.log(verdictLine(check.passed, check.verdict));
352
381
  return check.passed ? 0 : 1;
353
382
  }
354
- if (command === 'all') {
383
+ if (resolvedCommand === 'all') {
355
384
  const target = args.positional[0];
356
385
  const root = target ? path.resolve(target) : process.cwd();
357
386
  const checks = {};
@@ -403,7 +432,22 @@ function main(argv) {
403
432
  if (args.json) {
404
433
  const out = {
405
434
  command: 'all', root,
406
- checks: Object.fromEntries(Object.entries(checks).map(([key, check]) => [key, check.result])),
435
+ checks: Object.fromEntries(Object.entries(checks).map(([key, check]) => {
436
+ let result = check.result;
437
+ // Same --top split as the standalone flakes output: the
438
+ // embedded tests array is limited, totals stay whole-scan.
439
+ if (key === 'flakes' && result && args.top != null) {
440
+ result = { ...result, tests: result.tests.slice(0, args.top) };
441
+ }
442
+ // Same stdout strip as standalone `cache --json`: the raw
443
+ // result carries the build's full stdout from both runs.
444
+ if (key === 'cache' && result) result = stripCacheStdout(result);
445
+ // A check that could not run has no result: keep its reason
446
+ // in the JSON instead of emitting null, or a script consumer
447
+ // sees overall FAIL with no way to tell why.
448
+ if (result == null) result = { error: check.error || check.verdict };
449
+ return [key, result];
450
+ })),
407
451
  skipped, overall: overall ? 'PASS' : 'FAIL',
408
452
  };
409
453
  console.log(JSON.stringify(out, null, 2));
package/lib/context.mjs CHANGED
@@ -187,6 +187,143 @@ function readAiignore(root) {
187
187
  }
188
188
  }
189
189
 
190
+ function escapeRegExpChar(ch) {
191
+ return ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
192
+ }
193
+
194
+ /**
195
+ * Convert one .gitignore glob into a RegExp (anchored at both ends).
196
+ * `*` and `?` never cross a `/`; `**` does — `**/` at the start matches
197
+ * zero or more directories, a trailing `/**` matches everything under a
198
+ * directory, and a bare `**` matches anything. `[...]` is a character
199
+ * class, with git's `!` negation spelled `^` for the RegExp.
200
+ */
201
+ function gitignorePatternToRegExp(pattern) {
202
+ let out = '';
203
+ let i = 0;
204
+ while (i < pattern.length) {
205
+ const ch = pattern[i];
206
+ if (ch === '*') {
207
+ if (pattern[i + 1] === '*') {
208
+ if (pattern[i + 2] === '/') {
209
+ out += '(?:.*/)?';
210
+ i += 3;
211
+ } else {
212
+ out += '.*';
213
+ i += 2;
214
+ }
215
+ } else {
216
+ out += '[^/]*';
217
+ i += 1;
218
+ }
219
+ } else if (ch === '?') {
220
+ out += '[^/]';
221
+ i += 1;
222
+ } else if (ch === '[') {
223
+ let j = i + 1;
224
+ let negated = false;
225
+ if (pattern[j] === '!') {
226
+ negated = true;
227
+ j += 1;
228
+ }
229
+ if (pattern[j] === ']') j += 1; // a leading ] is literal
230
+ while (j < pattern.length && pattern[j] !== ']') j += 1;
231
+ if (j >= pattern.length) {
232
+ out += '\\[';
233
+ i += 1;
234
+ } else {
235
+ const inner = pattern.slice(i + 1 + (negated ? 1 : 0), j).replace(/\\/g, '\\\\');
236
+ out += `[${negated ? '^' : ''}${inner}]`;
237
+ i = j + 1;
238
+ }
239
+ } else {
240
+ out += escapeRegExpChar(ch);
241
+ i += 1;
242
+ }
243
+ }
244
+ return new RegExp(`^${out}$`);
245
+ }
246
+
247
+ /**
248
+ * Parse .gitignore content into rules. Supports the common subset of
249
+ * git's pattern language: blank lines and `#` comments are skipped, a
250
+ * leading `!` negates (last matching pattern wins), a trailing `/`
251
+ * matches directories only, a leading `/` — or a `/` anywhere else in
252
+ * the pattern — anchors it to the .gitignore's own directory, otherwise
253
+ * the pattern matches a basename at any depth. `\\#` and `\\!` at the
254
+ * start of a line are literal. Trailing spaces are stripped, as git
255
+ * does. `.git/info/exclude` and the global gitignore are not read —
256
+ * only `.gitignore` files in the scanned tree.
257
+ */
258
+ export function parseGitignore(text) {
259
+ const rules = [];
260
+ for (let raw of String(text).split(/\r?\n/)) {
261
+ raw = raw.replace(/ +$/, '');
262
+ if (!raw) continue;
263
+ if (raw.startsWith('\\#') || raw.startsWith('\\!')) raw = raw.slice(1);
264
+ else if (raw.startsWith('#')) continue;
265
+ let negate = false;
266
+ if (raw.startsWith('!')) {
267
+ negate = true;
268
+ raw = raw.slice(1);
269
+ if (!raw) continue;
270
+ }
271
+ let dirOnly = false;
272
+ if (raw.endsWith('/')) {
273
+ dirOnly = true;
274
+ raw = raw.slice(0, -1);
275
+ if (!raw) continue;
276
+ }
277
+ let anchored = false;
278
+ if (raw.startsWith('/')) {
279
+ anchored = true;
280
+ raw = raw.slice(1);
281
+ if (!raw) continue;
282
+ } else if (raw.includes('/')) {
283
+ anchored = true;
284
+ }
285
+ rules.push({ negate, dirOnly, anchored, regex: gitignorePatternToRegExp(raw) });
286
+ }
287
+ return rules;
288
+ }
289
+
290
+ /**
291
+ * Is this repo-relative path ignored by the .gitignore stack? Each level
292
+ * is `{ base, rules }`, where `base` is the .gitignore's directory
293
+ * relative to the repo root (`''` for the root). Deeper levels are
294
+ * checked after shallower ones, and the last matching pattern decides —
295
+ * so a `!` negation re-includes a file, but only when no parent
296
+ * directory was pruned first (the walk never descends into an ignored
297
+ * directory, which is also git's rule: a file cannot be re-included
298
+ * under an excluded parent).
299
+ */
300
+ export function isGitignored(relativePath, isDir, stack) {
301
+ const normalized = String(relativePath).split(path.sep).join('/').split('\\').join('/');
302
+ let ignored = false;
303
+ for (const { base, rules } of stack) {
304
+ let rel;
305
+ if (base === '') rel = normalized;
306
+ else if (normalized === base) continue; // a rule cannot ignore its own directory
307
+ else if (normalized.startsWith(`${base}/`)) rel = normalized.slice(base.length + 1);
308
+ else continue;
309
+ for (const rule of rules) {
310
+ if (rule.dirOnly && !isDir) continue;
311
+ const candidate = rule.anchored ? rel : rel.split('/').pop();
312
+ if (rule.regex.test(candidate)) ignored = !rule.negate;
313
+ }
314
+ }
315
+ return ignored;
316
+ }
317
+
318
+ function readGitignore(dir, base) {
319
+ try {
320
+ const rules = parseGitignore(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'));
321
+ return rules.length > 0 ? { base, rules } : null;
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+
190
327
  function walk(root, dir, out, state) {
191
328
  let entries;
192
329
  try {
@@ -205,11 +342,16 @@ function walk(root, dir, out, state) {
205
342
  if (entry.isDirectory()) {
206
343
  if (IGNORED_DIRS.has(entry.name)) continue;
207
344
  if (state.ignore.length > 0 && matchesAiignore(rel, state.ignore)) continue;
345
+ if (isGitignored(rel, true, state.gitignore)) continue;
346
+ const level = readGitignore(full, rel);
347
+ if (level) state.gitignore.push(level);
208
348
  walk(root, full, out, state);
349
+ if (level) state.gitignore.pop();
209
350
  continue;
210
351
  }
211
352
  if (!entry.isFile()) continue;
212
353
  if (state.ignore.length > 0 && matchesAiignore(rel, state.ignore)) continue;
354
+ if (isGitignored(rel, false, state.gitignore)) continue;
213
355
  if (state.files >= state.maxFiles) {
214
356
  state.truncated = true;
215
357
  continue;
@@ -233,7 +375,10 @@ function walk(root, dir, out, state) {
233
375
  * under-counting. Files listed in the repo's `.aiignore` are excluded
234
376
  * from the scan entirely — they are what the agent will not be handed,
235
377
  * so they are not noise any more — which is also what makes
236
- * `context --fix` followed by `context` come back clean.
378
+ * `context --fix` followed by `context` come back clean. Files and
379
+ * directories listed in `.gitignore` (at the root or nested) are
380
+ * excluded the same way: they are already invisible to most agents, so
381
+ * flagging them would be noise about noise.
237
382
  */
238
383
  export function scanDirectory(root, options = {}) {
239
384
  const absolute = path.resolve(root);
@@ -246,7 +391,10 @@ export function scanDirectory(root, options = {}) {
246
391
  if (!stat.isDirectory()) throw new Error(`not a directory: ${root}`);
247
392
  const maxFiles = options.maxFiles == null ? 10000 : options.maxFiles;
248
393
  const ignore = readAiignore(absolute);
249
- const state = { files: 0, bytes: 0, maxFiles, truncated: false, ignore };
394
+ const gitignore = [];
395
+ const rootLevel = readGitignore(absolute, '');
396
+ if (rootLevel) gitignore.push(rootLevel);
397
+ const state = { files: 0, bytes: 0, maxFiles, truncated: false, ignore, gitignore };
250
398
  const files = [];
251
399
  walk(absolute, absolute, files, state);
252
400
 
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.8"
45
+ }