@safetnsr/vet 1.20.1 → 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 checkContext(cwd: string): CheckResult;
3
+ export declare function runContextCommand(format: string, cwd: string): Promise<void>;
@@ -0,0 +1,359 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { c } from '../util.js';
5
+ import { cachedReadFile } from '../file-cache.js';
6
+ // ── Tiktoken lazy init ───────────────────────────────────────────────────────
7
+ import { encodingForModel } from 'js-tiktoken';
8
+ let _encoder = null;
9
+ function getEncoder() {
10
+ if (!_encoder) {
11
+ _encoder = encodingForModel('gpt-4');
12
+ }
13
+ return _encoder;
14
+ }
15
+ function countTokens(text) {
16
+ return getEncoder().encode(text).length;
17
+ }
18
+ // ── Constants ────────────────────────────────────────────────────────────────
19
+ const CONTEXT_FILES = ['CLAUDE.md', 'AGENTS.md', 'SOUL.md', '.cursorrules', 'codex.md'];
20
+ const CURSOR_RULES_DIR = join('.cursor', 'rules');
21
+ const MEMORY_DIR = 'memory';
22
+ const DAILY_DIR = join(MEMORY_DIR, 'daily');
23
+ const MODEL_COSTS = {
24
+ opus: 15, // $15 per MTok input
25
+ sonnet: 3, // $3 per MTok input
26
+ haiku: 0.25, // $0.25 per MTok input
27
+ };
28
+ const TOKEN_THRESHOLD = 8000;
29
+ const BLOATED_FILE_THRESHOLD = 10000;
30
+ function splitIntoSections(content, file) {
31
+ const lines = content.split('\n');
32
+ const sections = [];
33
+ let currentTitle = '(intro)';
34
+ let currentLines = [];
35
+ for (const line of lines) {
36
+ const headerMatch = line.match(/^(#{2,3})\s+(.+)/);
37
+ if (headerMatch) {
38
+ // flush previous
39
+ if (currentLines.length > 0) {
40
+ const text = currentLines.join('\n');
41
+ sections.push({ title: currentTitle, content: text, tokens: countTokens(text), file });
42
+ }
43
+ currentTitle = headerMatch[2].trim();
44
+ currentLines = [line];
45
+ }
46
+ else {
47
+ currentLines.push(line);
48
+ }
49
+ }
50
+ // flush last
51
+ if (currentLines.length > 0) {
52
+ const text = currentLines.join('\n');
53
+ sections.push({ title: currentTitle, content: text, tokens: countTokens(text), file });
54
+ }
55
+ return sections;
56
+ }
57
+ // ── File discovery ───────────────────────────────────────────────────────────
58
+ function discoverContextFiles(cwd) {
59
+ const files = [];
60
+ for (const name of CONTEXT_FILES) {
61
+ const full = join(cwd, name);
62
+ if (existsSync(full))
63
+ files.push(full);
64
+ }
65
+ // memory/*.md (not daily/)
66
+ const memDir = join(cwd, MEMORY_DIR);
67
+ if (existsSync(memDir) && statSync(memDir).isDirectory()) {
68
+ try {
69
+ for (const entry of readdirSync(memDir)) {
70
+ if (!entry.endsWith('.md'))
71
+ continue;
72
+ const full = join(memDir, entry);
73
+ try {
74
+ if (statSync(full).isFile())
75
+ files.push(full);
76
+ }
77
+ catch { /* skip */ }
78
+ }
79
+ }
80
+ catch { /* skip */ }
81
+ }
82
+ // .cursor/rules
83
+ const cursorDir = join(cwd, CURSOR_RULES_DIR);
84
+ if (existsSync(cursorDir) && statSync(cursorDir).isDirectory()) {
85
+ try {
86
+ for (const entry of readdirSync(cursorDir)) {
87
+ const full = join(cursorDir, entry);
88
+ try {
89
+ if (statSync(full).isFile())
90
+ files.push(full);
91
+ }
92
+ catch { /* skip */ }
93
+ }
94
+ }
95
+ catch { /* skip */ }
96
+ }
97
+ return files;
98
+ }
99
+ // ── Stale detection ──────────────────────────────────────────────────────────
100
+ function detectStaleSections(sections) {
101
+ const stale = new Set();
102
+ const claudeDir = join(homedir(), '.claude', 'projects');
103
+ if (!existsSync(claudeDir))
104
+ return stale;
105
+ // Collect recent session log content
106
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
107
+ let sessionContent = '';
108
+ try {
109
+ const projects = readdirSync(claudeDir);
110
+ for (const project of projects) {
111
+ const projectDir = join(claudeDir, project);
112
+ try {
113
+ if (!statSync(projectDir).isDirectory())
114
+ continue;
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ try {
120
+ const files = readdirSync(projectDir);
121
+ for (const file of files) {
122
+ if (!file.endsWith('.jsonl'))
123
+ continue;
124
+ const full = join(projectDir, file);
125
+ try {
126
+ const stat = statSync(full);
127
+ if (stat.mtimeMs < sevenDaysAgo)
128
+ continue;
129
+ sessionContent += readFileSync(full, 'utf-8') + '\n';
130
+ }
131
+ catch { /* skip */ }
132
+ }
133
+ }
134
+ catch { /* skip */ }
135
+ }
136
+ }
137
+ catch {
138
+ return stale;
139
+ }
140
+ if (!sessionContent)
141
+ return stale;
142
+ // Check each section: extract significant phrases and grep
143
+ for (const section of sections) {
144
+ if (section.title === '(intro)')
145
+ continue;
146
+ // Extract significant words from section content (skip short/generic)
147
+ const words = section.content
148
+ .replace(/[#`*_\-\[\](){}|]/g, ' ')
149
+ .split(/\s+/)
150
+ .filter(w => w.length > 4);
151
+ // Take a sample of phrases to check
152
+ const phrases = words.slice(0, 20);
153
+ if (phrases.length === 0)
154
+ continue;
155
+ const found = phrases.some(phrase => sessionContent.includes(phrase));
156
+ if (!found) {
157
+ stale.add(`${section.file}::${section.title}`);
158
+ }
159
+ }
160
+ return stale;
161
+ }
162
+ // ── Cost calculation ─────────────────────────────────────────────────────────
163
+ function calculateCost(tokens, model) {
164
+ const rate = MODEL_COSTS[model] || 3;
165
+ return (tokens / 1_000_000) * rate;
166
+ }
167
+ function formatCost(cost) {
168
+ if (cost < 0.001)
169
+ return `$${cost.toFixed(6)}`;
170
+ if (cost < 0.01)
171
+ return `$${cost.toFixed(4)}`;
172
+ return `$${cost.toFixed(3)}`;
173
+ }
174
+ // ── CheckResult (for full vet scan) ──────────────────────────────────────────
175
+ export function checkContext(cwd) {
176
+ const files = discoverContextFiles(cwd);
177
+ const issues = [];
178
+ let score = 100;
179
+ if (files.length === 0) {
180
+ score -= 15;
181
+ issues.push({
182
+ severity: 'error',
183
+ message: 'No agent context files found',
184
+ fixable: false,
185
+ });
186
+ return {
187
+ name: 'context',
188
+ score: Math.max(0, score),
189
+ maxScore: 100,
190
+ issues,
191
+ summary: 'no agent context files found',
192
+ };
193
+ }
194
+ const allSections = [];
195
+ let totalTokens = 0;
196
+ for (const filePath of files) {
197
+ const content = cachedReadFile(filePath);
198
+ if (!content)
199
+ continue;
200
+ const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
201
+ const sections = splitIntoSections(content, relPath);
202
+ allSections.push(...sections);
203
+ const fileTokens = sections.reduce((sum, s) => sum + s.tokens, 0);
204
+ totalTokens += fileTokens;
205
+ if (fileTokens > BLOATED_FILE_THRESHOLD) {
206
+ issues.push({
207
+ severity: 'warning',
208
+ message: `Context file exceeds 10K tokens: ${relPath} (${fileTokens} tokens)`,
209
+ file: relPath,
210
+ fixable: false,
211
+ fixHint: 'Split or trim this file to reduce token cost',
212
+ });
213
+ score -= 5;
214
+ }
215
+ }
216
+ // Stale detection
217
+ const staleSections = detectStaleSections(allSections);
218
+ let staleDeduction = 0;
219
+ let staleSavings = 0;
220
+ for (const key of staleSections) {
221
+ const [file, title] = key.split('::');
222
+ const section = allSections.find(s => s.file === file && s.title === title);
223
+ if (section)
224
+ staleSavings += section.tokens;
225
+ if (staleDeduction < 40) {
226
+ issues.push({
227
+ severity: 'warning',
228
+ message: `Stale section: "${title}" in ${file}`,
229
+ file,
230
+ fixable: false,
231
+ fixHint: 'Section not referenced in recent sessions — consider removing',
232
+ });
233
+ staleDeduction += 10;
234
+ }
235
+ }
236
+ score -= Math.min(staleDeduction, 40);
237
+ // Token threshold penalty
238
+ if (totalTokens > TOKEN_THRESHOLD) {
239
+ const over = totalTokens - TOKEN_THRESHOLD;
240
+ const penalty = Math.min(Math.floor(over / 2000) * 5, 30);
241
+ score -= penalty;
242
+ }
243
+ // Info issues
244
+ issues.push({
245
+ severity: 'info',
246
+ message: `Total context: ${totalTokens} tokens across ${files.length} file${files.length !== 1 ? 's' : ''}`,
247
+ fixable: false,
248
+ });
249
+ if (staleSavings > 0) {
250
+ const savings = formatCost(calculateCost(staleSavings, 'sonnet'));
251
+ issues.push({
252
+ severity: 'info',
253
+ message: `Potential savings: ${staleSavings} tokens (${savings}/call at sonnet rates) from removing stale sections`,
254
+ fixable: false,
255
+ });
256
+ }
257
+ return {
258
+ name: 'context',
259
+ score: Math.max(0, score),
260
+ maxScore: 100,
261
+ issues,
262
+ summary: `${totalTokens} tokens in ${files.length} context file${files.length !== 1 ? 's' : ''}${staleSections.size > 0 ? `, ${staleSections.size} stale section${staleSections.size !== 1 ? 's' : ''}` : ''}`,
263
+ };
264
+ }
265
+ // ── Subcommand output ────────────────────────────────────────────────────────
266
+ export async function runContextCommand(format, cwd) {
267
+ const files = discoverContextFiles(cwd);
268
+ if (files.length === 0) {
269
+ if (format === 'json') {
270
+ console.log(JSON.stringify({ files: [], sections: [], totalTokens: 0, costs: {}, stale: [], score: 0 }, null, 2));
271
+ }
272
+ else {
273
+ console.log(`\n ${c.bold}vet context${c.reset} — no agent context files found\n`);
274
+ }
275
+ return;
276
+ }
277
+ const allSections = [];
278
+ let totalTokens = 0;
279
+ for (const filePath of files) {
280
+ const content = cachedReadFile(filePath);
281
+ if (!content)
282
+ continue;
283
+ const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
284
+ const sections = splitIntoSections(content, relPath);
285
+ allSections.push(...sections);
286
+ totalTokens += sections.reduce((sum, s) => sum + s.tokens, 0);
287
+ }
288
+ const staleSections = detectStaleSections(allSections);
289
+ if (format === 'json') {
290
+ const result = {
291
+ files: files.map(f => f.startsWith(cwd) ? f.slice(cwd.length + 1) : f),
292
+ sections: allSections.map(s => ({
293
+ file: s.file,
294
+ title: s.title,
295
+ tokens: s.tokens,
296
+ stale: staleSections.has(`${s.file}::${s.title}`),
297
+ costs: {
298
+ opus: calculateCost(s.tokens, 'opus'),
299
+ sonnet: calculateCost(s.tokens, 'sonnet'),
300
+ haiku: calculateCost(s.tokens, 'haiku'),
301
+ },
302
+ })),
303
+ totalTokens,
304
+ costs: {
305
+ opus: calculateCost(totalTokens, 'opus'),
306
+ sonnet: calculateCost(totalTokens, 'sonnet'),
307
+ haiku: calculateCost(totalTokens, 'haiku'),
308
+ },
309
+ stale: [...staleSections],
310
+ staleSavingsTokens: allSections
311
+ .filter(s => staleSections.has(`${s.file}::${s.title}`))
312
+ .reduce((sum, s) => sum + s.tokens, 0),
313
+ };
314
+ console.log(JSON.stringify(result, null, 2));
315
+ return;
316
+ }
317
+ // ASCII table output
318
+ console.log(`\n ${c.bold}vet context${c.reset} — agent context cost audit\n`);
319
+ // Header
320
+ const fileW = 30;
321
+ const sectionW = 25;
322
+ const tokenW = 8;
323
+ const costW = 12;
324
+ console.log(` ${c.dim}${'─'.repeat(fileW + sectionW + tokenW + costW * 3 + 10)}${c.reset}`);
325
+ console.log(` ${pad('File', fileW)} ${pad('Section', sectionW)} ${padR('Tokens', tokenW)} ${padR('Opus', costW)} ${padR('Sonnet', costW)} ${padR('Haiku', costW)}`);
326
+ console.log(` ${c.dim}${'─'.repeat(fileW + sectionW + tokenW + costW * 3 + 10)}${c.reset}`);
327
+ for (const s of allSections) {
328
+ const isStale = staleSections.has(`${s.file}::${s.title}`);
329
+ const staleMarker = isStale ? ` ${c.yellow}⚠ stale${c.reset}` : '';
330
+ const file = truncate(s.file, fileW);
331
+ const title = truncate(s.title, sectionW);
332
+ console.log(` ${pad(file, fileW)} ${pad(title, sectionW)} ${padR(String(s.tokens), tokenW)} ${padR(formatCost(calculateCost(s.tokens, 'opus')), costW)} ${padR(formatCost(calculateCost(s.tokens, 'sonnet')), costW)} ${padR(formatCost(calculateCost(s.tokens, 'haiku')), costW)}${staleMarker}`);
333
+ }
334
+ console.log(` ${c.dim}${'─'.repeat(fileW + sectionW + tokenW + costW * 3 + 10)}${c.reset}`);
335
+ console.log(` ${pad(c.bold + 'Total' + c.reset, fileW)} ${pad('', sectionW)} ${padR(String(totalTokens), tokenW)} ${padR(formatCost(calculateCost(totalTokens, 'opus')), costW)} ${padR(formatCost(calculateCost(totalTokens, 'sonnet')), costW)} ${padR(formatCost(calculateCost(totalTokens, 'haiku')), costW)}`);
336
+ console.log('');
337
+ if (staleSections.size > 0) {
338
+ const staleToks = allSections
339
+ .filter(s => staleSections.has(`${s.file}::${s.title}`))
340
+ .reduce((sum, s) => sum + s.tokens, 0);
341
+ console.log(` ${c.yellow}⚠ ${staleSections.size} stale section${staleSections.size !== 1 ? 's' : ''} detected${c.reset} — ${staleToks} tokens (${formatCost(calculateCost(staleToks, 'sonnet'))}/call at sonnet rates)`);
342
+ console.log(` ${c.dim}These sections weren't referenced in recent Claude sessions${c.reset}\n`);
343
+ }
344
+ }
345
+ // ── String helpers ───────────────────────────────────────────────────────────
346
+ function pad(s, w) {
347
+ // Strip ANSI for length calculation
348
+ const clean = s.replace(/\x1b\[[0-9;]*m/g, '');
349
+ return s + ' '.repeat(Math.max(0, w - clean.length));
350
+ }
351
+ function padR(s, w) {
352
+ const clean = s.replace(/\x1b\[[0-9;]*m/g, '');
353
+ return ' '.repeat(Math.max(0, w - clean.length)) + s;
354
+ }
355
+ function truncate(s, max) {
356
+ if (s.length <= max)
357
+ return s;
358
+ return s.slice(0, max - 1) + '…';
359
+ }
@@ -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
@@ -31,6 +31,8 @@ import { checkLoop, runLoopCommand } from './checks/loop.js';
31
31
  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
+ import { checkContext, runContextCommand } from './checks/context.js';
35
+ import { checkSplit, runSplitCommand } from './checks/split.js';
34
36
  import { checkCompleteness } from './checks/completeness.js';
35
37
  import { score } from './scorer.js';
36
38
  import { reportPretty, reportJSON, reportBadge } from './reporter.js';
@@ -86,6 +88,8 @@ if (flags.has('--help') || flags.has('-h')) {
86
88
  npx @safetnsr/vet bloat detect agent-generated code bloat
87
89
  npx @safetnsr/vet guard [dir] scan for destructive operation bomb sites
88
90
  npx @safetnsr/vet explain [--since REF] [--verbose] [--json] risk-tier agent changes
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
89
93
 
90
94
  ${c.dim}categories:${c.reset}
91
95
  security (30%) scan, secrets, config, model usage
@@ -121,7 +125,7 @@ if (flags.has('--version') || flags.has('-v')) {
121
125
  }
122
126
  process.exit(0);
123
127
  }
124
- const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain'];
128
+ const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split'];
125
129
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
126
130
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
127
131
  const isCI = flags.has('--ci');
@@ -263,6 +267,28 @@ if (command === 'guard') {
263
267
  }
264
268
  process.exit(0);
265
269
  }
270
+ if (command === 'context') {
271
+ try {
272
+ const format = isJSON ? 'json' : 'ascii';
273
+ await runContextCommand(format, cwd);
274
+ }
275
+ catch (e) {
276
+ console.error(`${c.red}context failed:${c.reset}`, e instanceof Error ? e.message : e);
277
+ process.exit(1);
278
+ }
279
+ process.exit(0);
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
+ }
266
292
  if (command === 'explain') {
267
293
  try {
268
294
  const format = isJSON ? 'json' : 'ascii';
@@ -327,7 +353,7 @@ async function runChecks() {
327
353
  }
328
354
  }
329
355
  // Run ALL independent checks in parallel
330
- 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,] = 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([
331
357
  withTimeout('scan', () => checkScan(cwd)),
332
358
  withTimeout('secrets', () => checkSecrets(cwd)),
333
359
  withTimeout('config', () => checkConfig(cwd, ignore)),
@@ -355,6 +381,8 @@ async function runChecks() {
355
381
  withTimeout('semantic', () => checkSemantic(cwd), 60_000),
356
382
  withTimeout('hotspots', () => checkHotspots(cwd), 30_000),
357
383
  withTimeout('clones', () => checkClones(cwd), 60_000),
384
+ withTimeout('context', () => checkContext(cwd)),
385
+ withTimeout('split', () => checkSplit(cwd)),
358
386
  ]);
359
387
  // Git-dependent checks (diff + history) — parallel with each other
360
388
  const [diffResult, historyResult] = await Promise.all([
@@ -366,10 +394,10 @@ async function runChecks() {
366
394
  return score(cwd, {
367
395
  security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
368
396
  integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
369
- debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult],
397
+ debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult, splitResult],
370
398
  deps: [depsResult],
371
399
  architecture: [architectureResult],
372
- aiready: [aireadyResult, deepResult, semanticResult],
400
+ aiready: [aireadyResult, deepResult, semanticResult, contextResult],
373
401
  history: [hotspotsResult],
374
402
  });
375
403
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.20.1",
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": {
@@ -44,6 +44,7 @@
44
44
  "@safetnsr/model-graveyard": "^0.2.0"
45
45
  },
46
46
  "dependencies": {
47
- "@huggingface/transformers": "^3.8.1"
47
+ "@huggingface/transformers": "^3.8.1",
48
+ "js-tiktoken": "^1.0.21"
48
49
  }
49
50
  }