@safetnsr/vet 1.9.0 → 1.10.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/README.md CHANGED
@@ -40,6 +40,7 @@ a codebase that scores well here gives AI agents better context, fewer hallucina
40
40
  | **secrets** | scans dist/, build/, .next/ + .env files for leaked API keys using pattern + entropy analysis |
41
41
  | **history** | git commit churn, AI attribution ratios, suspiciously large changes |
42
42
  | **receipt** | parses Claude Code session logs — files changed, commands run, packages installed, SHA256 integrity hash |
43
+ | **compact** | compaction forensics — what context got dropped during Claude Code session compaction |
43
44
 
44
45
  plus: **integrity** (hallucinated imports), **deps** (unused/phantom dependencies), **owasp** (OWASP Top 10 for AI agents), **verify** (validates agent claims against actual changes).
45
46
 
@@ -91,6 +92,9 @@ npx @safetnsr/vet init
91
92
  # agent session receipt
92
93
  npx @safetnsr/vet receipt
93
94
  npx @safetnsr/vet receipt --json
95
+
96
+ # compaction forensics for claude sessions
97
+ npx @safetnsr/vet compact [log]
94
98
  ```
95
99
 
96
100
  ## --fix
@@ -3,4 +3,6 @@ export declare function levenshtein(a: string, b: string): number;
3
3
  export declare function extractImports(source: string): string[];
4
4
  export declare function extractPackageName(specifier: string): string | null;
5
5
  export declare function isBuiltin(specifier: string): boolean;
6
+ export declare function detectWorkspacePackages(cwd: string): Set<string>;
7
+ export declare function detectProvidedDeps(cwd: string): Set<string>;
6
8
  export declare function checkDeps(cwd: string): Promise<CheckResult>;
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import { walkFiles, readFile } from '../util.js';
4
4
  // ── Top packages list (~150 popular npm packages) ────────────────────────────
5
5
  const TOP_PACKAGES = [
@@ -146,6 +146,140 @@ async function checkRegistry(packages) {
146
146
  }
147
147
  return results;
148
148
  }
149
+ // ── Workspace detection ──────────────────────────────────────────────────────
150
+ export function detectWorkspacePackages(cwd) {
151
+ const names = new Set();
152
+ // Detect workspace globs from package.json, pnpm-workspace.yaml, lerna.json
153
+ const globs = [];
154
+ try {
155
+ const pkgRaw = readFile(join(cwd, 'package.json'));
156
+ if (pkgRaw) {
157
+ const pkg = JSON.parse(pkgRaw);
158
+ if (Array.isArray(pkg.workspaces)) {
159
+ globs.push(...pkg.workspaces);
160
+ }
161
+ else if (pkg.workspaces?.packages) {
162
+ globs.push(...pkg.workspaces.packages);
163
+ }
164
+ }
165
+ }
166
+ catch { /* skip */ }
167
+ try {
168
+ const pnpmWs = readFile(join(cwd, 'pnpm-workspace.yaml'));
169
+ if (pnpmWs) {
170
+ // Simple YAML parse: extract lines like " - 'packages/*'"
171
+ const matches = pnpmWs.matchAll(/['"]?([^'":\n]+\*[^'":\n]*)['"]?/g);
172
+ for (const m of matches)
173
+ globs.push(m[1].trim());
174
+ }
175
+ }
176
+ catch { /* skip */ }
177
+ try {
178
+ const lernaRaw = readFile(join(cwd, 'lerna.json'));
179
+ if (lernaRaw) {
180
+ const lerna = JSON.parse(lernaRaw);
181
+ if (Array.isArray(lerna.packages))
182
+ globs.push(...lerna.packages);
183
+ }
184
+ }
185
+ catch { /* skip */ }
186
+ // Resolve globs to workspace package.json files
187
+ for (const glob of globs) {
188
+ // Handle simple globs like "packages/*"
189
+ const parts = glob.replace(/\/$/, '').split('/');
190
+ const starIdx = parts.indexOf('*');
191
+ if (starIdx === -1) {
192
+ // Exact directory
193
+ try {
194
+ const pkgPath = join(cwd, glob, 'package.json');
195
+ const raw = readFile(pkgPath);
196
+ if (raw) {
197
+ const pkg = JSON.parse(raw);
198
+ if (pkg.name)
199
+ names.add(pkg.name);
200
+ }
201
+ }
202
+ catch { /* skip */ }
203
+ }
204
+ else {
205
+ // Wildcard — list directory at the non-wildcard prefix
206
+ const prefix = parts.slice(0, starIdx).join('/');
207
+ const prefixDir = join(cwd, prefix);
208
+ try {
209
+ if (existsSync(prefixDir) && statSync(prefixDir).isDirectory()) {
210
+ for (const entry of readdirSync(prefixDir)) {
211
+ const entryDir = join(prefixDir, entry);
212
+ try {
213
+ if (!statSync(entryDir).isDirectory())
214
+ continue;
215
+ // If there are more parts after *, recurse
216
+ const suffix = parts.slice(starIdx + 1);
217
+ const pkgDir = suffix.length > 0 ? join(entryDir, ...suffix) : entryDir;
218
+ const pkgPath = join(pkgDir, 'package.json');
219
+ const raw = readFile(pkgPath);
220
+ if (raw) {
221
+ const pkg = JSON.parse(raw);
222
+ if (pkg.name)
223
+ names.add(pkg.name);
224
+ }
225
+ }
226
+ catch { /* skip */ }
227
+ }
228
+ }
229
+ }
230
+ catch { /* skip */ }
231
+ }
232
+ }
233
+ return names;
234
+ }
235
+ // ── Plugin host-provided deps ────────────────────────────────────────────────
236
+ export function detectProvidedDeps(cwd) {
237
+ const provided = new Set();
238
+ try {
239
+ const pkgRaw = readFile(join(cwd, 'package.json'));
240
+ if (!pkgRaw)
241
+ return provided;
242
+ const pkg = JSON.parse(pkgRaw);
243
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
244
+ // Obsidian plugin
245
+ const hasObsidian = 'obsidian' in (allDeps || {});
246
+ const manifestPath = join(cwd, 'manifest.json');
247
+ let hasManifestId = false;
248
+ try {
249
+ const manifestRaw = readFile(manifestPath);
250
+ if (manifestRaw) {
251
+ const manifest = JSON.parse(manifestRaw);
252
+ if (manifest.id)
253
+ hasManifestId = true;
254
+ }
255
+ }
256
+ catch { /* skip */ }
257
+ if (hasObsidian || hasManifestId) {
258
+ provided.add('obsidian');
259
+ provided.add('electron');
260
+ // @codemirror/* is handled by prefix check below
261
+ provided.add('@codemirror/*');
262
+ }
263
+ // VSCode extension
264
+ if (pkg.engines?.vscode) {
265
+ provided.add('vscode');
266
+ }
267
+ // Electron app
268
+ if (allDeps?.electron) {
269
+ provided.add('electron');
270
+ }
271
+ }
272
+ catch { /* skip */ }
273
+ return provided;
274
+ }
275
+ function isProvidedPackage(pkg, provided) {
276
+ if (provided.has(pkg))
277
+ return true;
278
+ // Handle @codemirror/* wildcard
279
+ if (provided.has('@codemirror/*') && pkg.startsWith('@codemirror/'))
280
+ return true;
281
+ return false;
282
+ }
149
283
  // ── Main check ───────────────────────────────────────────────────────────────
150
284
  export async function checkDeps(cwd) {
151
285
  try {
@@ -260,9 +394,18 @@ export async function checkDeps(cwd) {
260
394
  });
261
395
  }
262
396
  }
397
+ // Detect workspace packages and host-provided deps
398
+ const workspacePackages = detectWorkspacePackages(cwd);
399
+ const providedDeps = detectProvidedDeps(cwd);
263
400
  // Phantom imports: imported but not declared
264
401
  for (const pkg of importedPackages) {
265
402
  if (!declaredSet.has(pkg)) {
403
+ // Skip workspace packages
404
+ if (workspacePackages.has(pkg))
405
+ continue;
406
+ // Skip host-provided deps
407
+ if (isProvidedPackage(pkg, providedDeps))
408
+ continue;
266
409
  issues.push({
267
410
  severity: 'warning',
268
411
  message: `phantom import: "${pkg}" is imported but not in package.json`,
@@ -102,6 +102,9 @@ function extractRelativeImports(source) {
102
102
  }
103
103
  return imports;
104
104
  }
105
+ function isBuildArtifactImport(importPath) {
106
+ return /(?:^|\/)(?:dist|build)\//.test(importPath);
107
+ }
105
108
  function checkHallucinatedImports(cwd, files) {
106
109
  const issues = [];
107
110
  const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
@@ -111,11 +114,17 @@ function checkHallucinatedImports(cwd, files) {
111
114
  continue;
112
115
  if (file.includes('node_modules'))
113
116
  continue;
117
+ // Skip .d.ts declaration files — they reference build outputs
118
+ if (file.endsWith('.d.ts'))
119
+ continue;
114
120
  const content = readFile(join(cwd, file));
115
121
  if (!content)
116
122
  continue;
117
123
  const relImports = extractRelativeImports(content);
118
124
  for (const imp of relImports) {
125
+ // Skip build artifact imports (./dist/..., ../build/...)
126
+ if (isBuildArtifactImport(imp.path))
127
+ continue;
119
128
  // Skip .js extensions pointing to .ts files (common in ESM TypeScript)
120
129
  // The resolver already handles this
121
130
  if (!resolveRelativeImport(imp.path, file, cwd)) {
@@ -1,6 +1,7 @@
1
1
  import { join, resolve } from 'node:path';
2
2
  import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
3
3
  import { readFile } from '../util.js';
4
+ import { detectWorkspacePackages } from './deps.js';
4
5
  // ── Memory file targets ──────────────────────────────────────────────────────
5
6
  const ROOT_FILES = ['CLAUDE.md', 'AGENTS.md', 'SOUL.md', '.cursorrules', 'codex.md'];
6
7
  const MEMORY_DIR = 'memory';
@@ -166,6 +167,10 @@ export function checkMemory(cwd) {
166
167
  }
167
168
  catch { /* skip */ }
168
169
  }
170
+ // Include workspace package names
171
+ const workspacePackages = detectWorkspacePackages(cwd);
172
+ for (const name of workspacePackages)
173
+ allDeps.add(name);
169
174
  // Collect all tool mentions across files for contradiction detection
170
175
  const globalToolMentions = new Map();
171
176
  for (const filePath of memoryFiles) {
@@ -38,6 +38,18 @@ function isConfigOrMetaFile(filePath) {
38
38
  const base = basename(filePath);
39
39
  const ext = extname(filePath).toLowerCase();
40
40
  const normalized = filePath.replace(/\\/g, '/');
41
+ // All .d.ts declaration files
42
+ if (filePath.endsWith('.d.ts'))
43
+ return true;
44
+ // *.config.* pattern (vite.config.ts, postcss.config.js, tailwind.config.ts, etc.)
45
+ if (/\.config\.[a-z]+$/i.test(base))
46
+ return true;
47
+ // *.rc.* or dotfile rc pattern (.eslintrc.js, .prettierrc.cjs, etc.)
48
+ if (/\.rc\.[a-z]+$/i.test(base) || /^\.[a-z]+rc$/i.test(base) || /^\.[a-z]+rc\.[a-z]+$/i.test(base))
49
+ return true;
50
+ // tsconfig variants (tsconfig.json, tsconfig.build.json, etc.)
51
+ if (/^tsconfig(\..+)?\.json$/i.test(base))
52
+ return true;
41
53
  // Dotfiles
42
54
  if (CONFIG_DOTFILES.has(base))
43
55
  return true;
@@ -303,6 +315,12 @@ export function checkVerify(cwd, since) {
303
315
  verified++;
304
316
  continue;
305
317
  }
318
+ // Skip barrel index files (index.ts/js/tsx/jsx under 15 lines)
319
+ const indexNames = new Set(['index.ts', 'index.js', 'index.tsx', 'index.jsx']);
320
+ if (indexNames.has(basename(relPath)) && lineCount < 15) {
321
+ verified++;
322
+ continue;
323
+ }
306
324
  if (lineCount < 10 && lineCount > 0) {
307
325
  issues.push({
308
326
  severity: 'warning',
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ import { reportPretty, reportJSON, reportBadge } from './reporter.js';
25
25
  const args = process.argv.slice(2);
26
26
  const flags = new Set(args.filter(a => a.startsWith('-') && !a.startsWith('--since')));
27
27
  const flagMap = new Map();
28
- // Parse --since=value or --since value
28
+ // Parse --since=value or --since value, --max-files=value
29
29
  for (let i = 0; i < args.length; i++) {
30
30
  if (args[i].startsWith('--since=')) {
31
31
  flagMap.set('since', args[i].split('=')[1]);
@@ -34,6 +34,13 @@ for (let i = 0; i < args.length; i++) {
34
34
  flagMap.set('since', args[i + 1]);
35
35
  i++;
36
36
  }
37
+ else if (args[i].startsWith('--max-files=')) {
38
+ flagMap.set('max-files', args[i].split('=')[1]);
39
+ }
40
+ else if (args[i] === '--max-files' && args[i + 1]) {
41
+ flagMap.set('max-files', args[i + 1]);
42
+ i++;
43
+ }
37
44
  }
38
45
  const positional = args.filter(a => !a.startsWith('-'));
39
46
  if (flags.has('--help') || flags.has('-h')) {
@@ -72,6 +79,7 @@ if (flags.has('--help') || flags.has('-h')) {
72
79
  --watch re-run on file changes
73
80
  --json JSON output
74
81
  --pretty force pretty output (even in pipes)
82
+ --max-files N limit file scanning (default: 2000)
75
83
  -h, --help show this help
76
84
  -v, --version show version
77
85
  `);
@@ -97,6 +105,7 @@ const isWatch = flags.has('--watch');
97
105
  const isBadge = flags.has('--badge');
98
106
  const isJSON = flags.has('--json') || (!process.stdout.isTTY && !flags.has('--pretty') && !isBadge);
99
107
  const since = flagMap.get('since');
108
+ const maxFiles = parseInt(flagMap.get('max-files') || '2000', 10) || 2000;
100
109
  // Load config
101
110
  let config = {};
102
111
  const configContent = readFile(resolve(cwd, '.vetrc'));
@@ -207,39 +216,69 @@ if (isFix) {
207
216
  }
208
217
  process.exit(0);
209
218
  }
219
+ /** Run a check with a per-check timeout (30s). Returns a skip result on timeout. */
220
+ async function withTimeout(name, fn, timeoutMs = 30_000) {
221
+ return new Promise((res) => {
222
+ const timer = setTimeout(() => {
223
+ if (!isJSON)
224
+ console.error(` ${c.yellow}⚠ ${name} check timed out after ${timeoutMs / 1000}s — skipped${c.reset}`);
225
+ res({ name, score: 100, maxScore: 100, issues: [], summary: `skipped (timeout after ${timeoutMs / 1000}s)` });
226
+ }, timeoutMs);
227
+ Promise.resolve(fn()).then((r) => { clearTimeout(timer); res(r); }).catch(() => { clearTimeout(timer); res({ name, score: 100, maxScore: 100, issues: [], summary: 'check failed' }); });
228
+ });
229
+ }
210
230
  async function runChecks() {
231
+ const globalStart = Date.now();
232
+ const GLOBAL_TIMEOUT = 120_000;
211
233
  try {
234
+ // Check file count and warn if large
235
+ const { walkFiles: wf } = await import('./util.js');
236
+ const allProjectFiles = wf(cwd, [], maxFiles);
237
+ if (allProjectFiles.length >= maxFiles) {
238
+ if (!isJSON)
239
+ console.log(` ${c.yellow}Large project (${allProjectFiles.length}+ files) — scanning first ${maxFiles} files. Use --max-files to increase.${c.reset}\n`);
240
+ }
212
241
  // Run all checks, grouped into categories
213
242
  // Security: scan, secrets, config, models, owasp, permissions
214
243
  const [scanResult, secretsResult, configResult, modelsResult, owaspResult] = await Promise.all([
215
- Promise.resolve(checkScan(cwd)),
216
- checkSecrets(cwd),
217
- Promise.resolve(checkConfig(cwd, ignore)),
218
- checkModels(cwd, ignore),
219
- Promise.resolve(checkOwasp(cwd)),
244
+ withTimeout('scan', () => checkScan(cwd)),
245
+ withTimeout('secrets', () => checkSecrets(cwd)),
246
+ withTimeout('config', () => checkConfig(cwd, ignore)),
247
+ withTimeout('models', () => checkModels(cwd, ignore)),
248
+ withTimeout('owasp', () => checkOwasp(cwd)),
220
249
  ]);
221
- const permissionsResult = checkPermissions(cwd);
250
+ const permissionsResult = await withTimeout('permissions', () => checkPermissions(cwd));
251
+ if (Date.now() - globalStart > GLOBAL_TIMEOUT) {
252
+ if (!isJSON)
253
+ console.error(` ${c.yellow}⚠ global timeout (${GLOBAL_TIMEOUT / 1000}s) reached — returning partial results${c.reset}`);
254
+ return score(cwd, { security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult], integrity: [], debt: [], deps: [] });
255
+ }
222
256
  // Integrity: diff, integrity checks
223
- const diffResult = checkDiff(cwd, { since });
224
- const integrityResult = await checkIntegrity(cwd, ignore);
257
+ const diffResult = await withTimeout('diff', () => checkDiff(cwd, { since }));
258
+ const integrityResult = await withTimeout('integrity', () => checkIntegrity(cwd, ignore));
225
259
  // Debt: ready, history, debt
226
260
  const [readyResult, debtResult] = await Promise.all([
227
- checkReady(cwd, ignore),
228
- checkDebt(cwd, ignore),
261
+ withTimeout('ready', () => checkReady(cwd, ignore)),
262
+ withTimeout('debt', () => checkDebt(cwd, ignore)),
229
263
  ]);
230
- const historyResult = checkHistory(cwd);
264
+ const historyResult = await withTimeout('history', () => checkHistory(cwd));
265
+ if (Date.now() - globalStart > GLOBAL_TIMEOUT) {
266
+ if (!isJSON)
267
+ console.error(` ${c.yellow}⚠ global timeout (${GLOBAL_TIMEOUT / 1000}s) reached — returning partial results${c.reset}`);
268
+ return score(cwd, { security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult], integrity: [diffResult, integrityResult], debt: [readyResult, historyResult, debtResult], deps: [] });
269
+ }
231
270
  // Deps: deps
232
- const depsResult = await checkDeps(cwd);
271
+ const depsResult = await withTimeout('deps', () => checkDeps(cwd));
233
272
  // Receipt is informational — fold into integrity category but keep low weight
234
- const receiptResult = await checkReceipt(cwd);
273
+ const receiptResult = await withTimeout('receipt', () => checkReceipt(cwd));
235
274
  // Compact: compaction forensics
236
- const compactResult = await checkCompact(cwd);
275
+ const compactResult = await withTimeout('compact', () => checkCompact(cwd));
237
276
  // Memory: stale facts in agent memory files
238
- const memoryResult = checkMemory(cwd);
277
+ const memoryResult = await withTimeout('memory', () => checkMemory(cwd));
239
278
  // Verify: agent claim validation
240
- const verifyResult = checkVerify(cwd, since);
279
+ const verifyResult = await withTimeout('verify', () => checkVerify(cwd, since));
241
280
  // Tests: test theater detection
242
- const testsResult = checkTests(cwd, ignore);
281
+ const testsResult = await withTimeout('tests', () => checkTests(cwd, ignore));
243
282
  return score(cwd, {
244
283
  security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult],
245
284
  integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
package/dist/util.d.ts CHANGED
@@ -17,7 +17,7 @@ export declare function isGitRepo(cwd: string): boolean;
17
17
  export declare function readFile(path: string): string | null;
18
18
  /** Returns true if the path exists (file or directory). Convenience alias for existsSync. */
19
19
  export declare function fileExists(path: string): boolean;
20
- export declare function walkFiles(dir: string, ignore?: string[]): string[];
20
+ export declare function walkFiles(dir: string, ignore?: string[], maxFiles?: number): string[];
21
21
  /** Check if a file is binary by sampling first 512 bytes for null bytes */
22
22
  export declare function isTextFile(filePath: string): boolean;
23
23
  /** Recursively collect all file paths under a directory */
package/dist/util.js CHANGED
@@ -41,11 +41,14 @@ export function readFile(path) {
41
41
  export function fileExists(path) {
42
42
  return existsSync(path);
43
43
  }
44
- export function walkFiles(dir, ignore = []) {
44
+ export function walkFiles(dir, ignore = [], maxFiles) {
45
45
  const results = [];
46
46
  const defaultIgnore = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'vendor', '__pycache__', '.venv', 'venv'];
47
47
  const allIgnore = [...defaultIgnore, ...ignore];
48
+ let stopped = false;
48
49
  function walk(d) {
50
+ if (stopped)
51
+ return;
49
52
  let entries;
50
53
  try {
51
54
  entries = readdirSync(d);
@@ -54,6 +57,8 @@ export function walkFiles(dir, ignore = []) {
54
57
  return;
55
58
  }
56
59
  for (const entry of entries) {
60
+ if (stopped)
61
+ return;
57
62
  if (allIgnore.includes(entry))
58
63
  continue;
59
64
  const full = join(d, entry);
@@ -61,15 +66,39 @@ export function walkFiles(dir, ignore = []) {
61
66
  const stat = statSync(full);
62
67
  if (stat.isDirectory())
63
68
  walk(full);
64
- else
69
+ else {
65
70
  results.push(relative(dir, full));
71
+ if (maxFiles && results.length >= maxFiles) {
72
+ stopped = true;
73
+ return;
74
+ }
75
+ }
66
76
  }
67
77
  catch { /* skip */ }
68
78
  }
69
79
  }
70
80
  walk(dir);
81
+ // When limited, prioritize src/ files over examples/docs/test
82
+ if (maxFiles && stopped) {
83
+ results.sort((a, b) => {
84
+ const aP = priorityBucket(a);
85
+ const bP = priorityBucket(b);
86
+ if (aP !== bP)
87
+ return aP - bP;
88
+ return a.localeCompare(b);
89
+ });
90
+ }
71
91
  return results;
72
92
  }
93
+ function priorityBucket(file) {
94
+ if (file.startsWith('src/') || file.startsWith('lib/') || file.startsWith('app/'))
95
+ return 0;
96
+ if (file.startsWith('test/') || file.startsWith('tests/') || file.startsWith('__tests__/'))
97
+ return 2;
98
+ if (file.startsWith('examples/') || file.startsWith('docs/') || file.startsWith('example/'))
99
+ return 3;
100
+ return 1;
101
+ }
73
102
  /** Check if a file is binary by sampling first 512 bytes for null bytes */
74
103
  export function isTextFile(filePath) {
75
104
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {