@safetnsr/vet 1.26.1 → 1.28.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/checks/fleet.d.ts +29 -0
- package/dist/checks/fleet.js +286 -0
- package/dist/checks/triage.d.ts +12 -0
- package/dist/checks/triage.js +261 -0
- package/dist/cli.js +29 -1
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
export type FleetStatus = 'OK' | 'SILENT_FAIL' | 'CONFLICT';
|
|
3
|
+
export interface FileTouch {
|
|
4
|
+
session: string;
|
|
5
|
+
action: 'write' | 'edit' | 'create' | 'delete';
|
|
6
|
+
timestamp: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FleetSession {
|
|
9
|
+
id: string;
|
|
10
|
+
file: string;
|
|
11
|
+
startTime: string | null;
|
|
12
|
+
endTime: string | null;
|
|
13
|
+
durationMin: number;
|
|
14
|
+
filesWritten: number;
|
|
15
|
+
filesRead: number;
|
|
16
|
+
toolCalls: number;
|
|
17
|
+
status: FleetStatus;
|
|
18
|
+
conflicts: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface FleetResult {
|
|
21
|
+
sessions: FleetSession[];
|
|
22
|
+
conflicts: Map<string, string[]>;
|
|
23
|
+
silentFails: string[];
|
|
24
|
+
totalFiles: number;
|
|
25
|
+
totalToolCalls: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function analyzeFleet(sessionsDir?: string, since?: string): FleetResult;
|
|
28
|
+
export declare function checkFleet(sessionsDir?: string, since?: string): CheckResult;
|
|
29
|
+
export declare function runFleetCommand(format: string, sessionsDir?: string, since?: string): Promise<void>;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { c } from '../util.js';
|
|
4
|
+
import { findSessionFiles } from './receipt.js';
|
|
5
|
+
const WRITE_TOOLS = new Set(['Write', 'write_to_file', 'write', 'MultiEdit']);
|
|
6
|
+
const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'edit']);
|
|
7
|
+
const READ_TOOLS = new Set(['Read', 'read_file', 'read', 'View']);
|
|
8
|
+
function classifyTool(name) {
|
|
9
|
+
if (WRITE_TOOLS.has(name))
|
|
10
|
+
return 'write';
|
|
11
|
+
if (EDIT_TOOLS.has(name))
|
|
12
|
+
return 'edit';
|
|
13
|
+
if (READ_TOOLS.has(name))
|
|
14
|
+
return 'read';
|
|
15
|
+
if (name === 'Bash' || name === 'bash' || name === 'execute_command')
|
|
16
|
+
return 'bash';
|
|
17
|
+
return 'other';
|
|
18
|
+
}
|
|
19
|
+
function extractFilePath(input) {
|
|
20
|
+
// Try common field names
|
|
21
|
+
for (const key of ['file_path', 'path', 'filePath', 'filename']) {
|
|
22
|
+
if (typeof input[key] === 'string')
|
|
23
|
+
return input[key];
|
|
24
|
+
}
|
|
25
|
+
// For Edit tool, check old_string path
|
|
26
|
+
if (typeof input['file'] === 'string')
|
|
27
|
+
return input['file'];
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function parseSessionJsonl(filePath, sinceMs) {
|
|
31
|
+
const calls = [];
|
|
32
|
+
let content;
|
|
33
|
+
try {
|
|
34
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return calls;
|
|
38
|
+
}
|
|
39
|
+
for (const line of content.split('\n')) {
|
|
40
|
+
if (!line.trim())
|
|
41
|
+
continue;
|
|
42
|
+
let entry;
|
|
43
|
+
try {
|
|
44
|
+
entry = JSON.parse(line);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const timestamp = entry['timestamp'] || '';
|
|
50
|
+
if (sinceMs && timestamp) {
|
|
51
|
+
const entryTime = new Date(timestamp).getTime();
|
|
52
|
+
if (!isNaN(entryTime) && entryTime < sinceMs)
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Extract tool_use blocks from content arrays
|
|
56
|
+
const contents = [];
|
|
57
|
+
if (Array.isArray(entry['content'])) {
|
|
58
|
+
contents.push(...entry['content']);
|
|
59
|
+
}
|
|
60
|
+
if (entry['message'] && typeof entry['message'] === 'object') {
|
|
61
|
+
const msg = entry['message'];
|
|
62
|
+
if (Array.isArray(msg['content'])) {
|
|
63
|
+
contents.push(...msg['content']);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const block of contents) {
|
|
67
|
+
if (typeof block !== 'object' || block === null)
|
|
68
|
+
continue;
|
|
69
|
+
const b = block;
|
|
70
|
+
if (b['type'] !== 'tool_use')
|
|
71
|
+
continue;
|
|
72
|
+
const toolName = b['name'] || '';
|
|
73
|
+
const input = b['input'] || {};
|
|
74
|
+
const action = classifyTool(toolName);
|
|
75
|
+
const fp = extractFilePath(input);
|
|
76
|
+
calls.push({ timestamp, toolName, filePath: fp, action });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return calls;
|
|
80
|
+
}
|
|
81
|
+
// ── Core analysis ────────────────────────────────────────────────────────────
|
|
82
|
+
function parseSinceFlag(since) {
|
|
83
|
+
if (!since)
|
|
84
|
+
return null;
|
|
85
|
+
// "8h", "24h", "1d", "30m"
|
|
86
|
+
const match = since.match(/^(\d+)(h|d|m)$/i);
|
|
87
|
+
if (match) {
|
|
88
|
+
const val = parseInt(match[1], 10);
|
|
89
|
+
const unit = match[2].toLowerCase();
|
|
90
|
+
const ms = unit === 'h' ? val * 3600000 : unit === 'd' ? val * 86400000 : val * 60000;
|
|
91
|
+
return Date.now() - ms;
|
|
92
|
+
}
|
|
93
|
+
// ISO date or timestamp
|
|
94
|
+
const ts = new Date(since).getTime();
|
|
95
|
+
return isNaN(ts) ? null : ts;
|
|
96
|
+
}
|
|
97
|
+
function sessionIdFromPath(filePath) {
|
|
98
|
+
const base = path.basename(filePath, '.jsonl');
|
|
99
|
+
// Shorten long UUIDs for display
|
|
100
|
+
if (base.length > 20)
|
|
101
|
+
return base.slice(0, 8);
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
export function analyzeFleet(sessionsDir, since) {
|
|
105
|
+
const sinceMs = parseSinceFlag(since);
|
|
106
|
+
const sessionFiles = findSessionFiles(sessionsDir);
|
|
107
|
+
// Filter by mtime if --since
|
|
108
|
+
const filteredFiles = sinceMs
|
|
109
|
+
? sessionFiles.filter(f => {
|
|
110
|
+
try {
|
|
111
|
+
return fs.statSync(f).mtimeMs >= sinceMs;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
: sessionFiles;
|
|
118
|
+
// Limit to last 20 sessions to avoid scanning entire history
|
|
119
|
+
const recentFiles = filteredFiles.slice(-20);
|
|
120
|
+
const fileTouches = new Map(); // filepath → touches
|
|
121
|
+
const sessions = [];
|
|
122
|
+
for (const sessionFile of recentFiles) {
|
|
123
|
+
const sid = sessionIdFromPath(sessionFile);
|
|
124
|
+
const calls = parseSessionJsonl(sessionFile, sinceMs);
|
|
125
|
+
if (calls.length === 0) {
|
|
126
|
+
sessions.push({
|
|
127
|
+
id: sid, file: sessionFile,
|
|
128
|
+
startTime: null, endTime: null, durationMin: 0,
|
|
129
|
+
filesWritten: 0, filesRead: 0, toolCalls: 0,
|
|
130
|
+
status: 'OK', conflicts: [],
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const timestamps = calls.filter(c => c.timestamp).map(c => c.timestamp);
|
|
135
|
+
const startTime = timestamps.length > 0 ? timestamps[0] : null;
|
|
136
|
+
const endTime = timestamps.length > 0 ? timestamps[timestamps.length - 1] : null;
|
|
137
|
+
const durationMs = startTime && endTime
|
|
138
|
+
? new Date(endTime).getTime() - new Date(startTime).getTime()
|
|
139
|
+
: 0;
|
|
140
|
+
const durationMin = Math.round(durationMs / 60000);
|
|
141
|
+
const writtenFiles = new Set();
|
|
142
|
+
const readFiles = new Set();
|
|
143
|
+
for (const call of calls) {
|
|
144
|
+
if ((call.action === 'write' || call.action === 'edit') && call.filePath) {
|
|
145
|
+
writtenFiles.add(call.filePath);
|
|
146
|
+
// Track file touches for conflict detection
|
|
147
|
+
const touches = fileTouches.get(call.filePath) || [];
|
|
148
|
+
touches.push({ session: sid, action: call.action, timestamp: call.timestamp });
|
|
149
|
+
fileTouches.set(call.filePath, touches);
|
|
150
|
+
}
|
|
151
|
+
else if (call.action === 'read' && call.filePath) {
|
|
152
|
+
readFiles.add(call.filePath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Silent fail: session had tool calls but zero writes
|
|
156
|
+
const isSilentFail = calls.length > 3 && writtenFiles.size === 0;
|
|
157
|
+
sessions.push({
|
|
158
|
+
id: sid, file: sessionFile,
|
|
159
|
+
startTime, endTime, durationMin,
|
|
160
|
+
filesWritten: writtenFiles.size,
|
|
161
|
+
filesRead: readFiles.size,
|
|
162
|
+
toolCalls: calls.length,
|
|
163
|
+
status: isSilentFail ? 'SILENT_FAIL' : 'OK',
|
|
164
|
+
conflicts: [],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Detect cross-agent conflicts
|
|
168
|
+
const conflicts = new Map();
|
|
169
|
+
for (const [fp, touches] of fileTouches) {
|
|
170
|
+
const uniqueSessions = [...new Set(touches.map(t => t.session))];
|
|
171
|
+
if (uniqueSessions.length >= 2) {
|
|
172
|
+
conflicts.set(fp, uniqueSessions);
|
|
173
|
+
// Mark sessions as conflicting
|
|
174
|
+
for (const s of sessions) {
|
|
175
|
+
if (uniqueSessions.includes(s.id) && s.status !== 'SILENT_FAIL') {
|
|
176
|
+
s.status = 'CONFLICT';
|
|
177
|
+
s.conflicts.push(fp);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const silentFails = sessions.filter(s => s.status === 'SILENT_FAIL').map(s => s.id);
|
|
183
|
+
return {
|
|
184
|
+
sessions,
|
|
185
|
+
conflicts,
|
|
186
|
+
silentFails,
|
|
187
|
+
totalFiles: fileTouches.size,
|
|
188
|
+
totalToolCalls: sessions.reduce((sum, s) => sum + s.toolCalls, 0),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// ── CheckResult integration ──────────────────────────────────────────────────
|
|
192
|
+
export function checkFleet(sessionsDir, since) {
|
|
193
|
+
const result = analyzeFleet(sessionsDir, since);
|
|
194
|
+
const issues = [];
|
|
195
|
+
for (const sid of result.silentFails) {
|
|
196
|
+
issues.push({
|
|
197
|
+
severity: 'error',
|
|
198
|
+
message: `session ${sid}: SILENT FAIL — ran but produced 0 file changes`,
|
|
199
|
+
fixable: false,
|
|
200
|
+
fixHint: 'check session logs for errors or stuck loops',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
for (const [fp, sids] of result.conflicts) {
|
|
204
|
+
issues.push({
|
|
205
|
+
severity: 'warning',
|
|
206
|
+
message: `file conflict: ${fp} — written by sessions: ${sids.join(', ')}`,
|
|
207
|
+
file: fp,
|
|
208
|
+
fixable: false,
|
|
209
|
+
fixHint: 'review file for merge conflicts or overwritten changes',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const score = Math.max(0, 100
|
|
213
|
+
- result.silentFails.length * 25
|
|
214
|
+
- result.conflicts.size * 15);
|
|
215
|
+
const summary = result.sessions.length === 0
|
|
216
|
+
? 'no session files found'
|
|
217
|
+
: `${result.sessions.length} sessions — ${result.silentFails.length} silent fails, ${result.conflicts.size} file conflicts`;
|
|
218
|
+
return { name: 'fleet', score, maxScore: 100, issues, summary };
|
|
219
|
+
}
|
|
220
|
+
// ── Subcommand output ────────────────────────────────────────────────────────
|
|
221
|
+
export async function runFleetCommand(format, sessionsDir, since) {
|
|
222
|
+
const result = analyzeFleet(sessionsDir, since);
|
|
223
|
+
if (format === 'json') {
|
|
224
|
+
console.log(JSON.stringify({
|
|
225
|
+
sessions: result.sessions.map(s => ({
|
|
226
|
+
id: s.id,
|
|
227
|
+
startTime: s.startTime,
|
|
228
|
+
endTime: s.endTime,
|
|
229
|
+
durationMin: s.durationMin,
|
|
230
|
+
filesWritten: s.filesWritten,
|
|
231
|
+
toolCalls: s.toolCalls,
|
|
232
|
+
status: s.status,
|
|
233
|
+
conflicts: s.conflicts,
|
|
234
|
+
})),
|
|
235
|
+
conflicts: Object.fromEntries(result.conflicts),
|
|
236
|
+
silentFails: result.silentFails,
|
|
237
|
+
totalFiles: result.totalFiles,
|
|
238
|
+
totalToolCalls: result.totalToolCalls,
|
|
239
|
+
}, null, 2));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
console.log(`\n ${c.bold}vet fleet${c.reset} — multi-agent session audit\n`);
|
|
243
|
+
if (result.sessions.length === 0) {
|
|
244
|
+
console.log(` ${c.dim}no session files found${c.reset}`);
|
|
245
|
+
console.log(` ${c.dim}default path: ~/.claude/projects/*/sessions/*.jsonl${c.reset}\n`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Session table
|
|
249
|
+
const padId = Math.max(8, ...result.sessions.map(s => s.id.length));
|
|
250
|
+
const header = ` ${'SESSION'.padEnd(padId)} ${'DURATION'.padEnd(8)} ${'FILES'.padEnd(7)} ${'CALLS'.padEnd(7)} STATUS`;
|
|
251
|
+
console.log(` ${c.dim}${header}${c.reset}`);
|
|
252
|
+
console.log(` ${c.dim}${'─'.repeat(header.length)}${c.reset}`);
|
|
253
|
+
for (const s of result.sessions) {
|
|
254
|
+
const dur = s.durationMin > 0 ? `${s.durationMin}m` : '—';
|
|
255
|
+
const files = String(s.filesWritten);
|
|
256
|
+
const calls = String(s.toolCalls);
|
|
257
|
+
const statusColor = s.status === 'SILENT_FAIL' ? c.red
|
|
258
|
+
: s.status === 'CONFLICT' ? c.yellow
|
|
259
|
+
: c.green;
|
|
260
|
+
const statusIcon = s.status === 'SILENT_FAIL' ? '✗'
|
|
261
|
+
: s.status === 'CONFLICT' ? '⚠'
|
|
262
|
+
: '✓';
|
|
263
|
+
console.log(` ${s.id.padEnd(padId)} ${dur.padEnd(8)} ${files.padEnd(7)} ${calls.padEnd(7)} ${statusColor}${statusIcon} ${s.status}${c.reset}`);
|
|
264
|
+
}
|
|
265
|
+
console.log();
|
|
266
|
+
// Conflicts
|
|
267
|
+
if (result.conflicts.size > 0) {
|
|
268
|
+
console.log(` ${c.yellow}${c.bold}conflicts${c.reset} ${c.dim}(same file written by multiple sessions)${c.reset}`);
|
|
269
|
+
for (const [fp, sids] of result.conflicts) {
|
|
270
|
+
console.log(` ${c.yellow}⚠${c.reset} ${fp} ${c.dim}— sessions: ${sids.join(', ')}${c.reset}`);
|
|
271
|
+
}
|
|
272
|
+
console.log();
|
|
273
|
+
}
|
|
274
|
+
// Silent failures
|
|
275
|
+
if (result.silentFails.length > 0) {
|
|
276
|
+
console.log(` ${c.red}${c.bold}silent failures${c.reset} ${c.dim}(sessions with 0 file changes)${c.reset}`);
|
|
277
|
+
for (const sid of result.silentFails) {
|
|
278
|
+
const s = result.sessions.find(x => x.id === sid);
|
|
279
|
+
console.log(` ${c.red}✗${c.reset} ${sid} ${c.dim}— ${s?.toolCalls ?? 0} tool calls, 0 writes${c.reset}`);
|
|
280
|
+
}
|
|
281
|
+
console.log();
|
|
282
|
+
}
|
|
283
|
+
// Summary
|
|
284
|
+
const ok = result.sessions.filter(s => s.status === 'OK').length;
|
|
285
|
+
console.log(` ${c.dim}summary: ${result.sessions.length} sessions. ${ok} ok, ${result.silentFails.length} silent fails, ${result.conflicts.size} conflicts.${c.reset}\n`);
|
|
286
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
export type TriageRank = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'SKIP';
|
|
3
|
+
export interface TriageEntry {
|
|
4
|
+
file: string;
|
|
5
|
+
rank: TriageRank;
|
|
6
|
+
reason: string;
|
|
7
|
+
signals: string[];
|
|
8
|
+
estimateMin: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function analyzeTriage(cwd: string, since?: string): TriageEntry[];
|
|
11
|
+
export declare function checkTriage(cwd: string, since?: string): CheckResult;
|
|
12
|
+
export declare function runTriageCommand(format: string, cwd?: string, since?: string): Promise<void>;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { gitExec, c } from '../util.js';
|
|
3
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
4
|
+
const SECURITY_PATH_RE = /auth|middleware|permission|secret|crypt|jwt|session|cors|password|login|token/i;
|
|
5
|
+
const SECURITY_SKIP_EXTS = new Set(['.css', '.md', '.json', '.svg', '.png']);
|
|
6
|
+
const SCHEMA_PATH_RE = /migration|schema|model|entity|prisma|knex|sequelize|drizzle/i;
|
|
7
|
+
const ERROR_HANDLER_RE = /try\s*\{|\.catch\(|}\s*catch|catch\s*\(/;
|
|
8
|
+
const TEST_PATH_RE = /test|spec|__tests__/;
|
|
9
|
+
const COMMENT_LINE_RE = /^\s*(\/\/|\/\*|\*|#)/;
|
|
10
|
+
const WHITESPACE_ONLY_RE = /^\s*$/;
|
|
11
|
+
function parseStatLine(line) {
|
|
12
|
+
// Format: " src/foo.ts | 12 +++---" or " src/foo.ts | Bin 0 -> 1234 bytes"
|
|
13
|
+
const match = line.match(/^\s*(.+?)\s*\|\s*(\d+)/);
|
|
14
|
+
if (!match)
|
|
15
|
+
return null;
|
|
16
|
+
const file = match[1].trim();
|
|
17
|
+
const total = parseInt(match[2], 10);
|
|
18
|
+
// Count + and - chars at end of line for approximation
|
|
19
|
+
const plusMinus = line.match(/\|\s*\d+\s*([+\-]+)\s*$/);
|
|
20
|
+
if (!plusMinus)
|
|
21
|
+
return { file, added: 0, removed: 0 };
|
|
22
|
+
const symbols = plusMinus[1];
|
|
23
|
+
const added = (symbols.match(/\+/g) || []).length;
|
|
24
|
+
const removed = (symbols.match(/-/g) || []).length;
|
|
25
|
+
// Scale up: the stat line shows proportional +/- not exact counts
|
|
26
|
+
// We'll use the full diff to get exact counts — this is just for file list
|
|
27
|
+
return { file, added, removed };
|
|
28
|
+
}
|
|
29
|
+
function parseDiff(diffOutput) {
|
|
30
|
+
const result = new Map();
|
|
31
|
+
if (!diffOutput.trim())
|
|
32
|
+
return result;
|
|
33
|
+
const fileDiffs = diffOutput.split(/^diff --git /m).filter(Boolean);
|
|
34
|
+
for (const fileDiff of fileDiffs) {
|
|
35
|
+
const lines = fileDiff.split('\n');
|
|
36
|
+
const headerMatch = lines[0]?.match(/a\/.+? b\/(.+)/);
|
|
37
|
+
if (!headerMatch)
|
|
38
|
+
continue;
|
|
39
|
+
const file = headerMatch[1].trim();
|
|
40
|
+
const removedLines = [];
|
|
41
|
+
const addedLines = [];
|
|
42
|
+
let inHunk = false;
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (line.startsWith('@@')) {
|
|
45
|
+
inHunk = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!inHunk)
|
|
49
|
+
continue;
|
|
50
|
+
if (line.startsWith('---') || line.startsWith('+++'))
|
|
51
|
+
continue;
|
|
52
|
+
if (line.startsWith('-')) {
|
|
53
|
+
removedLines.push(line.slice(1));
|
|
54
|
+
}
|
|
55
|
+
else if (line.startsWith('+')) {
|
|
56
|
+
addedLines.push(line.slice(1));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
result.set(file, {
|
|
60
|
+
file,
|
|
61
|
+
added: addedLines.length,
|
|
62
|
+
removed: removedLines.length,
|
|
63
|
+
removedLines,
|
|
64
|
+
addedLines,
|
|
65
|
+
allChangedLines: [...removedLines, ...addedLines],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
// ── Signal detection ─────────────────────────────────────────────────────────
|
|
71
|
+
function isSecurityPath(file) {
|
|
72
|
+
const ext = extname(file).toLowerCase();
|
|
73
|
+
if (SECURITY_SKIP_EXTS.has(ext))
|
|
74
|
+
return false;
|
|
75
|
+
return SECURITY_PATH_RE.test(file);
|
|
76
|
+
}
|
|
77
|
+
function hasErrorHandlerRemoval(fileDiff) {
|
|
78
|
+
return fileDiff.removedLines.some(line => ERROR_HANDLER_RE.test(line));
|
|
79
|
+
}
|
|
80
|
+
function isSchemaPath(file) {
|
|
81
|
+
return SCHEMA_PATH_RE.test(file);
|
|
82
|
+
}
|
|
83
|
+
function isCosmetic(fileDiff) {
|
|
84
|
+
const totalChanged = fileDiff.added + fileDiff.removed;
|
|
85
|
+
if (totalChanged < 5)
|
|
86
|
+
return true;
|
|
87
|
+
// All changed lines are comments or whitespace
|
|
88
|
+
const allCommentOrWhitespace = fileDiff.allChangedLines.every(line => WHITESPACE_ONLY_RE.test(line) || COMMENT_LINE_RE.test(line));
|
|
89
|
+
return allCommentOrWhitespace;
|
|
90
|
+
}
|
|
91
|
+
function isLargeChange(fileDiff, testFilesChanged) {
|
|
92
|
+
return fileDiff.added >= 50 && !testFilesChanged;
|
|
93
|
+
}
|
|
94
|
+
// ── Ranking ──────────────────────────────────────────────────────────────────
|
|
95
|
+
function rankFile(file, fileDiff, anyTestChanged) {
|
|
96
|
+
const sig1 = isSecurityPath(file);
|
|
97
|
+
const sig2 = hasErrorHandlerRemoval(fileDiff);
|
|
98
|
+
const sig3 = isSchemaPath(file);
|
|
99
|
+
const sig4 = isCosmetic(fileDiff);
|
|
100
|
+
const sig5 = isLargeChange(fileDiff, anyTestChanged);
|
|
101
|
+
const signals = [];
|
|
102
|
+
if (sig1)
|
|
103
|
+
signals.push('security path');
|
|
104
|
+
if (sig2)
|
|
105
|
+
signals.push('error handler removed');
|
|
106
|
+
if (sig3)
|
|
107
|
+
signals.push('schema/db path');
|
|
108
|
+
if (sig5)
|
|
109
|
+
signals.push(`${fileDiff.added} lines added, no tests changed`);
|
|
110
|
+
// CRITICAL: sig1 AND (sig2 OR sig3)
|
|
111
|
+
if (sig1 && (sig2 || sig3)) {
|
|
112
|
+
const reasonParts = ['security path'];
|
|
113
|
+
if (sig2)
|
|
114
|
+
reasonParts.push('error handler removed');
|
|
115
|
+
if (sig3)
|
|
116
|
+
reasonParts.push('schema change');
|
|
117
|
+
return { file, rank: 'CRITICAL', reason: reasonParts.join(' + '), signals, estimateMin: 5 };
|
|
118
|
+
}
|
|
119
|
+
// HIGH: sig1 OR sig2 OR sig3
|
|
120
|
+
if (sig1)
|
|
121
|
+
return { file, rank: 'HIGH', reason: 'security-relevant path', signals, estimateMin: 2 };
|
|
122
|
+
if (sig2)
|
|
123
|
+
return { file, rank: 'HIGH', reason: 'error handler removed', signals, estimateMin: 2 };
|
|
124
|
+
if (sig3)
|
|
125
|
+
return { file, rank: 'HIGH', reason: 'schema/db path', signals, estimateMin: 2 };
|
|
126
|
+
// MEDIUM: sig5
|
|
127
|
+
if (sig5) {
|
|
128
|
+
return {
|
|
129
|
+
file,
|
|
130
|
+
rank: 'MEDIUM',
|
|
131
|
+
reason: `${fileDiff.added} lines added, no tests changed`,
|
|
132
|
+
signals,
|
|
133
|
+
estimateMin: 1,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// SKIP: sig4 or no signals
|
|
137
|
+
const skipReason = sig4 ? 'cosmetic' : 'no signals';
|
|
138
|
+
return { file, rank: 'SKIP', reason: skipReason, signals: [], estimateMin: 0 };
|
|
139
|
+
}
|
|
140
|
+
// ── Core logic ───────────────────────────────────────────────────────────────
|
|
141
|
+
export function analyzeTriage(cwd, since = 'HEAD~1') {
|
|
142
|
+
const statOutput = gitExec(['diff', '--stat', since], cwd);
|
|
143
|
+
const diffOutput = gitExec(['diff', since], cwd);
|
|
144
|
+
if (!statOutput && !diffOutput)
|
|
145
|
+
return [];
|
|
146
|
+
// Parse full diff for per-file line data
|
|
147
|
+
const fileDiffs = parseDiff(diffOutput);
|
|
148
|
+
// Get file list from stat (in case diff missed any)
|
|
149
|
+
const statFiles = [];
|
|
150
|
+
for (const line of statOutput.split('\n')) {
|
|
151
|
+
const parsed = parseStatLine(line);
|
|
152
|
+
if (parsed)
|
|
153
|
+
statFiles.push(parsed.file);
|
|
154
|
+
}
|
|
155
|
+
// Union of files from stat and diff
|
|
156
|
+
const allFiles = new Set([...statFiles, ...fileDiffs.keys()]);
|
|
157
|
+
if (allFiles.size === 0)
|
|
158
|
+
return [];
|
|
159
|
+
// Determine if any test file changed
|
|
160
|
+
const anyTestChanged = [...allFiles].some(f => TEST_PATH_RE.test(f));
|
|
161
|
+
const entries = [];
|
|
162
|
+
for (const file of allFiles) {
|
|
163
|
+
const fd = fileDiffs.get(file) ?? {
|
|
164
|
+
file,
|
|
165
|
+
added: 0,
|
|
166
|
+
removed: 0,
|
|
167
|
+
removedLines: [],
|
|
168
|
+
addedLines: [],
|
|
169
|
+
allChangedLines: [],
|
|
170
|
+
};
|
|
171
|
+
entries.push(rankFile(file, fd, anyTestChanged));
|
|
172
|
+
}
|
|
173
|
+
// Sort: CRITICAL first, then HIGH, MEDIUM, SKIP
|
|
174
|
+
const rankOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, SKIP: 3 };
|
|
175
|
+
entries.sort((a, b) => rankOrder[a.rank] - rankOrder[b.rank]);
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
178
|
+
// ── CheckResult integration ──────────────────────────────────────────────────
|
|
179
|
+
export function checkTriage(cwd, since) {
|
|
180
|
+
const entries = analyzeTriage(cwd, since ?? 'HEAD~1');
|
|
181
|
+
const issues = [];
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
if (entry.rank === 'SKIP')
|
|
184
|
+
continue;
|
|
185
|
+
const severity = entry.rank === 'CRITICAL' ? 'error' : entry.rank === 'HIGH' ? 'warning' : 'info';
|
|
186
|
+
issues.push({
|
|
187
|
+
severity,
|
|
188
|
+
message: `[${entry.rank}] ${entry.file} — ${entry.reason}`,
|
|
189
|
+
file: entry.file,
|
|
190
|
+
fixable: false,
|
|
191
|
+
fixHint: `review ${entry.file} (~${entry.estimateMin} min)`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const critical = entries.filter(e => e.rank === 'CRITICAL').length;
|
|
195
|
+
const high = entries.filter(e => e.rank === 'HIGH').length;
|
|
196
|
+
const medium = entries.filter(e => e.rank === 'MEDIUM').length;
|
|
197
|
+
const skip = entries.filter(e => e.rank === 'SKIP').length;
|
|
198
|
+
const totalMin = entries.reduce((sum, e) => sum + e.estimateMin, 0);
|
|
199
|
+
const score = critical > 0 ? Math.max(0, 100 - critical * 25 - high * 10 - medium * 5)
|
|
200
|
+
: high > 0 ? Math.max(0, 100 - high * 10 - medium * 5)
|
|
201
|
+
: Math.max(0, 100 - medium * 5);
|
|
202
|
+
const summary = entries.length === 0
|
|
203
|
+
? 'no diff to analyze'
|
|
204
|
+
: `${entries.length} files changed — ${critical} critical, ${high} high, ${medium} medium, ${skip} skip (~${totalMin} min)`;
|
|
205
|
+
return { name: 'triage', score, maxScore: 100, issues, summary };
|
|
206
|
+
}
|
|
207
|
+
// ── Subcommand output ────────────────────────────────────────────────────────
|
|
208
|
+
export async function runTriageCommand(format, cwd, since) {
|
|
209
|
+
const dir = cwd || process.cwd();
|
|
210
|
+
const sinceRef = since ?? 'HEAD~1';
|
|
211
|
+
const entries = analyzeTriage(dir, sinceRef);
|
|
212
|
+
if (format === 'json') {
|
|
213
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
console.log(`\n ${c.bold}vet triage${c.reset} — diff review urgency\n`);
|
|
217
|
+
if (entries.length === 0) {
|
|
218
|
+
console.log(` ${c.dim}no diff to analyze${c.reset}\n`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const critical = entries.filter(e => e.rank === 'CRITICAL');
|
|
222
|
+
const high = entries.filter(e => e.rank === 'HIGH');
|
|
223
|
+
const medium = entries.filter(e => e.rank === 'MEDIUM');
|
|
224
|
+
const skip = entries.filter(e => e.rank === 'SKIP');
|
|
225
|
+
if (critical.length > 0) {
|
|
226
|
+
console.log(` ${c.red}${c.bold}CRITICAL${c.reset} ${c.dim}(est. 5 min each)${c.reset}`);
|
|
227
|
+
for (const e of critical) {
|
|
228
|
+
console.log(` ${c.red}✗${c.reset} ${e.file} ${c.dim}— ${e.reason}${c.reset}`);
|
|
229
|
+
}
|
|
230
|
+
console.log();
|
|
231
|
+
}
|
|
232
|
+
if (high.length > 0) {
|
|
233
|
+
console.log(` ${c.yellow}${c.bold}HIGH${c.reset} ${c.dim}(est. 2 min each)${c.reset}`);
|
|
234
|
+
for (const e of high) {
|
|
235
|
+
console.log(` ${c.yellow}⚠${c.reset} ${e.file} ${c.dim}— ${e.reason}${c.reset}`);
|
|
236
|
+
}
|
|
237
|
+
console.log();
|
|
238
|
+
}
|
|
239
|
+
if (medium.length > 0) {
|
|
240
|
+
console.log(` ${c.green}${c.bold}MEDIUM${c.reset} ${c.dim}(est. 1 min each)${c.reset}`);
|
|
241
|
+
for (const e of medium) {
|
|
242
|
+
console.log(` ${c.green}○${c.reset} ${e.file} ${c.dim}— ${e.reason}${c.reset}`);
|
|
243
|
+
}
|
|
244
|
+
console.log();
|
|
245
|
+
}
|
|
246
|
+
if (skip.length > 0) {
|
|
247
|
+
console.log(` ${c.dim}SKIP (${skip.length} file${skip.length !== 1 ? 's' : ''})${c.reset}`);
|
|
248
|
+
const shown = skip.slice(0, 3);
|
|
249
|
+
const rest = skip.length - shown.length;
|
|
250
|
+
for (const e of shown) {
|
|
251
|
+
console.log(` ${c.dim}· ${e.file} — ${e.reason}${c.reset}`);
|
|
252
|
+
}
|
|
253
|
+
if (rest > 0) {
|
|
254
|
+
console.log(` ${c.dim}· ... and ${rest} more${c.reset}`);
|
|
255
|
+
}
|
|
256
|
+
console.log();
|
|
257
|
+
}
|
|
258
|
+
const reviewCount = critical.length + high.length + medium.length;
|
|
259
|
+
const totalMin = entries.reduce((sum, e) => sum + e.estimateMin, 0);
|
|
260
|
+
console.log(` ${c.dim}summary: ${entries.length} files changed. review ${reviewCount} files (~${totalMin} min). skip ${skip.length}.${c.reset}\n`);
|
|
261
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -34,6 +34,8 @@ import { checkSandbox, runSandboxCommand } from './checks/sandbox.js';
|
|
|
34
34
|
import { checkExplain, runExplainCommand } from './checks/explain.js';
|
|
35
35
|
import { checkContext, runContextCommand } from './checks/context.js';
|
|
36
36
|
import { checkSplit, runSplitCommand } from './checks/split.js';
|
|
37
|
+
import { runTriageCommand } from './checks/triage.js';
|
|
38
|
+
import { runFleetCommand } from './checks/fleet.js';
|
|
37
39
|
import { checkSourceSecurity } from './checks/source-security.js';
|
|
38
40
|
import { checkCompleteness } from './checks/completeness.js';
|
|
39
41
|
import { score } from './scorer.js';
|
|
@@ -94,6 +96,8 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
94
96
|
npx @safetnsr/vet context [dir] audit agent context files for token cost + stale sections
|
|
95
97
|
npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
|
|
96
98
|
npx @safetnsr/vet sandbox [dir] score agent runtime blast radius
|
|
99
|
+
npx @safetnsr/vet triage [--since HEAD~1] [--json] rank diff files by review urgency
|
|
100
|
+
npx @safetnsr/vet fleet [--sessions dir] [--since 8h] [--json] multi-agent session audit
|
|
97
101
|
|
|
98
102
|
${c.dim}categories:${c.reset}
|
|
99
103
|
security (30%) scan, secrets, config, model usage
|
|
@@ -130,7 +134,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
130
134
|
}
|
|
131
135
|
process.exit(0);
|
|
132
136
|
}
|
|
133
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox'];
|
|
137
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox', 'triage', 'fleet'];
|
|
134
138
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
135
139
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
136
140
|
const isCI = flags.has('--ci');
|
|
@@ -318,6 +322,30 @@ if (command === 'sandbox') {
|
|
|
318
322
|
}
|
|
319
323
|
process.exit(0);
|
|
320
324
|
}
|
|
325
|
+
if (command === 'triage') {
|
|
326
|
+
try {
|
|
327
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
328
|
+
await runTriageCommand(format, cwd, since);
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
console.error(`${c.red}triage failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
if (command === 'fleet') {
|
|
337
|
+
try {
|
|
338
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
339
|
+
const sessionsDir = args.find(a => a.startsWith('--sessions='))?.split('=')[1]
|
|
340
|
+
|| (args.includes('--sessions') ? args[args.indexOf('--sessions') + 1] : undefined);
|
|
341
|
+
await runFleetCommand(format, sessionsDir, since);
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
console.error(`${c.red}fleet failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
321
349
|
if (!isGitRepo(cwd)) {
|
|
322
350
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
323
351
|
process.exit(1);
|