@bridge_gpt/mcp-server 0.2.14 → 0.2.16
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/README.md +2 -2
- package/build/agents.generated.js +19 -1
- package/build/commands.generated.js +1 -0
- package/build/conductor-bin.js +1 -1
- package/build/index.js +836 -33
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +820 -0
- package/build/sfcc/permissions.js +13 -1
- package/build/sfcc/reads-custom-object-def.js +56 -39
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +5 -1
- package/build/start-tickets-prereqs.js +43 -0
- package/build/version.generated.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* regression-check — the deterministic "blast radius" core for the portable
|
|
3
|
+
* regression-reviewer. Two modes:
|
|
4
|
+
*
|
|
5
|
+
* npx -y @bridge_gpt/mcp-server regression-check [--mode lightweight|heavy]
|
|
6
|
+
* [--diff <range>] [--symbols a,b,c] [--json]
|
|
7
|
+
*
|
|
8
|
+
* `lightweight` (BAPI-460, default) takes a proposed change (a git diff
|
|
9
|
+
* range, defaulting to the working tree against HEAD, or an explicit
|
|
10
|
+
* --symbols list) and reports, per changed symbol: its definition location,
|
|
11
|
+
* its REAL structural call-sites (via ast-grep), and its broader textual
|
|
12
|
+
* mention set (via ripgrep — tests/mocks/strings/config). The gap between the
|
|
13
|
+
* two is the signal: a symbol with many text mentions but few real call-sites
|
|
14
|
+
* likely has untouched callers, mocks, or config that the change should
|
|
15
|
+
* account for.
|
|
16
|
+
*
|
|
17
|
+
* `heavy` (BAPI-461) takes no diff — it scans the whole repo and surfaces
|
|
18
|
+
* where FUTURE change is dangerous: high fan-in (real call-sites via
|
|
19
|
+
* ast-grep) + high temporal coupling (files that historically change
|
|
20
|
+
* together) + high complexity. Scope is seeded from churn/coupling hotspots
|
|
21
|
+
* and capped (MAX_HEAVY_HOTSPOTS / MAX_HEAVY_SYMBOLS) so runtime and LLM
|
|
22
|
+
* context stay bounded on large repos.
|
|
23
|
+
*
|
|
24
|
+
* Read-only. Makes no code changes and no network calls. Missing tools
|
|
25
|
+
* (ast-grep, ripgrep, lizard) degrade gracefully — the affected section is
|
|
26
|
+
* flagged and the command still exits 0; only usage errors and an unreadable
|
|
27
|
+
* diff (lightweight mode) exit non-zero. Mirrors the `doctor` /
|
|
28
|
+
* `agent-capabilities` CLI shape: strict arg parsing -> non-throwing
|
|
29
|
+
* collection -> format -> exit code.
|
|
30
|
+
*
|
|
31
|
+
* This module also hosts two SHARED deterministic-tooling functions —
|
|
32
|
+
* `analyzeComplexityWithLizard` and `analyzeTemporalCoupling` — used by both
|
|
33
|
+
* `heavy` mode below and BAPI-459's refactor-reviewer, so that logic has a
|
|
34
|
+
* single implementation. They are not invoked by the lightweight pipeline.
|
|
35
|
+
*/
|
|
36
|
+
import { createDefaultStartTicketsDeps } from "./start-tickets.js";
|
|
37
|
+
import { isCommandOnPath, resolveFirstCommandOnPath } from "./start-tickets-prereqs.js";
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Usage / argument parsing
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
const VALID_MODES = ["lightweight", "heavy"];
|
|
42
|
+
/** Symbol set cap per run — keeps downstream LLM context bounded. */
|
|
43
|
+
export const MAX_SYMBOLS_PER_RUN = 40;
|
|
44
|
+
/** ast-grep ships two equivalent binary names; either satisfies the prereq. */
|
|
45
|
+
export const AST_GREP_CANDIDATES = ["ast-grep", "sg"];
|
|
46
|
+
export function getRegressionCheckUsage() {
|
|
47
|
+
return [
|
|
48
|
+
"Usage:",
|
|
49
|
+
" npx -y @bridge_gpt/mcp-server regression-check [--mode lightweight|heavy] [--diff <range>] [--symbols a,b,c] [--json]",
|
|
50
|
+
"",
|
|
51
|
+
"Deterministic blast-radius analysis for a proposed code change: extracts the",
|
|
52
|
+
"symbols touched by a git diff (or an explicit --symbols list), then uses",
|
|
53
|
+
"ast-grep to find their REAL structural call-sites and ripgrep for the wider",
|
|
54
|
+
"set of textual mentions (tests/mocks/strings/config). Read-only — makes no",
|
|
55
|
+
"code changes and no network calls.",
|
|
56
|
+
"",
|
|
57
|
+
"Flags:",
|
|
58
|
+
" --mode lightweight|heavy Analysis depth (default: lightweight). lightweight",
|
|
59
|
+
" traces a proposed diff's blast radius; heavy takes",
|
|
60
|
+
" no diff and scans the whole repo for fragile,",
|
|
61
|
+
" high-blast-radius locations (fan-in + temporal",
|
|
62
|
+
" coupling + complexity), seeded from churn/coupling",
|
|
63
|
+
" hotspots and bounded by a top-N cap.",
|
|
64
|
+
" --diff <range> A git diff range/ref to analyze, lightweight mode only (default: HEAD,",
|
|
65
|
+
" i.e. the working tree against HEAD)",
|
|
66
|
+
" --symbols a,b,c Explicit comma-separated symbol names, bypassing",
|
|
67
|
+
" diff parsing (searches every supported language)",
|
|
68
|
+
" --json Emit machine-readable JSON instead of a human summary",
|
|
69
|
+
" -h, --help Show this help",
|
|
70
|
+
"",
|
|
71
|
+
"Missing tools (ast-grep, ripgrep) degrade gracefully: the affected section is",
|
|
72
|
+
"flagged [DEGRADED] and the command still exits 0. Run",
|
|
73
|
+
"`npx -y @bridge_gpt/mcp-server doctor` to check tool availability.",
|
|
74
|
+
"",
|
|
75
|
+
"Exit code: 0 on a completed run (degraded or not); non-zero only for a usage",
|
|
76
|
+
"error or an unreadable diff.",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
export function parseRegressionCheckArgs(argv) {
|
|
80
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
81
|
+
return { status: "help", usage: getRegressionCheckUsage() };
|
|
82
|
+
}
|
|
83
|
+
let mode = "lightweight";
|
|
84
|
+
let diffRange;
|
|
85
|
+
let symbols;
|
|
86
|
+
let json = false;
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const arg = argv[i];
|
|
89
|
+
if (arg === "--json") {
|
|
90
|
+
json = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (arg === "--mode" || arg.startsWith("--mode=")) {
|
|
94
|
+
let value;
|
|
95
|
+
if (arg.startsWith("--mode=")) {
|
|
96
|
+
value = arg.slice("--mode=".length);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
if (i + 1 >= argv.length) {
|
|
100
|
+
return { status: "error", message: "--mode requires a value (lightweight or heavy)." };
|
|
101
|
+
}
|
|
102
|
+
value = argv[++i];
|
|
103
|
+
}
|
|
104
|
+
if (!VALID_MODES.includes(value)) {
|
|
105
|
+
return {
|
|
106
|
+
status: "error",
|
|
107
|
+
message: `Invalid --mode value: '${value}' (allowed: ${VALID_MODES.join(", ")}).`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
mode = value;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg === "--diff" || arg.startsWith("--diff=")) {
|
|
114
|
+
let value;
|
|
115
|
+
if (arg.startsWith("--diff=")) {
|
|
116
|
+
value = arg.slice("--diff=".length);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (i + 1 >= argv.length) {
|
|
120
|
+
return { status: "error", message: "--diff requires a value (a git diff range/ref)." };
|
|
121
|
+
}
|
|
122
|
+
value = argv[++i];
|
|
123
|
+
}
|
|
124
|
+
diffRange = value;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (arg === "--symbols" || arg.startsWith("--symbols=")) {
|
|
128
|
+
let value;
|
|
129
|
+
if (arg.startsWith("--symbols=")) {
|
|
130
|
+
value = arg.slice("--symbols=".length);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
if (i + 1 >= argv.length) {
|
|
134
|
+
return { status: "error", message: "--symbols requires a value (comma-separated symbol names)." };
|
|
135
|
+
}
|
|
136
|
+
value = argv[++i];
|
|
137
|
+
}
|
|
138
|
+
symbols = value
|
|
139
|
+
.split(",")
|
|
140
|
+
.map((s) => s.trim())
|
|
141
|
+
.filter((s) => s.length > 0);
|
|
142
|
+
if (symbols.length === 0) {
|
|
143
|
+
return { status: "error", message: "--symbols requires at least one non-empty symbol name." };
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (arg.startsWith("-")) {
|
|
148
|
+
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
status: "error",
|
|
152
|
+
message: `Unexpected positional argument: '${arg}'. regression-check takes only flags.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { status: "ok", options: { mode, diffRange, symbols, json } };
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Secret redaction
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
const SECRET_PATTERNS = [
|
|
161
|
+
// key/token/secret/password/bearer = "<value>" style assignments.
|
|
162
|
+
/\b[A-Za-z0-9_-]*(?:api[_-]?key|token|secret|password|bearer)[A-Za-z0-9_-]*\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi,
|
|
163
|
+
// Authorization: Bearer <value> headers.
|
|
164
|
+
/\bBearer\s+[A-Za-z0-9._-]{10,}/g,
|
|
165
|
+
// Common provider key prefixes (OpenAI/Anthropic-style sk-... tokens).
|
|
166
|
+
/\bsk-[A-Za-z0-9_-]{16,}/g,
|
|
167
|
+
];
|
|
168
|
+
/** Redact embedded credentials/tokens/secrets with a high-visibility placeholder. */
|
|
169
|
+
export function redactSecrets(text) {
|
|
170
|
+
let out = text;
|
|
171
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
172
|
+
out = out.replace(pattern, "[REDACTED_TOKEN]");
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
async function getDiffText(deps, diffRange) {
|
|
177
|
+
const args = diffRange ? ["diff", diffRange] : ["diff", "HEAD"];
|
|
178
|
+
const result = await deps.runCommand("git", args, { cwd: deps.cwd });
|
|
179
|
+
if (result.exitCode !== 0) {
|
|
180
|
+
return { ok: false, error: redactSecrets(result.stderr.trim() || `git diff exited ${result.exitCode}`) };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, diff: redactSecrets(result.stdout) };
|
|
183
|
+
}
|
|
184
|
+
// NOTE: the leading `^\s*` tolerates indentation so that *nested* definitions —
|
|
185
|
+
// an indented Python method (` def handle(self):`) or a nested TS
|
|
186
|
+
// function/const — are still extracted. `content` (see below) keeps the added
|
|
187
|
+
// line's original leading whitespace, so without `\s*` every indented method
|
|
188
|
+
// would silently yield zero symbols.
|
|
189
|
+
const PY_DEF_RE = /^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)/;
|
|
190
|
+
const TS_DEF_RE = /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\s*\*?\s+([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*)|(?:const|let)\s+([A-Za-z_$][\w$]*)\s*[:=])/;
|
|
191
|
+
function languageForFile(file) {
|
|
192
|
+
if (file.endsWith(".py"))
|
|
193
|
+
return "python";
|
|
194
|
+
if (file.endsWith(".ts") || file.endsWith(".tsx"))
|
|
195
|
+
return "typescript";
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Parse a unified diff (as produced by `git diff`) and extract every changed
|
|
200
|
+
* top-level def/class (Python) or function/class/const declaration (TS/TSX)
|
|
201
|
+
* found on an added (`+`) line, with its new-file line number. Removed-only
|
|
202
|
+
* symbols and unsupported languages are skipped. Never throws — malformed diff
|
|
203
|
+
* text simply yields fewer (or zero) symbols.
|
|
204
|
+
*/
|
|
205
|
+
export function extractChangedSymbolsFromDiff(diffText) {
|
|
206
|
+
const symbols = [];
|
|
207
|
+
let currentFile = null;
|
|
208
|
+
let newLineNo = 0;
|
|
209
|
+
for (const rawLine of diffText.split("\n")) {
|
|
210
|
+
if (rawLine.startsWith("+++ ")) {
|
|
211
|
+
const path = rawLine.slice(4).trim();
|
|
212
|
+
currentFile = path === "/dev/null" ? null : path.replace(/^b\//, "");
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (rawLine.startsWith("--- ") || rawLine.startsWith("diff --git") || rawLine.startsWith("index ")) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const hunkMatch = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
219
|
+
if (hunkMatch) {
|
|
220
|
+
newLineNo = parseInt(hunkMatch[1], 10);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (rawLine.startsWith("\\"))
|
|
224
|
+
continue; // ""
|
|
225
|
+
if (!currentFile)
|
|
226
|
+
continue;
|
|
227
|
+
if (rawLine.startsWith("-"))
|
|
228
|
+
continue; // removed line: no new-line number, not a "changed" addition
|
|
229
|
+
const isAdded = rawLine.startsWith("+");
|
|
230
|
+
const content = isAdded ? rawLine.slice(1) : rawLine.startsWith(" ") ? rawLine.slice(1) : rawLine;
|
|
231
|
+
if (isAdded) {
|
|
232
|
+
const language = languageForFile(currentFile);
|
|
233
|
+
if (language) {
|
|
234
|
+
let name = null;
|
|
235
|
+
if (language === "python") {
|
|
236
|
+
const m = content.match(PY_DEF_RE);
|
|
237
|
+
if (m)
|
|
238
|
+
name = m[1];
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const m = content.match(TS_DEF_RE);
|
|
242
|
+
if (m)
|
|
243
|
+
name = m[1] || m[2] || m[3] || null;
|
|
244
|
+
}
|
|
245
|
+
if (name) {
|
|
246
|
+
symbols.push({ symbol: name, file: currentFile, language, line: newLineNo });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
newLineNo += 1;
|
|
251
|
+
}
|
|
252
|
+
return symbols;
|
|
253
|
+
}
|
|
254
|
+
/** De-duplicate by file+symbol, then cap to {@link MAX_SYMBOLS_PER_RUN}. */
|
|
255
|
+
export function capChangedSymbols(symbols, max = MAX_SYMBOLS_PER_RUN) {
|
|
256
|
+
const deduped = [];
|
|
257
|
+
const seen = new Set();
|
|
258
|
+
for (const s of symbols) {
|
|
259
|
+
const key = `${s.file ?? ""}::${s.symbol}`;
|
|
260
|
+
if (seen.has(key))
|
|
261
|
+
continue;
|
|
262
|
+
seen.add(key);
|
|
263
|
+
deduped.push(s);
|
|
264
|
+
}
|
|
265
|
+
if (deduped.length <= max)
|
|
266
|
+
return { symbols: deduped, truncated: false };
|
|
267
|
+
return { symbols: deduped.slice(0, max), truncated: true };
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// ast-grep real call-sites
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
const AST_GREP_LANG = {
|
|
273
|
+
python: "python",
|
|
274
|
+
typescript: "typescript",
|
|
275
|
+
};
|
|
276
|
+
async function findCallSites(deps, astGrepBinary, symbol, language) {
|
|
277
|
+
const languages = language ? [language] : Object.keys(AST_GREP_LANG);
|
|
278
|
+
const byFile = {};
|
|
279
|
+
let count = 0;
|
|
280
|
+
for (const lang of languages) {
|
|
281
|
+
const pattern = `${symbol}($$$ARGS)`;
|
|
282
|
+
const result = await deps.runCommand(astGrepBinary, ["run", "--pattern", pattern, "--lang", AST_GREP_LANG[lang], "--json", "."], { cwd: deps.cwd });
|
|
283
|
+
// ast-grep exits 1 for "ran fine, zero matches" — only >=2 is a real failure.
|
|
284
|
+
if (result.exitCode >= 2) {
|
|
285
|
+
return { ok: false, error: redactSecrets(result.stderr.trim() || `ast-grep exited ${result.exitCode}`) };
|
|
286
|
+
}
|
|
287
|
+
let matches;
|
|
288
|
+
try {
|
|
289
|
+
matches = JSON.parse(result.stdout.trim() || "[]");
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return { ok: false, error: "ast-grep returned non-JSON output" };
|
|
293
|
+
}
|
|
294
|
+
if (!Array.isArray(matches)) {
|
|
295
|
+
return { ok: false, error: "ast-grep JSON output was not an array" };
|
|
296
|
+
}
|
|
297
|
+
for (const m of matches) {
|
|
298
|
+
const file = m && typeof m === "object" && typeof m.file === "string"
|
|
299
|
+
? m.file
|
|
300
|
+
: "(unknown)";
|
|
301
|
+
byFile[file] = (byFile[file] ?? 0) + 1;
|
|
302
|
+
count += 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { ok: true, count, byFile };
|
|
306
|
+
}
|
|
307
|
+
async function findBroadMentions(deps, symbol) {
|
|
308
|
+
const result = await deps.runCommand("rg", ["-l", "-w", "--", symbol, "."], { cwd: deps.cwd });
|
|
309
|
+
// ripgrep exits 1 for "ran fine, zero matches" — only >=2 is a real failure.
|
|
310
|
+
if (result.exitCode >= 2) {
|
|
311
|
+
return { ok: false, error: redactSecrets(result.stderr.trim() || `ripgrep exited ${result.exitCode}`) };
|
|
312
|
+
}
|
|
313
|
+
const files = result.stdout
|
|
314
|
+
.split("\n")
|
|
315
|
+
.map((l) => l.trim())
|
|
316
|
+
.filter((l) => l.length > 0);
|
|
317
|
+
return { ok: true, files };
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Lightweight pipeline orchestration
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
function explicitSymbolsToChangedSymbols(symbolNames) {
|
|
323
|
+
return symbolNames.map((symbol) => ({ symbol, file: null, language: null, line: null }));
|
|
324
|
+
}
|
|
325
|
+
export async function runLightweightRegressionCheck(deps, options) {
|
|
326
|
+
const degradedFlags = [];
|
|
327
|
+
const toolsUsed = new Set(["git"]);
|
|
328
|
+
let changedSymbols;
|
|
329
|
+
if (options.symbols && options.symbols.length > 0) {
|
|
330
|
+
changedSymbols = explicitSymbolsToChangedSymbols(options.symbols);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
const diffResult = await getDiffText(deps, options.diffRange);
|
|
334
|
+
if (!diffResult.ok) {
|
|
335
|
+
return { ok: false, error: `Unable to read git diff: ${diffResult.error}` };
|
|
336
|
+
}
|
|
337
|
+
changedSymbols = extractChangedSymbolsFromDiff(diffResult.diff);
|
|
338
|
+
}
|
|
339
|
+
const { symbols: capped, truncated } = capChangedSymbols(changedSymbols);
|
|
340
|
+
const astGrepBinary = await resolveFirstCommandOnPath(deps, AST_GREP_CANDIDATES);
|
|
341
|
+
if (!astGrepBinary) {
|
|
342
|
+
degradedFlags.push("ast-grep (or sg) not found on PATH — call-site analysis skipped for all symbols.");
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
toolsUsed.add("ast-grep");
|
|
346
|
+
}
|
|
347
|
+
const ripgrepFound = await isCommandOnPath(deps, "rg");
|
|
348
|
+
if (!ripgrepFound) {
|
|
349
|
+
degradedFlags.push("ripgrep (rg) not found on PATH — broad-mention analysis skipped for all symbols.");
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
toolsUsed.add("ripgrep");
|
|
353
|
+
}
|
|
354
|
+
const findings = [];
|
|
355
|
+
for (const sym of capped) {
|
|
356
|
+
const finding = {
|
|
357
|
+
symbol: sym.symbol,
|
|
358
|
+
file: sym.file,
|
|
359
|
+
language: sym.language,
|
|
360
|
+
definitionLocation: sym.file && sym.line ? { file: sym.file, line: sym.line } : null,
|
|
361
|
+
callSites: null,
|
|
362
|
+
broadMentions: null,
|
|
363
|
+
};
|
|
364
|
+
if (astGrepBinary) {
|
|
365
|
+
const result = await findCallSites(deps, astGrepBinary, sym.symbol, sym.language);
|
|
366
|
+
if (result.ok) {
|
|
367
|
+
finding.callSites = { count: result.count, byFile: result.byFile };
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
degradedFlags.push(`ast-grep call-site search failed for '${sym.symbol}': ${result.error}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (ripgrepFound) {
|
|
374
|
+
const result = await findBroadMentions(deps, sym.symbol);
|
|
375
|
+
if (result.ok) {
|
|
376
|
+
finding.broadMentions = result.files;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
degradedFlags.push(`ripgrep mention search failed for '${sym.symbol}': ${result.error}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
findings.push(finding);
|
|
383
|
+
}
|
|
384
|
+
const report = {
|
|
385
|
+
mode: "lightweight",
|
|
386
|
+
summary: {
|
|
387
|
+
symbolsAnalyzed: findings.map((f) => (f.file ? `${f.file}:${f.symbol}` : f.symbol)),
|
|
388
|
+
truncated,
|
|
389
|
+
toolsUsed: Array.from(toolsUsed),
|
|
390
|
+
degradedFlags,
|
|
391
|
+
},
|
|
392
|
+
findings,
|
|
393
|
+
};
|
|
394
|
+
return { ok: true, report };
|
|
395
|
+
}
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Output formatting
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
export function formatRegressionCheckJson(report) {
|
|
400
|
+
return JSON.stringify({
|
|
401
|
+
mode: report.mode,
|
|
402
|
+
summary: {
|
|
403
|
+
symbols_analyzed: report.summary.symbolsAnalyzed,
|
|
404
|
+
truncated: report.summary.truncated,
|
|
405
|
+
tools_used: report.summary.toolsUsed,
|
|
406
|
+
degraded_flags: report.summary.degradedFlags,
|
|
407
|
+
},
|
|
408
|
+
findings: report.findings.map((f) => ({
|
|
409
|
+
symbol: f.symbol,
|
|
410
|
+
file: f.file,
|
|
411
|
+
language: f.language,
|
|
412
|
+
definition_location: f.definitionLocation,
|
|
413
|
+
call_sites: f.callSites ? { count: f.callSites.count, by_file: f.callSites.byFile } : null,
|
|
414
|
+
broad_mentions: f.broadMentions,
|
|
415
|
+
})),
|
|
416
|
+
}, null, 2);
|
|
417
|
+
}
|
|
418
|
+
export function formatRegressionCheckReport(report) {
|
|
419
|
+
const lines = [
|
|
420
|
+
`regression-check report (${report.mode} mode)`,
|
|
421
|
+
"",
|
|
422
|
+
`Symbols analyzed: ${report.summary.symbolsAnalyzed.length}${report.summary.truncated ? " [TRUNCATED]" : ""}`,
|
|
423
|
+
`Tools used: ${report.summary.toolsUsed.length > 0 ? report.summary.toolsUsed.join(", ") : "none"}`,
|
|
424
|
+
"",
|
|
425
|
+
];
|
|
426
|
+
if (report.findings.length === 0) {
|
|
427
|
+
lines.push("No changed symbols found.");
|
|
428
|
+
}
|
|
429
|
+
for (const f of report.findings) {
|
|
430
|
+
const loc = f.definitionLocation ? `${f.definitionLocation.file}:${f.definitionLocation.line}` : "(unknown location)";
|
|
431
|
+
lines.push(`${f.file ?? "(explicit)"}:${f.symbol} (def @ ${loc})`);
|
|
432
|
+
if (f.callSites) {
|
|
433
|
+
const byFile = Object.entries(f.callSites.byFile)
|
|
434
|
+
.map(([file, n]) => `${file}: ${n}`)
|
|
435
|
+
.join(", ");
|
|
436
|
+
lines.push(` real call-sites: ${f.callSites.count}${byFile ? ` (${byFile})` : ""}`);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
lines.push(" real call-sites: [DEGRADED] ast-grep unavailable or failed");
|
|
440
|
+
}
|
|
441
|
+
if (f.broadMentions) {
|
|
442
|
+
const list = f.broadMentions.length > 0 ? ` (${f.broadMentions.join(", ")})` : "";
|
|
443
|
+
lines.push(` broad mentions: ${f.broadMentions.length} file(s)${list}`);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
lines.push(" broad mentions: [DEGRADED] ripgrep unavailable or failed");
|
|
447
|
+
}
|
|
448
|
+
lines.push("");
|
|
449
|
+
}
|
|
450
|
+
if (report.summary.degradedFlags.length > 0) {
|
|
451
|
+
lines.push(`[DEGRADED] ${report.summary.degradedFlags.length} issue(s):`);
|
|
452
|
+
for (const flag of report.summary.degradedFlags)
|
|
453
|
+
lines.push(` - ${flag}`);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
lines.push("No degradation — all tools ran successfully.");
|
|
457
|
+
}
|
|
458
|
+
return lines.join("\n");
|
|
459
|
+
}
|
|
460
|
+
/** Parse one `lizard --csv` line, honoring quoted fields (no embedded-quote escaping). */
|
|
461
|
+
function parseLizardCsvLine(line) {
|
|
462
|
+
const fields = [];
|
|
463
|
+
let cur = "";
|
|
464
|
+
let inQuotes = false;
|
|
465
|
+
for (const ch of line) {
|
|
466
|
+
if (ch === '"') {
|
|
467
|
+
inQuotes = !inQuotes;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (ch === "," && !inQuotes) {
|
|
471
|
+
fields.push(cur);
|
|
472
|
+
cur = "";
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
cur += ch;
|
|
476
|
+
}
|
|
477
|
+
fields.push(cur);
|
|
478
|
+
return fields;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Parse `lizard --csv` output. Columns (lizard's stable CSV schema): nloc, ccn,
|
|
482
|
+
* token_count, param_count, length, location, file_name, function_name,
|
|
483
|
+
* long_name, start_line, end_line.
|
|
484
|
+
*/
|
|
485
|
+
export function parseLizardCsv(csv) {
|
|
486
|
+
const findings = [];
|
|
487
|
+
for (const rawLine of csv.split("\n")) {
|
|
488
|
+
const line = rawLine.trim();
|
|
489
|
+
if (!line)
|
|
490
|
+
continue;
|
|
491
|
+
const fields = parseLizardCsvLine(line);
|
|
492
|
+
if (fields.length < 11)
|
|
493
|
+
continue;
|
|
494
|
+
const [nloc, ccn, , , , , file, functionName, , startLine, endLine] = fields;
|
|
495
|
+
const ccnNum = Number(ccn);
|
|
496
|
+
const nlocNum = Number(nloc);
|
|
497
|
+
const startLineNum = Number(startLine);
|
|
498
|
+
const endLineNum = Number(endLine);
|
|
499
|
+
if (![ccnNum, nlocNum, startLineNum, endLineNum].every(Number.isFinite))
|
|
500
|
+
continue;
|
|
501
|
+
findings.push({
|
|
502
|
+
functionName,
|
|
503
|
+
file,
|
|
504
|
+
ccn: ccnNum,
|
|
505
|
+
nloc: nlocNum,
|
|
506
|
+
startLine: startLineNum,
|
|
507
|
+
endLine: endLineNum,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return findings;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Per-function cyclomatic-complexity analysis via `lizard` (the single
|
|
514
|
+
* external dependency, covering Python and TypeScript). Degrades gracefully
|
|
515
|
+
* (never throws) when lizard is missing or the run fails.
|
|
516
|
+
*/
|
|
517
|
+
export async function analyzeComplexityWithLizard(files, deps) {
|
|
518
|
+
if (files.length === 0)
|
|
519
|
+
return { ok: true, findings: [] };
|
|
520
|
+
const found = await isCommandOnPath(deps, "lizard");
|
|
521
|
+
if (!found)
|
|
522
|
+
return { ok: false, error: "lizard not found on PATH" };
|
|
523
|
+
const result = await deps.runCommand("lizard", ["--csv", ...files], { cwd: deps.cwd });
|
|
524
|
+
if (result.exitCode !== 0 && result.stdout.trim().length === 0) {
|
|
525
|
+
return { ok: false, error: redactSecrets(result.stderr.trim() || `lizard exited ${result.exitCode}`) };
|
|
526
|
+
}
|
|
527
|
+
return { ok: true, findings: parseLizardCsv(result.stdout) };
|
|
528
|
+
}
|
|
529
|
+
const TEMPORAL_COUPLING_SOURCE_EXT = /\.(py|ts|tsx)$/;
|
|
530
|
+
const MIN_CO_OCCURRENCES = 4;
|
|
531
|
+
const MIN_FILE_FREQUENCY = 5;
|
|
532
|
+
const MIN_COUPLING_DEGREE = 0.5;
|
|
533
|
+
const MAX_FILES_PER_COMMIT = 15;
|
|
534
|
+
const MAX_PAIRS_RETURNED = 12;
|
|
535
|
+
/**
|
|
536
|
+
* Pure computation half of the temporal-coupling script (verified ~25-line
|
|
537
|
+
* git-log script from the refactor-reviewer agent, reimplemented in
|
|
538
|
+
* TypeScript so this module never shells out to python). Mega-commits
|
|
539
|
+
* (>{@link MAX_FILES_PER_COMMIT} files) and test files are excluded as noise.
|
|
540
|
+
* When `scopeFiles` is non-empty, only commits touching at least one scoped
|
|
541
|
+
* file are considered (so callers can scope to e.g. an epic's directories);
|
|
542
|
+
* all files in a qualifying commit still participate in pairing, so the
|
|
543
|
+
* result surfaces what tends to change alongside the scoped files.
|
|
544
|
+
*/
|
|
545
|
+
export function computeTemporalCoupling(gitLogOutput, scopeFiles = []) {
|
|
546
|
+
const scopeSet = scopeFiles.length > 0 ? new Set(scopeFiles) : null;
|
|
547
|
+
const commits = [];
|
|
548
|
+
let current = [];
|
|
549
|
+
for (const line of gitLogOutput.split("\n")) {
|
|
550
|
+
if (line.startsWith("@")) {
|
|
551
|
+
if (current.length)
|
|
552
|
+
commits.push(current);
|
|
553
|
+
current = [];
|
|
554
|
+
}
|
|
555
|
+
else if (line.trim() && TEMPORAL_COUPLING_SOURCE_EXT.test(line.trim())) {
|
|
556
|
+
current.push(line.trim());
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (current.length)
|
|
560
|
+
commits.push(current);
|
|
561
|
+
const fileFreq = new Map();
|
|
562
|
+
const pairFreq = new Map();
|
|
563
|
+
for (const files of commits) {
|
|
564
|
+
const uniq = Array.from(new Set(files)).filter((f) => !f.toLowerCase().includes("test"));
|
|
565
|
+
if (uniq.length === 0 || uniq.length > MAX_FILES_PER_COMMIT)
|
|
566
|
+
continue;
|
|
567
|
+
if (scopeSet && !uniq.some((f) => scopeSet.has(f)))
|
|
568
|
+
continue;
|
|
569
|
+
for (const f of uniq)
|
|
570
|
+
fileFreq.set(f, (fileFreq.get(f) ?? 0) + 1);
|
|
571
|
+
const sorted = uniq.slice().sort();
|
|
572
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
573
|
+
for (let j = i + 1; j < sorted.length; j++) {
|
|
574
|
+
const key = `${sorted[i]}${sorted[j]}`;
|
|
575
|
+
pairFreq.set(key, (pairFreq.get(key) ?? 0) + 1);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const rows = [];
|
|
580
|
+
for (const [key, n] of pairFreq) {
|
|
581
|
+
if (n < MIN_CO_OCCURRENCES)
|
|
582
|
+
continue;
|
|
583
|
+
const [a, b] = key.split("");
|
|
584
|
+
const fa = fileFreq.get(a) ?? 0;
|
|
585
|
+
const fb = fileFreq.get(b) ?? 0;
|
|
586
|
+
const deg = n / Math.min(fa, fb);
|
|
587
|
+
if (deg >= MIN_COUPLING_DEGREE && fa >= MIN_FILE_FREQUENCY && fb >= MIN_FILE_FREQUENCY) {
|
|
588
|
+
rows.push({ fileA: a, fileB: b, coOccurrences: n, couplingDegree: deg });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
rows.sort((x, y) => y.couplingDegree - x.couplingDegree);
|
|
592
|
+
return rows.slice(0, MAX_PAIRS_RETURNED);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Temporal-coupling analysis via `git log` (no JVM, no code-maat/Gitarch
|
|
596
|
+
* dependency — git and the pure-TS computation above are the only
|
|
597
|
+
* requirements). `files` optionally scopes the commit set (see
|
|
598
|
+
* {@link computeTemporalCoupling}); pass `[]` for a whole-repo scan.
|
|
599
|
+
*/
|
|
600
|
+
export async function analyzeTemporalCoupling(files, deps, options = {}) {
|
|
601
|
+
const commitLimit = options.commitLimit ?? 800;
|
|
602
|
+
const result = await deps.runCommand("git", ["log", "--no-merges", "-n", String(commitLimit), "--name-only", "--pretty=format:@%H"], { cwd: deps.cwd });
|
|
603
|
+
if (result.exitCode !== 0) {
|
|
604
|
+
return { ok: false, error: redactSecrets(result.stderr.trim() || `git log exited ${result.exitCode}`) };
|
|
605
|
+
}
|
|
606
|
+
return { ok: true, pairs: computeTemporalCoupling(result.stdout, files) };
|
|
607
|
+
}
|
|
608
|
+
/** Top-N churn-hotspot files seeded for heavy mode (merged with coupling hotspots below). */
|
|
609
|
+
export const HEAVY_CHURN_HOTSPOT_LIMIT = 30;
|
|
610
|
+
/** Documented cap on the candidate-file set passed to heavy mode's complexity/fan-in analysis. */
|
|
611
|
+
export const MAX_HEAVY_HOTSPOTS = 50;
|
|
612
|
+
/** Documented cap on the candidate symbols sent through whole-repo ast-grep fan-in counting. */
|
|
613
|
+
export const MAX_HEAVY_SYMBOLS = MAX_SYMBOLS_PER_RUN;
|
|
614
|
+
const CHURN_LOG_LIMIT = 500;
|
|
615
|
+
/**
|
|
616
|
+
* Find the highest-churn source files via raw `git log --name-only` output —
|
|
617
|
+
* the hotspot-seeding half of heavy mode's scope bounding. Uses the same
|
|
618
|
+
* array-based subprocess boundary (no shell pipes) as the rest of this
|
|
619
|
+
* module. Fail-open: a failed `git log` returns `ok: false` rather than
|
|
620
|
+
* throwing, so callers can degrade the signal instead of crashing.
|
|
621
|
+
*/
|
|
622
|
+
export async function analyzeChurnHotspots(deps, limit) {
|
|
623
|
+
const result = await deps.runCommand("git", ["log", "--no-merges", "-n", String(CHURN_LOG_LIMIT), "--name-only", "--pretty=format:"], { cwd: deps.cwd });
|
|
624
|
+
if (result.exitCode !== 0) {
|
|
625
|
+
return { ok: false, error: redactSecrets(result.stderr.trim() || `git log exited ${result.exitCode}`) };
|
|
626
|
+
}
|
|
627
|
+
const freq = new Map();
|
|
628
|
+
for (const rawLine of result.stdout.split("\n")) {
|
|
629
|
+
const line = rawLine.trim();
|
|
630
|
+
if (!line || !TEMPORAL_COUPLING_SOURCE_EXT.test(line))
|
|
631
|
+
continue;
|
|
632
|
+
freq.set(line, (freq.get(line) ?? 0) + 1);
|
|
633
|
+
}
|
|
634
|
+
const sorted = Array.from(freq.entries()).sort((a, b) => b[1] - a[1]);
|
|
635
|
+
return { ok: true, files: sorted.slice(0, limit).map(([file]) => file) };
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* `git log`/`git diff` always report paths relative to the repo root,
|
|
639
|
+
* regardless of the invoking cwd. Heavy mode threads those paths straight
|
|
640
|
+
* into `lizard`/`ast-grep` subprocess argv (unlike the lightweight pipeline,
|
|
641
|
+
* which only pattern-matches), so it must resolve and scan from the repo
|
|
642
|
+
* root rather than trusting `deps.cwd` — otherwise running from a
|
|
643
|
+
* subdirectory silently resolves every candidate path to a non-existent
|
|
644
|
+
* location and findings come back empty. Fails open to the original `deps`
|
|
645
|
+
* (and thus the prior, cwd-relative behavior) if resolution fails.
|
|
646
|
+
*/
|
|
647
|
+
async function resolveRepoRootDeps(deps) {
|
|
648
|
+
const result = await deps.runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: deps.cwd });
|
|
649
|
+
const root = result.exitCode === 0 ? result.stdout.trim() : "";
|
|
650
|
+
return root ? { ...deps, cwd: root } : deps;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Whole-repo fragility / blast-radius scan (decision D-4). Seeds candidate
|
|
654
|
+
* files from churn + temporal-coupling hotspots (deduped, capped at
|
|
655
|
+
* {@link MAX_HEAVY_HOTSPOTS}), discovers candidate symbols within that set via
|
|
656
|
+
* the SHARED `lizard` complexity tooling (capped at {@link MAX_HEAVY_SYMBOLS}),
|
|
657
|
+
* then ranks each by fan-in (ast-grep real call-sites) + coupling partners +
|
|
658
|
+
* complexity. Takes no diff. Never throws and never hard-fails — a missing
|
|
659
|
+
* tool degrades the affected signal and ranking proceeds on what remains.
|
|
660
|
+
*/
|
|
661
|
+
export async function runHeavyMode(deps) {
|
|
662
|
+
const repoDeps = await resolveRepoRootDeps(deps);
|
|
663
|
+
const degradedFlags = [];
|
|
664
|
+
const toolsUsed = new Set(["git"]);
|
|
665
|
+
const churnResult = await analyzeChurnHotspots(repoDeps, HEAVY_CHURN_HOTSPOT_LIMIT);
|
|
666
|
+
const churnFiles = churnResult.ok ? churnResult.files : [];
|
|
667
|
+
if (!churnResult.ok) {
|
|
668
|
+
degradedFlags.push(`git churn analysis failed: ${churnResult.error}`);
|
|
669
|
+
}
|
|
670
|
+
const couplingResult = await analyzeTemporalCoupling([], repoDeps);
|
|
671
|
+
const couplingPartners = new Map();
|
|
672
|
+
const couplingFiles = [];
|
|
673
|
+
if (couplingResult.ok) {
|
|
674
|
+
for (const pair of couplingResult.pairs) {
|
|
675
|
+
couplingFiles.push(pair.fileA, pair.fileB);
|
|
676
|
+
couplingPartners.set(pair.fileA, (couplingPartners.get(pair.fileA) ?? 0) + 1);
|
|
677
|
+
couplingPartners.set(pair.fileB, (couplingPartners.get(pair.fileB) ?? 0) + 1);
|
|
678
|
+
}
|
|
679
|
+
toolsUsed.add("git-log-temporal-coupling");
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
degradedFlags.push(`temporal coupling analysis failed: ${couplingResult.error}`);
|
|
683
|
+
}
|
|
684
|
+
const candidateFilesAll = Array.from(new Set([...churnFiles, ...couplingFiles]));
|
|
685
|
+
const fileCapApplied = candidateFilesAll.length > MAX_HEAVY_HOTSPOTS;
|
|
686
|
+
const candidateFiles = fileCapApplied ? candidateFilesAll.slice(0, MAX_HEAVY_HOTSPOTS) : candidateFilesAll;
|
|
687
|
+
const complexityResult = await analyzeComplexityWithLizard(candidateFiles, repoDeps);
|
|
688
|
+
let candidateSymbols;
|
|
689
|
+
if (complexityResult.ok) {
|
|
690
|
+
if (candidateFiles.length > 0)
|
|
691
|
+
toolsUsed.add("lizard");
|
|
692
|
+
candidateSymbols = complexityResult.findings.map((f) => ({
|
|
693
|
+
file: f.file,
|
|
694
|
+
line: f.startLine,
|
|
695
|
+
symbol: f.functionName,
|
|
696
|
+
complexityScore: f.ccn,
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
degradedFlags.push(`lizard complexity analysis failed: ${complexityResult.error}`);
|
|
701
|
+
degradedFlags.push("ast-grep fan-in skipped for these hotspots — no symbol-discovery fallback exists without lizard");
|
|
702
|
+
candidateSymbols = candidateFiles.map((file) => ({ file, line: 1, symbol: null, complexityScore: null }));
|
|
703
|
+
}
|
|
704
|
+
const symbolCapApplied = candidateSymbols.length > MAX_HEAVY_SYMBOLS;
|
|
705
|
+
if (symbolCapApplied) {
|
|
706
|
+
candidateSymbols = candidateSymbols
|
|
707
|
+
.slice()
|
|
708
|
+
.sort((a, b) => (b.complexityScore ?? 0) - (a.complexityScore ?? 0))
|
|
709
|
+
.slice(0, MAX_HEAVY_SYMBOLS);
|
|
710
|
+
}
|
|
711
|
+
const astGrepBinary = await resolveFirstCommandOnPath(repoDeps, AST_GREP_CANDIDATES);
|
|
712
|
+
if (!astGrepBinary && candidateSymbols.some((s) => s.symbol)) {
|
|
713
|
+
degradedFlags.push("ast-grep (or sg) not found on PATH — fan-in analysis skipped for all symbols.");
|
|
714
|
+
}
|
|
715
|
+
const findings = [];
|
|
716
|
+
for (const candidate of candidateSymbols) {
|
|
717
|
+
let fanInCount = null;
|
|
718
|
+
if (astGrepBinary && candidate.symbol) {
|
|
719
|
+
const language = languageForFile(candidate.file);
|
|
720
|
+
const result = await findCallSites(repoDeps, astGrepBinary, candidate.symbol, language);
|
|
721
|
+
if (result.ok) {
|
|
722
|
+
fanInCount = result.count;
|
|
723
|
+
toolsUsed.add("ast-grep");
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
degradedFlags.push(`ast-grep call-site search failed for '${candidate.symbol}': ${result.error}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const couplingPartnerCount = couplingPartners.get(candidate.file) ?? 0;
|
|
730
|
+
const fragilityRank = (fanInCount ?? 0) * 2 + couplingPartnerCount * 3 + (candidate.complexityScore ?? 0);
|
|
731
|
+
findings.push({
|
|
732
|
+
location: { file: candidate.file, line: candidate.line },
|
|
733
|
+
signals: {
|
|
734
|
+
fan_in_count: fanInCount,
|
|
735
|
+
coupling_partners: couplingPartnerCount,
|
|
736
|
+
complexity_score: candidate.complexityScore,
|
|
737
|
+
},
|
|
738
|
+
fragility_rank: fragilityRank,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
findings.sort((a, b) => b.fragility_rank - a.fragility_rank);
|
|
742
|
+
return {
|
|
743
|
+
mode: "heavy",
|
|
744
|
+
summary: {
|
|
745
|
+
hotspots_scanned: candidateFiles.length,
|
|
746
|
+
cap_applied: fileCapApplied || symbolCapApplied,
|
|
747
|
+
tools_used: Array.from(toolsUsed),
|
|
748
|
+
degraded_flags: degradedFlags,
|
|
749
|
+
},
|
|
750
|
+
findings,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Heavy mode — output formatting
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
export function formatHeavyReportJson(report) {
|
|
757
|
+
return JSON.stringify(report, null, 2);
|
|
758
|
+
}
|
|
759
|
+
export function formatHeavyReportText(report) {
|
|
760
|
+
const lines = [
|
|
761
|
+
"regression-check report (heavy mode)",
|
|
762
|
+
"",
|
|
763
|
+
`Hotspots scanned: ${report.summary.hotspots_scanned}${report.summary.cap_applied ? " [CAPPED]" : ""}`,
|
|
764
|
+
`Tools used: ${report.summary.tools_used.length > 0 ? report.summary.tools_used.join(", ") : "none"}`,
|
|
765
|
+
"",
|
|
766
|
+
];
|
|
767
|
+
if (report.findings.length === 0) {
|
|
768
|
+
lines.push("No fragility findings.");
|
|
769
|
+
}
|
|
770
|
+
for (const f of report.findings) {
|
|
771
|
+
lines.push(`${f.location.file}:${f.location.line} fragility_rank=${f.fragility_rank}`);
|
|
772
|
+
const fanIn = f.signals.fan_in_count === null ? "[DEGRADED]" : String(f.signals.fan_in_count);
|
|
773
|
+
const complexity = f.signals.complexity_score === null ? "[DEGRADED]" : String(f.signals.complexity_score);
|
|
774
|
+
lines.push(` fan-in: ${fanIn} coupling partners: ${f.signals.coupling_partners} complexity: ${complexity}`);
|
|
775
|
+
}
|
|
776
|
+
lines.push("");
|
|
777
|
+
if (report.summary.degraded_flags.length > 0) {
|
|
778
|
+
lines.push(`[DEGRADED] ${report.summary.degraded_flags.length} issue(s):`);
|
|
779
|
+
for (const flag of report.summary.degraded_flags)
|
|
780
|
+
lines.push(` - ${flag}`);
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
lines.push("No degradation — all tools ran successfully.");
|
|
784
|
+
}
|
|
785
|
+
return lines.join("\n");
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* CLI entry for the `regression-check` subcommand. Returns a process exit
|
|
789
|
+
* code. Help returns 0; parser errors return 1; an unreadable diff returns 1;
|
|
790
|
+
* otherwise it always returns 0 (degraded findings are reported, not failed).
|
|
791
|
+
*/
|
|
792
|
+
export async function runRegressionCheckCli(argv, overrides = {}) {
|
|
793
|
+
const log = overrides.log ?? ((m) => console.log(m));
|
|
794
|
+
const errorLog = overrides.errorLog ?? ((m) => console.error(m));
|
|
795
|
+
const parsed = parseRegressionCheckArgs(argv);
|
|
796
|
+
if (parsed.status === "help") {
|
|
797
|
+
log(parsed.usage);
|
|
798
|
+
return 0;
|
|
799
|
+
}
|
|
800
|
+
if (parsed.status === "error") {
|
|
801
|
+
errorLog(`Error: ${parsed.message}`);
|
|
802
|
+
errorLog("");
|
|
803
|
+
errorLog(getRegressionCheckUsage());
|
|
804
|
+
return 1;
|
|
805
|
+
}
|
|
806
|
+
const deps = overrides.deps ?? createDefaultStartTicketsDeps();
|
|
807
|
+
const { options } = parsed;
|
|
808
|
+
if (options.mode === "heavy") {
|
|
809
|
+
const report = await runHeavyMode(deps);
|
|
810
|
+
log(options.json ? formatHeavyReportJson(report) : formatHeavyReportText(report));
|
|
811
|
+
return 0;
|
|
812
|
+
}
|
|
813
|
+
const result = await runLightweightRegressionCheck(deps, options);
|
|
814
|
+
if (!result.ok) {
|
|
815
|
+
errorLog(`Error: ${result.error}`);
|
|
816
|
+
return 1;
|
|
817
|
+
}
|
|
818
|
+
log(options.json ? formatRegressionCheckJson(result.report) : formatRegressionCheckReport(result.report));
|
|
819
|
+
return 0;
|
|
820
|
+
}
|