edsger 0.55.4 → 0.56.1
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/dist/commands/quality-benchmark/index.d.ts +32 -0
- package/dist/commands/quality-benchmark/index.js +124 -0
- package/dist/index.js +24 -0
- package/dist/phases/quality-benchmark/index.d.ts +65 -0
- package/dist/phases/quality-benchmark/index.js +194 -0
- package/dist/phases/quality-benchmark/mcp-server.d.ts +46 -0
- package/dist/phases/quality-benchmark/mcp-server.js +252 -0
- package/dist/phases/quality-benchmark/parsers.d.ts +22 -0
- package/dist/phases/quality-benchmark/parsers.js +1022 -0
- package/dist/phases/quality-benchmark/prompts.d.ts +31 -0
- package/dist/phases/quality-benchmark/prompts.js +154 -0
- package/dist/phases/quality-benchmark/rubric.md +1066 -0
- package/dist/phases/quality-benchmark/tool-catalog.d.ts +33 -0
- package/dist/phases/quality-benchmark/tool-catalog.js +597 -0
- package/dist/phases/quality-benchmark/tool-runner.d.ts +69 -0
- package/dist/phases/quality-benchmark/tool-runner.js +399 -0
- package/dist/phases/quality-benchmark/types.d.ts +312 -0
- package/dist/phases/quality-benchmark/types.js +23 -0
- package/package.json +4 -4
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool output parsers. Each parser extracts the minimum information the
|
|
3
|
+
* LLM needs to score the dimension — either counts (Tier 1), counts plus
|
|
4
|
+
* top findings (Tier 2), or domain-specific metrics (Tier 3).
|
|
5
|
+
*
|
|
6
|
+
* Full raw outputs are saved to disk by the tool-runner and never enter
|
|
7
|
+
* the LLM context — these parsers operate on the stdout/stderr strings
|
|
8
|
+
* captured during execution.
|
|
9
|
+
*
|
|
10
|
+
* Severity mapping:
|
|
11
|
+
* tool-specific → our 4-tier enum (critical | high | medium | low)
|
|
12
|
+
*
|
|
13
|
+
* All parsers must be:
|
|
14
|
+
* - Defensive: never throw. Return `parsed: false` (via a counts-zero
|
|
15
|
+
* summary) if the output is malformed.
|
|
16
|
+
* - Cheap: simple JSON parse + small map. No regex over megabytes.
|
|
17
|
+
* - Stable: same input → same output (no randomness, no clocks).
|
|
18
|
+
*/
|
|
19
|
+
import { TOOL_CATALOG_BY_ID } from './tool-catalog.js';
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
function safeJson(s) {
|
|
24
|
+
if (!s) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(s);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse a newline-delimited JSON stream (one JSON value per line).
|
|
36
|
+
* Used by cargo clippy --message-format json and govulncheck -json.
|
|
37
|
+
*/
|
|
38
|
+
function safeJsonLines(s) {
|
|
39
|
+
if (!s) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const line of s.split(/\r?\n/)) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
out.push(JSON.parse(trimmed));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// skip non-JSON lines (some tools mix banners with JSON)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** First subscore from the catalog for a tool id (used as the finding tag). */
|
|
58
|
+
function primarySubscore(toolId) {
|
|
59
|
+
const entry = TOOL_CATALOG_BY_ID.get(toolId);
|
|
60
|
+
if (!entry || entry.subscores.length === 0) {
|
|
61
|
+
return 'code_quality.lint';
|
|
62
|
+
}
|
|
63
|
+
return entry.subscores[0];
|
|
64
|
+
}
|
|
65
|
+
function clampSnippet(s) {
|
|
66
|
+
if (!s) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return s.length > 200 ? `${s.slice(0, 197)}...` : s;
|
|
70
|
+
}
|
|
71
|
+
function severityRank(s) {
|
|
72
|
+
return s === 'critical' ? 4 : s === 'high' ? 3 : s === 'medium' ? 2 : 1;
|
|
73
|
+
}
|
|
74
|
+
function topBySeverity(findings, n = 10) {
|
|
75
|
+
return [...findings]
|
|
76
|
+
.sort((a, b) => severityRank(b.severity) - severityRank(a.severity))
|
|
77
|
+
.slice(0, n);
|
|
78
|
+
}
|
|
79
|
+
function countBySeverity(findings) {
|
|
80
|
+
const c = { critical: 0, high: 0, medium: 0, low: 0, total: findings.length };
|
|
81
|
+
for (const f of findings) {
|
|
82
|
+
c[f.severity]++;
|
|
83
|
+
}
|
|
84
|
+
return c;
|
|
85
|
+
}
|
|
86
|
+
function emptyCounts() {
|
|
87
|
+
return {
|
|
88
|
+
tool_id: 'unknown',
|
|
89
|
+
summary: { tier: 'counts', counts: { errors: 0, warnings: 0, info: 0 } },
|
|
90
|
+
oneliner: 'no output parsed',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function relPath(file, ctx) {
|
|
94
|
+
if (file.startsWith(ctx.repo_root)) {
|
|
95
|
+
return file.slice(ctx.repo_root.length).replace(/^[/\\]/, '');
|
|
96
|
+
}
|
|
97
|
+
return file;
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Tier 1 — counts only
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
const eslintParser = (stdout) => {
|
|
103
|
+
const data = safeJson(stdout);
|
|
104
|
+
if (!Array.isArray(data)) {
|
|
105
|
+
return { ...emptyCounts(), tool_id: 'eslint' };
|
|
106
|
+
}
|
|
107
|
+
let errors = 0;
|
|
108
|
+
let warnings = 0;
|
|
109
|
+
for (const file of data) {
|
|
110
|
+
for (const m of file.messages ?? []) {
|
|
111
|
+
if (m.severity === 2) {
|
|
112
|
+
errors++;
|
|
113
|
+
}
|
|
114
|
+
else if (m.severity === 1) {
|
|
115
|
+
warnings++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
tool_id: 'eslint',
|
|
121
|
+
summary: { tier: 'counts', counts: { errors, warnings, info: 0 } },
|
|
122
|
+
oneliner: `${errors} errors, ${warnings} warnings`,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const tscParser = (stdout, stderr) => {
|
|
126
|
+
const body = `${stdout}\n${stderr}`;
|
|
127
|
+
const errorLines = body.match(/^[^\n]*error TS\d+/gm) ?? [];
|
|
128
|
+
const errors = errorLines.length;
|
|
129
|
+
return {
|
|
130
|
+
tool_id: 'tsc-typecheck',
|
|
131
|
+
summary: { tier: 'counts', counts: { errors, warnings: 0, info: 0 } },
|
|
132
|
+
oneliner: `${errors} type errors`,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
const ruffParser = (stdout) => {
|
|
136
|
+
const data = safeJson(stdout);
|
|
137
|
+
if (!Array.isArray(data)) {
|
|
138
|
+
return { ...emptyCounts(), tool_id: 'ruff' };
|
|
139
|
+
}
|
|
140
|
+
let errors = 0;
|
|
141
|
+
let warnings = 0;
|
|
142
|
+
for (const v of data) {
|
|
143
|
+
const code = v.code ?? '';
|
|
144
|
+
// E/F prefix in ruff (and most flake8-like tools) = error severity.
|
|
145
|
+
if (/^[EF]/.test(code)) {
|
|
146
|
+
errors++;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
warnings++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
tool_id: 'ruff',
|
|
154
|
+
summary: { tier: 'counts', counts: { errors, warnings, info: 0 } },
|
|
155
|
+
oneliner: `${errors} errors, ${warnings} warnings`,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
const mypyParser = (stdout) => {
|
|
159
|
+
const lines = stdout.split(/\r?\n/);
|
|
160
|
+
let errors = 0;
|
|
161
|
+
let warnings = 0;
|
|
162
|
+
let info = 0;
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
if (/:\s*error:/.test(line)) {
|
|
165
|
+
errors++;
|
|
166
|
+
}
|
|
167
|
+
else if (/:\s*warning:/.test(line)) {
|
|
168
|
+
warnings++;
|
|
169
|
+
}
|
|
170
|
+
else if (/:\s*note:/.test(line)) {
|
|
171
|
+
info++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
tool_id: 'mypy',
|
|
176
|
+
summary: { tier: 'counts', counts: { errors, warnings, info } },
|
|
177
|
+
oneliner: `${errors} errors, ${warnings} warnings`,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
const golangciLintParser = (stdout) => {
|
|
181
|
+
const data = safeJson(stdout);
|
|
182
|
+
const issues = data?.Issues ?? [];
|
|
183
|
+
let errors = 0;
|
|
184
|
+
let warnings = 0;
|
|
185
|
+
for (const i of issues) {
|
|
186
|
+
if ((i.Severity ?? '').toLowerCase() === 'error') {
|
|
187
|
+
errors++;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
warnings++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
tool_id: 'golangci-lint',
|
|
195
|
+
summary: { tier: 'counts', counts: { errors, warnings, info: 0 } },
|
|
196
|
+
oneliner: `${errors} errors, ${warnings} warnings`,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const clippyParser = (stdout) => {
|
|
200
|
+
const events = safeJsonLines(stdout);
|
|
201
|
+
let errors = 0;
|
|
202
|
+
let warnings = 0;
|
|
203
|
+
for (const e of events) {
|
|
204
|
+
if (e.reason !== 'compiler-message') {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const lvl = e.message?.level;
|
|
208
|
+
if (lvl === 'error' || lvl === 'error: internal compiler error') {
|
|
209
|
+
errors++;
|
|
210
|
+
}
|
|
211
|
+
else if (lvl === 'warning') {
|
|
212
|
+
warnings++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
tool_id: 'clippy',
|
|
217
|
+
summary: { tier: 'counts', counts: { errors, warnings, info: 0 } },
|
|
218
|
+
oneliner: `${errors} errors, ${warnings} warnings`,
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
const rubocopParser = (stdout) => {
|
|
222
|
+
const data = safeJson(stdout);
|
|
223
|
+
if (!data) {
|
|
224
|
+
return { ...emptyCounts(), tool_id: 'rubocop' };
|
|
225
|
+
}
|
|
226
|
+
let errors = 0;
|
|
227
|
+
let warnings = 0;
|
|
228
|
+
for (const f of data.files ?? []) {
|
|
229
|
+
for (const o of f.offenses ?? []) {
|
|
230
|
+
const sev = (o.severity ?? '').toLowerCase();
|
|
231
|
+
if (sev === 'error' || sev === 'fatal') {
|
|
232
|
+
errors++;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
warnings++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
tool_id: 'rubocop',
|
|
241
|
+
summary: { tier: 'counts', counts: { errors, warnings, info: 0 } },
|
|
242
|
+
oneliner: `${errors} errors, ${warnings} warnings`,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
const depcheckParser = (stdout) => {
|
|
246
|
+
const data = safeJson(stdout);
|
|
247
|
+
if (!data) {
|
|
248
|
+
return { ...emptyCounts(), tool_id: 'depcheck' };
|
|
249
|
+
}
|
|
250
|
+
const unused = (data.dependencies?.length ?? 0) + (data.devDependencies?.length ?? 0);
|
|
251
|
+
return {
|
|
252
|
+
tool_id: 'depcheck',
|
|
253
|
+
summary: {
|
|
254
|
+
tier: 'counts',
|
|
255
|
+
counts: { errors: unused, warnings: 0, info: 0 },
|
|
256
|
+
},
|
|
257
|
+
oneliner: `${unused} unused dependencies`,
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
const npmOutdatedParser = (stdout) => {
|
|
261
|
+
const data = safeJson(stdout);
|
|
262
|
+
if (!data) {
|
|
263
|
+
return {
|
|
264
|
+
tool_id: 'npm-outdated',
|
|
265
|
+
summary: { tier: 'counts', counts: { errors: 0, warnings: 0, info: 0 } },
|
|
266
|
+
oneliner: 'no outdated dependencies',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const count = Object.keys(data).length;
|
|
270
|
+
return {
|
|
271
|
+
tool_id: 'npm-outdated',
|
|
272
|
+
summary: {
|
|
273
|
+
tier: 'counts',
|
|
274
|
+
counts: { errors: 0, warnings: count, info: 0 },
|
|
275
|
+
},
|
|
276
|
+
oneliner: `${count} outdated dependencies`,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
const goModOutdatedParser = (stdout) => {
|
|
280
|
+
const items = safeJsonLines(stdout);
|
|
281
|
+
const count = items.filter((i) => i.Update).length;
|
|
282
|
+
return {
|
|
283
|
+
tool_id: 'go-mod-outdated',
|
|
284
|
+
summary: {
|
|
285
|
+
tier: 'counts',
|
|
286
|
+
counts: { errors: 0, warnings: count, info: 0 },
|
|
287
|
+
},
|
|
288
|
+
oneliner: `${count} outdated modules`,
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
const cargoOutdatedParser = (stdout) => {
|
|
292
|
+
const data = safeJson(stdout);
|
|
293
|
+
const items = data?.dependencies ?? [];
|
|
294
|
+
const count = items.filter((d) => d.project && d.latest && d.project !== d.latest).length;
|
|
295
|
+
return {
|
|
296
|
+
tool_id: 'cargo-outdated',
|
|
297
|
+
summary: {
|
|
298
|
+
tier: 'counts',
|
|
299
|
+
counts: { errors: 0, warnings: count, info: 0 },
|
|
300
|
+
},
|
|
301
|
+
oneliner: `${count} outdated crates`,
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Tier 2 — counts + top-N findings (severity + file:line preserved)
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
function mapSemgrepSeverity(s) {
|
|
308
|
+
switch ((s ?? '').toUpperCase()) {
|
|
309
|
+
case 'ERROR':
|
|
310
|
+
return 'high';
|
|
311
|
+
case 'WARNING':
|
|
312
|
+
return 'medium';
|
|
313
|
+
case 'INFO':
|
|
314
|
+
return 'low';
|
|
315
|
+
default:
|
|
316
|
+
return 'medium';
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const semgrepParser = (stdout, _stderr, ctx) => {
|
|
320
|
+
const data = safeJson(stdout);
|
|
321
|
+
const results = data?.results ?? [];
|
|
322
|
+
const findings = results.map((r) => {
|
|
323
|
+
const cwe = r.extra?.metadata?.cwe;
|
|
324
|
+
const cweArr = Array.isArray(cwe) ? cwe : cwe ? [cwe] : undefined;
|
|
325
|
+
const sev = mapSemgrepSeverity(r.extra?.severity);
|
|
326
|
+
// Bump SAST findings of suspected high-severity rules to critical
|
|
327
|
+
const id = r.check_id ?? '';
|
|
328
|
+
const severity = sev === 'high' && /sqli|rce|deserialization|secret/.test(id)
|
|
329
|
+
? 'critical'
|
|
330
|
+
: sev;
|
|
331
|
+
return {
|
|
332
|
+
file: relPath(r.path ?? '', ctx),
|
|
333
|
+
line: r.start?.line ?? 1,
|
|
334
|
+
issue: r.extra?.message ?? id,
|
|
335
|
+
severity,
|
|
336
|
+
snippet: clampSnippet(r.extra?.lines),
|
|
337
|
+
source: 'tool:semgrep',
|
|
338
|
+
rule_id: id || undefined,
|
|
339
|
+
cwe: cweArr,
|
|
340
|
+
subscore_key: primarySubscore('semgrep'),
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
tool_id: 'semgrep',
|
|
345
|
+
summary: {
|
|
346
|
+
tier: 'findings',
|
|
347
|
+
counts: countBySeverity(findings),
|
|
348
|
+
top_findings: topBySeverity(findings),
|
|
349
|
+
},
|
|
350
|
+
oneliner: `${findings.length} SAST findings`,
|
|
351
|
+
};
|
|
352
|
+
};
|
|
353
|
+
function mapBanditSeverity(s) {
|
|
354
|
+
switch ((s ?? '').toUpperCase()) {
|
|
355
|
+
case 'HIGH':
|
|
356
|
+
return 'high';
|
|
357
|
+
case 'MEDIUM':
|
|
358
|
+
return 'medium';
|
|
359
|
+
case 'LOW':
|
|
360
|
+
return 'low';
|
|
361
|
+
default:
|
|
362
|
+
return 'medium';
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const banditParser = (stdout, _stderr, ctx) => {
|
|
366
|
+
const data = safeJson(stdout);
|
|
367
|
+
const results = data?.results ?? [];
|
|
368
|
+
const findings = results.map((r) => ({
|
|
369
|
+
file: relPath(r.filename ?? '', ctx),
|
|
370
|
+
line: r.line_number ?? 1,
|
|
371
|
+
issue: r.issue_text ?? r.test_id ?? 'bandit issue',
|
|
372
|
+
severity: mapBanditSeverity(r.issue_severity),
|
|
373
|
+
snippet: clampSnippet(r.code),
|
|
374
|
+
source: 'tool:bandit',
|
|
375
|
+
rule_id: r.test_id,
|
|
376
|
+
cwe: r.cwe?.id ? [`CWE-${r.cwe.id}`] : undefined,
|
|
377
|
+
subscore_key: primarySubscore('bandit'),
|
|
378
|
+
}));
|
|
379
|
+
return {
|
|
380
|
+
tool_id: 'bandit',
|
|
381
|
+
summary: {
|
|
382
|
+
tier: 'findings',
|
|
383
|
+
counts: countBySeverity(findings),
|
|
384
|
+
top_findings: topBySeverity(findings),
|
|
385
|
+
},
|
|
386
|
+
oneliner: `${findings.length} security findings`,
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
const gosecParser = (stdout, _stderr, ctx) => {
|
|
390
|
+
const data = safeJson(stdout);
|
|
391
|
+
const issues = data?.Issues ?? [];
|
|
392
|
+
const findings = issues.map((i) => ({
|
|
393
|
+
file: relPath(i.file ?? '', ctx),
|
|
394
|
+
line: parseInt((i.line ?? '1').split('-')[0] ?? '1', 10) || 1,
|
|
395
|
+
issue: i.details ?? i.rule_id ?? 'gosec issue',
|
|
396
|
+
severity: mapBanditSeverity(i.severity), // same HIGH/MEDIUM/LOW scheme
|
|
397
|
+
snippet: clampSnippet(i.code),
|
|
398
|
+
source: 'tool:gosec',
|
|
399
|
+
rule_id: i.rule_id,
|
|
400
|
+
cwe: i.cwe?.ID ? [`CWE-${i.cwe.ID}`] : undefined,
|
|
401
|
+
subscore_key: primarySubscore('gosec'),
|
|
402
|
+
}));
|
|
403
|
+
return {
|
|
404
|
+
tool_id: 'gosec',
|
|
405
|
+
summary: {
|
|
406
|
+
tier: 'findings',
|
|
407
|
+
counts: countBySeverity(findings),
|
|
408
|
+
top_findings: topBySeverity(findings),
|
|
409
|
+
},
|
|
410
|
+
oneliner: `${findings.length} security findings`,
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
const brakemanParser = (stdout, _stderr, ctx) => {
|
|
414
|
+
const data = safeJson(stdout);
|
|
415
|
+
const warnings = data?.warnings ?? [];
|
|
416
|
+
const findings = warnings.map((w) => {
|
|
417
|
+
const conf = (w.confidence ?? '').toLowerCase();
|
|
418
|
+
const severity = conf === 'high' ? 'high' : conf === 'medium' ? 'medium' : 'low';
|
|
419
|
+
return {
|
|
420
|
+
file: relPath(w.file ?? '', ctx),
|
|
421
|
+
line: w.line ?? 1,
|
|
422
|
+
issue: w.message ?? w.warning_type ?? 'brakeman warning',
|
|
423
|
+
severity,
|
|
424
|
+
snippet: clampSnippet(w.code),
|
|
425
|
+
source: 'tool:brakeman',
|
|
426
|
+
rule_id: w.warning_type,
|
|
427
|
+
subscore_key: primarySubscore('brakeman'),
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
return {
|
|
431
|
+
tool_id: 'brakeman',
|
|
432
|
+
summary: {
|
|
433
|
+
tier: 'findings',
|
|
434
|
+
counts: countBySeverity(findings),
|
|
435
|
+
top_findings: topBySeverity(findings),
|
|
436
|
+
},
|
|
437
|
+
oneliner: `${findings.length} Rails security findings`,
|
|
438
|
+
};
|
|
439
|
+
};
|
|
440
|
+
const gitleaksParser = (stdout, _stderr, ctx) => {
|
|
441
|
+
// gitleaks emits a JSON array of leaks. With --redact the secret value is
|
|
442
|
+
// replaced; we still get file/line/rule.
|
|
443
|
+
const data = safeJson(stdout);
|
|
444
|
+
const leaks = Array.isArray(data) ? data : [];
|
|
445
|
+
const findings = leaks.map((l) => ({
|
|
446
|
+
file: relPath(l.File ?? '', ctx),
|
|
447
|
+
line: l.StartLine ?? 1,
|
|
448
|
+
issue: l.Description ?? l.RuleID ?? 'secret leak',
|
|
449
|
+
// Every gitleaks hit is treated as critical — secrets in source are a P0.
|
|
450
|
+
severity: 'critical',
|
|
451
|
+
snippet: clampSnippet(l.Match),
|
|
452
|
+
source: 'tool:gitleaks',
|
|
453
|
+
rule_id: l.RuleID,
|
|
454
|
+
subscore_key: primarySubscore('gitleaks'),
|
|
455
|
+
}));
|
|
456
|
+
return {
|
|
457
|
+
tool_id: 'gitleaks',
|
|
458
|
+
summary: {
|
|
459
|
+
tier: 'findings',
|
|
460
|
+
counts: countBySeverity(findings),
|
|
461
|
+
top_findings: topBySeverity(findings),
|
|
462
|
+
},
|
|
463
|
+
oneliner: `${findings.length} secret leaks`,
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
function mapNpmSeverity(s) {
|
|
467
|
+
switch ((s ?? '').toLowerCase()) {
|
|
468
|
+
case 'critical':
|
|
469
|
+
return 'critical';
|
|
470
|
+
case 'high':
|
|
471
|
+
return 'high';
|
|
472
|
+
case 'moderate':
|
|
473
|
+
return 'medium';
|
|
474
|
+
case 'low':
|
|
475
|
+
case 'info':
|
|
476
|
+
return 'low';
|
|
477
|
+
default:
|
|
478
|
+
return 'medium';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function npmAuditCommon(toolId) {
|
|
482
|
+
return (stdout) => {
|
|
483
|
+
// npm/pnpm/yarn audit JSON has nested vulnerability map; we read just the
|
|
484
|
+
// metadata.vulnerabilities object which is { critical, high, moderate, low }
|
|
485
|
+
const data = safeJson(stdout);
|
|
486
|
+
const meta = data?.metadata?.vulnerabilities;
|
|
487
|
+
const critical = meta?.critical ?? 0;
|
|
488
|
+
const high = meta?.high ?? 0;
|
|
489
|
+
const medium = meta?.moderate ?? 0;
|
|
490
|
+
const low = meta?.low ?? 0;
|
|
491
|
+
const total = critical + high + medium + low;
|
|
492
|
+
// Build top findings from the vulnerabilities map. These are package-level
|
|
493
|
+
// (no file:line), so we synthesise file = "package.json" and line = 1.
|
|
494
|
+
const vulns = Object.entries(data?.vulnerabilities ?? {});
|
|
495
|
+
const findings = vulns.map(([pkg, v]) => {
|
|
496
|
+
const via = (v.via ?? []).find((x) => typeof x !== 'string');
|
|
497
|
+
return {
|
|
498
|
+
file: 'package.json',
|
|
499
|
+
line: 1,
|
|
500
|
+
issue: `${pkg}: ${via?.title ?? 'known vulnerability'}`,
|
|
501
|
+
severity: mapNpmSeverity(v.severity),
|
|
502
|
+
source: `tool:${toolId}`,
|
|
503
|
+
rule_id: pkg,
|
|
504
|
+
subscore_key: primarySubscore(toolId),
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
return {
|
|
508
|
+
tool_id: toolId,
|
|
509
|
+
summary: {
|
|
510
|
+
tier: 'findings',
|
|
511
|
+
counts: { critical, high, medium, low, total },
|
|
512
|
+
top_findings: topBySeverity(findings),
|
|
513
|
+
},
|
|
514
|
+
oneliner: `${total} vulnerable packages (${critical} critical, ${high} high)`,
|
|
515
|
+
};
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const npmAuditParser = npmAuditCommon('npm-audit');
|
|
519
|
+
const pnpmAuditParser = npmAuditCommon('pnpm-audit');
|
|
520
|
+
const yarnAuditParser = npmAuditCommon('yarn-audit');
|
|
521
|
+
function mapOsvSeverity(score) {
|
|
522
|
+
if (score === undefined) {
|
|
523
|
+
return 'medium';
|
|
524
|
+
}
|
|
525
|
+
if (score >= 9) {
|
|
526
|
+
return 'critical';
|
|
527
|
+
}
|
|
528
|
+
if (score >= 7) {
|
|
529
|
+
return 'high';
|
|
530
|
+
}
|
|
531
|
+
if (score >= 4) {
|
|
532
|
+
return 'medium';
|
|
533
|
+
}
|
|
534
|
+
return 'low';
|
|
535
|
+
}
|
|
536
|
+
const pipAuditParser = (stdout) => {
|
|
537
|
+
const data = safeJson(stdout);
|
|
538
|
+
const deps = data?.dependencies ?? [];
|
|
539
|
+
const findings = [];
|
|
540
|
+
for (const d of deps) {
|
|
541
|
+
for (const v of d.vulns ?? []) {
|
|
542
|
+
findings.push({
|
|
543
|
+
file: 'requirements.txt',
|
|
544
|
+
line: 1,
|
|
545
|
+
issue: `${d.name}: ${v.description ?? v.id ?? 'vulnerable'}`,
|
|
546
|
+
severity: 'high', // pip-audit doesn't surface CVSS in JSON; assume high
|
|
547
|
+
source: 'tool:pip-audit',
|
|
548
|
+
rule_id: v.id,
|
|
549
|
+
subscore_key: primarySubscore('pip-audit'),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
tool_id: 'pip-audit',
|
|
555
|
+
summary: {
|
|
556
|
+
tier: 'findings',
|
|
557
|
+
counts: countBySeverity(findings),
|
|
558
|
+
top_findings: topBySeverity(findings),
|
|
559
|
+
},
|
|
560
|
+
oneliner: `${findings.length} vulnerable packages`,
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
const govulncheckParser = (stdout) => {
|
|
564
|
+
// govulncheck -json emits a stream; vulnerabilities are events with
|
|
565
|
+
// {finding: {osv, fixed_version, trace: [{module, package, function, position}]}}
|
|
566
|
+
const events = safeJsonLines(stdout);
|
|
567
|
+
const findings = [];
|
|
568
|
+
for (const e of events) {
|
|
569
|
+
const f = e.finding;
|
|
570
|
+
if (!f) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const tr = f.trace?.[0];
|
|
574
|
+
findings.push({
|
|
575
|
+
file: tr?.position?.filename ?? tr?.module ?? 'go.mod',
|
|
576
|
+
line: tr?.position?.line ?? 1,
|
|
577
|
+
issue: `${tr?.package ?? tr?.module ?? 'module'}: ${f.osv ?? 'vulnerable'}`,
|
|
578
|
+
severity: 'high',
|
|
579
|
+
source: 'tool:govulncheck',
|
|
580
|
+
rule_id: f.osv,
|
|
581
|
+
subscore_key: primarySubscore('govulncheck'),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
tool_id: 'govulncheck',
|
|
586
|
+
summary: {
|
|
587
|
+
tier: 'findings',
|
|
588
|
+
counts: countBySeverity(findings),
|
|
589
|
+
top_findings: topBySeverity(findings),
|
|
590
|
+
},
|
|
591
|
+
oneliner: `${findings.length} Go vulnerabilities`,
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
const cargoAuditParser = (stdout) => {
|
|
595
|
+
const data = safeJson(stdout);
|
|
596
|
+
const list = data?.vulnerabilities?.list ?? [];
|
|
597
|
+
const findings = list.map((v) => ({
|
|
598
|
+
file: 'Cargo.toml',
|
|
599
|
+
line: 1,
|
|
600
|
+
issue: `${v.package?.name ?? 'crate'}: ${v.advisory?.title ?? v.advisory?.id ?? 'vulnerable'}`,
|
|
601
|
+
severity: 'high',
|
|
602
|
+
source: 'tool:cargo-audit',
|
|
603
|
+
rule_id: v.advisory?.id,
|
|
604
|
+
subscore_key: primarySubscore('cargo-audit'),
|
|
605
|
+
}));
|
|
606
|
+
return {
|
|
607
|
+
tool_id: 'cargo-audit',
|
|
608
|
+
summary: {
|
|
609
|
+
tier: 'findings',
|
|
610
|
+
counts: countBySeverity(findings),
|
|
611
|
+
top_findings: topBySeverity(findings),
|
|
612
|
+
},
|
|
613
|
+
oneliner: `${findings.length} Rust crate vulnerabilities`,
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
const cargoDenyParser = (stdout) => {
|
|
617
|
+
const events = safeJsonLines(stdout);
|
|
618
|
+
const findings = [];
|
|
619
|
+
for (const e of events) {
|
|
620
|
+
if (e.type !== 'diagnostic') {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const sev = (e.fields?.severity ?? '').toLowerCase();
|
|
624
|
+
if (sev !== 'error' && sev !== 'warning') {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
findings.push({
|
|
628
|
+
file: 'Cargo.toml',
|
|
629
|
+
line: 1,
|
|
630
|
+
issue: e.fields?.message ?? 'cargo-deny violation',
|
|
631
|
+
severity: sev === 'error' ? 'high' : 'medium',
|
|
632
|
+
source: 'tool:cargo-deny',
|
|
633
|
+
rule_id: e.fields?.advisory,
|
|
634
|
+
subscore_key: primarySubscore('cargo-deny'),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
tool_id: 'cargo-deny',
|
|
639
|
+
summary: {
|
|
640
|
+
tier: 'findings',
|
|
641
|
+
counts: countBySeverity(findings),
|
|
642
|
+
top_findings: topBySeverity(findings),
|
|
643
|
+
},
|
|
644
|
+
oneliner: `${findings.length} cargo-deny violations`,
|
|
645
|
+
};
|
|
646
|
+
};
|
|
647
|
+
const osvScannerParser = (stdout, _stderr, ctx) => {
|
|
648
|
+
const data = safeJson(stdout);
|
|
649
|
+
const results = data?.results ?? [];
|
|
650
|
+
const findings = [];
|
|
651
|
+
for (const r of results) {
|
|
652
|
+
const path = r.source?.path ?? 'manifest';
|
|
653
|
+
for (const p of r.packages ?? []) {
|
|
654
|
+
for (const v of p.vulnerabilities ?? []) {
|
|
655
|
+
// The CVSS score is encoded as a string; pull the first numeric.
|
|
656
|
+
const scoreStr = v.severity?.[0]?.score ?? '';
|
|
657
|
+
const m = scoreStr.match(/(\d+(\.\d+)?)/);
|
|
658
|
+
const score = m ? parseFloat(m[1]) : undefined;
|
|
659
|
+
findings.push({
|
|
660
|
+
file: relPath(path, ctx),
|
|
661
|
+
line: 1,
|
|
662
|
+
issue: `${p.package?.name ?? 'package'}: ${v.summary ?? v.id ?? 'vulnerable'}`,
|
|
663
|
+
severity: mapOsvSeverity(score),
|
|
664
|
+
source: 'tool:osv-scanner',
|
|
665
|
+
rule_id: v.id,
|
|
666
|
+
subscore_key: primarySubscore('osv-scanner'),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
tool_id: 'osv-scanner',
|
|
673
|
+
summary: {
|
|
674
|
+
tier: 'findings',
|
|
675
|
+
counts: countBySeverity(findings),
|
|
676
|
+
top_findings: topBySeverity(findings),
|
|
677
|
+
},
|
|
678
|
+
oneliner: `${findings.length} cross-language vulnerabilities`,
|
|
679
|
+
};
|
|
680
|
+
};
|
|
681
|
+
const bundlerAuditParser = (stdout) => {
|
|
682
|
+
// bundler-audit JSON format: { results: [{ gem, advisory: { id, title, ... } }] }
|
|
683
|
+
const data = safeJson(stdout);
|
|
684
|
+
const results = data?.results ?? [];
|
|
685
|
+
const findings = results.map((r) => ({
|
|
686
|
+
file: 'Gemfile.lock',
|
|
687
|
+
line: 1,
|
|
688
|
+
issue: `${r.gem?.name ?? 'gem'}: ${r.advisory?.title ?? r.advisory?.id ?? 'vulnerable'}`,
|
|
689
|
+
severity: 'high',
|
|
690
|
+
source: 'tool:bundler-audit',
|
|
691
|
+
rule_id: r.advisory?.id,
|
|
692
|
+
subscore_key: primarySubscore('bundler-audit'),
|
|
693
|
+
}));
|
|
694
|
+
return {
|
|
695
|
+
tool_id: 'bundler-audit',
|
|
696
|
+
summary: {
|
|
697
|
+
tier: 'findings',
|
|
698
|
+
counts: countBySeverity(findings),
|
|
699
|
+
top_findings: topBySeverity(findings),
|
|
700
|
+
},
|
|
701
|
+
oneliner: `${findings.length} Ruby gem vulnerabilities`,
|
|
702
|
+
};
|
|
703
|
+
};
|
|
704
|
+
const vultureParser = (stdout, _stderr, ctx) => {
|
|
705
|
+
// vulture text output: `path:line: unused X 'name' (confidence%)`
|
|
706
|
+
const findings = [];
|
|
707
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
708
|
+
const m = line.match(/^(.+?):(\d+):\s*(.+?)\s*\((\d+)%\s*confidence\)/);
|
|
709
|
+
if (!m) {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
findings.push({
|
|
713
|
+
file: relPath(m[1], ctx),
|
|
714
|
+
line: parseInt(m[2], 10),
|
|
715
|
+
issue: m[3],
|
|
716
|
+
severity: 'low',
|
|
717
|
+
source: 'tool:vulture',
|
|
718
|
+
subscore_key: primarySubscore('vulture'),
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
tool_id: 'vulture',
|
|
723
|
+
summary: {
|
|
724
|
+
tier: 'findings',
|
|
725
|
+
counts: countBySeverity(findings),
|
|
726
|
+
top_findings: topBySeverity(findings, 20),
|
|
727
|
+
},
|
|
728
|
+
oneliner: `${findings.length} dead-code candidates`,
|
|
729
|
+
};
|
|
730
|
+
};
|
|
731
|
+
const madgeParser = (stdout, _stderr, ctx) => {
|
|
732
|
+
const data = safeJson(stdout);
|
|
733
|
+
if (!data) {
|
|
734
|
+
return {
|
|
735
|
+
tool_id: 'madge',
|
|
736
|
+
summary: {
|
|
737
|
+
tier: 'findings',
|
|
738
|
+
counts: countBySeverity([]),
|
|
739
|
+
top_findings: [],
|
|
740
|
+
},
|
|
741
|
+
oneliner: 'no circular dependencies',
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
// Newer madge --circular --json returns array of cycles (each = array of files).
|
|
745
|
+
const cycles = Array.isArray(data) ? data : Object.values(data);
|
|
746
|
+
const findings = cycles.map((cycle) => ({
|
|
747
|
+
file: relPath(cycle[0] ?? '', ctx),
|
|
748
|
+
line: 1,
|
|
749
|
+
issue: `Circular import chain: ${cycle.map((f) => relPath(f, ctx)).join(' -> ')}`,
|
|
750
|
+
severity: 'medium',
|
|
751
|
+
source: 'tool:madge',
|
|
752
|
+
subscore_key: primarySubscore('madge'),
|
|
753
|
+
}));
|
|
754
|
+
return {
|
|
755
|
+
tool_id: 'madge',
|
|
756
|
+
summary: {
|
|
757
|
+
tier: 'findings',
|
|
758
|
+
counts: countBySeverity(findings),
|
|
759
|
+
top_findings: topBySeverity(findings),
|
|
760
|
+
},
|
|
761
|
+
oneliner: `${findings.length} circular dependencies`,
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
const jscpdParser = (stdout, _stderr, ctx) => {
|
|
765
|
+
// jscpd writes JSON to a file, but its stdout (when --output specified) can
|
|
766
|
+
// also include the report. We accept either.
|
|
767
|
+
const data = safeJson(stdout);
|
|
768
|
+
const stats = data?.statistics?.total;
|
|
769
|
+
const dupPct = stats?.percentage ?? 0;
|
|
770
|
+
const cloneCount = stats?.clones ?? 0;
|
|
771
|
+
const dupes = data?.duplicates ?? [];
|
|
772
|
+
const findings = dupes.map((d) => ({
|
|
773
|
+
file: relPath(d.firstFile?.name ?? '', ctx),
|
|
774
|
+
line: d.firstFile?.start ?? 1,
|
|
775
|
+
issue: `Duplicated with ${relPath(d.secondFile?.name ?? '', ctx)}:${d.secondFile?.start ?? 1} (${d.lines ?? 0} lines)`,
|
|
776
|
+
severity: 'low',
|
|
777
|
+
source: 'tool:jscpd',
|
|
778
|
+
subscore_key: primarySubscore('jscpd'),
|
|
779
|
+
}));
|
|
780
|
+
return {
|
|
781
|
+
tool_id: 'jscpd',
|
|
782
|
+
summary: {
|
|
783
|
+
tier: 'metrics',
|
|
784
|
+
metrics: {
|
|
785
|
+
duplication_pct: dupPct,
|
|
786
|
+
clones: cloneCount,
|
|
787
|
+
top_clones: topBySeverity(findings, 10),
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
oneliner: `${dupPct.toFixed(1)}% duplicated (${cloneCount} clones)`,
|
|
791
|
+
};
|
|
792
|
+
};
|
|
793
|
+
const gocycloParser = (stdout, _stderr, ctx) => {
|
|
794
|
+
// -json returns [{ complexity, package, function, file, line }]
|
|
795
|
+
// -over 15 means only functions above 15 are reported.
|
|
796
|
+
const data = safeJson(stdout);
|
|
797
|
+
const items = data ?? [];
|
|
798
|
+
const findings = items.map((i) => ({
|
|
799
|
+
file: relPath(i.file ?? '', ctx),
|
|
800
|
+
line: i.line ?? 1,
|
|
801
|
+
issue: `${i.function ?? 'function'} has cyclomatic complexity ${i.complexity ?? '?'}`,
|
|
802
|
+
severity: (i.complexity ?? 0) >= 25 ? 'high' : 'medium',
|
|
803
|
+
source: 'tool:gocyclo',
|
|
804
|
+
subscore_key: primarySubscore('gocyclo'),
|
|
805
|
+
}));
|
|
806
|
+
return {
|
|
807
|
+
tool_id: 'gocyclo',
|
|
808
|
+
summary: {
|
|
809
|
+
tier: 'findings',
|
|
810
|
+
counts: countBySeverity(findings),
|
|
811
|
+
top_findings: topBySeverity(findings),
|
|
812
|
+
},
|
|
813
|
+
oneliner: `${findings.length} high-complexity functions`,
|
|
814
|
+
};
|
|
815
|
+
};
|
|
816
|
+
// ---------------------------------------------------------------------------
|
|
817
|
+
// Tier 3 — structured metrics
|
|
818
|
+
// ---------------------------------------------------------------------------
|
|
819
|
+
const sccParser = (stdout) => {
|
|
820
|
+
const data = safeJson(stdout);
|
|
821
|
+
if (!Array.isArray(data)) {
|
|
822
|
+
return {
|
|
823
|
+
tool_id: 'scc',
|
|
824
|
+
summary: { tier: 'metrics', metrics: {} },
|
|
825
|
+
oneliner: 'no LOC stats',
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
const totalLines = data.reduce((s, e) => s + (e.Lines ?? 0), 0);
|
|
829
|
+
const totalCode = data.reduce((s, e) => s + (e.Code ?? 0), 0);
|
|
830
|
+
const totalFiles = data.reduce((s, e) => s + (e.Files ?? 0), 0);
|
|
831
|
+
const byLang = data
|
|
832
|
+
.map((e) => ({
|
|
833
|
+
language: e.Name ?? 'unknown',
|
|
834
|
+
lines: e.Lines ?? 0,
|
|
835
|
+
code: e.Code ?? 0,
|
|
836
|
+
files: e.Files ?? 0,
|
|
837
|
+
}))
|
|
838
|
+
.sort((a, b) => b.code - a.code)
|
|
839
|
+
.slice(0, 10);
|
|
840
|
+
return {
|
|
841
|
+
tool_id: 'scc',
|
|
842
|
+
summary: {
|
|
843
|
+
tier: 'metrics',
|
|
844
|
+
metrics: {
|
|
845
|
+
total_lines: totalLines,
|
|
846
|
+
total_code_lines: totalCode,
|
|
847
|
+
total_files: totalFiles,
|
|
848
|
+
languages: byLang,
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
oneliner: `${totalCode.toLocaleString()} code lines across ${totalFiles} files`,
|
|
852
|
+
};
|
|
853
|
+
};
|
|
854
|
+
const lizardParser = (stdout) => {
|
|
855
|
+
// Lizard --xml output is structured XML; we use a minimal text extraction
|
|
856
|
+
// since we asked for thresholds (-C 15 -L 80). The summary line at the end
|
|
857
|
+
// has the format: "Total nloc Avg.NLOC AvgCCN Avg.token Fun Cnt ..."
|
|
858
|
+
// We do a defensive pass.
|
|
859
|
+
const lastLines = stdout.split(/\r?\n/).slice(-20).join('\n');
|
|
860
|
+
const m = lastLines.match(/Total\s+nloc[\s\S]+?(\d+)\s+(\d+\.?\d*)\s+(\d+\.?\d*)\s+(\d+\.?\d*)\s+(\d+)/);
|
|
861
|
+
let totalNloc = 0;
|
|
862
|
+
let avgNloc = 0;
|
|
863
|
+
let avgCcn = 0;
|
|
864
|
+
let funcCount = 0;
|
|
865
|
+
if (m) {
|
|
866
|
+
totalNloc = parseInt(m[1], 10);
|
|
867
|
+
avgNloc = parseFloat(m[2]);
|
|
868
|
+
avgCcn = parseFloat(m[3]);
|
|
869
|
+
funcCount = parseInt(m[5], 10);
|
|
870
|
+
}
|
|
871
|
+
// Count warnings (functions exceeding thresholds)
|
|
872
|
+
const warningCount = (stdout.match(/warnings? \(/g) || []).length;
|
|
873
|
+
return {
|
|
874
|
+
tool_id: 'lizard',
|
|
875
|
+
summary: {
|
|
876
|
+
tier: 'metrics',
|
|
877
|
+
metrics: {
|
|
878
|
+
total_nloc: totalNloc,
|
|
879
|
+
avg_nloc_per_function: avgNloc,
|
|
880
|
+
avg_cyclomatic_complexity: avgCcn,
|
|
881
|
+
function_count: funcCount,
|
|
882
|
+
flagged_functions: warningCount,
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
oneliner: `avg CCN ${avgCcn.toFixed(1)}, ${warningCount} flagged functions`,
|
|
886
|
+
};
|
|
887
|
+
};
|
|
888
|
+
const radonParser = (stdout) => {
|
|
889
|
+
// radon cc -j produces { "<file>": [ { complexity, rank, ... }, ... ] }
|
|
890
|
+
const data = safeJson(stdout);
|
|
891
|
+
const buckets = {
|
|
892
|
+
A: 0,
|
|
893
|
+
B: 0,
|
|
894
|
+
C: 0,
|
|
895
|
+
D: 0,
|
|
896
|
+
E: 0,
|
|
897
|
+
F: 0,
|
|
898
|
+
};
|
|
899
|
+
let total = 0;
|
|
900
|
+
let maxCcn = 0;
|
|
901
|
+
for (const entries of Object.values(data ?? {})) {
|
|
902
|
+
for (const e of entries) {
|
|
903
|
+
total++;
|
|
904
|
+
const r = (e.rank ?? '').toUpperCase();
|
|
905
|
+
if (r in buckets) {
|
|
906
|
+
buckets[r]++;
|
|
907
|
+
}
|
|
908
|
+
if ((e.complexity ?? 0) > maxCcn) {
|
|
909
|
+
maxCcn = e.complexity ?? 0;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
tool_id: 'radon',
|
|
915
|
+
summary: {
|
|
916
|
+
tier: 'metrics',
|
|
917
|
+
metrics: {
|
|
918
|
+
total_functions: total,
|
|
919
|
+
max_complexity: maxCcn,
|
|
920
|
+
rank_buckets: buckets,
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
oneliner: `${total} functions, ${buckets.D + buckets.E + buckets.F} above C-rank`,
|
|
924
|
+
};
|
|
925
|
+
};
|
|
926
|
+
const licenseCheckerParser = (stdout) => {
|
|
927
|
+
const data = safeJson(stdout);
|
|
928
|
+
if (!data) {
|
|
929
|
+
return {
|
|
930
|
+
tool_id: 'license-checker',
|
|
931
|
+
summary: { tier: 'metrics', metrics: {} },
|
|
932
|
+
oneliner: 'no license data',
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
const dist = {};
|
|
936
|
+
let total = 0;
|
|
937
|
+
for (const v of Object.values(data)) {
|
|
938
|
+
const l = v.licenses;
|
|
939
|
+
const lic = typeof l === 'string' ? l : Array.isArray(l) ? l.join('+') : 'UNKNOWN';
|
|
940
|
+
dist[lic] = (dist[lic] ?? 0) + 1;
|
|
941
|
+
total++;
|
|
942
|
+
}
|
|
943
|
+
// Pull suspicious license classes
|
|
944
|
+
const restricted = Object.entries(dist)
|
|
945
|
+
.filter(([k]) => /GPL|AGPL|SSPL/i.test(k))
|
|
946
|
+
.reduce((s, [, n]) => s + n, 0);
|
|
947
|
+
return {
|
|
948
|
+
tool_id: 'license-checker',
|
|
949
|
+
summary: {
|
|
950
|
+
tier: 'metrics',
|
|
951
|
+
metrics: {
|
|
952
|
+
total_packages: total,
|
|
953
|
+
license_distribution: dist,
|
|
954
|
+
restricted_licenses: restricted,
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
oneliner: `${total} packages, ${restricted} under restrictive licenses`,
|
|
958
|
+
};
|
|
959
|
+
};
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
// Registry
|
|
962
|
+
// ---------------------------------------------------------------------------
|
|
963
|
+
export const PARSERS = {
|
|
964
|
+
// Tier 1
|
|
965
|
+
eslint: eslintParser,
|
|
966
|
+
'tsc-typecheck': tscParser,
|
|
967
|
+
ruff: ruffParser,
|
|
968
|
+
mypy: mypyParser,
|
|
969
|
+
'golangci-lint': golangciLintParser,
|
|
970
|
+
clippy: clippyParser,
|
|
971
|
+
rubocop: rubocopParser,
|
|
972
|
+
depcheck: depcheckParser,
|
|
973
|
+
'npm-outdated': npmOutdatedParser,
|
|
974
|
+
'go-mod-outdated': goModOutdatedParser,
|
|
975
|
+
'cargo-outdated': cargoOutdatedParser,
|
|
976
|
+
// Tier 2
|
|
977
|
+
semgrep: semgrepParser,
|
|
978
|
+
bandit: banditParser,
|
|
979
|
+
gosec: gosecParser,
|
|
980
|
+
brakeman: brakemanParser,
|
|
981
|
+
gitleaks: gitleaksParser,
|
|
982
|
+
'npm-audit': npmAuditParser,
|
|
983
|
+
'pnpm-audit': pnpmAuditParser,
|
|
984
|
+
'yarn-audit': yarnAuditParser,
|
|
985
|
+
'pip-audit': pipAuditParser,
|
|
986
|
+
govulncheck: govulncheckParser,
|
|
987
|
+
'cargo-audit': cargoAuditParser,
|
|
988
|
+
'cargo-deny': cargoDenyParser,
|
|
989
|
+
'osv-scanner': osvScannerParser,
|
|
990
|
+
'bundler-audit': bundlerAuditParser,
|
|
991
|
+
vulture: vultureParser,
|
|
992
|
+
madge: madgeParser,
|
|
993
|
+
gocyclo: gocycloParser,
|
|
994
|
+
// Mixed (T3 metrics + T2 sub-findings)
|
|
995
|
+
jscpd: jscpdParser,
|
|
996
|
+
// Tier 3
|
|
997
|
+
scc: sccParser,
|
|
998
|
+
lizard: lizardParser,
|
|
999
|
+
radon: radonParser,
|
|
1000
|
+
'license-checker': licenseCheckerParser,
|
|
1001
|
+
};
|
|
1002
|
+
/** Run the parser for a tool, defensively swallowing errors. */
|
|
1003
|
+
export function parseToolOutput(toolId, stdout, stderr, ctx) {
|
|
1004
|
+
const fn = PARSERS[toolId];
|
|
1005
|
+
if (!fn) {
|
|
1006
|
+
return {
|
|
1007
|
+
tool_id: toolId,
|
|
1008
|
+
summary: { tier: 'counts', counts: { errors: 0, warnings: 0, info: 0 } },
|
|
1009
|
+
oneliner: `no parser for ${toolId}`,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
return fn(stdout, stderr, ctx);
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
return {
|
|
1017
|
+
tool_id: toolId,
|
|
1018
|
+
summary: { tier: 'counts', counts: { errors: 0, warnings: 0, info: 0 } },
|
|
1019
|
+
oneliner: `parser error: ${err instanceof Error ? err.message : String(err)}`,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
}
|