@safetnsr/vet 1.26.0 → 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.
@@ -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);
@@ -358,6 +371,14 @@ async function withTimeout(name, fn, timeoutMs = 30_000) {
358
371
  /** Get files changed vs main/master branch or --since ref */
359
372
  function getChangedFiles(cwd, sinceRef) {
360
373
  const base = sinceRef || (() => {
374
+ // In GitHub Actions PR context, use GITHUB_BASE_REF
375
+ const ghBase = process.env.GITHUB_BASE_REF;
376
+ if (ghBase) {
377
+ const ref = gitExec(['merge-base', 'HEAD', `origin/${ghBase}`], cwd);
378
+ if (ref)
379
+ return ref;
380
+ }
381
+ // Fallback: try merge-base with main/master
361
382
  const main = gitExec(['merge-base', 'HEAD', 'origin/main'], cwd);
362
383
  if (main)
363
384
  return main;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {