@safetnsr/vet 1.26.1 → 1.27.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/triage.d.ts +12 -0
- package/dist/checks/triage.js +261 -0
- package/dist/cli.js +14 -1
- package/package.json +1 -1
|
@@ -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,7 @@ 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';
|
|
37
38
|
import { checkSourceSecurity } from './checks/source-security.js';
|
|
38
39
|
import { checkCompleteness } from './checks/completeness.js';
|
|
39
40
|
import { score } from './scorer.js';
|
|
@@ -94,6 +95,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
94
95
|
npx @safetnsr/vet context [dir] audit agent context files for token cost + stale sections
|
|
95
96
|
npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
|
|
96
97
|
npx @safetnsr/vet sandbox [dir] score agent runtime blast radius
|
|
98
|
+
npx @safetnsr/vet triage [--since HEAD~1] [--json] rank diff files by review urgency
|
|
97
99
|
|
|
98
100
|
${c.dim}categories:${c.reset}
|
|
99
101
|
security (30%) scan, secrets, config, model usage
|
|
@@ -130,7 +132,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
130
132
|
}
|
|
131
133
|
process.exit(0);
|
|
132
134
|
}
|
|
133
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox'];
|
|
135
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox', 'triage'];
|
|
134
136
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
135
137
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
136
138
|
const isCI = flags.has('--ci');
|
|
@@ -318,6 +320,17 @@ if (command === 'sandbox') {
|
|
|
318
320
|
}
|
|
319
321
|
process.exit(0);
|
|
320
322
|
}
|
|
323
|
+
if (command === 'triage') {
|
|
324
|
+
try {
|
|
325
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
326
|
+
await runTriageCommand(format, cwd, since);
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
console.error(`${c.red}triage failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
process.exit(0);
|
|
333
|
+
}
|
|
321
334
|
if (!isGitRepo(cwd)) {
|
|
322
335
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
323
336
|
process.exit(1);
|