@safetnsr/vet 1.21.0 → 1.22.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,3 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkSplit(cwd: string): CheckResult;
3
+ export declare function runSplitCommand(format: string, cwd: string, since?: string, apply?: boolean, force?: boolean): Promise<void>;
@@ -0,0 +1,325 @@
1
+ import { gitExec, c } from '../util.js';
2
+ // ── Constants ────────────────────────────────────────────────────────────────
3
+ const CONFIG_FILES = new Set([
4
+ 'package.json', 'package-lock.json', 'tsconfig.json', 'tsconfig.build.json',
5
+ '.eslintrc', '.eslintrc.json', '.eslintrc.js', '.prettierrc', '.prettierrc.json',
6
+ '.env', '.env.example', '.env.local', '.gitignore', '.npmignore',
7
+ 'jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vite.config.ts',
8
+ 'webpack.config.js', 'rollup.config.js', 'esbuild.config.js',
9
+ 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
10
+ '.dockerignore', 'Makefile', '.editorconfig',
11
+ ]);
12
+ const TEST_PATTERNS = [/^test\//, /^tests\//, /^__tests__\//, /\.test\./, /\.spec\./];
13
+ const FIX_INDICATORS = /\bfix(es|ed)?\b|\bbug\b|\berror\b|\bcrash\b|\bpatch\b/i;
14
+ // ── Diff parsing ─────────────────────────────────────────────────────────────
15
+ function parseDiff(diffOutput) {
16
+ if (!diffOutput.trim())
17
+ return [];
18
+ const hunks = [];
19
+ const fileDiffs = diffOutput.split(/^diff --git /m).filter(Boolean);
20
+ for (const fileDiff of fileDiffs) {
21
+ const lines = fileDiff.split('\n');
22
+ // Parse file paths
23
+ const headerMatch = lines[0]?.match(/a\/(.+?) b\/(.+)/);
24
+ if (!headerMatch)
25
+ continue;
26
+ const file = headerMatch[2];
27
+ // Detect binary
28
+ if (fileDiff.includes('Binary files')) {
29
+ hunks.push({
30
+ file, oldStart: 0, oldCount: 0, newStart: 0, newCount: 0,
31
+ content: '', isNew: false, isDeleted: false, isRenamed: false, isBinary: true,
32
+ });
33
+ continue;
34
+ }
35
+ const isNew = fileDiff.includes('new file mode');
36
+ const isDeleted = fileDiff.includes('deleted file mode');
37
+ const isRenamed = fileDiff.includes('rename from') || fileDiff.includes('similarity index');
38
+ // Parse individual hunks within the file
39
+ const hunkHeaderRE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
40
+ let currentHunkLines = [];
41
+ let currentMatch = null;
42
+ for (const line of lines) {
43
+ const match = line.match(hunkHeaderRE);
44
+ if (match) {
45
+ // Save previous hunk
46
+ if (currentMatch) {
47
+ hunks.push({
48
+ file,
49
+ oldStart: parseInt(currentMatch[1], 10),
50
+ oldCount: parseInt(currentMatch[2] || '1', 10),
51
+ newStart: parseInt(currentMatch[3], 10),
52
+ newCount: parseInt(currentMatch[4] || '1', 10),
53
+ content: currentHunkLines.join('\n'),
54
+ isNew, isDeleted, isRenamed, isBinary: false,
55
+ });
56
+ }
57
+ currentMatch = match;
58
+ currentHunkLines = [];
59
+ }
60
+ else if (currentMatch) {
61
+ currentHunkLines.push(line);
62
+ }
63
+ }
64
+ // Save last hunk
65
+ if (currentMatch) {
66
+ hunks.push({
67
+ file,
68
+ oldStart: parseInt(currentMatch[1], 10),
69
+ oldCount: parseInt(currentMatch[2] || '1', 10),
70
+ newStart: parseInt(currentMatch[3], 10),
71
+ newCount: parseInt(currentMatch[4] || '1', 10),
72
+ content: currentHunkLines.join('\n'),
73
+ isNew, isDeleted, isRenamed, isBinary: false,
74
+ });
75
+ }
76
+ else if (isNew || isDeleted) {
77
+ // File with no hunks (e.g., empty new file)
78
+ hunks.push({
79
+ file, oldStart: 0, oldCount: 0, newStart: 0, newCount: 0,
80
+ content: '', isNew, isDeleted, isRenamed, isBinary: false,
81
+ });
82
+ }
83
+ }
84
+ return hunks;
85
+ }
86
+ // ── Clustering ───────────────────────────────────────────────────────────────
87
+ function isTestFile(file) {
88
+ return TEST_PATTERNS.some(p => p.test(file));
89
+ }
90
+ function isConfigFile(file) {
91
+ const basename = file.split('/').pop() || file;
92
+ return CONFIG_FILES.has(basename) || basename.startsWith('.');
93
+ }
94
+ function getClusterKey(file) {
95
+ if (isTestFile(file))
96
+ return 'test';
97
+ if (isConfigFile(file))
98
+ return 'config';
99
+ // Group by first directory
100
+ const parts = file.split('/');
101
+ if (parts.length > 1)
102
+ return `src:${parts[0]}`;
103
+ return 'src:root';
104
+ }
105
+ function generateCommitMessage(cluster) {
106
+ const fileList = cluster.files.length <= 3
107
+ ? cluster.files.map(f => f.split('/').pop()).join(', ')
108
+ : `${cluster.files.length} files`;
109
+ // Test cluster
110
+ if (cluster.prefix === 'test') {
111
+ return `test: update ${fileList}`;
112
+ }
113
+ // Config cluster
114
+ if (cluster.prefix === 'config') {
115
+ return `chore: update ${fileList}`;
116
+ }
117
+ // Check if all files are new
118
+ const allNew = cluster.hunks.every(h => h.isNew);
119
+ if (allNew) {
120
+ return `feat: add ${fileList}`;
121
+ }
122
+ // Check if all files are deleted
123
+ const allDeleted = cluster.hunks.every(h => h.isDeleted);
124
+ if (allDeleted) {
125
+ return `refactor: remove ${fileList}`;
126
+ }
127
+ // Check hunk content for fix indicators
128
+ const allContent = cluster.hunks.map(h => h.content).join('\n');
129
+ if (FIX_INDICATORS.test(allContent)) {
130
+ return `fix: update ${fileList}`;
131
+ }
132
+ return `refactor: update ${fileList}`;
133
+ }
134
+ function clusterHunks(hunks) {
135
+ // Filter out binary files
136
+ const nonBinary = hunks.filter(h => !h.isBinary);
137
+ if (nonBinary.length === 0)
138
+ return [];
139
+ const groups = new Map();
140
+ for (const hunk of nonBinary) {
141
+ const key = getClusterKey(hunk.file);
142
+ if (!groups.has(key))
143
+ groups.set(key, []);
144
+ groups.get(key).push(hunk);
145
+ }
146
+ const clusters = [];
147
+ for (const [key, groupHunks] of groups) {
148
+ const files = [...new Set(groupHunks.map(h => h.file))];
149
+ const cluster = {
150
+ name: key,
151
+ prefix: key.startsWith('src:') ? 'src' : key,
152
+ files,
153
+ hunks: groupHunks,
154
+ commitMessage: '',
155
+ };
156
+ cluster.commitMessage = generateCommitMessage(cluster);
157
+ clusters.push(cluster);
158
+ }
159
+ // Sort: config first, then src, then test
160
+ clusters.sort((a, b) => {
161
+ const order = (c) => c.prefix === 'config' ? 0 : c.prefix === 'src' ? 1 : 2;
162
+ return order(a) - order(b);
163
+ });
164
+ return clusters;
165
+ }
166
+ // ── Score calculation ────────────────────────────────────────────────────────
167
+ function analyzeCommit(cwd, sha) {
168
+ const diff = gitExec(['diff', `${sha}~1`, sha], cwd);
169
+ if (!diff)
170
+ return { fileCount: 0, clusterCount: 0, totalHunks: 0 };
171
+ const hunks = parseDiff(diff);
172
+ const nonBinary = hunks.filter(h => !h.isBinary);
173
+ const clusters = clusterHunks(nonBinary);
174
+ const files = new Set(nonBinary.map(h => h.file));
175
+ return { fileCount: files.size, clusterCount: clusters.length, totalHunks: nonBinary.length };
176
+ }
177
+ // ── Main check (for scorecard) ───────────────────────────────────────────────
178
+ export function checkSplit(cwd) {
179
+ const issues = [];
180
+ // Get recent commits (last 10)
181
+ const log = gitExec(['log', '--oneline', '-10', '--format=%H'], cwd);
182
+ if (!log) {
183
+ return {
184
+ name: 'split',
185
+ score: 100,
186
+ maxScore: 100,
187
+ issues: [{ severity: 'info', message: 'no commits to analyze', fixable: false }],
188
+ summary: 'no commits',
189
+ };
190
+ }
191
+ const shas = log.split('\n').filter(Boolean);
192
+ let totalPenalty = 0;
193
+ let analyzedCount = 0;
194
+ for (const sha of shas) {
195
+ // Check if commit has a parent
196
+ const parent = gitExec(['rev-parse', `${sha}~1`], cwd);
197
+ if (!parent)
198
+ continue;
199
+ const analysis = analyzeCommit(cwd, sha);
200
+ analyzedCount++;
201
+ if (analysis.fileCount === 0)
202
+ continue;
203
+ // Penalty for large multi-concern commits
204
+ if (analysis.clusterCount > 1 && analysis.fileCount > 5) {
205
+ const severity = analysis.clusterCount > 3 ? 'warning' : 'info';
206
+ const shortSha = sha.substring(0, 7);
207
+ const penalty = Math.min(20, (analysis.clusterCount - 1) * 5);
208
+ totalPenalty += penalty;
209
+ issues.push({
210
+ severity,
211
+ message: `commit ${shortSha} touches ${analysis.fileCount} files across ${analysis.clusterCount} concerns`,
212
+ fixable: true,
213
+ fixHint: `run: vet split --since ${shortSha}~1`,
214
+ });
215
+ }
216
+ if (analysis.fileCount > 20) {
217
+ totalPenalty += 15;
218
+ issues.push({
219
+ severity: 'warning',
220
+ message: `commit ${sha.substring(0, 7)} modifies ${analysis.fileCount} files — likely needs splitting`,
221
+ fixable: true,
222
+ fixHint: 'run: vet split',
223
+ });
224
+ }
225
+ }
226
+ const score = Math.max(0, 100 - totalPenalty);
227
+ const summary = issues.length === 0
228
+ ? 'all recent commits are atomic'
229
+ : `${issues.length} commit(s) could be split into smaller atomic commits`;
230
+ return { name: 'split', score, maxScore: 100, issues, summary };
231
+ }
232
+ // ── Subcommand ───────────────────────────────────────────────────────────────
233
+ export async function runSplitCommand(format, cwd, since, apply, force) {
234
+ const ref = since || 'HEAD~1';
235
+ // Get the diff
236
+ const diff = gitExec(['diff', ref, 'HEAD'], cwd);
237
+ if (!diff.trim()) {
238
+ if (format === 'json') {
239
+ console.log(JSON.stringify({ clusters: [], message: 'no changes to split' }));
240
+ }
241
+ else {
242
+ console.log(`\n ${c.bold}vet split${c.reset} — commit surgery\n`);
243
+ console.log(` ${c.dim}no changes between ${ref} and HEAD${c.reset}\n`);
244
+ }
245
+ return;
246
+ }
247
+ const hunks = parseDiff(diff);
248
+ const clusters = clusterHunks(hunks);
249
+ if (clusters.length <= 1) {
250
+ if (format === 'json') {
251
+ console.log(JSON.stringify({
252
+ clusters: clusters.map(cl => ({
253
+ name: cl.name, prefix: cl.prefix, files: cl.files,
254
+ hunkCount: cl.hunks.length, commitMessage: cl.commitMessage,
255
+ })),
256
+ message: 'commit is already atomic',
257
+ }));
258
+ }
259
+ else {
260
+ console.log(`\n ${c.bold}vet split${c.reset} — commit surgery\n`);
261
+ console.log(` ${c.green}commit is already atomic — no split needed${c.reset}\n`);
262
+ }
263
+ return;
264
+ }
265
+ // JSON output
266
+ if (format === 'json') {
267
+ const output = {
268
+ ref,
269
+ clusterCount: clusters.length,
270
+ clusters: clusters.map(cl => ({
271
+ name: cl.name,
272
+ prefix: cl.prefix,
273
+ files: cl.files,
274
+ hunkCount: cl.hunks.length,
275
+ commitMessage: cl.commitMessage,
276
+ })),
277
+ };
278
+ console.log(JSON.stringify(output, null, 2));
279
+ return;
280
+ }
281
+ // ASCII table output
282
+ console.log(`\n ${c.bold}vet split${c.reset} — commit surgery\n`);
283
+ console.log(` analyzing changes since ${c.cyan}${ref}${c.reset}\n`);
284
+ console.log(` ${c.dim}# commit message${' '.repeat(35)}files hunks${c.reset}`);
285
+ for (let i = 0; i < clusters.length; i++) {
286
+ const cl = clusters[i];
287
+ const num = String(i + 1).padStart(2);
288
+ const msg = cl.commitMessage.padEnd(50).substring(0, 50);
289
+ const files = String(cl.files.length).padStart(5);
290
+ const hunkCount = String(cl.hunks.length).padStart(6);
291
+ console.log(` ${num} ${msg}${files}${hunkCount}`);
292
+ for (const file of cl.files) {
293
+ console.log(` ${c.dim}${file}${c.reset}`);
294
+ }
295
+ }
296
+ console.log(`\n ${c.bold}${clusters.length} atomic commits${c.reset} proposed\n`);
297
+ if (!apply) {
298
+ console.log(` ${c.dim}dry run — use --apply to execute${c.reset}\n`);
299
+ return;
300
+ }
301
+ // Apply mode: safety checks
302
+ const currentBranch = gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
303
+ if ((currentBranch === 'main' || currentBranch === 'master') && !force) {
304
+ console.log(` ${c.red}refusing to rewrite history on ${currentBranch}${c.reset}`);
305
+ console.log(` ${c.dim}use --force to override${c.reset}\n`);
306
+ return;
307
+ }
308
+ // Create backup branch
309
+ const backupBranch = `vet-split-backup-${Date.now()}`;
310
+ gitExec(['branch', backupBranch], cwd);
311
+ console.log(` ${c.dim}backup branch: ${backupBranch}${c.reset}`);
312
+ // Soft reset to the ref point
313
+ gitExec(['reset', '--soft', ref], cwd);
314
+ gitExec(['reset', 'HEAD'], cwd);
315
+ // Apply each cluster as a separate commit
316
+ for (const cl of clusters) {
317
+ for (const file of cl.files) {
318
+ gitExec(['add', file], cwd);
319
+ }
320
+ gitExec(['commit', '-m', cl.commitMessage], cwd);
321
+ console.log(` ${c.green}committed:${c.reset} ${cl.commitMessage}`);
322
+ }
323
+ console.log(`\n ${c.green}split complete${c.reset} — ${clusters.length} atomic commits created`);
324
+ console.log(` ${c.dim}backup: ${backupBranch}${c.reset}\n`);
325
+ }
package/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { checkBloat, runBloatCommand } from './checks/bloat.js';
32
32
  import { checkGuard, runGuardCommand } from './checks/guard.js';
33
33
  import { checkExplain, runExplainCommand } from './checks/explain.js';
34
34
  import { checkContext, runContextCommand } from './checks/context.js';
35
+ import { checkSplit, runSplitCommand } from './checks/split.js';
35
36
  import { checkCompleteness } from './checks/completeness.js';
36
37
  import { score } from './scorer.js';
37
38
  import { reportPretty, reportJSON, reportBadge } from './reporter.js';
@@ -88,6 +89,7 @@ if (flags.has('--help') || flags.has('-h')) {
88
89
  npx @safetnsr/vet guard [dir] scan for destructive operation bomb sites
89
90
  npx @safetnsr/vet explain [--since REF] [--verbose] [--json] risk-tier agent changes
90
91
  npx @safetnsr/vet context [dir] audit agent context files for token cost + stale sections
92
+ npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
91
93
 
92
94
  ${c.dim}categories:${c.reset}
93
95
  security (30%) scan, secrets, config, model usage
@@ -123,7 +125,7 @@ if (flags.has('--version') || flags.has('-v')) {
123
125
  }
124
126
  process.exit(0);
125
127
  }
126
- const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context'];
128
+ const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split'];
127
129
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
128
130
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
129
131
  const isCI = flags.has('--ci');
@@ -276,6 +278,17 @@ if (command === 'context') {
276
278
  }
277
279
  process.exit(0);
278
280
  }
