@safetnsr/vet 1.12.0 → 1.14.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/dist/categories.js +29 -5
- package/dist/checks/bloat.d.ts +3 -0
- package/dist/checks/bloat.js +369 -0
- package/dist/checks/completeness.d.ts +9 -0
- package/dist/checks/completeness.js +146 -0
- package/dist/checks/deps.js +1 -1
- package/dist/checks/loop.d.ts +39 -0
- package/dist/checks/loop.js +391 -0
- package/dist/cli.js +35 -4
- package/package.json +1 -1
package/dist/categories.js
CHANGED
|
@@ -16,10 +16,10 @@ export function toGrade(score) {
|
|
|
16
16
|
}
|
|
17
17
|
// ── Category weights ─────────────────────────────────────────────────────────
|
|
18
18
|
const WEIGHTS = {
|
|
19
|
-
security: 0.
|
|
20
|
-
integrity: 0.
|
|
21
|
-
debt: 0.
|
|
22
|
-
deps: 0.
|
|
19
|
+
security: 0.25,
|
|
20
|
+
integrity: 0.35,
|
|
21
|
+
debt: 0.30,
|
|
22
|
+
deps: 0.10,
|
|
23
23
|
};
|
|
24
24
|
// ── Scoring floor for non-security checks ────────────────────────────────────
|
|
25
25
|
const SECURITY_CHECKS = new Set(['scan', 'secrets', 'permissions', 'owasp']);
|
|
@@ -37,6 +37,28 @@ function averageScore(checks) {
|
|
|
37
37
|
const total = checks.reduce((sum, c) => sum + applyScoreFloor(c), 0);
|
|
38
38
|
return Math.round(total / checks.length);
|
|
39
39
|
}
|
|
40
|
+
// ── Completeness multiplier ─────────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* Extract completeness score and apply it as a multiplier to the overall score.
|
|
43
|
+
* A repo with completeness=0 (no JS/TS source) gets heavily penalized.
|
|
44
|
+
* completeness 0-30 → multiplier 0.3-0.6
|
|
45
|
+
* completeness 30-70 → multiplier 0.6-0.85
|
|
46
|
+
* completeness 70-100 → multiplier 0.85-1.0
|
|
47
|
+
*/
|
|
48
|
+
function completenessMultiplier(categories) {
|
|
49
|
+
const integrity = categories.find(c => c.name === 'integrity');
|
|
50
|
+
if (!integrity)
|
|
51
|
+
return 1.0;
|
|
52
|
+
const comp = integrity.checks.find(c => c.name === 'completeness');
|
|
53
|
+
if (!comp)
|
|
54
|
+
return 1.0;
|
|
55
|
+
const s = comp.score;
|
|
56
|
+
if (s >= 70)
|
|
57
|
+
return 0.85 + (s - 70) * (0.15 / 30);
|
|
58
|
+
if (s >= 30)
|
|
59
|
+
return 0.6 + (s - 30) * (0.25 / 40);
|
|
60
|
+
return 0.3 + s * (0.3 / 30);
|
|
61
|
+
}
|
|
40
62
|
// ── Group checks into categories ─────────────────────────────────────────────
|
|
41
63
|
export function buildCategories(checkMap) {
|
|
42
64
|
const categories = [];
|
|
@@ -63,7 +85,9 @@ export function buildVetResult(project, categories) {
|
|
|
63
85
|
weightedSum += cat.score * cat.weight;
|
|
64
86
|
totalWeight += cat.weight;
|
|
65
87
|
}
|
|
66
|
-
const
|
|
88
|
+
const rawScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
89
|
+
const compMult = completenessMultiplier(categories);
|
|
90
|
+
const overallScore = Math.round(rawScore * compMult);
|
|
67
91
|
const grade = toGrade(overallScore);
|
|
68
92
|
const allIssues = categories.flatMap(c => c.issues);
|
|
69
93
|
// Read version from package.json
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { join, extname } from 'node:path';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { walkFiles, readFile, gitExec, c } from '../util.js';
|
|
4
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
5
|
+
const AI_PATTERNS = /\b(claude|copilot|ai|agent)\b|\[(claude|ai)\]/i;
|
|
6
|
+
const AI_FILE_MARKERS = ['.claude/', 'CLAUDE.md', 'AGENTS.md'];
|
|
7
|
+
const NON_CODE_EXTS = new Set(['.txt', '.log', '.csv', '.json']);
|
|
8
|
+
const SOURCE_EXTS = new Set(['.ts', '.js', '.tsx', '.jsx']);
|
|
9
|
+
const TEN_MB = 10 * 1024 * 1024;
|
|
10
|
+
const BRANCH_RE = /\b(if|else|switch|case)\b|&&|\|\||\?[^?.:]/g;
|
|
11
|
+
// ── Baseline detection ───────────────────────────────────────────────────────
|
|
12
|
+
function findBaseline(cwd) {
|
|
13
|
+
// 1. Search git log for AI-pattern commits
|
|
14
|
+
const log = gitExec(['log', '--oneline', '--all', '--reverse'], cwd);
|
|
15
|
+
if (!log) {
|
|
16
|
+
return { sha: '', message: '', method: 'empty' };
|
|
17
|
+
}
|
|
18
|
+
const lines = log.split('\n').filter(Boolean);
|
|
19
|
+
if (lines.length === 0) {
|
|
20
|
+
return { sha: '', message: '', method: 'empty' };
|
|
21
|
+
}
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const spaceIdx = line.indexOf(' ');
|
|
24
|
+
if (spaceIdx === -1)
|
|
25
|
+
continue;
|
|
26
|
+
const msg = line.substring(spaceIdx + 1);
|
|
27
|
+
if (AI_PATTERNS.test(msg)) {
|
|
28
|
+
const sha = line.substring(0, spaceIdx);
|
|
29
|
+
// Use parent of this commit as baseline (the commit before AI started)
|
|
30
|
+
const parent = gitExec(['rev-parse', `${sha}~1`], cwd);
|
|
31
|
+
if (parent) {
|
|
32
|
+
const parentMsg = gitExec(['log', '--oneline', '-1', parent], cwd);
|
|
33
|
+
return { sha: parent, message: parentMsg.substring(parentMsg.indexOf(' ') + 1) || parent, method: 'ai-commit-parent' };
|
|
34
|
+
}
|
|
35
|
+
// If no parent (first commit is AI), use this commit itself
|
|
36
|
+
return { sha, message: msg, method: 'ai-commit' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// 2. Fallback: find first commit adding AI marker files
|
|
40
|
+
for (const marker of AI_FILE_MARKERS) {
|
|
41
|
+
const result = gitExec(['log', '--all', '--reverse', '--diff-filter=A', '--', marker], cwd);
|
|
42
|
+
if (result) {
|
|
43
|
+
const firstLine = result.split('\n').find(l => l.startsWith('commit '));
|
|
44
|
+
if (firstLine) {
|
|
45
|
+
const sha = firstLine.replace('commit ', '').trim();
|
|
46
|
+
const shortSha = sha.substring(0, 7);
|
|
47
|
+
const parent = gitExec(['rev-parse', `${sha}~1`], cwd);
|
|
48
|
+
if (parent) {
|
|
49
|
+
const parentLog = gitExec(['log', '--oneline', '-1', parent], cwd);
|
|
50
|
+
return { sha: parent, message: parentLog.substring(parentLog.indexOf(' ') + 1) || parent, method: 'marker-file' };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 3. Fallback: initial commit (everything is baseline)
|
|
56
|
+
const initial = lines[0];
|
|
57
|
+
const spaceIdx = initial.indexOf(' ');
|
|
58
|
+
const sha = spaceIdx !== -1 ? initial.substring(0, spaceIdx) : initial;
|
|
59
|
+
const msg = spaceIdx !== -1 ? initial.substring(spaceIdx + 1) : sha;
|
|
60
|
+
return { sha, message: msg, method: 'initial' };
|
|
61
|
+
}
|
|
62
|
+
function analyzeFileGrowth(cwd, baselineSha) {
|
|
63
|
+
if (!baselineSha) {
|
|
64
|
+
return { files: [], baselineLOC: 0, currentLOC: 0 };
|
|
65
|
+
}
|
|
66
|
+
const numstat = gitExec(['diff', baselineSha, 'HEAD', '--numstat'], cwd);
|
|
67
|
+
if (!numstat) {
|
|
68
|
+
return { files: [], baselineLOC: 0, currentLOC: 0 };
|
|
69
|
+
}
|
|
70
|
+
const files = [];
|
|
71
|
+
let totalBaselineLOC = 0;
|
|
72
|
+
let totalCurrentLOC = 0;
|
|
73
|
+
for (const line of numstat.split('\n').filter(Boolean)) {
|
|
74
|
+
const parts = line.split('\t');
|
|
75
|
+
if (parts.length < 3)
|
|
76
|
+
continue;
|
|
77
|
+
const [addStr, delStr, file] = parts;
|
|
78
|
+
// Binary files show as '-'
|
|
79
|
+
if (addStr === '-' || delStr === '-')
|
|
80
|
+
continue;
|
|
81
|
+
const additions = parseInt(addStr, 10) || 0;
|
|
82
|
+
const deletions = parseInt(delStr, 10) || 0;
|
|
83
|
+
// Get baseline line count for this file
|
|
84
|
+
const baselineContent = gitExec(['show', `${baselineSha}:${file}`], cwd);
|
|
85
|
+
const baselineLines = baselineContent ? baselineContent.split('\n').length : 0;
|
|
86
|
+
const isNew = baselineLines === 0;
|
|
87
|
+
const currentLines = Math.max(0, baselineLines + additions - deletions);
|
|
88
|
+
const denominator = Math.max(baselineLines, 1);
|
|
89
|
+
const growthPct = ((currentLines / denominator) - 1) * 100;
|
|
90
|
+
totalBaselineLOC += baselineLines;
|
|
91
|
+
totalCurrentLOC += currentLines;
|
|
92
|
+
files.push({ file, additions, deletions, baselineLines, currentLines, growthPct, isNew });
|
|
93
|
+
}
|
|
94
|
+
// If baseline LOC is 0 but we have current LOC, count all current files
|
|
95
|
+
if (totalBaselineLOC === 0) {
|
|
96
|
+
// Count current LOC from all tracked files
|
|
97
|
+
const lsFiles = gitExec(['ls-files'], cwd);
|
|
98
|
+
if (lsFiles) {
|
|
99
|
+
for (const f of lsFiles.split('\n').filter(Boolean)) {
|
|
100
|
+
const content = readFile(join(cwd, f));
|
|
101
|
+
if (content)
|
|
102
|
+
totalCurrentLOC += content.split('\n').length;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { files, baselineLOC: totalBaselineLOC, currentLOC: totalCurrentLOC };
|
|
107
|
+
}
|
|
108
|
+
function findNonCodeBombs(cwd) {
|
|
109
|
+
const bombs = [];
|
|
110
|
+
const allFiles = walkFiles(cwd);
|
|
111
|
+
for (const file of allFiles) {
|
|
112
|
+
const ext = extname(file).toLowerCase();
|
|
113
|
+
if (!NON_CODE_EXTS.has(ext))
|
|
114
|
+
continue;
|
|
115
|
+
try {
|
|
116
|
+
const stat = statSync(join(cwd, file));
|
|
117
|
+
if (stat.size > TEN_MB) {
|
|
118
|
+
bombs.push({ file, sizeBytes: stat.size });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { /* skip */ }
|
|
122
|
+
}
|
|
123
|
+
return bombs;
|
|
124
|
+
}
|
|
125
|
+
function findPaddingFiles(cwd) {
|
|
126
|
+
const paddings = [];
|
|
127
|
+
const allFiles = walkFiles(cwd);
|
|
128
|
+
for (const file of allFiles) {
|
|
129
|
+
const ext = extname(file).toLowerCase();
|
|
130
|
+
if (!SOURCE_EXTS.has(ext))
|
|
131
|
+
continue;
|
|
132
|
+
const content = readFile(join(cwd, file));
|
|
133
|
+
if (!content)
|
|
134
|
+
continue;
|
|
135
|
+
const lines = content.split('\n');
|
|
136
|
+
const loc = lines.length;
|
|
137
|
+
if (loc <= 1000)
|
|
138
|
+
continue;
|
|
139
|
+
// Count branch constructs
|
|
140
|
+
let branches = 0;
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
const matches = line.match(BRANCH_RE);
|
|
143
|
+
if (matches)
|
|
144
|
+
branches += matches.length;
|
|
145
|
+
}
|
|
146
|
+
const density = branches / loc;
|
|
147
|
+
if (density < 0.02) {
|
|
148
|
+
paddings.push({ file, loc, density });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return paddings;
|
|
152
|
+
}
|
|
153
|
+
// ── Scoring ──────────────────────────────────────────────────────────────────
|
|
154
|
+
function calculateScore(bloatRatio, bloatedFiles, bombs, paddings) {
|
|
155
|
+
let s = 100;
|
|
156
|
+
const penalties = [];
|
|
157
|
+
// Bloat ratio penalties
|
|
158
|
+
if (bloatRatio > 20) {
|
|
159
|
+
s -= 40;
|
|
160
|
+
penalties.push(`bloat ratio ${bloatRatio.toFixed(1)}x (>20x): -40`);
|
|
161
|
+
}
|
|
162
|
+
else if (bloatRatio > 10) {
|
|
163
|
+
s -= 30;
|
|
164
|
+
penalties.push(`bloat ratio ${bloatRatio.toFixed(1)}x (>10x): -30`);
|
|
165
|
+
}
|
|
166
|
+
else if (bloatRatio > 5) {
|
|
167
|
+
s -= 20;
|
|
168
|
+
penalties.push(`bloat ratio ${bloatRatio.toFixed(1)}x (>5x): -20`);
|
|
169
|
+
}
|
|
170
|
+
// Per-file growth penalties
|
|
171
|
+
const growthPenalty = Math.min(30, bloatedFiles.length * 5);
|
|
172
|
+
if (growthPenalty > 0) {
|
|
173
|
+
s -= growthPenalty;
|
|
174
|
+
penalties.push(`${bloatedFiles.length} file(s) >500% growth: -${growthPenalty}`);
|
|
175
|
+
}
|
|
176
|
+
// Non-code bomb penalties
|
|
177
|
+
const bombPenalty = Math.min(20, bombs.length * 10);
|
|
178
|
+
if (bombPenalty > 0) {
|
|
179
|
+
s -= bombPenalty;
|
|
180
|
+
penalties.push(`${bombs.length} non-code bomb(s) >10MB: -${bombPenalty}`);
|
|
181
|
+
}
|
|
182
|
+
// Padding penalties
|
|
183
|
+
const paddingPenalty = Math.min(20, paddings.length * 5);
|
|
184
|
+
if (paddingPenalty > 0) {
|
|
185
|
+
s -= paddingPenalty;
|
|
186
|
+
penalties.push(`${paddings.length} low-complexity padding file(s): -${paddingPenalty}`);
|
|
187
|
+
}
|
|
188
|
+
return { score: Math.max(0, s), penalties };
|
|
189
|
+
}
|
|
190
|
+
// ── Format helpers ───────────────────────────────────────────────────────────
|
|
191
|
+
function formatSize(bytes) {
|
|
192
|
+
if (bytes >= 1024 * 1024 * 1024)
|
|
193
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(0)}GB`;
|
|
194
|
+
if (bytes >= 1024 * 1024)
|
|
195
|
+
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
|
196
|
+
if (bytes >= 1024)
|
|
197
|
+
return `${(bytes / 1024).toFixed(0)}KB`;
|
|
198
|
+
return `${bytes}B`;
|
|
199
|
+
}
|
|
200
|
+
// ── Main check (for full vet run) ────────────────────────────────────────────
|
|
201
|
+
export async function checkBloat(cwd) {
|
|
202
|
+
const baseline = findBaseline(cwd);
|
|
203
|
+
if (!baseline.sha) {
|
|
204
|
+
return {
|
|
205
|
+
name: 'bloat',
|
|
206
|
+
score: 100,
|
|
207
|
+
maxScore: 100,
|
|
208
|
+
issues: [{ severity: 'info', message: 'empty repo — no bloat analysis possible', fixable: false }],
|
|
209
|
+
summary: 'empty repo',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const { files, baselineLOC, currentLOC } = analyzeFileGrowth(cwd, baseline.sha);
|
|
213
|
+
// Check if repo has any tracked files
|
|
214
|
+
const trackedFiles = gitExec(['ls-files'], cwd);
|
|
215
|
+
if (!trackedFiles || trackedFiles.trim().length === 0) {
|
|
216
|
+
return {
|
|
217
|
+
name: 'bloat',
|
|
218
|
+
score: 100,
|
|
219
|
+
maxScore: 100,
|
|
220
|
+
issues: [{ severity: 'info', message: 'empty repo — no files to analyze', fixable: false }],
|
|
221
|
+
summary: 'empty repo',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const bloatRatio = baselineLOC > 0 ? currentLOC / baselineLOC : 1;
|
|
225
|
+
const bloatedFiles = files.filter(f => f.growthPct > 500);
|
|
226
|
+
const bombs = findNonCodeBombs(cwd);
|
|
227
|
+
const paddings = findPaddingFiles(cwd);
|
|
228
|
+
const { score: finalScore, penalties } = calculateScore(bloatRatio, bloatedFiles, bombs, paddings);
|
|
229
|
+
const issues = [];
|
|
230
|
+
for (const f of bloatedFiles) {
|
|
231
|
+
issues.push({
|
|
232
|
+
severity: 'warning',
|
|
233
|
+
message: `file growth +${Math.round(f.growthPct)}%: ${f.file} (${f.baselineLines} → ${f.currentLines} lines)`,
|
|
234
|
+
file: f.file,
|
|
235
|
+
fixable: false,
|
|
236
|
+
fixHint: 'review for unnecessary code generation',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
for (const b of bombs) {
|
|
240
|
+
issues.push({
|
|
241
|
+
severity: 'warning',
|
|
242
|
+
message: `non-code bomb: ${b.file} (${formatSize(b.sizeBytes)})`,
|
|
243
|
+
file: b.file,
|
|
244
|
+
fixable: true,
|
|
245
|
+
fixHint: 'add to .gitignore or remove large non-code file',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
for (const p of paddings) {
|
|
249
|
+
issues.push({
|
|
250
|
+
severity: 'warning',
|
|
251
|
+
message: `low-complexity padding: ${p.file} (${p.loc} LOC, density ${p.density.toFixed(3)})`,
|
|
252
|
+
file: p.file,
|
|
253
|
+
fixable: false,
|
|
254
|
+
fixHint: 'review for auto-generated boilerplate',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (bloatRatio > 5) {
|
|
258
|
+
issues.push({
|
|
259
|
+
severity: bloatRatio > 20 ? 'error' : 'warning',
|
|
260
|
+
message: `bloat ratio ${bloatRatio.toFixed(1)}x (baseline: ${baselineLOC} LOC → current: ${currentLOC} LOC)`,
|
|
261
|
+
fixable: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const parts = [];
|
|
265
|
+
parts.push(`${bloatRatio.toFixed(1)}x bloat ratio`);
|
|
266
|
+
if (bloatedFiles.length > 0)
|
|
267
|
+
parts.push(`${bloatedFiles.length} bloated file(s)`);
|
|
268
|
+
if (bombs.length > 0)
|
|
269
|
+
parts.push(`${bombs.length} non-code bomb(s)`);
|
|
270
|
+
if (paddings.length > 0)
|
|
271
|
+
parts.push(`${paddings.length} padding file(s)`);
|
|
272
|
+
return {
|
|
273
|
+
name: 'bloat',
|
|
274
|
+
score: finalScore,
|
|
275
|
+
maxScore: 100,
|
|
276
|
+
issues,
|
|
277
|
+
summary: parts.join(', '),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// ── Standalone subcommand ────────────────────────────────────────────────────
|
|
281
|
+
export async function runBloatCommand(format) {
|
|
282
|
+
const cwd = process.cwd();
|
|
283
|
+
const baseline = findBaseline(cwd);
|
|
284
|
+
if (!baseline.sha) {
|
|
285
|
+
if (format === 'json') {
|
|
286
|
+
console.log(JSON.stringify({ baseline: null, bloatRatio: 1, score: 100, files: [], message: 'empty repo' }));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
console.log(`\n ${c.bold}vet bloat${c.reset} — agent code bloat detector\n`);
|
|
290
|
+
console.log(` ${c.dim}empty repo — no bloat analysis possible${c.reset}\n`);
|
|
291
|
+
console.log(` score: 100/100\n`);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const { files, baselineLOC, currentLOC } = analyzeFileGrowth(cwd, baseline.sha);
|
|
296
|
+
const bloatRatio = baselineLOC > 0 ? currentLOC / baselineLOC : 1;
|
|
297
|
+
const bloatedFiles = files.filter(f => f.growthPct > 500);
|
|
298
|
+
const bombs = findNonCodeBombs(cwd);
|
|
299
|
+
const paddings = findPaddingFiles(cwd);
|
|
300
|
+
const { score: finalScore } = calculateScore(bloatRatio, bloatedFiles, bombs, paddings);
|
|
301
|
+
if (format === 'json') {
|
|
302
|
+
const result = {
|
|
303
|
+
baseline: { sha: baseline.sha, message: baseline.message, method: baseline.method },
|
|
304
|
+
bloatRatio: Math.round(bloatRatio * 10) / 10,
|
|
305
|
+
baselineLOC,
|
|
306
|
+
currentLOC,
|
|
307
|
+
score: finalScore,
|
|
308
|
+
bloatedFiles: bloatedFiles.map(f => ({ file: f.file, growthPct: Math.round(f.growthPct), lines: f.currentLines })),
|
|
309
|
+
nonCodeBombs: bombs.map(b => ({ file: b.file, size: b.sizeBytes })),
|
|
310
|
+
paddingFiles: paddings.map(p => ({ file: p.file, loc: p.loc, density: Math.round(p.density * 1000) / 1000 })),
|
|
311
|
+
};
|
|
312
|
+
console.log(JSON.stringify(result, null, 2));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// ASCII output
|
|
316
|
+
console.log(`\n ${c.bold}vet bloat${c.reset} — agent code bloat detector\n`);
|
|
317
|
+
console.log(` baseline: ${baseline.sha.substring(0, 7)} (${baseline.message})`);
|
|
318
|
+
console.log(` bloat ratio: ${bloatRatio.toFixed(1)}x (baseline: ${baselineLOC} LOC → current: ${currentLOC} LOC)\n`);
|
|
319
|
+
const tableRows = [];
|
|
320
|
+
for (const f of bloatedFiles) {
|
|
321
|
+
tableRows.push({
|
|
322
|
+
file: f.file,
|
|
323
|
+
growth: f.isNew ? 'new' : `+${Math.round(f.growthPct)}%`,
|
|
324
|
+
lines: String(f.currentLines),
|
|
325
|
+
complexity: '',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
for (const b of bombs) {
|
|
329
|
+
tableRows.push({
|
|
330
|
+
file: b.file,
|
|
331
|
+
growth: 'new',
|
|
332
|
+
lines: formatSize(b.sizeBytes),
|
|
333
|
+
complexity: 'non-code bomb',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
for (const p of paddings) {
|
|
337
|
+
// Only add if not already in bloatedFiles
|
|
338
|
+
if (!bloatedFiles.some(f => f.file === p.file)) {
|
|
339
|
+
tableRows.push({
|
|
340
|
+
file: p.file,
|
|
341
|
+
growth: '',
|
|
342
|
+
lines: String(p.loc),
|
|
343
|
+
complexity: 'low (padding)',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
// Annotate existing row
|
|
348
|
+
const row = tableRows.find(r => r.file === p.file);
|
|
349
|
+
if (row)
|
|
350
|
+
row.complexity = 'low (padding)';
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (tableRows.length > 0) {
|
|
354
|
+
console.log(` ${c.dim}# file${' '.repeat(30)}growth lines complexity${c.reset}`);
|
|
355
|
+
for (let i = 0; i < tableRows.length; i++) {
|
|
356
|
+
const r = tableRows[i];
|
|
357
|
+
const num = String(i + 1).padStart(2);
|
|
358
|
+
const file = r.file.padEnd(35).substring(0, 35);
|
|
359
|
+
const growth = r.growth.padEnd(10);
|
|
360
|
+
const lines = r.lines.padEnd(11);
|
|
361
|
+
console.log(` ${num} ${file}${growth}${lines}${r.complexity}`);
|
|
362
|
+
}
|
|
363
|
+
console.log('');
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
console.log(` ${c.green}no bloat detected${c.reset}\n`);
|
|
367
|
+
}
|
|
368
|
+
console.log(` score: ${finalScore}/100\n`);
|
|
369
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Completeness check — scores repos on presence of good practices.
|
|
4
|
+
*
|
|
5
|
+
* Unlike other checks (penalty-based), this is BONUS-based:
|
|
6
|
+
* starts at 0 and adds points for good signals.
|
|
7
|
+
* This prevents empty/joke repos from scoring 100.
|
|
8
|
+
*/
|
|
9
|
+
export declare function checkCompleteness(cwd: string, ignore: string[]): Promise<CheckResult>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { walkFiles, readFile } from '../util.js';
|
|
4
|
+
/**
|
|
5
|
+
* Completeness check — scores repos on presence of good practices.
|
|
6
|
+
*
|
|
7
|
+
* Unlike other checks (penalty-based), this is BONUS-based:
|
|
8
|
+
* starts at 0 and adds points for good signals.
|
|
9
|
+
* This prevents empty/joke repos from scoring 100.
|
|
10
|
+
*/
|
|
11
|
+
export async function checkCompleteness(cwd, ignore) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
let points = 0;
|
|
14
|
+
const maxPoints = 100;
|
|
15
|
+
const files = walkFiles(cwd, ignore);
|
|
16
|
+
const fileNames = files.map(f => f.toLowerCase());
|
|
17
|
+
// ── Source code presence (0-25 points) ──
|
|
18
|
+
const jstsFiles = files.filter(f => /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/.test(f));
|
|
19
|
+
const srcFiles = jstsFiles.filter(f => !/node_modules|\.test\.|\.spec\.|__tests__/.test(f));
|
|
20
|
+
if (srcFiles.length === 0) {
|
|
21
|
+
issues.push({
|
|
22
|
+
file: '',
|
|
23
|
+
message: 'no JS/TS source files found',
|
|
24
|
+
severity: 'warning',
|
|
25
|
+
fixable: false,
|
|
26
|
+
});
|
|
27
|
+
// No source = max 30 points total (can't be a good JS/TS project)
|
|
28
|
+
return {
|
|
29
|
+
name: 'completeness',
|
|
30
|
+
score: 0,
|
|
31
|
+
maxScore: 100,
|
|
32
|
+
issues,
|
|
33
|
+
summary: 'no JS/TS source files',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (srcFiles.length >= 3)
|
|
37
|
+
points += 25;
|
|
38
|
+
else if (srcFiles.length >= 1)
|
|
39
|
+
points += 15;
|
|
40
|
+
// ── Tests presence (0-20 points) ──
|
|
41
|
+
const testFiles = jstsFiles.filter(f => /\.test\.|\.spec\.|__tests__/.test(f));
|
|
42
|
+
const hasTestDir = fileNames.some(f => f.startsWith('test/') || f.startsWith('tests/') || f.startsWith('__tests__/'));
|
|
43
|
+
if (testFiles.length >= 5) {
|
|
44
|
+
points += 20;
|
|
45
|
+
}
|
|
46
|
+
else if (testFiles.length >= 1 || hasTestDir) {
|
|
47
|
+
points += 10;
|
|
48
|
+
issues.push({
|
|
49
|
+
file: '',
|
|
50
|
+
message: `only ${testFiles.length} test file(s) found`,
|
|
51
|
+
severity: 'info',
|
|
52
|
+
fixable: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
issues.push({
|
|
57
|
+
file: '',
|
|
58
|
+
message: 'no test files found',
|
|
59
|
+
severity: 'warning',
|
|
60
|
+
fixable: false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// ── Package.json quality (0-15 points) ──
|
|
64
|
+
const pkgPath = join(cwd, 'package.json');
|
|
65
|
+
if (existsSync(pkgPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const pkg = JSON.parse(readFile(pkgPath) ?? '{}');
|
|
68
|
+
let pkgPoints = 0;
|
|
69
|
+
if (pkg.name)
|
|
70
|
+
pkgPoints += 3;
|
|
71
|
+
if (pkg.description)
|
|
72
|
+
pkgPoints += 3;
|
|
73
|
+
if (pkg.license)
|
|
74
|
+
pkgPoints += 3;
|
|
75
|
+
if (pkg.scripts?.test)
|
|
76
|
+
pkgPoints += 3;
|
|
77
|
+
if (pkg.scripts?.build || pkg.scripts?.compile)
|
|
78
|
+
pkgPoints += 3;
|
|
79
|
+
points += Math.min(15, pkgPoints);
|
|
80
|
+
}
|
|
81
|
+
catch { /* skip */ }
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
issues.push({
|
|
85
|
+
file: 'package.json',
|
|
86
|
+
message: 'no package.json found',
|
|
87
|
+
severity: 'info',
|
|
88
|
+
fixable: false,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// ── TypeScript (0-10 points) ──
|
|
92
|
+
const hasTsConfig = existsSync(join(cwd, 'tsconfig.json'));
|
|
93
|
+
const tsFiles = files.filter(f => /\.tsx?$/.test(f));
|
|
94
|
+
if (hasTsConfig && tsFiles.length > 0) {
|
|
95
|
+
points += 10;
|
|
96
|
+
}
|
|
97
|
+
else if (tsFiles.length > 0) {
|
|
98
|
+
points += 5;
|
|
99
|
+
}
|
|
100
|
+
// ── Documentation (0-10 points) ──
|
|
101
|
+
const hasReadme = fileNames.some(f => f === 'readme.md' || f === 'readme');
|
|
102
|
+
const hasChangelog = fileNames.some(f => f.includes('changelog'));
|
|
103
|
+
const hasContributing = fileNames.some(f => f.includes('contributing'));
|
|
104
|
+
if (hasReadme)
|
|
105
|
+
points += 5;
|
|
106
|
+
if (hasChangelog || hasContributing)
|
|
107
|
+
points += 5;
|
|
108
|
+
// ── CI/CD (0-10 points) ──
|
|
109
|
+
const hasCI = existsSync(join(cwd, '.github/workflows')) ||
|
|
110
|
+
existsSync(join(cwd, '.gitlab-ci.yml')) ||
|
|
111
|
+
existsSync(join(cwd, '.circleci'));
|
|
112
|
+
if (hasCI)
|
|
113
|
+
points += 10;
|
|
114
|
+
// ── Linting/Formatting (0-10 points) ──
|
|
115
|
+
const hasLint = existsSync(join(cwd, '.eslintrc.json')) ||
|
|
116
|
+
existsSync(join(cwd, '.eslintrc.js')) ||
|
|
117
|
+
existsSync(join(cwd, '.eslintrc.cjs')) ||
|
|
118
|
+
existsSync(join(cwd, 'eslint.config.js')) ||
|
|
119
|
+
existsSync(join(cwd, 'eslint.config.mjs')) ||
|
|
120
|
+
existsSync(join(cwd, 'biome.json')) ||
|
|
121
|
+
existsSync(join(cwd, 'biome.jsonc'));
|
|
122
|
+
const hasPrettier = existsSync(join(cwd, '.prettierrc')) ||
|
|
123
|
+
existsSync(join(cwd, '.prettierrc.json')) ||
|
|
124
|
+
existsSync(join(cwd, 'prettier.config.js'));
|
|
125
|
+
if (hasLint)
|
|
126
|
+
points += 5;
|
|
127
|
+
if (hasPrettier || hasLint)
|
|
128
|
+
points += 5;
|
|
129
|
+
const score = Math.min(maxPoints, points);
|
|
130
|
+
const parts = [];
|
|
131
|
+
if (srcFiles.length > 0)
|
|
132
|
+
parts.push(`${srcFiles.length} source files`);
|
|
133
|
+
if (testFiles.length > 0)
|
|
134
|
+
parts.push(`${testFiles.length} test files`);
|
|
135
|
+
if (hasTsConfig)
|
|
136
|
+
parts.push('TypeScript');
|
|
137
|
+
if (hasCI)
|
|
138
|
+
parts.push('CI');
|
|
139
|
+
return {
|
|
140
|
+
name: 'completeness',
|
|
141
|
+
score,
|
|
142
|
+
maxScore: 100,
|
|
143
|
+
issues,
|
|
144
|
+
summary: parts.length > 0 ? parts.join(', ') : 'minimal project',
|
|
145
|
+
};
|
|
146
|
+
}
|
package/dist/checks/deps.js
CHANGED
|
@@ -521,7 +521,7 @@ export async function checkDeps(cwd) {
|
|
|
521
521
|
// ── Scoring ────────────────────────────────────────────────────────────────
|
|
522
522
|
const errors = issues.filter(i => i.severity === 'error').length;
|
|
523
523
|
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
524
|
-
const rawScore = 100 - (errors *
|
|
524
|
+
const rawScore = 100 - (errors * 20) - (warnings * 5);
|
|
525
525
|
const finalScore = Math.max(0, Math.min(100, rawScore));
|
|
526
526
|
// ── Summary ────────────────────────────────────────────────────────────────
|
|
527
527
|
const parts = [];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
interface SessionEntry {
|
|
3
|
+
type?: string;
|
|
4
|
+
role?: string;
|
|
5
|
+
content?: unknown;
|
|
6
|
+
model?: string;
|
|
7
|
+
usage?: {
|
|
8
|
+
input_tokens?: number;
|
|
9
|
+
output_tokens?: number;
|
|
10
|
+
cache_read_input_tokens?: number;
|
|
11
|
+
cache_creation_input_tokens?: number;
|
|
12
|
+
};
|
|
13
|
+
message?: {
|
|
14
|
+
role?: string;
|
|
15
|
+
content?: unknown;
|
|
16
|
+
model?: string;
|
|
17
|
+
usage?: SessionEntry['usage'];
|
|
18
|
+
};
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
interface Iteration {
|
|
22
|
+
index: number;
|
|
23
|
+
fileChanges: number;
|
|
24
|
+
uniqueFiles: Set<string>;
|
|
25
|
+
testCount: number;
|
|
26
|
+
outcome: 'pass' | 'fail' | 'unknown';
|
|
27
|
+
inputTokens: number;
|
|
28
|
+
outputTokens: number;
|
|
29
|
+
cost: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function analyzeSession(entries: SessionEntry[]): {
|
|
32
|
+
iterations: Iteration[];
|
|
33
|
+
totalCost: number;
|
|
34
|
+
allFiles: Set<string>;
|
|
35
|
+
model: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function checkLoop(cwd: string): Promise<CheckResult>;
|
|
38
|
+
export declare function runLoopCommand(format: 'ascii' | 'json', sessionPath?: string): Promise<void>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { c } from '../util.js';
|
|
5
|
+
import { findLatestSession } from './receipt.js';
|
|
6
|
+
// ── Pricing table (per 1M tokens) ────────────────────────────────────────────
|
|
7
|
+
const PRICING = {
|
|
8
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
9
|
+
'claude-sonnet-4-5': { input: 3, output: 15 },
|
|
10
|
+
'claude-opus-4-6': { input: 15, output: 75 },
|
|
11
|
+
'claude-haiku-3-5': { input: 0.25, output: 1.25 },
|
|
12
|
+
};
|
|
13
|
+
const FALLBACK_PRICING = { input: 3, output: 15 };
|
|
14
|
+
function getPricing(model) {
|
|
15
|
+
for (const [key, price] of Object.entries(PRICING)) {
|
|
16
|
+
if (model.includes(key))
|
|
17
|
+
return price;
|
|
18
|
+
}
|
|
19
|
+
// Partial match
|
|
20
|
+
if (model.includes('opus'))
|
|
21
|
+
return { input: 15, output: 75 };
|
|
22
|
+
if (model.includes('haiku'))
|
|
23
|
+
return { input: 0.25, output: 1.25 };
|
|
24
|
+
return FALLBACK_PRICING;
|
|
25
|
+
}
|
|
26
|
+
function calcCost(inputTokens, outputTokens, model) {
|
|
27
|
+
const price = getPricing(model);
|
|
28
|
+
return (inputTokens / 1_000_000) * price.input + (outputTokens / 1_000_000) * price.output;
|
|
29
|
+
}
|
|
30
|
+
// ── Test command detection ────────────────────────────────────────────────────
|
|
31
|
+
const TEST_PATTERNS = [
|
|
32
|
+
/\bjest\b/,
|
|
33
|
+
/\bvitest\b/,
|
|
34
|
+
/\bpytest\b/,
|
|
35
|
+
/npm\s+test\b/,
|
|
36
|
+
/npm\s+run\s+test\b/,
|
|
37
|
+
/node\s+--test\b/,
|
|
38
|
+
/npx\s+vitest\b/,
|
|
39
|
+
/npx\s+jest\b/,
|
|
40
|
+
/\bmake\s+test\b/,
|
|
41
|
+
/\bcargo\s+test\b/,
|
|
42
|
+
];
|
|
43
|
+
function isTestCommand(cmd) {
|
|
44
|
+
return TEST_PATTERNS.some(p => p.test(cmd));
|
|
45
|
+
}
|
|
46
|
+
// ── File write detection ──────────────────────────────────────────────────────
|
|
47
|
+
function isFileWrite(name, input) {
|
|
48
|
+
// Tool name contains str_replace or write
|
|
49
|
+
if (/str_replace|write|edit/i.test(name)) {
|
|
50
|
+
const fp = input['path'] || input['file_path'] || undefined;
|
|
51
|
+
return { isWrite: true, filePath: fp };
|
|
52
|
+
}
|
|
53
|
+
// Bash command with redirect
|
|
54
|
+
if (name === 'bash' || name === 'shell') {
|
|
55
|
+
const cmd = input['command'] || '';
|
|
56
|
+
const hasRedirect = /(?:>>?|tee\s|cat\s*>|sed\s+-i)\s*\S/.test(cmd);
|
|
57
|
+
if (hasRedirect) {
|
|
58
|
+
// Try to extract the target file
|
|
59
|
+
const match = cmd.match(/(?:>>?\s*|tee\s+|cat\s*>\s*)([^\s|;&]+)/);
|
|
60
|
+
return { isWrite: true, filePath: match?.[1] };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { isWrite: false };
|
|
64
|
+
}
|
|
65
|
+
// ── Test outcome extraction ───────────────────────────────────────────────────
|
|
66
|
+
function extractTestCount(text) {
|
|
67
|
+
// "12 passing", "12 passed", "12 tests"
|
|
68
|
+
const m = text.match(/(\d+)\s+(?:passing|passed|tests?)/i);
|
|
69
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
70
|
+
}
|
|
71
|
+
function extractOutcome(text) {
|
|
72
|
+
const lower = text.toLowerCase();
|
|
73
|
+
// Non-zero explicit failure count wins immediately
|
|
74
|
+
if (/\b[1-9]\d*\s+fail(?:ed|ing|ure)?\b/i.test(text))
|
|
75
|
+
return 'fail';
|
|
76
|
+
// FAIL (all-caps marker used by Jest etc.)
|
|
77
|
+
if (/\bFAIL\b/.test(text))
|
|
78
|
+
return 'fail';
|
|
79
|
+
// Exit code failure
|
|
80
|
+
if (/exit code [1-9]/i.test(lower))
|
|
81
|
+
return 'fail';
|
|
82
|
+
// "0 failing" — explicitly zero failures
|
|
83
|
+
if (/\b0\s+fail(?:ed|ing|ure)?\b/i.test(lower))
|
|
84
|
+
return 'pass';
|
|
85
|
+
// Positive pass signals
|
|
86
|
+
if (/\bpassing\b|\bpassed\b/.test(lower))
|
|
87
|
+
return 'pass';
|
|
88
|
+
if (/test result:\s*ok\b/i.test(text))
|
|
89
|
+
return 'pass';
|
|
90
|
+
if (/all tests passed/i.test(text))
|
|
91
|
+
return 'pass';
|
|
92
|
+
// Generic fail words (no count context)
|
|
93
|
+
if (/\b(?:failed|failing|failure)\b/i.test(text))
|
|
94
|
+
return 'fail';
|
|
95
|
+
if (/\berror\b/.test(lower))
|
|
96
|
+
return 'fail';
|
|
97
|
+
if (/\bpass\b/.test(lower))
|
|
98
|
+
return 'pass';
|
|
99
|
+
return 'unknown';
|
|
100
|
+
}
|
|
101
|
+
// ── Core parsing ─────────────────────────────────────────────────────────────
|
|
102
|
+
async function parseEntries(filePath) {
|
|
103
|
+
const entries = [];
|
|
104
|
+
const rl = createInterface({
|
|
105
|
+
input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
|
|
106
|
+
crlfDelay: Infinity,
|
|
107
|
+
});
|
|
108
|
+
for await (const line of rl) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed)
|
|
111
|
+
continue;
|
|
112
|
+
try {
|
|
113
|
+
entries.push(JSON.parse(trimmed));
|
|
114
|
+
}
|
|
115
|
+
catch { /* skip malformed */ }
|
|
116
|
+
}
|
|
117
|
+
return entries;
|
|
118
|
+
}
|
|
119
|
+
function getToolUseBlocks(content) {
|
|
120
|
+
if (!Array.isArray(content))
|
|
121
|
+
return [];
|
|
122
|
+
return content.filter((b) => b && typeof b === 'object' && b.type === 'tool_use');
|
|
123
|
+
}
|
|
124
|
+
function getTextContent(content) {
|
|
125
|
+
if (typeof content === 'string')
|
|
126
|
+
return content;
|
|
127
|
+
if (Array.isArray(content)) {
|
|
128
|
+
return content.map(b => {
|
|
129
|
+
if (typeof b === 'string')
|
|
130
|
+
return b;
|
|
131
|
+
if (b && typeof b === 'object') {
|
|
132
|
+
const obj = b;
|
|
133
|
+
if (typeof obj['text'] === 'string')
|
|
134
|
+
return obj['text'];
|
|
135
|
+
if (typeof obj['content'] === 'string')
|
|
136
|
+
return obj['content'];
|
|
137
|
+
}
|
|
138
|
+
return '';
|
|
139
|
+
}).join('\n');
|
|
140
|
+
}
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
export function analyzeSession(entries) {
|
|
144
|
+
let model = 'claude-sonnet-4-6'; // default
|
|
145
|
+
const iterations = [];
|
|
146
|
+
// Detect model
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (typeof entry['model'] === 'string' && entry['model']) {
|
|
149
|
+
model = entry['model'];
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (entry['message'] && typeof entry['message']['model'] === 'string') {
|
|
153
|
+
model = entry['message']['model'];
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// We segment by test command invocations.
|
|
158
|
+
// Each time we see a test command tool_use, we close the current iteration and start a new one.
|
|
159
|
+
// The iteration collects file writes BEFORE the test command.
|
|
160
|
+
let currentIteration = {
|
|
161
|
+
index: 1,
|
|
162
|
+
fileChanges: 0,
|
|
163
|
+
uniqueFiles: new Set(),
|
|
164
|
+
testCount: 0,
|
|
165
|
+
outcome: 'unknown',
|
|
166
|
+
inputTokens: 0,
|
|
167
|
+
outputTokens: 0,
|
|
168
|
+
cost: 0,
|
|
169
|
+
};
|
|
170
|
+
let hasStarted = false;
|
|
171
|
+
let pendingTestEntry = false; // next tool_result belongs to a test command
|
|
172
|
+
const allFiles = new Set();
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
// Accumulate token usage
|
|
175
|
+
const usage = entry['usage'] || entry['message']?.['usage'];
|
|
176
|
+
if (usage) {
|
|
177
|
+
const inp = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
|
|
178
|
+
const out = usage.output_tokens || 0;
|
|
179
|
+
currentIteration.inputTokens += inp;
|
|
180
|
+
currentIteration.outputTokens += out;
|
|
181
|
+
}
|
|
182
|
+
// Assistant messages with tool_use blocks
|
|
183
|
+
const contentToCheck = entry['content'] || entry['message']?.['content'];
|
|
184
|
+
const blocks = getToolUseBlocks(contentToCheck);
|
|
185
|
+
for (const block of blocks) {
|
|
186
|
+
const name = block.name || '';
|
|
187
|
+
const input = block.input || {};
|
|
188
|
+
const cmd = input['command'] || '';
|
|
189
|
+
// Detect test command
|
|
190
|
+
if ((name === 'bash' || name === 'shell') && isTestCommand(cmd)) {
|
|
191
|
+
if (hasStarted) {
|
|
192
|
+
// Close current iteration
|
|
193
|
+
currentIteration.cost = calcCost(currentIteration.inputTokens, currentIteration.outputTokens, model);
|
|
194
|
+
iterations.push(currentIteration);
|
|
195
|
+
currentIteration = {
|
|
196
|
+
index: iterations.length + 1,
|
|
197
|
+
fileChanges: 0,
|
|
198
|
+
uniqueFiles: new Set(),
|
|
199
|
+
testCount: 0,
|
|
200
|
+
outcome: 'unknown',
|
|
201
|
+
inputTokens: 0,
|
|
202
|
+
outputTokens: 0,
|
|
203
|
+
cost: 0,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
hasStarted = true;
|
|
207
|
+
pendingTestEntry = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// Detect file writes (only if we've started or not yet — track globally)
|
|
211
|
+
const { isWrite, filePath } = isFileWrite(name, input);
|
|
212
|
+
if (isWrite) {
|
|
213
|
+
currentIteration.fileChanges++;
|
|
214
|
+
if (filePath) {
|
|
215
|
+
currentIteration.uniqueFiles.add(filePath);
|
|
216
|
+
allFiles.add(filePath);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Tool results (test outcomes)
|
|
221
|
+
if (pendingTestEntry && (entry['type'] === 'tool_result' || entry['role'] === 'tool')) {
|
|
222
|
+
const text = getTextContent(entry['content']);
|
|
223
|
+
const count = extractTestCount(text);
|
|
224
|
+
if (count > 0)
|
|
225
|
+
currentIteration.testCount = count;
|
|
226
|
+
currentIteration.outcome = extractOutcome(text);
|
|
227
|
+
pendingTestEntry = false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Finalize last iteration if we started one
|
|
231
|
+
if (hasStarted) {
|
|
232
|
+
currentIteration.cost = calcCost(currentIteration.inputTokens, currentIteration.outputTokens, model);
|
|
233
|
+
iterations.push(currentIteration);
|
|
234
|
+
}
|
|
235
|
+
const totalCost = iterations.reduce((s, it) => s + it.cost, 0);
|
|
236
|
+
return { iterations, totalCost, allFiles, model };
|
|
237
|
+
}
|
|
238
|
+
// ── Score calculation ─────────────────────────────────────────────────────────
|
|
239
|
+
function calculateScore(iterations, totalCost, allFiles) {
|
|
240
|
+
const issues = [];
|
|
241
|
+
const runawayFlags = [];
|
|
242
|
+
let penalty = 0;
|
|
243
|
+
if (iterations.length > 10) {
|
|
244
|
+
runawayFlags.push(`${iterations.length} iterations (threshold: 10)`);
|
|
245
|
+
penalty += 30;
|
|
246
|
+
issues.push({ severity: 'error', message: `runaway: ${iterations.length} iterations (threshold: 10)`, fixable: false });
|
|
247
|
+
}
|
|
248
|
+
if (totalCost > 1) {
|
|
249
|
+
runawayFlags.push(`$${totalCost.toFixed(2)} total cost (threshold: $1.00)`);
|
|
250
|
+
penalty += 20;
|
|
251
|
+
issues.push({ severity: 'error', message: `runaway: $${totalCost.toFixed(2)} total cost (threshold: $1.00)`, fixable: false });
|
|
252
|
+
}
|
|
253
|
+
if (allFiles.size > 20) {
|
|
254
|
+
runawayFlags.push(`${allFiles.size} unique files touched (threshold: 20)`);
|
|
255
|
+
penalty += 10;
|
|
256
|
+
issues.push({ severity: 'warning', message: `runaway: ${allFiles.size} unique files touched (threshold: 20)`, fixable: false });
|
|
257
|
+
}
|
|
258
|
+
for (const it of iterations) {
|
|
259
|
+
if (it.fileChanges > 5) {
|
|
260
|
+
penalty += 5;
|
|
261
|
+
issues.push({ severity: 'warning', message: `iteration ${it.index}: ${it.fileChanges} file changes (threshold: 5)`, fixable: false });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const score = Math.max(0, 100 - penalty);
|
|
265
|
+
return { score, issues, runawayFlags };
|
|
266
|
+
}
|
|
267
|
+
// ── Check function (for full vet run) ────────────────────────────────────────
|
|
268
|
+
export async function checkLoop(cwd) {
|
|
269
|
+
const sessionFile = findLatestSession();
|
|
270
|
+
if (!sessionFile) {
|
|
271
|
+
return {
|
|
272
|
+
name: 'loop',
|
|
273
|
+
score: 100,
|
|
274
|
+
maxScore: 100,
|
|
275
|
+
issues: [{ severity: 'info', message: 'no claude session files found (~/.claude/projects/)', fixable: false }],
|
|
276
|
+
summary: 'no session logs found',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = await parseEntries(sessionFile);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return {
|
|
285
|
+
name: 'loop',
|
|
286
|
+
score: 100,
|
|
287
|
+
maxScore: 100,
|
|
288
|
+
issues: [{ severity: 'warning', message: 'could not parse session file', fixable: false }],
|
|
289
|
+
summary: 'session parse error',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (entries.length === 0) {
|
|
293
|
+
return {
|
|
294
|
+
name: 'loop',
|
|
295
|
+
score: 100,
|
|
296
|
+
maxScore: 100,
|
|
297
|
+
issues: [{ severity: 'info', message: 'session file is empty', fixable: false }],
|
|
298
|
+
summary: 'empty session file',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const { iterations, totalCost, allFiles } = analyzeSession(entries);
|
|
302
|
+
if (iterations.length === 0) {
|
|
303
|
+
return {
|
|
304
|
+
name: 'loop',
|
|
305
|
+
score: 100,
|
|
306
|
+
maxScore: 100,
|
|
307
|
+
issues: [{ severity: 'info', message: 'no /loop iterations detected (no test commands found)', fixable: false }],
|
|
308
|
+
summary: 'no loop iterations detected',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const { score, issues, runawayFlags } = calculateScore(iterations, totalCost, allFiles);
|
|
312
|
+
const failCount = iterations.filter(it => it.outcome === 'fail').length;
|
|
313
|
+
const passCount = iterations.filter(it => it.outcome === 'pass').length;
|
|
314
|
+
return {
|
|
315
|
+
name: 'loop',
|
|
316
|
+
score,
|
|
317
|
+
maxScore: 100,
|
|
318
|
+
issues,
|
|
319
|
+
summary: `${iterations.length} iteration${iterations.length !== 1 ? 's' : ''}: ${passCount} pass, ${failCount} fail${runawayFlags.length > 0 ? ` — runaway: ${runawayFlags.join(', ')}` : ''}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// ── Standalone subcommand ─────────────────────────────────────────────────────
|
|
323
|
+
export async function runLoopCommand(format, sessionPath) {
|
|
324
|
+
const filePath = sessionPath || findLatestSession();
|
|
325
|
+
if (!filePath) {
|
|
326
|
+
console.error('no claude session files found in ~/.claude/projects/');
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
if (!fs.existsSync(filePath) && !sessionPath) {
|
|
330
|
+
console.error('session file not found');
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
const entries = await parseEntries(filePath);
|
|
334
|
+
const { iterations, totalCost, allFiles } = analyzeSession(entries);
|
|
335
|
+
const { score, issues, runawayFlags } = calculateScore(iterations, totalCost, allFiles);
|
|
336
|
+
if (format === 'json') {
|
|
337
|
+
const result = {
|
|
338
|
+
name: 'loop',
|
|
339
|
+
score,
|
|
340
|
+
maxScore: 100,
|
|
341
|
+
issues,
|
|
342
|
+
summary: iterations.length === 0
|
|
343
|
+
? 'no loop iterations detected'
|
|
344
|
+
: `${iterations.length} iteration${iterations.length !== 1 ? 's' : ''} found`,
|
|
345
|
+
};
|
|
346
|
+
console.log(JSON.stringify(result, null, 2));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// ASCII output
|
|
350
|
+
const sessionId = path.basename(filePath, '.jsonl').slice(0, 40);
|
|
351
|
+
console.log(`\n ${c.bold}vet loop${c.reset} — /loop session forensics\n`);
|
|
352
|
+
console.log(` session: ${sessionId}`);
|
|
353
|
+
console.log(` iterations: ${iterations.length}`);
|
|
354
|
+
console.log(` total cost: $${totalCost.toFixed(2)}\n`);
|
|
355
|
+
if (iterations.length === 0) {
|
|
356
|
+
console.log(` ${c.green}no /loop iterations detected${c.reset}\n`);
|
|
357
|
+
console.log(` score: 100/100\n`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// Table header
|
|
361
|
+
const colW = { num: 3, files: 6, tests: 6, outcome: 8, cost: 8 };
|
|
362
|
+
const header = [
|
|
363
|
+
'#'.padEnd(colW.num),
|
|
364
|
+
'files'.padEnd(colW.files),
|
|
365
|
+
'tests'.padEnd(colW.tests),
|
|
366
|
+
'outcome'.padEnd(colW.outcome),
|
|
367
|
+
'cost',
|
|
368
|
+
].join(' ');
|
|
369
|
+
console.log(` ${c.dim}${header}${c.reset}`);
|
|
370
|
+
for (const it of iterations) {
|
|
371
|
+
const outcomeStr = it.outcome === 'pass'
|
|
372
|
+
? `${c.green}pass${c.reset}`
|
|
373
|
+
: it.outcome === 'fail'
|
|
374
|
+
? `${c.red}fail${c.reset}`
|
|
375
|
+
: `${c.dim}?${c.reset}`;
|
|
376
|
+
const numStr = String(it.index).padEnd(colW.num);
|
|
377
|
+
const filesStr = String(it.fileChanges).padEnd(colW.files);
|
|
378
|
+
const testsStr = String(it.testCount || '-').padEnd(colW.tests);
|
|
379
|
+
const costStr = `$${it.cost.toFixed(2)}`;
|
|
380
|
+
// outcome padding needs to account for color codes (invisible)
|
|
381
|
+
const outcomePadded = outcomeStr + ' '.repeat(Math.max(0, colW.outcome - it.outcome.length));
|
|
382
|
+
console.log(` ${numStr} ${filesStr} ${testsStr} ${outcomePadded} ${costStr}`);
|
|
383
|
+
}
|
|
384
|
+
console.log('');
|
|
385
|
+
for (const flag of runawayFlags) {
|
|
386
|
+
console.log(` ${c.yellow}⚠ runaway: ${flag}${c.reset}`);
|
|
387
|
+
}
|
|
388
|
+
if (runawayFlags.length > 0)
|
|
389
|
+
console.log('');
|
|
390
|
+
console.log(` score: ${score}/100\n`);
|
|
391
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,9 @@ import { checkMap, renderMapReport } from './checks/map.js';
|
|
|
21
21
|
import { checkPermissions } from './checks/permissions.js';
|
|
22
22
|
import { checkCompact, runCompactCommand } from './checks/compact.js';
|
|
23
23
|
import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
|
|
24
|
+
import { checkLoop, runLoopCommand } from './checks/loop.js';
|
|
25
|
+
import { checkBloat, runBloatCommand } from './checks/bloat.js';
|
|
26
|
+
import { checkCompleteness } from './checks/completeness.js';
|
|
24
27
|
import { score } from './scorer.js';
|
|
25
28
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
26
29
|
import { clearCache } from './file-cache.js';
|
|
@@ -70,6 +73,8 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
70
73
|
npx @safetnsr/vet permissions [dir] audit Claude Code config for dangerous grants
|
|
71
74
|
npx @safetnsr/vet compact [log] compaction forensics for claude code sessions
|
|
72
75
|
npx @safetnsr/vet subsidy [--plan tier] [--since date] show AI cost vs subscription
|
|
76
|
+
npx @safetnsr/vet loop [log] /loop session forensics — per-iteration timeline
|
|
77
|
+
npx @safetnsr/vet bloat detect agent-generated code bloat
|
|
73
78
|
|
|
74
79
|
${c.dim}categories:${c.reset}
|
|
75
80
|
security (30%) scan, secrets, config, model usage
|
|
@@ -105,7 +110,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
105
110
|
}
|
|
106
111
|
process.exit(0);
|
|
107
112
|
}
|
|
108
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy'];
|
|
113
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat'];
|
|
109
114
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
110
115
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
111
116
|
const isCI = flags.has('--ci');
|
|
@@ -213,6 +218,29 @@ if (command === 'subsidy') {
|
|
|
213
218
|
}
|
|
214
219
|
process.exit(0);
|
|
215
220
|
}
|
|
221
|
+
if (command === 'loop') {
|
|
222
|
+
try {
|
|
223
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
224
|
+
const sessionArg = positional.find(p => p !== 'loop' && !COMMANDS.includes(p));
|
|
225
|
+
await runLoopCommand(format, sessionArg);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
console.error(`${c.red}loop failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}
|
|
233
|
+
if (command === 'bloat') {
|
|
234
|
+
try {
|
|
235
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
236
|
+
await runBloatCommand(format);
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
console.error(`${c.red}bloat failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
216
244
|
if (!isGitRepo(cwd)) {
|
|
217
245
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
218
246
|
process.exit(1);
|
|
@@ -264,7 +292,7 @@ async function runChecks() {
|
|
|
264
292
|
}
|
|
265
293
|
}
|
|
266
294
|
// Run ALL independent checks in parallel
|
|
267
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult,] = await Promise.all([
|
|
295
|
+
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult,] = await Promise.all([
|
|
268
296
|
withTimeout('scan', () => checkScan(cwd)),
|
|
269
297
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
270
298
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -281,6 +309,9 @@ async function runChecks() {
|
|
|
281
309
|
withTimeout('memory', () => checkMemory(cwd)),
|
|
282
310
|
withTimeout('verify', () => checkVerify(cwd, since)),
|
|
283
311
|
withTimeout('tests', () => checkTests(cwd, ignore)),
|
|
312
|
+
withTimeout('loop', () => checkLoop(cwd)),
|
|
313
|
+
withTimeout('completeness', () => checkCompleteness(cwd, ignore)),
|
|
314
|
+
withTimeout('bloat', () => checkBloat(cwd)),
|
|
284
315
|
]);
|
|
285
316
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
286
317
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -291,8 +322,8 @@ async function runChecks() {
|
|
|
291
322
|
clearCache();
|
|
292
323
|
return score(cwd, {
|
|
293
324
|
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult],
|
|
294
|
-
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
|
|
295
|
-
debt: [readyResult, historyResult, debtResult],
|
|
325
|
+
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult],
|
|
326
|
+
debt: [readyResult, historyResult, debtResult, bloatResult],
|
|
296
327
|
deps: [depsResult],
|
|
297
328
|
});
|
|
298
329
|
}
|