dev-diagnosis-cli 0.1.7 → 0.2.0
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 +113 -1
- package/README.md +127 -17
- package/bin/dev-diagnosis.js +239 -182
- package/lib/checks.mjs +280 -0
- package/lib/context.mjs +199 -11
- package/lib/mcp.mjs +271 -0
- package/lib/report.mjs +12 -0
- package/lib/setup.mjs +249 -0
- package/lib/skill.mjs +81 -0
- package/package.json +2 -2
- /package/examples/runs/{run-1 → run-01}/results.xml +0 -0
- /package/examples/runs/{run-2 → run-02}/results.xml +0 -0
package/lib/checks.mjs
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four checks, as functions both the CLI and the MCP server call.
|
|
3
|
+
*
|
|
4
|
+
* This is the one place the check logic lives: bin/dev-diagnosis.js
|
|
5
|
+
* renders it for humans, lib/mcp.mjs serves it to agents, and neither
|
|
6
|
+
* re-implements a verdict. Each runner returns the same shape —
|
|
7
|
+
* `{ result, passed, verdict }` — and a check that could not run
|
|
8
|
+
* returns `{ result: null, passed: false, verdict, error }` instead of
|
|
9
|
+
* throwing, so the `all` report can say why it failed.
|
|
10
|
+
*/
|
|
11
|
+
import { scanDirectory, applyAiignore, formatBytes } from './context.mjs';
|
|
12
|
+
import {
|
|
13
|
+
buildContexts, findCommandCopies, fixForDirectory, lookupCommand,
|
|
14
|
+
searchToolchainBins,
|
|
15
|
+
} from './pathcheck.mjs';
|
|
16
|
+
import { runCacheCheck as runCacheCheckImpl } from './cache.mjs';
|
|
17
|
+
import { scanRuns } from './flakes.mjs';
|
|
18
|
+
import { formatMs } from './report.mjs';
|
|
19
|
+
|
|
20
|
+
export class UsageError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'UsageError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// "login", "login and daemon-like", "login, non-login and daemon-like".
|
|
28
|
+
function joinNames(names) {
|
|
29
|
+
if (names.length <= 1) return names.join('');
|
|
30
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runContextCheck(target) {
|
|
34
|
+
const result = scanDirectory(target || process.cwd());
|
|
35
|
+
const passed = result.score >= 85;
|
|
36
|
+
return { result, passed, verdict: passed ? 'context is clean' : `${result.status.toLowerCase()} (score ${result.score}/100)` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply the context check's .aiignore suggestions and record the
|
|
41
|
+
* outcome on the check result, so --json output carries it too.
|
|
42
|
+
*/
|
|
43
|
+
export function applyContextFix(check) {
|
|
44
|
+
const fix = applyAiignore(check.result.root, check.result.suggestions);
|
|
45
|
+
check.result.fix = fix;
|
|
46
|
+
return fix;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Apply a context check's .aiignore fix, then re-scan so the returned
|
|
51
|
+
* check describes the repo as it stands after the fix. The fix outcome
|
|
52
|
+
* is carried onto the re-scanned result, so --json and MCP output still
|
|
53
|
+
* say what was written. Without the re-scan, `context --fix` on a noisy
|
|
54
|
+
* repo would report the pre-fix verdict — exit 1 and FAIL — even though
|
|
55
|
+
* the fix landed and a re-run scores clean. The exit code of a fix
|
|
56
|
+
* command should say whether the repo is fixed now, the way
|
|
57
|
+
* eslint --fix does: it exits 0 when everything it could fix is fixed.
|
|
58
|
+
*/
|
|
59
|
+
export function applyContextFixAndRescan(check) {
|
|
60
|
+
const fix = applyContextFix(check);
|
|
61
|
+
const fixed = runContextCheck(check.result.root);
|
|
62
|
+
fixed.result.fix = fix;
|
|
63
|
+
return fixed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Re-scan the context check of an `all` run after --fix, and refresh
|
|
68
|
+
* the run's overall verdict and summary to match. The pre-fix report
|
|
69
|
+
* stays printed above; what changes is the verdict the run reports.
|
|
70
|
+
* The fix outcome recorded by applyContextFix is carried over, so the
|
|
71
|
+
* post-fix result still says what was written.
|
|
72
|
+
*/
|
|
73
|
+
export function rescanContext(run) {
|
|
74
|
+
const before = run.checks.context;
|
|
75
|
+
const fixed = runContextCheck(run.root);
|
|
76
|
+
if (before?.result?.fix) fixed.result.fix = before.result.fix;
|
|
77
|
+
run.checks.context = fixed;
|
|
78
|
+
run.overall = Object.values(run.checks).every((check) => check.passed);
|
|
79
|
+
run.summary = buildSummary(run.checks, run.skipped);
|
|
80
|
+
return fixed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function runPathCheck(commandName) {
|
|
84
|
+
if (!commandName) throw new UsageError('path needs a command, for example: dev-diagnosis path node');
|
|
85
|
+
const contexts = buildContexts();
|
|
86
|
+
const rows = contexts.map((ctx) => {
|
|
87
|
+
const found = lookupCommand(commandName, ctx.pathValue);
|
|
88
|
+
return { ...ctx, resolved: found.resolved, path: found.path, reason: found.reason || null };
|
|
89
|
+
});
|
|
90
|
+
const copies = findCommandCopies(commandName, process.env.PATH || '');
|
|
91
|
+
const toolchainHits = rows[0].resolved ? [] : searchToolchainBins(commandName);
|
|
92
|
+
let fix = null;
|
|
93
|
+
if (!rows[0].resolved && toolchainHits.length > 0) fix = fixForDirectory(toolchainHits[0].directory);
|
|
94
|
+
else if (!rows[0].resolved && rows.some((row) => row.resolved)) {
|
|
95
|
+
fix = 'it resolves in another shell context — check which startup file sets your PATH';
|
|
96
|
+
}
|
|
97
|
+
const passed = rows[0].resolved;
|
|
98
|
+
// The verdict must agree with the four-context table above it: "missing
|
|
99
|
+
// in the current shell" was wrong on both sides — when the command
|
|
100
|
+
// missed everywhere it sounded like it might exist elsewhere, and when
|
|
101
|
+
// it resolved in another context it never said where. Name the contexts
|
|
102
|
+
// it resolves in, or say plainly that no context has it.
|
|
103
|
+
const foundIn = rows.filter((row) => row.resolved).map((row) => row.name);
|
|
104
|
+
let verdict;
|
|
105
|
+
if (passed) verdict = `${commandName} resolves to ${rows[0].path}`;
|
|
106
|
+
else if (foundIn.length > 0) verdict = `${commandName} is missing in the current shell, but resolves in ${joinNames(foundIn)}`;
|
|
107
|
+
else verdict = `${commandName} is not found in any shell context`;
|
|
108
|
+
return {
|
|
109
|
+
result: { command: commandName, contexts: rows, copies, toolchainHits, fix },
|
|
110
|
+
passed,
|
|
111
|
+
verdict,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function runFlakesCheck(dir) {
|
|
116
|
+
if (!dir) throw new UsageError('flakes needs a runs directory, for example: dev-diagnosis flakes examples/runs');
|
|
117
|
+
const result = scanRuns(dir);
|
|
118
|
+
const unparsable = result.warnings.length;
|
|
119
|
+
const skippedRuns = result.skippedRuns ? result.skippedRuns.length : 0;
|
|
120
|
+
// A file that could not be parsed, or a run directory that produced no
|
|
121
|
+
// reports, means the verdict is based on incomplete data: "no flaky
|
|
122
|
+
// tests" on a half-read run set is not a pass, so the check fails and
|
|
123
|
+
// the verdict says what was unreadable. The warnings themselves are
|
|
124
|
+
// printed with the table.
|
|
125
|
+
const passed = result.totals.flaky === 0 && result.totals.alwaysFailing === 0 && unparsable === 0 && skippedRuns === 0;
|
|
126
|
+
const base = result.totals.flaky === 0 && result.totals.alwaysFailing === 0
|
|
127
|
+
? `no flaky tests across ${result.totals.runs} runs`
|
|
128
|
+
: `${result.totals.flaky} flaky, ${result.totals.alwaysFailing} always-failing across ${result.totals.runs} runs`;
|
|
129
|
+
const problems = [];
|
|
130
|
+
if (unparsable > 0) problems.push(`${unparsable} file${unparsable === 1 ? '' : 's'} could not be parsed`);
|
|
131
|
+
if (skippedRuns > 0) problems.push(`${skippedRuns} run director${skippedRuns === 1 ? 'y' : 'ies'} had no JUnit XML files`);
|
|
132
|
+
return {
|
|
133
|
+
result,
|
|
134
|
+
passed,
|
|
135
|
+
verdict: problems.length > 0 ? `${base}; ${problems.join('; ')}` : base,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function runCacheCheck(options) {
|
|
140
|
+
if (!options.buildCommand || options.buildCommand.length === 0) {
|
|
141
|
+
throw new UsageError('cache needs a build command after --, for example: dev-diagnosis cache -- node build.mjs');
|
|
142
|
+
}
|
|
143
|
+
const result = runCacheCheckImpl({
|
|
144
|
+
root: options.cwd || process.cwd(),
|
|
145
|
+
cacheDir: options.cacheDir,
|
|
146
|
+
command: options.buildCommand,
|
|
147
|
+
});
|
|
148
|
+
const passed = result.verdict !== 'cache costs';
|
|
149
|
+
return {
|
|
150
|
+
result,
|
|
151
|
+
passed,
|
|
152
|
+
verdict: `${result.verdict}: cold ${formatMs(result.coldMs)}, warm ${formatMs(result.warmMs)} (${result.verdictDetail})`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The raw cache result carries the build's full stdout from both runs;
|
|
157
|
+
// JSON output drops it, so a chatty build does not bloat the machine-
|
|
158
|
+
// readable output. Used by the standalone cache output and by the
|
|
159
|
+
// `all --json` builder below, so both shapes stay identical.
|
|
160
|
+
export function stripCacheStdout(result) {
|
|
161
|
+
const { coldStdout, warmStdout, ...rest } = result;
|
|
162
|
+
return rest;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One-line summary of an `all` run: how many checks passed, failed or
|
|
167
|
+
* were skipped, how much context noise was found, and what the cache
|
|
168
|
+
* saved when it ran. `contextNoiseBytes` is the listed noise — the
|
|
169
|
+
* same bytes the token estimate is based on.
|
|
170
|
+
*/
|
|
171
|
+
export function buildSummary(checks, skipped) {
|
|
172
|
+
const ran = Object.values(checks);
|
|
173
|
+
const passed = ran.filter((check) => check.passed).length;
|
|
174
|
+
const noiseBytes = checks.context && checks.context.result
|
|
175
|
+
? checks.context.result.noise.reduce((sum, item) => sum + item.bytes, 0)
|
|
176
|
+
: 0;
|
|
177
|
+
const cacheResult = checks.cache && checks.cache.result ? checks.cache.result : null;
|
|
178
|
+
return {
|
|
179
|
+
checks: { total: ran.length + skipped.length, passed, failed: ran.length - passed, skipped: skipped.length },
|
|
180
|
+
contextNoiseBytes: noiseBytes,
|
|
181
|
+
cacheSavedPct: cacheResult ? cacheResult.savedPct : null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function formatSummary(summary) {
|
|
186
|
+
const parts = [
|
|
187
|
+
`${summary.checks.total} checks — ${summary.checks.passed} passed, ${summary.checks.failed} failed, ${summary.checks.skipped} skipped`,
|
|
188
|
+
];
|
|
189
|
+
if (summary.contextNoiseBytes > 0) parts.push(`context noise ${formatBytes(summary.contextNoiseBytes)}`);
|
|
190
|
+
if (summary.cacheSavedPct != null) parts.push(`cache saved ${summary.cacheSavedPct}%`);
|
|
191
|
+
return `Summary: ${parts.join('; ')}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run every check that has what it needs, the way `all` does. The
|
|
196
|
+
* context fix is not applied here: the caller decides when, so the
|
|
197
|
+
* human report can print the diagnosis before writing .aiignore, and
|
|
198
|
+
* the JSON report can include the fix outcome. Call the returned
|
|
199
|
+
* `applyContextFix()` (memoized) when the fix should happen.
|
|
200
|
+
*/
|
|
201
|
+
export function runAll(options = {}) {
|
|
202
|
+
const root = options.root;
|
|
203
|
+
const checks = {};
|
|
204
|
+
const skipped = [];
|
|
205
|
+
|
|
206
|
+
const contextCheck = runContextCheck(root);
|
|
207
|
+
checks.context = contextCheck;
|
|
208
|
+
let contextFix = null;
|
|
209
|
+
const applyFix = () => {
|
|
210
|
+
if (!contextFix) contextFix = applyContextFix(contextCheck);
|
|
211
|
+
return contextFix;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (options.commandName) checks.path = runPathCheck(options.commandName);
|
|
215
|
+
else skipped.push('path — pass --command <name>, for example: dev-diagnosis all --command node');
|
|
216
|
+
|
|
217
|
+
if (options.flakesDir) {
|
|
218
|
+
try {
|
|
219
|
+
checks.flakes = runFlakesCheck(options.flakesDir);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
checks.flakes = { result: null, passed: false, verdict: `could not run: ${error.message}`, error: error.message };
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
skipped.push('flakes — pass --flakes <runs-dir>, for example: dev-diagnosis all --flakes examples/runs');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (options.buildCommand && options.buildCommand.length > 0) {
|
|
228
|
+
try {
|
|
229
|
+
checks.cache = runCacheCheck({ buildCommand: options.buildCommand, cacheDir: options.cacheDir, cwd: root });
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (String(error.message).includes('no cache directory found')) {
|
|
232
|
+
// Nothing to measure is not a failure: report it as skipped so
|
|
233
|
+
// `all` on a repo without a build cache does not end in FAIL.
|
|
234
|
+
skipped.push('cache — no cache directory found in this project; pass --cache-dir <dir> to measure one');
|
|
235
|
+
} else {
|
|
236
|
+
checks.cache = { result: null, passed: false, verdict: `could not run: ${error.message}`, error: error.message };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
skipped.push('cache — add -- <build-command>, for example: dev-diagnosis all -- node build.mjs');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const overall = Object.values(checks).every((check) => check.passed);
|
|
244
|
+
return {
|
|
245
|
+
root, checks, skipped, overall,
|
|
246
|
+
summary: buildSummary(checks, skipped),
|
|
247
|
+
applyContextFix: applyFix,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The `all --json` shape: each check's result (flakes limited by
|
|
253
|
+
* --top, cache without the build stdout), failed checks carrying their
|
|
254
|
+
* reason instead of null, plus the summary.
|
|
255
|
+
*/
|
|
256
|
+
export function allJson(run, top = null) {
|
|
257
|
+
return {
|
|
258
|
+
command: 'all',
|
|
259
|
+
root: run.root,
|
|
260
|
+
checks: Object.fromEntries(Object.entries(run.checks).map(([key, check]) => {
|
|
261
|
+
let result = check.result;
|
|
262
|
+
// Same --top split as the standalone flakes output: the embedded
|
|
263
|
+
// tests array is limited, totals stay whole-scan.
|
|
264
|
+
if (key === 'flakes' && result && top != null) {
|
|
265
|
+
result = { ...result, tests: result.tests.slice(0, top) };
|
|
266
|
+
}
|
|
267
|
+
// Same stdout strip as standalone `cache --json`: the raw result
|
|
268
|
+
// carries the build's full stdout from both runs.
|
|
269
|
+
if (key === 'cache' && result) result = stripCacheStdout(result);
|
|
270
|
+
// A check that could not run has no result: keep its reason in
|
|
271
|
+
// the JSON instead of emitting null, or a script consumer sees
|
|
272
|
+
// overall FAIL with no way to tell why.
|
|
273
|
+
if (result == null) result = { error: check.error || check.verdict };
|
|
274
|
+
return [key, result];
|
|
275
|
+
})),
|
|
276
|
+
skipped: run.skipped,
|
|
277
|
+
overall: run.overall ? 'PASS' : 'FAIL',
|
|
278
|
+
summary: run.summary,
|
|
279
|
+
};
|
|
280
|
+
}
|
package/lib/context.mjs
CHANGED
|
@@ -56,6 +56,17 @@ export function formatBytes(bytes) {
|
|
|
56
56
|
return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Rough token cost of the listed noise: about 4 bytes per token, the
|
|
61
|
+
* rule of thumb most tokenizers land near. It is an estimate, not a
|
|
62
|
+
* count — the label in the output says so. Returns null when there is
|
|
63
|
+
* no listed noise, so a clean repo does not claim a saving of zero.
|
|
64
|
+
*/
|
|
65
|
+
export function estimateTokens(bytes) {
|
|
66
|
+
if (!bytes || bytes <= 0) return null;
|
|
67
|
+
return Math.round(bytes / 4);
|
|
68
|
+
}
|
|
69
|
+
|
|
59
70
|
function noiseCategory(reason) {
|
|
60
71
|
if (reason === 'lockfile') return 'lockfile';
|
|
61
72
|
if (reason === 'local log file') return 'log';
|
|
@@ -187,6 +198,143 @@ function readAiignore(root) {
|
|
|
187
198
|
}
|
|
188
199
|
}
|
|
189
200
|
|
|
201
|
+
function escapeRegExpChar(ch) {
|
|
202
|
+
return ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Convert one .gitignore glob into a RegExp (anchored at both ends).
|
|
207
|
+
* `*` and `?` never cross a `/`; `**` does — `**/` at the start matches
|
|
208
|
+
* zero or more directories, a trailing `/**` matches everything under a
|
|
209
|
+
* directory, and a bare `**` matches anything. `[...]` is a character
|
|
210
|
+
* class, with git's `!` negation spelled `^` for the RegExp.
|
|
211
|
+
*/
|
|
212
|
+
function gitignorePatternToRegExp(pattern) {
|
|
213
|
+
let out = '';
|
|
214
|
+
let i = 0;
|
|
215
|
+
while (i < pattern.length) {
|
|
216
|
+
const ch = pattern[i];
|
|
217
|
+
if (ch === '*') {
|
|
218
|
+
if (pattern[i + 1] === '*') {
|
|
219
|
+
if (pattern[i + 2] === '/') {
|
|
220
|
+
out += '(?:.*/)?';
|
|
221
|
+
i += 3;
|
|
222
|
+
} else {
|
|
223
|
+
out += '.*';
|
|
224
|
+
i += 2;
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
out += '[^/]*';
|
|
228
|
+
i += 1;
|
|
229
|
+
}
|
|
230
|
+
} else if (ch === '?') {
|
|
231
|
+
out += '[^/]';
|
|
232
|
+
i += 1;
|
|
233
|
+
} else if (ch === '[') {
|
|
234
|
+
let j = i + 1;
|
|
235
|
+
let negated = false;
|
|
236
|
+
if (pattern[j] === '!') {
|
|
237
|
+
negated = true;
|
|
238
|
+
j += 1;
|
|
239
|
+
}
|
|
240
|
+
if (pattern[j] === ']') j += 1; // a leading ] is literal
|
|
241
|
+
while (j < pattern.length && pattern[j] !== ']') j += 1;
|
|
242
|
+
if (j >= pattern.length) {
|
|
243
|
+
out += '\\[';
|
|
244
|
+
i += 1;
|
|
245
|
+
} else {
|
|
246
|
+
const inner = pattern.slice(i + 1 + (negated ? 1 : 0), j).replace(/\\/g, '\\\\');
|
|
247
|
+
out += `[${negated ? '^' : ''}${inner}]`;
|
|
248
|
+
i = j + 1;
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
out += escapeRegExpChar(ch);
|
|
252
|
+
i += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return new RegExp(`^${out}$`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Parse .gitignore content into rules. Supports the common subset of
|
|
260
|
+
* git's pattern language: blank lines and `#` comments are skipped, a
|
|
261
|
+
* leading `!` negates (last matching pattern wins), a trailing `/`
|
|
262
|
+
* matches directories only, a leading `/` — or a `/` anywhere else in
|
|
263
|
+
* the pattern — anchors it to the .gitignore's own directory, otherwise
|
|
264
|
+
* the pattern matches a basename at any depth. `\\#` and `\\!` at the
|
|
265
|
+
* start of a line are literal. Trailing spaces are stripped, as git
|
|
266
|
+
* does. `.git/info/exclude` and the global gitignore are not read —
|
|
267
|
+
* only `.gitignore` files in the scanned tree.
|
|
268
|
+
*/
|
|
269
|
+
export function parseGitignore(text) {
|
|
270
|
+
const rules = [];
|
|
271
|
+
for (let raw of String(text).split(/\r?\n/)) {
|
|
272
|
+
raw = raw.replace(/ +$/, '');
|
|
273
|
+
if (!raw) continue;
|
|
274
|
+
if (raw.startsWith('\\#') || raw.startsWith('\\!')) raw = raw.slice(1);
|
|
275
|
+
else if (raw.startsWith('#')) continue;
|
|
276
|
+
let negate = false;
|
|
277
|
+
if (raw.startsWith('!')) {
|
|
278
|
+
negate = true;
|
|
279
|
+
raw = raw.slice(1);
|
|
280
|
+
if (!raw) continue;
|
|
281
|
+
}
|
|
282
|
+
let dirOnly = false;
|
|
283
|
+
if (raw.endsWith('/')) {
|
|
284
|
+
dirOnly = true;
|
|
285
|
+
raw = raw.slice(0, -1);
|
|
286
|
+
if (!raw) continue;
|
|
287
|
+
}
|
|
288
|
+
let anchored = false;
|
|
289
|
+
if (raw.startsWith('/')) {
|
|
290
|
+
anchored = true;
|
|
291
|
+
raw = raw.slice(1);
|
|
292
|
+
if (!raw) continue;
|
|
293
|
+
} else if (raw.includes('/')) {
|
|
294
|
+
anchored = true;
|
|
295
|
+
}
|
|
296
|
+
rules.push({ negate, dirOnly, anchored, regex: gitignorePatternToRegExp(raw) });
|
|
297
|
+
}
|
|
298
|
+
return rules;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Is this repo-relative path ignored by the .gitignore stack? Each level
|
|
303
|
+
* is `{ base, rules }`, where `base` is the .gitignore's directory
|
|
304
|
+
* relative to the repo root (`''` for the root). Deeper levels are
|
|
305
|
+
* checked after shallower ones, and the last matching pattern decides —
|
|
306
|
+
* so a `!` negation re-includes a file, but only when no parent
|
|
307
|
+
* directory was pruned first (the walk never descends into an ignored
|
|
308
|
+
* directory, which is also git's rule: a file cannot be re-included
|
|
309
|
+
* under an excluded parent).
|
|
310
|
+
*/
|
|
311
|
+
export function isGitignored(relativePath, isDir, stack) {
|
|
312
|
+
const normalized = String(relativePath).split(path.sep).join('/').split('\\').join('/');
|
|
313
|
+
let ignored = false;
|
|
314
|
+
for (const { base, rules } of stack) {
|
|
315
|
+
let rel;
|
|
316
|
+
if (base === '') rel = normalized;
|
|
317
|
+
else if (normalized === base) continue; // a rule cannot ignore its own directory
|
|
318
|
+
else if (normalized.startsWith(`${base}/`)) rel = normalized.slice(base.length + 1);
|
|
319
|
+
else continue;
|
|
320
|
+
for (const rule of rules) {
|
|
321
|
+
if (rule.dirOnly && !isDir) continue;
|
|
322
|
+
const candidate = rule.anchored ? rel : rel.split('/').pop();
|
|
323
|
+
if (rule.regex.test(candidate)) ignored = !rule.negate;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return ignored;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readGitignore(dir, base) {
|
|
330
|
+
try {
|
|
331
|
+
const rules = parseGitignore(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'));
|
|
332
|
+
return rules.length > 0 ? { base, rules } : null;
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
190
338
|
function walk(root, dir, out, state) {
|
|
191
339
|
let entries;
|
|
192
340
|
try {
|
|
@@ -205,11 +353,16 @@ function walk(root, dir, out, state) {
|
|
|
205
353
|
if (entry.isDirectory()) {
|
|
206
354
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
207
355
|
if (state.ignore.length > 0 && matchesAiignore(rel, state.ignore)) continue;
|
|
356
|
+
if (isGitignored(rel, true, state.gitignore)) continue;
|
|
357
|
+
const level = readGitignore(full, rel);
|
|
358
|
+
if (level) state.gitignore.push(level);
|
|
208
359
|
walk(root, full, out, state);
|
|
360
|
+
if (level) state.gitignore.pop();
|
|
209
361
|
continue;
|
|
210
362
|
}
|
|
211
363
|
if (!entry.isFile()) continue;
|
|
212
364
|
if (state.ignore.length > 0 && matchesAiignore(rel, state.ignore)) continue;
|
|
365
|
+
if (isGitignored(rel, false, state.gitignore)) continue;
|
|
213
366
|
if (state.files >= state.maxFiles) {
|
|
214
367
|
state.truncated = true;
|
|
215
368
|
continue;
|
|
@@ -233,7 +386,10 @@ function walk(root, dir, out, state) {
|
|
|
233
386
|
* under-counting. Files listed in the repo's `.aiignore` are excluded
|
|
234
387
|
* from the scan entirely — they are what the agent will not be handed,
|
|
235
388
|
* so they are not noise any more — which is also what makes
|
|
236
|
-
* `context --fix` followed by `context` come back clean.
|
|
389
|
+
* `context --fix` followed by `context` come back clean. Files and
|
|
390
|
+
* directories listed in `.gitignore` (at the root or nested) are
|
|
391
|
+
* excluded the same way: they are already invisible to most agents, so
|
|
392
|
+
* flagging them would be noise about noise.
|
|
237
393
|
*/
|
|
238
394
|
export function scanDirectory(root, options = {}) {
|
|
239
395
|
const absolute = path.resolve(root);
|
|
@@ -246,40 +402,67 @@ export function scanDirectory(root, options = {}) {
|
|
|
246
402
|
if (!stat.isDirectory()) throw new Error(`not a directory: ${root}`);
|
|
247
403
|
const maxFiles = options.maxFiles == null ? 10000 : options.maxFiles;
|
|
248
404
|
const ignore = readAiignore(absolute);
|
|
249
|
-
const
|
|
405
|
+
const gitignore = [];
|
|
406
|
+
const rootLevel = readGitignore(absolute, '');
|
|
407
|
+
if (rootLevel) gitignore.push(rootLevel);
|
|
408
|
+
const state = { files: 0, bytes: 0, maxFiles, truncated: false, ignore, gitignore };
|
|
250
409
|
const files = [];
|
|
251
410
|
walk(absolute, absolute, files, state);
|
|
252
411
|
|
|
253
|
-
const
|
|
412
|
+
const allNoise = [];
|
|
254
413
|
// One row per build-output directory, not one row per file inside it:
|
|
255
414
|
// a dist/ with hundreds of files would otherwise flood the noise list
|
|
256
415
|
// (and the score uses the summed bytes either way).
|
|
257
416
|
const dirNoise = new Map();
|
|
258
417
|
const review = [];
|
|
259
|
-
const
|
|
418
|
+
const reviewSuggestions = new Set();
|
|
260
419
|
for (const file of files) {
|
|
261
420
|
const result = classifyPath(file.relativePath, file.size);
|
|
262
421
|
if (result.noise) {
|
|
263
|
-
const item = {
|
|
422
|
+
const item = {
|
|
423
|
+
...result.noise,
|
|
424
|
+
bytes: file.size,
|
|
425
|
+
path: file.relativePath.split(path.sep).join('/'),
|
|
426
|
+
suggestion: result.suggestion || null,
|
|
427
|
+
};
|
|
264
428
|
if (item.label.endsWith('/')) {
|
|
265
429
|
const existing = dirNoise.get(item.label);
|
|
266
430
|
if (existing) existing.bytes += file.size;
|
|
267
431
|
else {
|
|
268
|
-
const aggregated = { label: item.label, reason: item.reason, bytes: file.size, path: item.label };
|
|
432
|
+
const aggregated = { label: item.label, reason: item.reason, bytes: file.size, path: item.label, suggestion: result.suggestion || null };
|
|
269
433
|
dirNoise.set(item.label, aggregated);
|
|
270
|
-
|
|
434
|
+
allNoise.push(aggregated);
|
|
271
435
|
}
|
|
272
436
|
} else {
|
|
273
|
-
|
|
437
|
+
allNoise.push(item);
|
|
274
438
|
}
|
|
275
|
-
if (result.suggestion) suggestions.add(result.suggestion);
|
|
276
439
|
} else if (result.review) {
|
|
277
440
|
review.push({ ...result.review, bytes: file.size, path: file.relativePath.split(path.sep).join('/') });
|
|
278
|
-
if (result.suggestion)
|
|
441
|
+
if (result.suggestion) reviewSuggestions.add(result.suggestion);
|
|
279
442
|
}
|
|
280
443
|
}
|
|
281
444
|
|
|
282
|
-
const scored = scoreNoise(
|
|
445
|
+
const scored = scoreNoise(allNoise, state.bytes);
|
|
446
|
+
// Only noise in a category that actually lost points is reported. A
|
|
447
|
+
// 95 B lockfile in a 1.7 MB repo loses 0 points, so listing it as
|
|
448
|
+
// "noise to keep away" — with a fix suggestion — would contradict the
|
|
449
|
+
// PASS verdict on a 100/100 scan. The score itself still uses every
|
|
450
|
+
// noise byte; the filter only decides what is worth fixing.
|
|
451
|
+
const costly = new Set(scored.breakdown.filter((item) => item.lost > 0).map((item) => item.id));
|
|
452
|
+
const noise = allNoise
|
|
453
|
+
.filter((item) => costly.has(noiseCategory(item.reason)))
|
|
454
|
+
.map(({ suggestion, ...item }) => item);
|
|
455
|
+
const suggestions = new Set();
|
|
456
|
+
for (const item of allNoise) {
|
|
457
|
+
if (item.suggestion && costly.has(noiseCategory(item.reason))) suggestions.add(item.suggestion);
|
|
458
|
+
}
|
|
459
|
+
// Review suggestions are advisory only and are never part of
|
|
460
|
+
// `suggestions`, so --fix never writes them: a review item costs no
|
|
461
|
+
// score points, so writing its entry would fix nothing — and a
|
|
462
|
+
// basename pattern like `secret.js` would hide every same-named file
|
|
463
|
+
// in the repo, including harmless test fixtures. They are reported
|
|
464
|
+
// separately so the user can add them to .aiignore by hand.
|
|
465
|
+
const noiseBytes = noise.reduce((sum, item) => sum + item.bytes, 0);
|
|
283
466
|
return {
|
|
284
467
|
root: absolute,
|
|
285
468
|
files: state.files,
|
|
@@ -288,10 +471,15 @@ export function scanDirectory(root, options = {}) {
|
|
|
288
471
|
noise,
|
|
289
472
|
review,
|
|
290
473
|
suggestions: [...suggestions].sort(),
|
|
474
|
+
// Advisory only: --fix never writes these (see above).
|
|
475
|
+
reviewSuggestions: [...reviewSuggestions].sort(),
|
|
291
476
|
score: scored.score,
|
|
292
477
|
lost: scored.lost,
|
|
293
478
|
breakdown: scored.breakdown,
|
|
294
479
|
status: statusFor(scored.score),
|
|
480
|
+
// What the listed noise costs in tokens, at ~4 bytes per token
|
|
481
|
+
// (estimate, not a count); null when there is no listed noise.
|
|
482
|
+
estimatedTokens: estimateTokens(noiseBytes),
|
|
295
483
|
};
|
|
296
484
|
}
|
|
297
485
|
|