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 CHANGED
@@ -1,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.7 — 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
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 — 72 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
@@ -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
- const { coldStdout, warmStdout, ...rest } = result;
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 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/package.json CHANGED
@@ -41,5 +41,5 @@
41
41
  "test": "node --test"
42
42
  },
43
43
  "type": "module",
44
- "version": "0.1.7"
44
+ "version": "0.1.8"
45
45
  }