dev-diagnosis-cli 0.1.7 → 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 +24 -1
- package/README.md +9 -5
- package/bin/dev-diagnosis.js +15 -4
- package/lib/context.mjs +150 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
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
|
|
4
27
|
|
|
5
28
|
- Fix: `dev-diagnosis <path>` no longer errors with "unknown command" —
|
|
6
29
|
the usage line promises `[all] [path]`, but a bare path was treated as a
|
package/README.md
CHANGED
|
@@ -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`. 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.
|
|
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,7 +103,7 @@ 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
|
|
|
@@ -112,14 +112,18 @@ Reads JUnit XML across CI runs — each subdirectory of `<junit-dir>` is one run
|
|
|
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
|
|
@@ -61,7 +61,7 @@ Commands:
|
|
|
61
61
|
each *.xml file directly under it is a run of its own.
|
|
62
62
|
|
|
63
63
|
Options:
|
|
64
|
-
--json machine-readable output (context, path, flakes, all)
|
|
64
|
+
--json machine-readable output (context, path, cache, flakes, all)
|
|
65
65
|
--fix write the context check's .aiignore suggestions into
|
|
66
66
|
<path>/.aiignore (context and all only). Entries that
|
|
67
67
|
are already there are left alone; nothing else in the
|
|
@@ -292,8 +292,7 @@ function runCache(options) {
|
|
|
292
292
|
|
|
293
293
|
function printCache(result, json) {
|
|
294
294
|
if (json) {
|
|
295
|
-
|
|
296
|
-
console.log(JSON.stringify(rest, null, 2));
|
|
295
|
+
console.log(JSON.stringify(stripCacheStdout(result), null, 2));
|
|
297
296
|
return;
|
|
298
297
|
}
|
|
299
298
|
console.log(`Cache dir: ${result.cacheDir}`);
|
|
@@ -302,6 +301,15 @@ function printCache(result, json) {
|
|
|
302
301
|
console.log(`Verdict: ${result.verdict} — ${result.verdictDetail}`);
|
|
303
302
|
}
|
|
304
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
|
+
|
|
305
313
|
function main(argv) {
|
|
306
314
|
const args = parseArgs(argv);
|
|
307
315
|
if (args.error) {
|
|
@@ -431,6 +439,9 @@ function main(argv) {
|
|
|
431
439
|
if (key === 'flakes' && result && args.top != null) {
|
|
432
440
|
result = { ...result, tests: result.tests.slice(0, args.top) };
|
|
433
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);
|
|
434
445
|
// A check that could not run has no result: keep its reason
|
|
435
446
|
// in the JSON instead of emitting null, or a script consumer
|
|
436
447
|
// sees overall FAIL with no way to tell why.
|
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/package.json
CHANGED