dev-diagnosis-cli 0.1.1 → 0.1.3

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,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 — unreleased
4
+
5
+ - Fix: nested build-output directories are reported by their full path
6
+ from the repo root (`packages/foo/dist/`) instead of a bare `dist/` —
7
+ the bare entry only matches top-level output, so `--fix` on a repo
8
+ whose build output lives in a subdirectory wrote an `.aiignore`
9
+ entry the next scan ignored, and the scan kept failing after the
10
+ fix. Files under one build directory are also rolled up into a
11
+ single row instead of one row per file. (53 tests.)
12
+
13
+ ## 0.1.2 — 2026-11-11
14
+
15
+ - New: `context --fix` (and `all --fix`) writes the `.aiignore`
16
+ suggestions into `<path>/.aiignore` instead of only printing them —
17
+ entries already there are left alone, nothing else in the file is
18
+ touched, and no file is created when there is nothing to add. The fix
19
+ outcome is included in `--json` output.
20
+ - Fix: the context scan now honours the repo's `.aiignore` — files it
21
+ lists are excluded from the scan entirely, so applying the fix and
22
+ re-scanning comes back clean instead of failing on the same noise
23
+ forever. (50 tests.)
24
+
3
25
  ## 0.1.1 — 2026-11-11
4
26
 
5
27
  - Docs: README now has npm version, CI, license and Node badges, and the
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`.
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.
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
 
@@ -92,7 +92,7 @@ The demo repo is intentionally noisy, so `all` exits 1: the context and flakes c
92
92
 
93
93
  ### `context` — what an AI agent should not read
94
94
 
95
- 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.
95
+ 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. 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
96
 
97
97
  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.
98
98
 
@@ -111,7 +111,7 @@ Reads JUnit XML across CI runs — each subdirectory of `<junit-dir>` is one run
111
111
  ## Development
112
112
 
113
113
  ```sh
114
- npm test # node:test, no extra dependencies — 41 tests
114
+ npm test # node:test, no extra dependencies — 53 tests
115
115
  npm run example # run the CLI against the demo fixture
116
116
  ```
117
117
 
@@ -15,7 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
- import { scanDirectory, formatBytes } from '../lib/context.mjs';
18
+ import { scanDirectory, applyAiignore, formatBytes } from '../lib/context.mjs';
19
19
  import {
20
20
  buildContexts, findCommandCopies, fixForDirectory, lookupCommand,
21
21
  searchToolchainBins,
@@ -31,8 +31,8 @@ const VERSION = JSON.parse(
31
31
  const USAGE = `dev-diagnosis: one diagnosis for your repo, your commands, your cache and your tests.
32
32
 
33
33
  Usage:
34
- dev-diagnosis [all] [path] [--command <name>] [--flakes <dir>] [-- <build-command...>]
35
- dev-diagnosis context [path] [--json]
34
+ dev-diagnosis [all] [path] [--command <name>] [--flakes <dir>] [--fix] [-- <build-command...>]
35
+ dev-diagnosis context [path] [--json] [--fix]
36
36
  dev-diagnosis path <command> [--json]
37
37
  dev-diagnosis cache [--cache-dir <dir>] [--cwd <dir>] -- <build-command...>
38
38
  dev-diagnosis flakes <runs-dir> [--json] [--top N]
@@ -61,6 +61,10 @@ Commands:
61
61
 
62
62
  Options:
63
63
  --json machine-readable output (context, path, flakes, all)
64
+ --fix write the context check's .aiignore suggestions into
65
+ <path>/.aiignore (context and all only). Entries that
66
+ are already there are left alone; nothing else in the
67
+ file is changed.
64
68
  --top N show only the N highest-ranked flaky tests
65
69
  -h, --help show this help
66
70
  -v, --version print the version
@@ -78,7 +82,7 @@ class UsageError extends Error {
78
82
 
79
83
  function parseArgs(argv) {
80
84
  const parsed = {
81
- command: null, positional: [], json: false, help: false, version: false,
85
+ command: null, positional: [], json: false, fix: false, help: false, version: false,
82
86
  commandName: null, flakesDir: null, cacheDir: null, cwd: null, top: null,
83
87
  buildCommand: null, error: null,
84
88
  };
@@ -90,6 +94,7 @@ function parseArgs(argv) {
90
94
  break;
91
95
  }
92
96
  if (arg === '--json' || arg === '-j') parsed.json = true;
97
+ else if (arg === '--fix') parsed.fix = true;
93
98
  else if (arg === '--help' || arg === '-h') parsed.help = true;
94
99
  else if (arg === '--version' || arg === '-v') parsed.version = true;
95
100
  else if (arg === '--command') {
@@ -166,6 +171,28 @@ function runContext(target) {
166
171
  return { result, passed, verdict: passed ? 'context is clean' : `${result.status.toLowerCase()} (score ${result.score}/100)` };
167
172
  }
168
173
 
174
+ // Apply the context check's .aiignore suggestions and record the outcome
175
+ // on the check result, so --json output carries it too.
176
+ function applyContextFix(check) {
177
+ const fix = applyAiignore(check.result.root, check.result.suggestions);
178
+ check.result.fix = fix;
179
+ return fix;
180
+ }
181
+
182
+ function printFixLine(fix, suggestionCount) {
183
+ if (fix.added.length > 0) {
184
+ const noun = fix.added.length === 1 ? 'entry' : 'entries';
185
+ const lead = fix.created
186
+ ? `Created .aiignore with ${fix.added.length} ${noun}`
187
+ : `Added ${fix.added.length} ${noun} to .aiignore`;
188
+ console.log(`${lead}: ${fix.added.join(', ')}`);
189
+ } else if (suggestionCount === 0) {
190
+ console.log('Nothing to add to .aiignore: no suggestions.');
191
+ } else {
192
+ console.log('Nothing added: .aiignore already has all suggestions.');
193
+ }
194
+ }
195
+
169
196
  function printPath(result, json) {
170
197
  if (json) {
171
198
  console.log(JSON.stringify(result, null, 2));
@@ -276,11 +303,23 @@ function main(argv) {
276
303
  }
277
304
 
278
305
  const command = args.command || 'all';
306
+ if (args.fix && command !== 'context' && command !== 'all') {
307
+ console.error('Error: --fix only applies to context (and all).');
308
+ console.error('Run with --help to see the available options.');
309
+ return 2;
310
+ }
279
311
  try {
280
312
  if (command === 'context') {
281
313
  const target = args.positional[0];
282
314
  const check = runContext(target);
315
+ // In --json mode the fix must be part of the printed object, so it
316
+ // runs before printing; in text mode the report prints first, so a
317
+ // failed write still leaves the diagnosis on screen.
318
+ if (args.fix && args.json) applyContextFix(check);
283
319
  printContext(check.result, args.json);
320
+ if (args.fix && !args.json) {
321
+ printFixLine(applyContextFix(check), check.result.suggestions.length);
322
+ }
284
323
  if (!args.json) console.log(verdictLine(check.passed, check.verdict));
285
324
  return check.passed ? 0 : 1;
286
325
  }
@@ -312,6 +351,12 @@ function main(argv) {
312
351
 
313
352
  const contextCheck = runContext(root);
314
353
  checks.context = contextCheck;
354
+ let contextFix = null;
355
+ const getContextFix = () => {
356
+ if (!contextFix) contextFix = applyContextFix(contextCheck);
357
+ return contextFix;
358
+ };
359
+ if (args.fix && args.json) getContextFix();
315
360
  const pathCheck = args.commandName ? runPath(args.commandName) : null;
316
361
  if (pathCheck) checks.path = pathCheck;
317
362
  else skipped.push('path — pass --command <name>, for example: dev-diagnosis all --command node');
@@ -360,6 +405,7 @@ function main(argv) {
360
405
  console.log(`dev-diagnosis — ${root}`);
361
406
  console.log(section('context'));
362
407
  printContext(contextCheck.result, false);
408
+ if (args.fix) printFixLine(getContextFix(), contextCheck.result.suggestions.length);
363
409
  console.log(verdictLine(contextCheck.passed, contextCheck.verdict));
364
410
  if (pathCheck) {
365
411
  console.log(section('path'));
package/lib/context.mjs CHANGED
@@ -96,9 +96,15 @@ export function classifyPath(relativePath, size = 0) {
96
96
  const parts = normalized.split('/');
97
97
  const basename = parts[parts.length - 1];
98
98
 
99
- for (const part of parts.slice(0, -1)) {
100
- if (NOISE_DIRS.has(part)) {
101
- return { noise: { label: `${part}/`, reason: NOISE_DIRS.get(part) }, suggestion: `${part}/` };
99
+ // A noise directory is reported by its full path from the repo root
100
+ // (packages/foo/dist/), not just its name: two build directories in
101
+ // different parts of the tree would otherwise print the same label,
102
+ // and the .aiignore entry --fix writes has to match the whole path —
103
+ // a bare dist/ only ignores top-level build output.
104
+ for (let i = 0; i < parts.length - 1; i += 1) {
105
+ if (NOISE_DIRS.has(parts[i])) {
106
+ const dir = `${parts.slice(0, i + 1).join('/')}/`;
107
+ return { noise: { label: dir, reason: NOISE_DIRS.get(parts[i]) }, suggestion: dir };
102
108
  }
103
109
  }
104
110
  if (LOCKFILES.has(basename)) {
@@ -125,6 +131,56 @@ export function classifyPath(relativePath, size = 0) {
125
131
  return { suggestion: null };
126
132
  }
127
133
 
134
+ function patternToRegExp(pattern) {
135
+ let out = '';
136
+ for (const ch of pattern) {
137
+ if (ch === '*') out += '[^/]*';
138
+ else if (ch === '?') out += '[^/]';
139
+ else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
140
+ }
141
+ return new RegExp(`^${out}$`);
142
+ }
143
+
144
+ /**
145
+ * Does a .aiignore pattern match this repo-relative path? Patterns are
146
+ * the small subset the tool itself writes: `dir/` matches everything
147
+ * under that directory, a pattern with a slash matches the whole path,
148
+ * anything else matches the basename as a glob (`*` and `?` only).
149
+ * Comment, blank and negation (`!`) lines never match: negation is not
150
+ * supported, and silently treating one as an ignore would hide files
151
+ * the user explicitly wanted back.
152
+ */
153
+ export function matchesAiignore(relativePath, patterns) {
154
+ const normalized = String(relativePath).split(path.sep).join('/').split('\\').join('/');
155
+ const basename = normalized.split('/').pop();
156
+ for (const raw of patterns) {
157
+ const pattern = String(raw).trim();
158
+ if (!pattern || pattern.startsWith('#') || pattern.startsWith('!')) continue;
159
+ if (pattern.endsWith('/')) {
160
+ const dir = pattern.slice(0, -1);
161
+ if (normalized === dir || normalized.startsWith(`${dir}/`)) return true;
162
+ continue;
163
+ }
164
+ if (pattern.includes('/')) {
165
+ if (patternToRegExp(pattern).test(normalized)) return true;
166
+ } else if (patternToRegExp(pattern).test(basename)) {
167
+ return true;
168
+ }
169
+ }
170
+ return false;
171
+ }
172
+
173
+ function readAiignore(root) {
174
+ try {
175
+ return fs.readFileSync(path.join(root, '.aiignore'), 'utf8')
176
+ .split(/\r?\n/)
177
+ .map((line) => line.trim())
178
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('!'));
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
128
184
  function walk(root, dir, out, state) {
129
185
  let entries;
130
186
  try {
@@ -142,10 +198,12 @@ function walk(root, dir, out, state) {
142
198
  const rel = path.relative(root, full).split(path.sep).join('/');
143
199
  if (entry.isDirectory()) {
144
200
  if (IGNORED_DIRS.has(entry.name)) continue;
201
+ if (state.ignore.length > 0 && matchesAiignore(rel, state.ignore)) continue;
145
202
  walk(root, full, out, state);
146
203
  continue;
147
204
  }
148
205
  if (!entry.isFile()) continue;
206
+ if (state.ignore.length > 0 && matchesAiignore(rel, state.ignore)) continue;
149
207
  if (state.files >= state.maxFiles) {
150
208
  state.truncated = true;
151
209
  continue;
@@ -166,7 +224,10 @@ function walk(root, dir, out, state) {
166
224
  * Scan a directory and describe how much of it is context noise.
167
225
  * Synchronous and dependency-free, like the original. `maxFiles` caps the
168
226
  * walk; a capped walk reports `truncated: true` instead of silently
169
- * under-counting.
227
+ * under-counting. Files listed in the repo's `.aiignore` are excluded
228
+ * from the scan entirely — they are what the agent will not be handed,
229
+ * so they are not noise any more — which is also what makes
230
+ * `context --fix` followed by `context` come back clean.
170
231
  */
171
232
  export function scanDirectory(root, options = {}) {
172
233
  const absolute = path.resolve(root);
@@ -178,17 +239,33 @@ export function scanDirectory(root, options = {}) {
178
239
  }
179
240
  if (!stat.isDirectory()) throw new Error(`not a directory: ${root}`);
180
241
  const maxFiles = options.maxFiles == null ? 10000 : options.maxFiles;
181
- const state = { files: 0, bytes: 0, maxFiles, truncated: false };
242
+ const ignore = readAiignore(absolute);
243
+ const state = { files: 0, bytes: 0, maxFiles, truncated: false, ignore };
182
244
  const files = [];
183
245
  walk(absolute, absolute, files, state);
184
246
 
185
247
  const noise = [];
248
+ // One row per build-output directory, not one row per file inside it:
249
+ // a dist/ with hundreds of files would otherwise flood the noise list
250
+ // (and the score uses the summed bytes either way).
251
+ const dirNoise = new Map();
186
252
  const review = [];
187
253
  const suggestions = new Set();
188
254
  for (const file of files) {
189
255
  const result = classifyPath(file.relativePath, file.size);
190
256
  if (result.noise) {
191
- noise.push({ ...result.noise, bytes: file.size, path: file.relativePath.split(path.sep).join('/') });
257
+ const item = { ...result.noise, bytes: file.size, path: file.relativePath.split(path.sep).join('/') };
258
+ if (item.label.endsWith('/')) {
259
+ const existing = dirNoise.get(item.label);
260
+ if (existing) existing.bytes += file.size;
261
+ else {
262
+ const aggregated = { label: item.label, reason: item.reason, bytes: file.size, path: item.label };
263
+ dirNoise.set(item.label, aggregated);
264
+ noise.push(aggregated);
265
+ }
266
+ } else {
267
+ noise.push(item);
268
+ }
192
269
  if (result.suggestion) suggestions.add(result.suggestion);
193
270
  } else if (result.review) {
194
271
  review.push({ ...result.review, bytes: file.size, path: file.relativePath.split(path.sep).join('/') });
@@ -211,3 +288,43 @@ export function scanDirectory(root, options = {}) {
211
288
  status: statusFor(scored.score),
212
289
  };
213
290
  }
291
+
292
+ /**
293
+ * Write the scan's .aiignore suggestions into `<root>/.aiignore`.
294
+ * Only appends entries that are not already there (compared after
295
+ * trimming); existing content is never removed or reordered, and no file
296
+ * is created when there is nothing to add. Returns what happened so the
297
+ * CLI can report it: `{ path, added, alreadyPresent, created }`.
298
+ */
299
+ export function applyAiignore(root, suggestions) {
300
+ const target = path.join(path.resolve(root), '.aiignore');
301
+ const wanted = [...new Set(suggestions)].sort();
302
+ if (wanted.length === 0) {
303
+ return { path: target, added: [], alreadyPresent: [], created: false };
304
+ }
305
+ let content = '';
306
+ let created = false;
307
+ try {
308
+ content = fs.readFileSync(target, 'utf8');
309
+ } catch (error) {
310
+ if (error.code !== 'ENOENT') throw new Error(`cannot read .aiignore: ${error.message}`);
311
+ created = true;
312
+ }
313
+ const present = new Set(
314
+ content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
315
+ );
316
+ const added = wanted.filter((entry) => !present.has(entry));
317
+ if (added.length === 0) {
318
+ return { path: target, added: [], alreadyPresent: wanted, created: false };
319
+ }
320
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
321
+ let next = content;
322
+ if (next.length > 0 && !next.endsWith('\n')) next += newline;
323
+ next += added.map((entry) => entry + newline).join('');
324
+ try {
325
+ fs.writeFileSync(target, next);
326
+ } catch (error) {
327
+ throw new Error(`cannot write .aiignore: ${error.message}`);
328
+ }
329
+ return { path: target, added, alreadyPresent: wanted.filter((entry) => present.has(entry)), created };
330
+ }
package/package.json CHANGED
@@ -1 +1 @@
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.1"}
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.3"}
File without changes
File without changes