@safetnsr/vet 1.12.0 → 1.13.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.
@@ -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,7 @@ 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';
24
25
  import { score } from './scorer.js';
25
26
  import { reportPretty, reportJSON, reportBadge } from './reporter.js';
26
27
  import { clearCache } from './file-cache.js';
@@ -70,6 +71,7 @@ if (flags.has('--help') || flags.has('-h')) {
70
71
  npx @safetnsr/vet permissions [dir] audit Claude Code config for dangerous grants
71
72
  npx @safetnsr/vet compact [log] compaction forensics for claude code sessions
72
73
  npx @safetnsr/vet subsidy [--plan tier] [--since date] show AI cost vs subscription
74
+ npx @safetnsr/vet loop [log] /loop session forensics — per-iteration timeline
73
75
 
74
76
  ${c.dim}categories:${c.reset}
75
77
  security (30%) scan, secrets, config, model usage
@@ -105,7 +107,7 @@ if (flags.has('--version') || flags.has('-v')) {
105
107
  }
106
108
  process.exit(0);
107
109
  }
108
- const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy'];
110
+ const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop'];
109
111
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
110
112
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
111
113
  const isCI = flags.has('--ci');
@@ -213,6 +215,18 @@ if (command === 'subsidy') {
213
215
  }
214
216
  process.exit(0);
215
217
  }
218
+ if (command === 'loop') {
219
+ try {
220
+ const format = isJSON ? 'json' : 'ascii';
221
+ const sessionArg = positional.find(p => p !== 'loop' && !COMMANDS.includes(p));
222
+ await runLoopCommand(format, sessionArg);
223
+ }
224
+ catch (e) {
225
+ console.error(`${c.red}loop failed:${c.reset}`, e instanceof Error ? e.message : e);
226
+ process.exit(1);
227
+ }
228
+ process.exit(0);
229
+ }
216
230
  if (!isGitRepo(cwd)) {
217
231
  console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
218
232
  process.exit(1);
@@ -264,7 +278,7 @@ async function runChecks() {
264
278
  }
265
279
  }
266
280
  // 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([
281
+ const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult,] = await Promise.all([
268
282
  withTimeout('scan', () => checkScan(cwd)),
269
283
  withTimeout('secrets', () => checkSecrets(cwd)),
270
284
  withTimeout('config', () => checkConfig(cwd, ignore)),
@@ -281,6 +295,7 @@ async function runChecks() {
281
295
  withTimeout('memory', () => checkMemory(cwd)),
282
296
  withTimeout('verify', () => checkVerify(cwd, since)),
283
297
  withTimeout('tests', () => checkTests(cwd, ignore)),
298
+ withTimeout('loop', () => checkLoop(cwd)),
284
299
  ]);
285
300
  // Git-dependent checks (diff + history) — parallel with each other
286
301
  const [diffResult, historyResult] = await Promise.all([
@@ -291,7 +306,7 @@ async function runChecks() {
291
306
  clearCache();
292
307
  return score(cwd, {
293
308
  security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult],
294
- integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
309
+ integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult],
295
310
  debt: [readyResult, historyResult, debtResult],
296
311
  deps: [depsResult],
297
312
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {