@safetnsr/vet 1.0.0 → 1.2.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.
@@ -57,18 +57,18 @@ export function levenshtein(a, b) {
57
57
  // ── Import extraction ────────────────────────────────────────────────────────
58
58
  export function extractImports(source) {
59
59
  const imports = new Set();
60
- // import ... from 'pkg'
60
+ // static import: import X from <specifier>
61
61
  const importFrom = /import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
62
62
  let match;
63
63
  while ((match = importFrom.exec(source)) !== null) {
64
64
  imports.add(match[1]);
65
65
  }
66
- // require('pkg')
66
+ // CommonJS require
67
67
  const requirePat = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
68
68
  while ((match = requirePat.exec(source)) !== null) {
69
69
  imports.add(match[1]);
70
70
  }
71
- // import('pkg')
71
+ // dynamic import()
72
72
  const dynamicImport = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
73
73
  while ((match = dynamicImport.exec(source)) !== null) {
74
74
  imports.add(match[1]);
@@ -204,9 +204,11 @@ export async function checkDeps(cwd) {
204
204
  // ── 3 & 4. Dead deps + phantom imports ─────────────────────────────────────
205
205
  const sourceExts = new Set(['.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
206
206
  const allFiles = walkFiles(cwd);
207
+ const isTestFile = (f) => /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /^test[/\\]/.test(f);
207
208
  const sourceFiles = allFiles.filter(f => {
208
209
  const ext = f.substring(f.lastIndexOf('.'));
209
- return sourceExts.has(ext);
210
+ // Skip test files — they contain import strings as test fixtures, not real imports
211
+ return sourceExts.has(ext) && !isTestFile(f);
210
212
  });
211
213
  const importedPackages = new Set();
212
214
  for (const file of sourceFiles) {
@@ -36,25 +36,68 @@ function resolveRelativeImport(importPath, fromFile, cwd) {
36
36
  }
37
37
  return false;
38
38
  }
39
+ function isInsideStringLiteral(line, matchIndex) {
40
+ // Check if the match position is inside a string literal (template literal, quote)
41
+ // by counting unescaped quotes before the match
42
+ let inSingle = false;
43
+ let inDouble = false;
44
+ let inTemplate = false;
45
+ for (let i = 0; i < matchIndex && i < line.length; i++) {
46
+ const ch = line[i];
47
+ if (ch === '\\') {
48
+ i++;
49
+ continue;
50
+ }
51
+ if (ch === "'" && !inDouble && !inTemplate)
52
+ inSingle = !inSingle;
53
+ else if (ch === '"' && !inSingle && !inTemplate)
54
+ inDouble = !inDouble;
55
+ else if (ch === '`' && !inSingle && !inDouble)
56
+ inTemplate = !inTemplate;
57
+ }
58
+ // If we're inside a string context AND the line itself is not an import/require statement,
59
+ // then this is likely a string literal containing import-like text
60
+ return inSingle || inDouble || inTemplate;
61
+ }
62
+ function isCommentLine(line) {
63
+ const trimmed = line.trim();
64
+ return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
65
+ }
39
66
  function extractRelativeImports(source) {
40
67
  const imports = [];
41
68
  const lines = source.split('\n');
42
69
  for (let i = 0; i < lines.length; i++) {
43
70
  const line = lines[i];
44
- // import ... from './foo' or '../bar'
45
- const fromMatch = line.match(/from\s+['"](\.[^'"]+)['"]/);
46
- if (fromMatch) {
47
- imports.push({ path: fromMatch[1], line: i + 1 });
71
+ // Skip comment lines
72
+ if (isCommentLine(line))
73
+ continue;
74
+ const trimmed = line.trim();
75
+ // import ... from './foo' or '../bar' — must be an actual import statement
76
+ if (/^\s*(?:import|export)\s/.test(line)) {
77
+ const fromMatch = line.match(/from\s+['"](\.[^'"]+)['"]/);
78
+ if (fromMatch) {
79
+ imports.push({ path: fromMatch[1], line: i + 1 });
80
+ }
48
81
  }
49
- // require('./foo')
82
+ // require('./foo') — must be at statement level, not inside a string
50
83
  const reqMatch = line.match(/require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
51
- if (reqMatch) {
52
- imports.push({ path: reqMatch[1], line: i + 1 });
84
+ if (reqMatch && !isInsideStringLiteral(line, line.indexOf(reqMatch[0]))) {
85
+ // Skip if the require is inside a string literal (test fixtures)
86
+ const beforeReq = line.substring(0, line.indexOf(reqMatch[0]));
87
+ if (!/['"`]/.test(beforeReq.slice(-1))) {
88
+ imports.push({ path: reqMatch[1], line: i + 1 });
89
+ }
53
90
  }
54
- // import('./foo')
55
- const dynMatch = line.match(/import\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
56
- if (dynMatch) {
57
- imports.push({ path: dynMatch[1], line: i + 1 });
91
+ // Dynamic import('./foo') — actual import() call, not in string
92
+ if (/^\s*(?:const|let|var|await|return)?\s*/.test(line)) {
93
+ const dynMatch = line.match(/import\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
94
+ if (dynMatch && !isCommentLine(line)) {
95
+ // Make sure it's not inside a string literal (e.g. a test describing imports)
96
+ const matchIdx = line.indexOf(dynMatch[0]);
97
+ if (!isInsideStringLiteral(line, matchIdx)) {
98
+ imports.push({ path: dynMatch[1], line: i + 1 });
99
+ }
100
+ }
58
101
  }
59
102
  }
60
103
  return imports;
@@ -89,19 +132,25 @@ function checkHallucinatedImports(cwd, files) {
89
132
  return issues;
90
133
  }
91
134
  // ── Empty catch blocks ───────────────────────────────────────────────────────
135
+ function isTestFile(file) {
136
+ return /\.(test|spec)\.[jt]sx?$/.test(file) || file.includes('__tests__') || /^test[/\\]/.test(file);
137
+ }
92
138
  function checkEmptyCatch(cwd, files) {
93
139
  const issues = [];
94
140
  const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
95
141
  for (const file of files) {
96
142
  if (!sourceExts.has(extname(file)))
97
143
  continue;
144
+ // Skip test files — empty catches in tests are usually intentional (testing error paths)
145
+ if (isTestFile(file))
146
+ continue;
98
147
  const content = readFile(join(cwd, file));
99
148
  if (!content)
100
149
  continue;
101
150
  const lines = content.split('\n');
102
151
  for (let i = 0; i < lines.length; i++) {
103
152
  const line = lines[i];
104
- // catch(e) {} or catch(err) {}empty catch
153
+ // single-line catch with param and empty body error silently swallowed
105
154
  if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
106
155
  issues.push({
107
156
  severity: 'error',
@@ -113,7 +162,7 @@ function checkEmptyCatch(cwd, files) {
113
162
  });
114
163
  continue;
115
164
  }
116
- // catch {} (no param)
165
+ // single-line catch without param and empty body
117
166
  if (/catch\s*\{\s*\}/.test(line)) {
118
167
  issues.push({
119
168
  severity: 'error',
@@ -234,6 +283,9 @@ function checkUnhandledAsync(cwd, files) {
234
283
  for (const file of files) {
235
284
  if (!sourceExts.has(extname(file)))
236
285
  continue;
286
+ // Skip test files — test runners handle errors at the framework level
287
+ if (isTestFile(file))
288
+ continue;
237
289
  const content = readFile(join(cwd, file));
238
290
  if (!content)
239
291
  continue;
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkMemory(cwd: string): CheckResult;
@@ -0,0 +1,275 @@
1
+ import { join, resolve } from 'node:path';
2
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
3
+ // ── Memory file targets ──────────────────────────────────────────────────────
4
+ const ROOT_FILES = ['CLAUDE.md', 'AGENTS.md', 'SOUL.md', '.cursorrules', 'codex.md'];
5
+ const MEMORY_DIR = 'memory';
6
+ const DAILY_DIR = join(MEMORY_DIR, 'daily');
7
+ const MAX_DAILY_FILES = 30;
8
+ // ── Tool categories for contradiction detection ─────────────────────────────
9
+ const TOOL_CATEGORIES = {
10
+ 'test framework': [/\bvitest\b/i, /\bjest\b/i, /\bmocha\b/i, /\bnode:test\b/i, /\bava\b/i],
11
+ 'package manager': [/\bnpm\b/, /\byarn\b/, /\bpnpm\b/, /\bbun\b/],
12
+ };
13
+ // ── Helpers ──────────────────────────────────────────────────────────────────
14
+ function safeRead(path) {
15
+ try {
16
+ return readFileSync(path, 'utf-8');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function collectMemoryFiles(cwd) {
23
+ const files = [];
24
+ // Root-level memory files
25
+ for (const name of ROOT_FILES) {
26
+ const full = join(cwd, name);
27
+ if (existsSync(full))
28
+ files.push(full);
29
+ }
30
+ // memory/*.md
31
+ const memDir = join(cwd, MEMORY_DIR);
32
+ if (existsSync(memDir) && statSync(memDir).isDirectory()) {
33
+ try {
34
+ for (const entry of readdirSync(memDir)) {
35
+ if (!entry.endsWith('.md'))
36
+ continue;
37
+ const full = join(memDir, entry);
38
+ try {
39
+ if (statSync(full).isFile())
40
+ files.push(full);
41
+ }
42
+ catch { /* skip */ }
43
+ }
44
+ }
45
+ catch { /* skip */ }
46
+ }
47
+ // memory/daily/*.md (capped)
48
+ const dailyDir = join(cwd, DAILY_DIR);
49
+ if (existsSync(dailyDir) && statSync(dailyDir).isDirectory()) {
50
+ try {
51
+ const entries = readdirSync(dailyDir).filter(e => e.endsWith('.md')).sort().reverse();
52
+ for (const entry of entries.slice(0, MAX_DAILY_FILES)) {
53
+ const full = join(dailyDir, entry);
54
+ try {
55
+ if (statSync(full).isFile())
56
+ files.push(full);
57
+ }
58
+ catch { /* skip */ }
59
+ }
60
+ }
61
+ catch { /* skip */ }
62
+ }
63
+ return files;
64
+ }
65
+ /** Extract @scope/package references */
66
+ function extractScopedPackages(content) {
67
+ const results = [];
68
+ const lines = content.split('\n');
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const matches = lines[i].matchAll(/@[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+/g);
71
+ for (const m of matches) {
72
+ results.push({ pkg: m[0], line: i + 1 });
73
+ }
74
+ }
75
+ return results;
76
+ }
77
+ /** Extract file/dir path references */
78
+ function extractPaths(content) {
79
+ const results = [];
80
+ const lines = content.split('\n');
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const line = lines[i];
83
+ // Skip lines that are purely URLs
84
+ // Match absolute paths
85
+ const absMatches = line.matchAll(/(?:^|\s|["`'(])(\/((?:var|home|usr|etc|opt|tmp|root|srv|mnt)[^\s"'`),;]*))(?=[\s"'`),;]|$)/g);
86
+ for (const m of absMatches) {
87
+ const p = m[1].replace(/[.,:;)]+$/, '');
88
+ if (p.startsWith('//') || p.includes('://'))
89
+ continue;
90
+ if (p.length < 4)
91
+ continue;
92
+ results.push({ path: p, line: i + 1 });
93
+ }
94
+ // Match relative paths starting with ./ or ../
95
+ const relMatches = line.matchAll(/(?:^|\s|["`'(])(\.\.?\/[^\s"'`),;]+)/g);
96
+ for (const m of relMatches) {
97
+ const p = m[1].replace(/[.,:;)]+$/, '');
98
+ if (p.includes('://'))
99
+ continue;
100
+ results.push({ path: p, line: i + 1 });
101
+ }
102
+ }
103
+ return results;
104
+ }
105
+ /** Extract tool mentions per category */
106
+ function extractToolMentions(content, fileName) {
107
+ const mentions = new Map();
108
+ const lines = content.split('\n');
109
+ for (const [category, patterns] of Object.entries(TOOL_CATEGORIES)) {
110
+ for (let i = 0; i < lines.length; i++) {
111
+ for (const regex of patterns) {
112
+ if (regex.test(lines[i])) {
113
+ const toolMatch = lines[i].match(regex);
114
+ if (toolMatch) {
115
+ const existing = mentions.get(category) || [];
116
+ existing.push({ tool: toolMatch[0].toLowerCase(), file: fileName, line: i + 1 });
117
+ mentions.set(category, existing);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return mentions;
124
+ }
125
+ /** Count meaningful fact claims in a file */
126
+ function countFacts(content) {
127
+ let count = 0;
128
+ const lines = content.split('\n');
129
+ for (const line of lines) {
130
+ const trimmed = line.trim();
131
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('---') || trimmed.startsWith('```'))
132
+ continue;
133
+ // A "fact" is a line with at least some substantive content
134
+ if (trimmed.length > 15 && /[a-zA-Z]/.test(trimmed)) {
135
+ // Contains a keyword-like pattern (assignment, instruction, reference)
136
+ if (/[:=→\->]|use |stack|requires?|install|run |npm |config|version|path|file|dir|tool/i.test(trimmed)) {
137
+ count++;
138
+ }
139
+ }
140
+ }
141
+ return count;
142
+ }
143
+ // ── Main check ───────────────────────────────────────────────────────────────
144
+ export function checkMemory(cwd) {
145
+ const memoryFiles = collectMemoryFiles(cwd);
146
+ const issues = [];
147
+ let deductions = 0;
148
+ if (memoryFiles.length === 0) {
149
+ return {
150
+ name: 'memory',
151
+ score: 100,
152
+ maxScore: 100,
153
+ issues: [],
154
+ summary: 'no agent memory files found',
155
+ };
156
+ }
157
+ // Load package.json deps
158
+ const pkgPath = join(cwd, 'package.json');
159
+ const allDeps = new Set();
160
+ if (existsSync(pkgPath)) {
161
+ try {
162
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
163
+ for (const key of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
164
+ if (pkg[key]) {
165
+ for (const name of Object.keys(pkg[key])) {
166
+ allDeps.add(name);
167
+ }
168
+ }
169
+ }
170
+ }
171
+ catch { /* skip */ }
172
+ }
173
+ // Collect all tool mentions across files for contradiction detection
174
+ const globalToolMentions = new Map();
175
+ for (const filePath of memoryFiles) {
176
+ const content = safeRead(filePath);
177
+ if (!content)
178
+ continue;
179
+ const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
180
+ // 1. Stale package references
181
+ if (allDeps.size > 0) {
182
+ const pkgRefs = extractScopedPackages(content);
183
+ for (const { pkg, line } of pkgRefs) {
184
+ if (!allDeps.has(pkg)) {
185
+ issues.push({
186
+ severity: 'warning',
187
+ message: `Stale package: ${pkg} not in package.json`,
188
+ file: relPath,
189
+ line,
190
+ fixable: false,
191
+ fixHint: 'Remove or update this reference',
192
+ });
193
+ deductions += 10;
194
+ }
195
+ }
196
+ }
197
+ // 2. Broken path references
198
+ const pathRefs = extractPaths(content);
199
+ for (const { path: p, line } of pathRefs) {
200
+ const resolved = p.startsWith('/') ? p : resolve(cwd, p);
201
+ if (!existsSync(resolved)) {
202
+ issues.push({
203
+ severity: 'error',
204
+ message: `Broken path reference: ${p}`,
205
+ file: relPath,
206
+ line,
207
+ fixable: false,
208
+ fixHint: 'Remove or update this path reference',
209
+ });
210
+ deductions += 15;
211
+ }
212
+ }
213
+ // 3. Collect tool mentions for contradiction check
214
+ const toolMentions = extractToolMentions(content, relPath);
215
+ for (const [category, mentions] of toolMentions) {
216
+ const existing = globalToolMentions.get(category) || [];
217
+ existing.push(...mentions);
218
+ globalToolMentions.set(category, existing);
219
+ }
220
+ // 4. Bloat check
221
+ if (content.length > 5000) {
222
+ const factCount = countFacts(content);
223
+ if (factCount < 3) {
224
+ issues.push({
225
+ severity: 'info',
226
+ message: `Bloated memory file: ${content.length} chars but only ${factCount} fact claims`,
227
+ file: relPath,
228
+ line: 1,
229
+ fixable: false,
230
+ fixHint: 'Trim this file to only essential facts',
231
+ });
232
+ deductions += 5;
233
+ }
234
+ }
235
+ }
236
+ // 3. Contradiction detection
237
+ for (const [category, mentions] of globalToolMentions) {
238
+ const uniqueTools = new Map();
239
+ for (const m of mentions) {
240
+ if (!uniqueTools.has(m.tool)) {
241
+ uniqueTools.set(m.tool, { file: m.file, line: m.line });
242
+ }
243
+ }
244
+ if (uniqueTools.size > 1) {
245
+ const tools = [...uniqueTools.entries()];
246
+ for (let i = 0; i < tools.length; i++) {
247
+ for (let j = i + 1; j < tools.length; j++) {
248
+ // Only flag if they're in different files
249
+ if (tools[i][1].file !== tools[j][1].file) {
250
+ issues.push({
251
+ severity: 'warning',
252
+ message: `Contradiction in ${category}: "${tools[i][0]}" in ${tools[i][1].file} vs "${tools[j][0]}" in ${tools[j][1].file}`,
253
+ file: tools[i][1].file,
254
+ line: tools[i][1].line,
255
+ fixable: false,
256
+ fixHint: `Standardize on one ${category} across memory files`,
257
+ });
258
+ deductions += 10;
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ const finalScore = Math.max(0, 100 - deductions);
265
+ const issueCount = issues.length;
266
+ return {
267
+ name: 'memory',
268
+ score: finalScore,
269
+ maxScore: 100,
270
+ issues,
271
+ summary: issueCount === 0
272
+ ? `${memoryFiles.length} memory file${memoryFiles.length !== 1 ? 's' : ''} scanned, clean`
273
+ : `${issueCount} stale fact${issueCount !== 1 ? 's' : ''} found in agent memory files`,
274
+ };
275
+ }
@@ -8,9 +8,14 @@ async function tryModelGraveyard(cwd) {
8
8
  return null;
9
9
  const report = await mod.scan(cwd);
10
10
  const issues = [];
11
+ // Files that define deprecated model registries should not be flagged
12
+ const SELF_FILES = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset', 'fix/models'];
11
13
  for (const match of report.matches) {
12
14
  if (!match.model)
13
15
  continue;
16
+ // Skip self-referencing files (model definition/fix files)
17
+ if (match.file && SELF_FILES.some(s => match.file.toLowerCase().includes(s)))
18
+ continue;
14
19
  if (match.model.status === 'deprecated' || match.model.status === 'eol') {
15
20
  issues.push({
16
21
  severity: 'error',
@@ -0,0 +1,51 @@
1
+ export declare function collectAgentConfigFiles(cwd: string): string[];
2
+ export declare function collectMcpConfigFiles(cwd: string): string[];
3
+ export declare function readTextFile(filePath: string): string | null;
4
+ export interface OwaspFinding {
5
+ asiId: string;
6
+ severity: 'error' | 'warning' | 'info';
7
+ message: string;
8
+ file?: string;
9
+ line?: number;
10
+ fixHint?: string;
11
+ }
12
+ export declare function checkASI01(cwd: string, configFiles: string[]): {
13
+ findings: OwaspFinding[];
14
+ deduction: number;
15
+ };
16
+ export declare function checkASI02(cwd: string, mcpFiles: string[]): {
17
+ findings: OwaspFinding[];
18
+ deduction: number;
19
+ };
20
+ export declare function checkASI03(cwd: string, configFiles: string[]): {
21
+ findings: OwaspFinding[];
22
+ deduction: number;
23
+ };
24
+ export declare function checkASI04(cwd: string, mcpFiles: string[], agentConfigFiles: string[]): {
25
+ findings: OwaspFinding[];
26
+ deduction: number;
27
+ };
28
+ export declare function checkASI05(cwd: string, configFiles: string[]): {
29
+ findings: OwaspFinding[];
30
+ deduction: number;
31
+ };
32
+ export declare function checkASI06(cwd: string): {
33
+ findings: OwaspFinding[];
34
+ deduction: number;
35
+ };
36
+ export declare function checkASI07(cwd: string, configFiles: string[]): {
37
+ findings: OwaspFinding[];
38
+ deduction: number;
39
+ };
40
+ export declare function checkASI08(cwd: string, configFiles: string[]): {
41
+ findings: OwaspFinding[];
42
+ deduction: number;
43
+ };
44
+ export declare function checkASI09(cwd: string, configFiles: string[]): {
45
+ findings: OwaspFinding[];
46
+ deduction: number;
47
+ };
48
+ export declare function checkASI10(cwd: string, configFiles: string[]): {
49
+ findings: OwaspFinding[];
50
+ deduction: number;
51
+ };