dev-diagnosis-cli 0.1.8 → 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 +89 -0
- package/README.md +120 -14
- package/bin/dev-diagnosis.js +234 -188
- package/lib/checks.mjs +280 -0
- package/lib/context.mjs +49 -9
- 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';
|
|
@@ -398,36 +409,60 @@ export function scanDirectory(root, options = {}) {
|
|
|
398
409
|
const files = [];
|
|
399
410
|
walk(absolute, absolute, files, state);
|
|
400
411
|
|
|
401
|
-
const
|
|
412
|
+
const allNoise = [];
|
|
402
413
|
// One row per build-output directory, not one row per file inside it:
|
|
403
414
|
// a dist/ with hundreds of files would otherwise flood the noise list
|
|
404
415
|
// (and the score uses the summed bytes either way).
|
|
405
416
|
const dirNoise = new Map();
|
|
406
417
|
const review = [];
|
|
407
|
-
const
|
|
418
|
+
const reviewSuggestions = new Set();
|
|
408
419
|
for (const file of files) {
|
|
409
420
|
const result = classifyPath(file.relativePath, file.size);
|
|
410
421
|
if (result.noise) {
|
|
411
|
-
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
|
+
};
|
|
412
428
|
if (item.label.endsWith('/')) {
|
|
413
429
|
const existing = dirNoise.get(item.label);
|
|
414
430
|
if (existing) existing.bytes += file.size;
|
|
415
431
|
else {
|
|
416
|
-
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 };
|
|
417
433
|
dirNoise.set(item.label, aggregated);
|
|
418
|
-
|
|
434
|
+
allNoise.push(aggregated);
|
|
419
435
|
}
|
|
420
436
|
} else {
|
|
421
|
-
|
|
437
|
+
allNoise.push(item);
|
|
422
438
|
}
|
|
423
|
-
if (result.suggestion) suggestions.add(result.suggestion);
|
|
424
439
|
} else if (result.review) {
|
|
425
440
|
review.push({ ...result.review, bytes: file.size, path: file.relativePath.split(path.sep).join('/') });
|
|
426
|
-
if (result.suggestion)
|
|
441
|
+
if (result.suggestion) reviewSuggestions.add(result.suggestion);
|
|
427
442
|
}
|
|
428
443
|
}
|
|
429
444
|
|
|
430
|
-
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);
|
|
431
466
|
return {
|
|
432
467
|
root: absolute,
|
|
433
468
|
files: state.files,
|
|
@@ -436,10 +471,15 @@ export function scanDirectory(root, options = {}) {
|
|
|
436
471
|
noise,
|
|
437
472
|
review,
|
|
438
473
|
suggestions: [...suggestions].sort(),
|
|
474
|
+
// Advisory only: --fix never writes these (see above).
|
|
475
|
+
reviewSuggestions: [...reviewSuggestions].sort(),
|
|
439
476
|
score: scored.score,
|
|
440
477
|
lost: scored.lost,
|
|
441
478
|
breakdown: scored.breakdown,
|
|
442
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),
|
|
443
483
|
};
|
|
444
484
|
}
|
|
445
485
|
|
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server mode: `dev-diagnosis --mcp`.
|
|
3
|
+
*
|
|
4
|
+
* A stdio MCP server in the standard library only — the same code the
|
|
5
|
+
* CLI runs, served over JSON-RPC 2.0, one message per line. Agents
|
|
6
|
+
* connect with the usual config:
|
|
7
|
+
*
|
|
8
|
+
* { "mcpServers": { "dev-diagnosis": { "command": "dev-diagnosis", "args": ["--mcp"] } } }
|
|
9
|
+
*
|
|
10
|
+
* Protocol: `initialize` (2024-11-05), `notifications/initialized`,
|
|
11
|
+
* `tools/list`, `tools/call`, `ping`. Each tool returns the same JSON
|
|
12
|
+
* the CLI's --json output produces, so an agent that already knows the
|
|
13
|
+
* CLI output knows the tool output too.
|
|
14
|
+
*
|
|
15
|
+
* Sandbox: `--sandbox <dir>` (alias `--root`) restricts every path the
|
|
16
|
+
* tools touch to that directory. A path outside it is rejected with a
|
|
17
|
+
* plain error before anything runs. The `path` tool takes only a
|
|
18
|
+
* command name, so it has nothing to sandbox and is always allowed.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import {
|
|
23
|
+
UsageError, allJson, applyContextFixAndRescan, rescanContext, runAll,
|
|
24
|
+
runCacheCheck, runContextCheck, runFlakesCheck, runPathCheck,
|
|
25
|
+
stripCacheStdout,
|
|
26
|
+
} from './checks.mjs';
|
|
27
|
+
|
|
28
|
+
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
29
|
+
|
|
30
|
+
const TOOLS = [
|
|
31
|
+
{
|
|
32
|
+
name: 'diagnose_context',
|
|
33
|
+
description: 'Find the files an AI coding agent should not read in a repo: build output, lockfiles, logs and oversized files. Returns a 0-100 score, the noise found, the token estimate, and the .aiignore suggestions that would fix it.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
path: { type: 'string', description: 'Repo to scan (default: the sandbox root, or the current directory without one).' },
|
|
38
|
+
fix: { type: 'boolean', description: 'Write the .aiignore suggestions into <path>/.aiignore.' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'diagnose_path',
|
|
44
|
+
description: 'Diagnose why a CLI command is missing: one row per shell context (current, login, non-login, daemon-like), shadowed copies, toolchain installs, and a copy-paste fix.',
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
command: { type: 'string', description: 'Command to look up, for example "node".' },
|
|
49
|
+
},
|
|
50
|
+
required: ['command'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'diagnose_cache',
|
|
55
|
+
description: 'Time one build command cold (cache moved aside) and warm (cache present) and say whether the cache pays. The cache directory is restored afterwards.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
path: { type: 'string', description: 'Project directory to run the build in (default: the sandbox root, or the current directory without one).' },
|
|
60
|
+
command: { type: 'array', items: { type: 'string' }, description: 'Build command as an argv array, for example ["node", "build.mjs"].' },
|
|
61
|
+
cacheDir: { type: 'string', description: 'Cache directory to measure (default: auto-detected under the project).' },
|
|
62
|
+
},
|
|
63
|
+
required: ['command'],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'diagnose_flakes',
|
|
68
|
+
description: 'Rank flaky tests from JUnit XML across CI runs, including passed-on-retry. Each subdirectory of the runs directory is one run; each *.xml file directly under it is a run of its own.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
path: { type: 'string', description: 'Runs directory to scan.' },
|
|
73
|
+
top: { type: 'integer', description: 'Show only the N highest-ranked flaky tests.' },
|
|
74
|
+
},
|
|
75
|
+
required: ['path'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'diagnose_all',
|
|
80
|
+
description: 'Run every check that has what it needs and print one unified report: context always runs, path runs with a command name, flakes with a runs directory, cache with a build command. Checks without their input are listed as skipped.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
path: { type: 'string', description: 'Repo to diagnose (default: the sandbox root, or the current directory without one).' },
|
|
85
|
+
command: { type: 'string', description: 'Command name for the path check, for example "node".' },
|
|
86
|
+
flakesDir: { type: 'string', description: 'JUnit runs directory for the flakes check.' },
|
|
87
|
+
buildCommand: { type: 'array', items: { type: 'string' }, description: 'Build command as an argv array for the cache check.' },
|
|
88
|
+
cacheDir: { type: 'string', description: 'Cache directory to measure (default: auto-detected).' },
|
|
89
|
+
fix: { type: 'boolean', description: 'Write the context check .aiignore suggestions.' },
|
|
90
|
+
top: { type: 'integer', description: 'Show only the N highest-ranked flaky tests.' },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** Resolve a tool argument to an absolute path inside the sandbox, or throw. */
|
|
97
|
+
export function resolveInSandbox(sandbox, value, label) {
|
|
98
|
+
if (value == null || value === '') return sandbox || process.cwd();
|
|
99
|
+
const resolved = path.resolve(sandbox || process.cwd(), value);
|
|
100
|
+
if (!sandbox) return resolved;
|
|
101
|
+
const root = path.resolve(sandbox);
|
|
102
|
+
if (resolved === root) return resolved;
|
|
103
|
+
const rel = path.relative(root, resolved);
|
|
104
|
+
if (rel.startsWith(`..${path.sep}`) || rel === '..' || path.isAbsolute(rel)) {
|
|
105
|
+
throw new Error(`${label} is outside the sandbox (${sandbox}): ${value}`);
|
|
106
|
+
}
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function textResult(payload) {
|
|
111
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function errorResult(message) {
|
|
115
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function callTool(name, args, sandbox) {
|
|
119
|
+
const input = args && typeof args === 'object' ? args : {};
|
|
120
|
+
switch (name) {
|
|
121
|
+
case 'diagnose_context': {
|
|
122
|
+
const target = resolveInSandbox(sandbox, input.path, 'path');
|
|
123
|
+
const check = runContextCheck(target);
|
|
124
|
+
// With fix, the tool answers with the post-fix scan — an agent
|
|
125
|
+
// reading the result needs to know whether the repo is fixed
|
|
126
|
+
// now, not what it scored before the fix.
|
|
127
|
+
if (input.fix) return textResult(applyContextFixAndRescan(check).result);
|
|
128
|
+
return textResult(check.result);
|
|
129
|
+
}
|
|
130
|
+
case 'diagnose_path': {
|
|
131
|
+
if (!input.command) throw new UsageError('diagnose_path needs a command');
|
|
132
|
+
return textResult(runPathCheck(input.command).result);
|
|
133
|
+
}
|
|
134
|
+
case 'diagnose_cache': {
|
|
135
|
+
if (!Array.isArray(input.command) || input.command.length === 0) {
|
|
136
|
+
throw new UsageError('diagnose_cache needs a build command array');
|
|
137
|
+
}
|
|
138
|
+
const root = resolveInSandbox(sandbox, input.path, 'path');
|
|
139
|
+
const cacheDir = input.cacheDir
|
|
140
|
+
? resolveInSandbox(sandbox, path.resolve(root, input.cacheDir), 'cacheDir')
|
|
141
|
+
: undefined;
|
|
142
|
+
const check = runCacheCheck({ buildCommand: input.command, cacheDir, cwd: root });
|
|
143
|
+
return textResult(stripCacheStdout(check.result));
|
|
144
|
+
}
|
|
145
|
+
case 'diagnose_flakes': {
|
|
146
|
+
if (!input.path) throw new UsageError('diagnose_flakes needs a runs directory');
|
|
147
|
+
const dir = resolveInSandbox(sandbox, input.path, 'path');
|
|
148
|
+
const check = runFlakesCheck(dir);
|
|
149
|
+
const out = input.top != null ? { ...check.result, tests: check.result.tests.slice(0, input.top) } : check.result;
|
|
150
|
+
return textResult(out);
|
|
151
|
+
}
|
|
152
|
+
case 'diagnose_all': {
|
|
153
|
+
const root = resolveInSandbox(sandbox, input.path, 'path');
|
|
154
|
+
// flakesDir is relative to the sandbox (or cwd), like the CLI's
|
|
155
|
+
// --flakes is relative to the process cwd — not to the repo root.
|
|
156
|
+
const flakesDir = input.flakesDir
|
|
157
|
+
? resolveInSandbox(sandbox, input.flakesDir, 'flakesDir')
|
|
158
|
+
: null;
|
|
159
|
+
// cacheDir is relative to the repo root, like the CLI's
|
|
160
|
+
// --cache-dir.
|
|
161
|
+
const cacheDir = input.cacheDir
|
|
162
|
+
? resolveInSandbox(sandbox, path.resolve(root, input.cacheDir), 'cacheDir')
|
|
163
|
+
: null;
|
|
164
|
+
const run = runAll({
|
|
165
|
+
root,
|
|
166
|
+
commandName: input.command || null,
|
|
167
|
+
flakesDir,
|
|
168
|
+
buildCommand: Array.isArray(input.buildCommand) && input.buildCommand.length > 0 ? input.buildCommand : null,
|
|
169
|
+
cacheDir,
|
|
170
|
+
});
|
|
171
|
+
if (input.fix) {
|
|
172
|
+
run.applyContextFix();
|
|
173
|
+
rescanContext(run);
|
|
174
|
+
}
|
|
175
|
+
return textResult(allJson(run, input.top != null ? input.top : null));
|
|
176
|
+
}
|
|
177
|
+
default:
|
|
178
|
+
throw new Error(`unknown tool: ${name}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function handleMessage(message, sandbox, version) {
|
|
183
|
+
if (message == null || typeof message !== 'object' || Array.isArray(message)) {
|
|
184
|
+
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } };
|
|
185
|
+
}
|
|
186
|
+
const { method, id, params } = message;
|
|
187
|
+
// A notification (no id) never gets a response.
|
|
188
|
+
const isNotification = id === undefined;
|
|
189
|
+
if (typeof method !== 'string') {
|
|
190
|
+
return isNotification ? null : { jsonrpc: '2.0', id: id ?? null, error: { code: -32600, message: 'Invalid Request' } };
|
|
191
|
+
}
|
|
192
|
+
if (method.startsWith('notifications/')) return null;
|
|
193
|
+
if (isNotification) return null;
|
|
194
|
+
|
|
195
|
+
const respond = (result) => ({ jsonrpc: '2.0', id, result });
|
|
196
|
+
const fail = (code, text) => ({ jsonrpc: '2.0', id, error: { code, message: text } });
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
switch (method) {
|
|
200
|
+
case 'initialize':
|
|
201
|
+
return respond({
|
|
202
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
203
|
+
capabilities: { tools: {} },
|
|
204
|
+
serverInfo: { name: 'dev-diagnosis', version },
|
|
205
|
+
});
|
|
206
|
+
case 'ping':
|
|
207
|
+
return respond({});
|
|
208
|
+
case 'tools/list':
|
|
209
|
+
return respond({ tools: TOOLS });
|
|
210
|
+
case 'tools/call': {
|
|
211
|
+
if (!params || typeof params.name !== 'string') return fail(-32602, 'tools/call needs a tool name');
|
|
212
|
+
try {
|
|
213
|
+
return respond(callTool(params.name, params.arguments, sandbox));
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// A tool failure is a tool result with isError, not a
|
|
216
|
+
// JSON-RPC error: the call itself worked, the check did not.
|
|
217
|
+
return respond(errorResult(error.message));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return fail(-32601, `Method not found: ${method}`);
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return fail(-32603, error.message);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Serve MCP over stdio until stdin closes. One JSON message per line;
|
|
230
|
+
* a malformed line gets a -32700 parse error and the server keeps going.
|
|
231
|
+
* Resolves when stdin ends; never throws for a bad message.
|
|
232
|
+
*/
|
|
233
|
+
export function runMcpServer(options = {}) {
|
|
234
|
+
const sandbox = options.sandbox ? path.resolve(options.sandbox) : null;
|
|
235
|
+
const version = options.version || '0.2.0';
|
|
236
|
+
if (sandbox) {
|
|
237
|
+
let stat;
|
|
238
|
+
try { stat = fs.statSync(sandbox); } catch { stat = null; }
|
|
239
|
+
if (!stat || !stat.isDirectory()) throw new Error(`sandbox is not a directory: ${sandbox}`);
|
|
240
|
+
}
|
|
241
|
+
let buffer = '';
|
|
242
|
+
const write = (payload) => process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
243
|
+
process.stdin.setEncoding('utf8');
|
|
244
|
+
process.stdin.on('data', (chunk) => {
|
|
245
|
+
buffer += chunk;
|
|
246
|
+
let newline = buffer.indexOf('\n');
|
|
247
|
+
while (newline !== -1) {
|
|
248
|
+
const line = buffer.slice(0, newline).replace(/\r$/, '');
|
|
249
|
+
buffer = buffer.slice(newline + 1);
|
|
250
|
+
if (line.trim() === '') {
|
|
251
|
+
newline = buffer.indexOf('\n');
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let message;
|
|
255
|
+
try {
|
|
256
|
+
message = JSON.parse(line);
|
|
257
|
+
} catch {
|
|
258
|
+
write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
259
|
+
newline = buffer.indexOf('\n');
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const response = handleMessage(message, sandbox, version);
|
|
263
|
+
if (response) write(response);
|
|
264
|
+
newline = buffer.indexOf('\n');
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return new Promise((resolve) => {
|
|
268
|
+
process.stdin.on('end', resolve);
|
|
269
|
+
process.stdin.resume();
|
|
270
|
+
});
|
|
271
|
+
}
|
package/lib/report.mjs
CHANGED
|
@@ -16,3 +16,15 @@ export function formatMs(ms) {
|
|
|
16
16
|
if (ms < 1000) return `${Math.round(ms)} ms`;
|
|
17
17
|
return `${(ms / 1000).toFixed(2)} s`;
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/** Short form for a token estimate: 420, 4.2k, 1.2M. */
|
|
21
|
+
export function formatTokens(tokens) {
|
|
22
|
+
if (tokens == null) return '—';
|
|
23
|
+
if (tokens < 1000) return String(tokens);
|
|
24
|
+
if (tokens < 1000000) {
|
|
25
|
+
const value = tokens / 1000;
|
|
26
|
+
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}k`;
|
|
27
|
+
}
|
|
28
|
+
const value = tokens / 1000000;
|
|
29
|
+
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}M`;
|
|
30
|
+
}
|