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 +50 -1
- package/README.md +11 -7
- package/bin/dev-diagnosis.js +65 -21
- package/lib/context.mjs +150 -2
- package/lib/flakes.mjs +18 -0
- package/package.json +45 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
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
|
|
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 —
|
|
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
|
|
package/bin/dev-diagnosis.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
|
@@ -284,8 +292,7 @@ function runCache(options) {
|
|
|
284
292
|
|
|
285
293
|
function printCache(result, json) {
|
|
286
294
|
if (json) {
|
|
287
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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]) =>
|
|
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
|
|
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
|
-
{
|
|
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
|
+
}
|