281
+ if (command === 'split') {
282
+ try {
283
+ const format = isJSON ? 'json' : 'ascii';
284
+ await runSplitCommand(format, cwd, since, flags.has('--apply'), flags.has('--force'));
285
+ }
286
+ catch (e) {
287
+ console.error(`${c.red}split failed:${c.reset}`, e instanceof Error ? e.message : e);
288
+ process.exit(1);
289
+ }
290
+ process.exit(0);
291
+ }
279
292
  if (command === 'explain') {
280
293
  try {
281
294
  const format = isJSON ? 'json' : 'ascii';
@@ -340,7 +353,7 @@ async function runChecks() {
340
353
  }
341
354
  }
342
355
  // Run ALL independent checks in parallel
343
- const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult,] = await Promise.all([
356
+ const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult,] = await Promise.all([
344
357
  withTimeout('scan', () => checkScan(cwd)),
345
358
  withTimeout('secrets', () => checkSecrets(cwd)),
346
359
  withTimeout('config', () => checkConfig(cwd, ignore)),
@@ -369,6 +382,7 @@ async function runChecks() {
369
382
  withTimeout('hotspots', () => checkHotspots(cwd), 30_000),
370
383
  withTimeout('clones', () => checkClones(cwd), 60_000),
371
384
  withTimeout('context', () => checkContext(cwd)),
385
+ withTimeout('split', () => checkSplit(cwd)),
372
386
  ]);
373
387
  // Git-dependent checks (diff + history) — parallel with each other
374
388
  const [diffResult, historyResult] = await Promise.all([
@@ -380,7 +394,7 @@ async function runChecks() {
380
394
  return score(cwd, {
381
395
  security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
382
396
  integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
383
- debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult],
397
+ debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult, splitResult],
384
398
  deps: [depsResult],
385
399
  architecture: [architectureResult],
386
400
  aiready: [aireadyResult, deepResult, semanticResult, contextResult],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {