@safetnsr/vet 1.8.5 → 1.9.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,26 @@
1
+ import type { CheckResult } from '../types.js';
2
+ interface SessionEntry {
3
+ type?: string;
4
+ role?: string;
5
+ content?: unknown;
6
+ meta?: {
7
+ type?: string;
8
+ [key: string]: unknown;
9
+ };
10
+ [key: string]: unknown;
11
+ }
12
+ interface CompactionEvent {
13
+ messageIndex: number;
14
+ summary: string;
15
+ preContext: string[];
16
+ droppedInstructions: string[];
17
+ droppedFilePaths: string[];
18
+ droppedIdentifiers: string[];
19
+ }
20
+ export declare function extractFilePaths(text: string): string[];
21
+ export declare function extractIdentifiers(text: string): string[];
22
+ export declare function extractInstructions(text: string): string[];
23
+ export declare function detectCompactions(entries: SessionEntry[]): CompactionEvent[];
24
+ export declare function checkCompact(cwd: string): Promise<CheckResult>;
25
+ export declare function runCompactCommand(format: 'ascii' | 'json', sessionPath?: string): Promise<void>;
26
+ export {};
@@ -0,0 +1,280 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { c } from '../util.js';
5
+ import { findLatestSession } from './receipt.js';
6
+ // ── Extraction helpers ───────────────────────────────────────────────────────
7
+ const FILE_PATH_RE = /(?:(?:\/[\w.@-]+)+(?:\/[\w.@-]+)*|[\w@-]+(?:\/[\w.@-]+)+)(?:\.\w+)?/g;
8
+ const IDENTIFIER_RE = /\b(?:[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*|[a-z]+(?:_[a-z]+)+)\b/g;
9
+ const INSTRUCTION_RE = /(?:^|\.\s+)((?:always|never|must|don't|do not)\s[^.!?\n]{5,})/gim;
10
+ export function extractFilePaths(text) {
11
+ const matches = text.match(FILE_PATH_RE) || [];
12
+ // Deduplicate and filter out very short ones
13
+ return [...new Set(matches)].filter(m => m.includes('/') || m.includes('.'));
14
+ }
15
+ export function extractIdentifiers(text) {
16
+ const matches = text.match(IDENTIFIER_RE) || [];
17
+ return [...new Set(matches)];
18
+ }
19
+ export function extractInstructions(text) {
20
+ const results = [];
21
+ let match;
22
+ const re = new RegExp(INSTRUCTION_RE.source, INSTRUCTION_RE.flags);
23
+ while ((match = re.exec(text)) !== null) {
24
+ const instruction = match[1].trim();
25
+ if (instruction.length > 10)
26
+ results.push(instruction);
27
+ }
28
+ return [...new Set(results)];
29
+ }
30
+ function getTextContent(content) {
31
+ if (typeof content === 'string')
32
+ return content;
33
+ if (Array.isArray(content)) {
34
+ return content
35
+ .map(block => {
36
+ if (typeof block === 'string')
37
+ return block;
38
+ if (block && typeof block === 'object') {
39
+ const b = block;
40
+ if (typeof b.text === 'string')
41
+ return b.text;
42
+ if (typeof b.content === 'string')
43
+ return b.content;
44
+ }
45
+ return '';
46
+ })
47
+ .join('\n');
48
+ }
49
+ return '';
50
+ }
51
+ // ── Core parsing ─────────────────────────────────────────────────────────────
52
+ async function parseEntries(filePath) {
53
+ const entries = [];
54
+ const rl = createInterface({
55
+ input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
56
+ crlfDelay: Infinity,
57
+ });
58
+ for await (const line of rl) {
59
+ const trimmed = line.trim();
60
+ if (!trimmed)
61
+ continue;
62
+ try {
63
+ entries.push(JSON.parse(trimmed));
64
+ }
65
+ catch { /* skip malformed */ }
66
+ }
67
+ return entries;
68
+ }
69
+ function isCompactionEvent(entry, index, hasExchanges) {
70
+ // Explicit type markers
71
+ if (typeof entry.type === 'string' && /compact|summary/i.test(entry.type))
72
+ return true;
73
+ if (entry.meta && typeof entry.meta.type === 'string' && /compact/i.test(entry.meta.type))
74
+ return true;
75
+ // System message with long content after user/assistant exchanges
76
+ if (entry.role === 'system' && hasExchanges) {
77
+ const text = getTextContent(entry.content);
78
+ if (text.length > 500)
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ export function detectCompactions(entries) {
84
+ const events = [];
85
+ let hasExchanges = false;
86
+ let preContextTexts = [];
87
+ for (let i = 0; i < entries.length; i++) {
88
+ const entry = entries[i];
89
+ const role = entry.role ||
90
+ (entry.message
91
+ ? entry.message?.role
92
+ : undefined);
93
+ if (role === 'user' || role === 'assistant') {
94
+ hasExchanges = true;
95
+ const text = getTextContent(entry.content);
96
+ if (text)
97
+ preContextTexts.push(text);
98
+ }
99
+ if (isCompactionEvent(entry, i, hasExchanges)) {
100
+ const summaryText = getTextContent(entry.content);
101
+ const preText = preContextTexts.join('\n');
102
+ // Extract keywords from pre-context
103
+ const preFilePaths = extractFilePaths(preText);
104
+ const preIdentifiers = extractIdentifiers(preText);
105
+ const preInstructions = extractInstructions(preText);
106
+ // Check which are absent from summary
107
+ const summaryLower = summaryText.toLowerCase();
108
+ const droppedFilePaths = preFilePaths.filter(fp => !summaryText.includes(fp));
109
+ const droppedIdentifiers = preIdentifiers.filter(id => !summaryLower.includes(id.toLowerCase()));
110
+ const droppedInstructions = preInstructions.filter(inst => {
111
+ // Check if the core of the instruction is preserved
112
+ const words = inst.toLowerCase().split(/\s+/).filter(w => w.length > 3);
113
+ const preserved = words.filter(w => summaryLower.includes(w));
114
+ return preserved.length < words.length * 0.5;
115
+ });
116
+ events.push({
117
+ messageIndex: i,
118
+ summary: summaryText.slice(0, 200),
119
+ preContext: preContextTexts.slice(-5), // keep last 5 for context
120
+ droppedInstructions,
121
+ droppedFilePaths,
122
+ droppedIdentifiers,
123
+ });
124
+ // Reset pre-context after compaction (summary becomes the new context)
125
+ preContextTexts = [summaryText];
126
+ hasExchanges = false;
127
+ }
128
+ }
129
+ return events;
130
+ }
131
+ // ── Score calculation ────────────────────────────────────────────────────────
132
+ function calculateScore(events) {
133
+ const issues = [];
134
+ for (const event of events) {
135
+ for (const inst of event.droppedInstructions) {
136
+ issues.push({
137
+ severity: 'error',
138
+ message: `compaction #${event.messageIndex}: dropped instruction: "${inst.slice(0, 80)}"`,
139
+ fixable: false,
140
+ });
141
+ }
142
+ for (const fp of event.droppedFilePaths) {
143
+ issues.push({
144
+ severity: 'warning',
145
+ message: `compaction #${event.messageIndex}: dropped file reference: ${fp}`,
146
+ fixable: false,
147
+ });
148
+ }
149
+ for (const id of event.droppedIdentifiers) {
150
+ issues.push({
151
+ severity: 'info',
152
+ message: `compaction #${event.messageIndex}: dropped identifier: ${id}`,
153
+ fixable: false,
154
+ });
155
+ }
156
+ }
157
+ const errors = issues.filter(i => i.severity === 'error').length;
158
+ const warnings = issues.filter(i => i.severity === 'warning').length;
159
+ const infos = issues.filter(i => i.severity === 'info').length;
160
+ const score = Math.max(0, 100 - (errors * 30 + warnings * 15 + infos * 5));
161
+ return { score, issues };
162
+ }
163
+ // ── Check function (for full vet run) ────────────────────────────────────────
164
+ export async function checkCompact(cwd) {
165
+ const sessionFile = findLatestSession();
166
+ if (!sessionFile) {
167
+ return {
168
+ name: 'compact',
169
+ score: 100,
170
+ maxScore: 100,
171
+ issues: [{ severity: 'info', message: 'no claude session files found (~/.claude/projects/)', fixable: false }],
172
+ summary: 'no session logs found',
173
+ };
174
+ }
175
+ let entries;
176
+ try {
177
+ entries = await parseEntries(sessionFile);
178
+ }
179
+ catch {
180
+ return {
181
+ name: 'compact',
182
+ score: 100,
183
+ maxScore: 100,
184
+ issues: [{ severity: 'warning', message: 'could not parse session file', fixable: false }],
185
+ summary: 'session parse error',
186
+ };
187
+ }
188
+ if (entries.length === 0) {
189
+ return {
190
+ name: 'compact',
191
+ score: 100,
192
+ maxScore: 100,
193
+ issues: [{ severity: 'info', message: 'session file is empty', fixable: false }],
194
+ summary: 'empty session file',
195
+ };
196
+ }
197
+ const events = detectCompactions(entries);
198
+ if (events.length === 0) {
199
+ return {
200
+ name: 'compact',
201
+ score: 100,
202
+ maxScore: 100,
203
+ issues: [],
204
+ summary: 'no compactions detected',
205
+ };
206
+ }
207
+ const { score, issues } = calculateScore(events);
208
+ const errors = issues.filter(i => i.severity === 'error').length;
209
+ const warnings = issues.filter(i => i.severity === 'warning').length;
210
+ const infos = issues.filter(i => i.severity === 'info').length;
211
+ return {
212
+ name: 'compact',
213
+ score,
214
+ maxScore: 100,
215
+ issues,
216
+ summary: `${events.length} compaction${events.length !== 1 ? 's' : ''}: ${errors} instructions, ${warnings} file refs, ${infos} identifiers dropped`,
217
+ };
218
+ }
219
+ // ── Standalone subcommand ────────────────────────────────────────────────────
220
+ export async function runCompactCommand(format, sessionPath) {
221
+ const filePath = sessionPath || findLatestSession();
222
+ if (!filePath) {
223
+ console.error('no claude session files found in ~/.claude/projects/');
224
+ process.exit(1);
225
+ }
226
+ if (!fs.existsSync(filePath) && !sessionPath) {
227
+ console.error('session file not found');
228
+ process.exit(1);
229
+ }
230
+ const entries = await parseEntries(filePath);
231
+ const events = detectCompactions(entries);
232
+ const { score, issues } = calculateScore(events);
233
+ if (format === 'json') {
234
+ const result = {
235
+ name: 'compact',
236
+ score,
237
+ maxScore: 100,
238
+ issues,
239
+ summary: events.length === 0
240
+ ? 'no compactions detected'
241
+ : `${events.length} compaction${events.length !== 1 ? 's' : ''} found`,
242
+ };
243
+ console.log(JSON.stringify(result, null, 2));
244
+ return;
245
+ }
246
+ // ASCII output
247
+ const sessionId = path.basename(filePath, '.jsonl').slice(0, 30);
248
+ console.log(`\n ${c.bold}vet compact${c.reset} — compaction forensics\n`);
249
+ console.log(` session: ${sessionId}`);
250
+ console.log(` compactions found: ${events.length}\n`);
251
+ if (events.length === 0) {
252
+ console.log(` ${c.green}no compactions detected${c.reset}\n`);
253
+ console.log(` score: 100/100\n`);
254
+ return;
255
+ }
256
+ for (let i = 0; i < events.length; i++) {
257
+ const event = events[i];
258
+ console.log(` compaction ${i + 1} (message #${event.messageIndex}):`);
259
+ for (const inst of event.droppedInstructions) {
260
+ console.log(` ${c.red}✗${c.reset} dropped instruction: "${inst.slice(0, 60)}"`);
261
+ }
262
+ for (const fp of event.droppedFilePaths) {
263
+ console.log(` ${c.yellow}⚠${c.reset} dropped file reference: ${fp}`);
264
+ }
265
+ for (const id of event.droppedIdentifiers) {
266
+ console.log(` ${c.dim}i${c.reset} dropped identifier: ${id}`);
267
+ }
268
+ if (event.droppedInstructions.length === 0 &&
269
+ event.droppedFilePaths.length === 0 &&
270
+ event.droppedIdentifiers.length === 0) {
271
+ console.log(` ${c.green}no drops detected${c.reset}`);
272
+ }
273
+ console.log('');
274
+ }
275
+ const totalInstructions = events.reduce((s, e) => s + e.droppedInstructions.length, 0);
276
+ const totalFileRefs = events.reduce((s, e) => s + e.droppedFilePaths.length, 0);
277
+ const totalIdentifiers = events.reduce((s, e) => s + e.droppedIdentifiers.length, 0);
278
+ console.log(` total drops: ${totalInstructions} instructions, ${totalFileRefs} file refs, ${totalIdentifiers} identifiers`);
279
+ console.log(` score: ${score}/100\n`);
280
+ }
@@ -16,6 +16,10 @@ function isAiFramework(cwd) {
16
16
  return true;
17
17
  if (Array.isArray(pkg.keywords) && pkg.keywords.some((k) => AI_PKG_KEYWORDS.has(k.toLowerCase())))
18
18
  return true;
19
+ // Check if any AI SDK is in dependencies
20
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
21
+ if (aiDeps.some(d => allDeps[d]))
22
+ return true;
19
23
  }
20
24
  catch { /* skip */ }
21
25
  }
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { checkVerify } from './checks/verify.js';
19
19
  import { checkTests } from './checks/tests.js';
20
20
  import { checkMap, renderMapReport } from './checks/map.js';
21
21
  import { checkPermissions } from './checks/permissions.js';
22
+ import { checkCompact, runCompactCommand } from './checks/compact.js';
22
23
  import { score } from './scorer.js';
23
24
  import { reportPretty, reportJSON, reportBadge } from './reporter.js';
24
25
  const args = process.argv.slice(2);
@@ -51,6 +52,7 @@ if (flags.has('--help') || flags.has('-h')) {
51
52
  npx @safetnsr/vet receipt show last agent session receipt
52
53
  npx @safetnsr/vet map [dir] show agent visibility map
53
54
  npx @safetnsr/vet permissions [dir] audit Claude Code config for dangerous grants
55
+ npx @safetnsr/vet compact [log] compaction forensics for claude code sessions
54
56
 
55
57
  ${c.dim}categories:${c.reset}
56
58
  security (30%) scan, secrets, config, model usage
@@ -85,7 +87,7 @@ if (flags.has('--version') || flags.has('-v')) {
85
87
  }
86
88
  process.exit(0);
87
89
  }
88
- const COMMANDS = ['init', 'receipt', 'map', 'permissions'];
90
+ const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact'];
89
91
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
90
92
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
91
93
  const isCI = flags.has('--ci');
@@ -167,6 +169,18 @@ if (command === 'permissions') {
167
169
  }
168
170
  process.exit(result.score < 60 ? 1 : 0);
169
171
  }
172
+ if (command === 'compact') {
173
+ try {
174
+ const format = isJSON ? 'json' : 'ascii';
175
+ const sessionArg = positional.find(p => p !== 'compact' && !COMMANDS.includes(p));
176
+ await runCompactCommand(format, sessionArg);
177
+ }
178
+ catch (e) {
179
+ console.error(`${c.red}compact failed:${c.reset}`, e instanceof Error ? e.message : e);
180
+ process.exit(1);
181
+ }
182
+ process.exit(0);
183
+ }
170
184
  if (!isGitRepo(cwd)) {
171
185
  console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
172
186
  process.exit(1);
@@ -218,6 +232,8 @@ async function runChecks() {
218
232
  const depsResult = await checkDeps(cwd);
219
233
  // Receipt is informational — fold into integrity category but keep low weight
220
234
  const receiptResult = await checkReceipt(cwd);
235
+ // Compact: compaction forensics
236
+ const compactResult = await checkCompact(cwd);
221
237
  // Memory: stale facts in agent memory files
222
238
  const memoryResult = checkMemory(cwd);
223
239
  // Verify: agent claim validation
@@ -226,7 +242,7 @@ async function runChecks() {
226
242
  const testsResult = checkTests(cwd, ignore);
227
243
  return score(cwd, {
228
244
  security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult],
229
- integrity: [diffResult, integrityResult, receiptResult, memoryResult, verifyResult, testsResult],
245
+ integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
230
246
  debt: [readyResult, historyResult, debtResult],
231
247
  deps: [depsResult],
232
248
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.8.5",
3
+ "version": "1.9.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {