@safetnsr/vet 0.6.0 → 1.1.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
@@ -1,6 +1,6 @@
1
1
  # vet
2
2
 
3
- vet your AI-generated code. one command, eight checks, zero config.
3
+ vet your AI-generated code. one command, nine checks, zero config.
4
4
 
5
5
  ```bash
6
6
  npx @safetnsr/vet
@@ -20,6 +20,7 @@ works with Claude Code, Cursor, Copilot, Codex, Aider, Windsurf, Cline — anyth
20
20
  | **scan** | malicious patterns in agent configs? | scans .claude/, .cursorrules, CLAUDE.md, .mcp/ for prompt injection, shell injection, exfiltration endpoints |
21
21
  | **secrets** | leaked secrets in build output? | scans dist/, build/, .next/ + .env files for API keys, tokens, connection strings using pattern + entropy analysis |
22
22
  | **receipt** | what did the last agent session do? | parses ~/.claude/projects/ JSONL session logs — files changed, commands run, packages installed, SHA256 integrity hash |
23
+ | **debt** | AI-generated technical debt (duplicates, orphans, wrappers) | detects near-duplicate functions, orphaned exports, wrapper pass-throughs, naming drift |
23
24
 
24
25
  ## usage
25
26
 
@@ -0,0 +1,9 @@
1
+ import type { CheckResult, CategoryResult, VetResult } from './types.js';
2
+ export declare function toGrade(score: number): string;
3
+ export declare function buildCategories(checkMap: {
4
+ security: CheckResult[];
5
+ integrity: CheckResult[];
6
+ debt: CheckResult[];
7
+ deps: CheckResult[];
8
+ }): CategoryResult[];
9
+ export declare function buildVetResult(project: string, categories: CategoryResult[]): VetResult;
@@ -0,0 +1,79 @@
1
+ import { basename } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ // ── Grade thresholds ─────────────────────────────────────────────────────────
6
+ export function toGrade(score) {
7
+ if (score >= 90)
8
+ return 'A';
9
+ if (score >= 75)
10
+ return 'B';
11
+ if (score >= 60)
12
+ return 'C';
13
+ if (score >= 40)
14
+ return 'D';
15
+ return 'F';
16
+ }
17
+ // ── Category weights ─────────────────────────────────────────────────────────
18
+ const WEIGHTS = {
19
+ security: 0.30,
20
+ integrity: 0.30,
21
+ debt: 0.25,
22
+ deps: 0.15,
23
+ };
24
+ // ── Average scores within a category ────────────────────────────────────────
25
+ function averageScore(checks) {
26
+ if (checks.length === 0)
27
+ return 100;
28
+ const total = checks.reduce((sum, c) => sum + c.score, 0);
29
+ return Math.round(total / checks.length);
30
+ }
31
+ // ── Group checks into categories ─────────────────────────────────────────────
32
+ export function buildCategories(checkMap) {
33
+ const categories = [];
34
+ for (const name of ['security', 'integrity', 'debt', 'deps']) {
35
+ const checks = checkMap[name];
36
+ const score = averageScore(checks);
37
+ const issues = checks.flatMap(c => c.issues);
38
+ categories.push({
39
+ name,
40
+ score,
41
+ weight: WEIGHTS[name],
42
+ checks,
43
+ issues,
44
+ });
45
+ }
46
+ return categories;
47
+ }
48
+ // ── Build VetResult from categories ─────────────────────────────────────────
49
+ export function buildVetResult(project, categories) {
50
+ // Weighted average
51
+ let weightedSum = 0;
52
+ let totalWeight = 0;
53
+ for (const cat of categories) {
54
+ weightedSum += cat.score * cat.weight;
55
+ totalWeight += cat.weight;
56
+ }
57
+ const overallScore = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0;
58
+ const grade = toGrade(overallScore);
59
+ const allIssues = categories.flatMap(c => c.issues);
60
+ // Read version from package.json
61
+ let version = '1.0.0';
62
+ try {
63
+ const __filename = fileURLToPath(import.meta.url);
64
+ const __dirname = dirname(__filename);
65
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
66
+ version = pkg.version || version;
67
+ }
68
+ catch { /* use default */ }
69
+ return {
70
+ project: basename(project),
71
+ version,
72
+ score: overallScore,
73
+ grade,
74
+ categories,
75
+ totalIssues: allIssues.length,
76
+ fixableIssues: allIssues.filter(i => i.fixable).length,
77
+ timestamp: new Date().toISOString(),
78
+ };
79
+ }
@@ -162,8 +162,8 @@ export function checkConfig(cwd, ignore) {
162
162
  });
163
163
  return {
164
164
  name: 'config',
165
- score: 1,
166
- maxScore: 10,
165
+ score: 10,
166
+ maxScore: 100,
167
167
  issues,
168
168
  summary: 'no agent configs — critically under-configured',
169
169
  };
@@ -190,16 +190,16 @@ export function checkConfig(cwd, ignore) {
190
190
  if (!fileExists(join(cwd, '.gitignore'))) {
191
191
  issues.push({ severity: 'warning', message: 'no .gitignore — agents may write to wrong directories', fixable: false });
192
192
  }
193
- // Score: weighted average of sub-scores
194
- const subScore = (best.existence * 0.2 + best.completeness * 0.3 + best.consistency * 0.25 + best.specificity * 0.25);
195
- const gitignorePenalty = fileExists(join(cwd, '.gitignore')) ? 0 : 1;
196
- const finalScore = Math.max(0, Math.min(10, subScore - gitignorePenalty));
193
+ // Score: weighted average of sub-scores (sub-scores are 0-10, multiply by 10 → 0-100)
194
+ const subScore = (best.existence * 0.2 + best.completeness * 0.3 + best.consistency * 0.25 + best.specificity * 0.25) * 10;
195
+ const gitignorePenalty = fileExists(join(cwd, '.gitignore')) ? 0 : 10;
196
+ const finalScore = Math.max(0, Math.min(100, subScore - gitignorePenalty));
197
197
  const agents = analyses.map(a => a.agent);
198
198
  const uniqueAgents = [...new Set(agents)];
199
199
  return {
200
200
  name: 'config',
201
- score: Math.round(finalScore * 10) / 10,
202
- maxScore: 10,
201
+ score: Math.round(finalScore),
202
+ maxScore: 100,
203
203
  issues,
204
204
  summary: `${uniqueAgents.join(', ')} — ${best.completeness >= 7 && best.specificity >= 7 ? 'well configured' : `needs work (${Math.round(finalScore)}/10)`}`,
205
205
  };
@@ -316,8 +316,8 @@ export async function checkDebt(cwd, ignore) {
316
316
  if (sourceFiles.length === 0) {
317
317
  return {
318
318
  name: 'debt',
319
- score: 10,
320
- maxScore: 10,
319
+ score: 100,
320
+ maxScore: 100,
321
321
  issues: [],
322
322
  summary: 'no source files to analyze',
323
323
  };
@@ -344,12 +344,12 @@ export async function checkDebt(cwd, ignore) {
344
344
  const driftIssues = findNamingDrift(allFuncs);
345
345
  issues.push(...driftIssues);
346
346
  // ── Scoring ──────────────────────────────────────────────────────────────
347
- const dupPenalty = Math.min(6, dupIssues.length * 1.5);
348
- const orphanPenalty = Math.min(3, orphanIssues.length * 0.5);
349
- const wrapperPenalty = Math.min(1.5, wrapperIssues.length * 0.3);
350
- const driftPenalty = Math.min(1, driftIssues.length * 0.2);
351
- const rawScore = 10 - dupPenalty - orphanPenalty - wrapperPenalty - driftPenalty;
352
- const finalScore = Math.max(0, Math.round(rawScore * 10) / 10);
347
+ const dupPenalty = Math.min(60, dupIssues.length * 15);
348
+ const orphanPenalty = Math.min(30, orphanIssues.length * 5);
349
+ const wrapperPenalty = Math.min(15, wrapperIssues.length * 3);
350
+ const driftPenalty = Math.min(10, driftIssues.length * 2);
351
+ const rawScore = 100 - dupPenalty - orphanPenalty - wrapperPenalty - driftPenalty;
352
+ const finalScore = Math.max(0, Math.round(rawScore));
353
353
  // ── Summary ──────────────────────────────────────────────────────────────
354
354
  const parts = [];
355
355
  if (dupIssues.length > 0)
@@ -366,7 +366,7 @@ export async function checkDebt(cwd, ignore) {
366
366
  return {
367
367
  name: 'debt',
368
368
  score: finalScore,
369
- maxScore: 10,
369
+ maxScore: 100,
370
370
  issues,
371
371
  summary,
372
372
  };
@@ -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]);
@@ -155,8 +155,8 @@ export async function checkDeps(cwd) {
155
155
  if (!hasPkgJson) {
156
156
  return {
157
157
  name: 'deps',
158
- score: 10,
159
- maxScore: 10,
158
+ score: 100,
159
+ maxScore: 100,
160
160
  issues: [],
161
161
  summary: 'no package.json found',
162
162
  };
@@ -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) {
@@ -252,8 +254,8 @@ export async function checkDeps(cwd) {
252
254
  // ── Scoring ────────────────────────────────────────────────────────────────
253
255
  const errors = issues.filter(i => i.severity === 'error').length;
254
256
  const warnings = issues.filter(i => i.severity === 'warning').length;
255
- const rawScore = 10 - (errors * 3) - (warnings * 1);
256
- const finalScore = Math.max(0, Math.min(10, rawScore));
257
+ const rawScore = 100 - (errors * 30) - (warnings * 10);
258
+ const finalScore = Math.max(0, Math.min(100, rawScore));
257
259
  // ── Summary ────────────────────────────────────────────────────────────────
258
260
  const parts = [];
259
261
  if (errors > 0)
@@ -269,7 +271,7 @@ export async function checkDeps(cwd) {
269
271
  return {
270
272
  name: 'deps',
271
273
  score: finalScore,
272
- maxScore: 10,
274
+ maxScore: 100,
273
275
  issues,
274
276
  summary,
275
277
  };
@@ -77,7 +77,7 @@ export function checkDiff(cwd, opts = {}) {
77
77
  const issues = [];
78
78
  const diff = getDiff(cwd, opts);
79
79
  if (!diff) {
80
- return { name: 'diff', score: 10, maxScore: 10, issues: [], summary: 'no changes to check' };
80
+ return { name: 'diff', score: 100, maxScore: 100, issues: [], summary: 'no changes to check' };
81
81
  }
82
82
  const files = parseDiff(diff);
83
83
  const allPatterns = [...GENERIC_PATTERNS, ...AI_PATTERNS];
@@ -162,13 +162,13 @@ export function checkDiff(cwd, opts = {}) {
162
162
  // Recalibrated scoring
163
163
  const errors = issues.filter(i => i.severity === 'error').length;
164
164
  const warnings = issues.filter(i => i.severity === 'warning').length;
165
- const score = Math.max(0, Math.min(10, 10 - errors * 2 - warnings * 0.75));
165
+ const score = Math.max(0, Math.min(100, 100 - errors * 20 - warnings * 7.5));
166
166
  const aiIssues = issues.filter(i => i.message.startsWith('[ai]')).length;
167
167
  const totalFiles = files.length;
168
168
  return {
169
169
  name: 'diff',
170
- score: Math.round(score * 10) / 10,
171
- maxScore: 10,
170
+ score: Math.round(score),
171
+ maxScore: 100,
172
172
  issues,
173
173
  summary: issues.length === 0
174
174
  ? `${totalFiles} file${totalFiles !== 1 ? 's' : ''} changed, clean`
@@ -5,7 +5,7 @@ export function checkHistory(cwd) {
5
5
  // Get recent commits (last 50) — use execFileSync to avoid shell pipe interpretation
6
6
  const log = gitExec(['log', '--oneline', '-50', '--format=%H|%an|%s'], cwd);
7
7
  if (!log) {
8
- return { name: 'history', score: 10, maxScore: 10, issues: [], summary: 'no git history to analyze' };
8
+ return { name: 'history', score: 100, maxScore: 100, issues: [], summary: 'no git history to analyze' };
9
9
  }
10
10
  const commits = log.split('\n').filter(Boolean).map(line => {
11
11
  const parts = line.split('|');
@@ -66,11 +66,11 @@ export function checkHistory(cwd) {
66
66
  const aiPct = commits.length > 0 ? Math.round((aiCommits / commits.length) * 100) : 0;
67
67
  const infos = issues.filter(i => i.severity === 'info').length;
68
68
  const warnings = issues.filter(i => i.severity === 'warning').length;
69
- const score = Math.max(0, Math.min(10, 10 - warnings * 1 - infos * 0.2));
69
+ const score = Math.max(0, Math.min(100, 100 - warnings * 10 - infos * 2));
70
70
  return {
71
71
  name: 'history',
72
- score: Math.round(score * 10) / 10,
73
- maxScore: 10,
72
+ score: Math.round(score),
73
+ maxScore: 100,
74
74
  issues,
75
75
  summary: `${commits.length} recent commits${aiPct > 0 ? ` (~${aiPct}% AI-attributed)` : ''}, ${issues.length} observation${issues.length !== 1 ? 's' : ''}`,
76
76
  };
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkIntegrity(cwd: string, ignore: string[]): Promise<CheckResult>;
@@ -0,0 +1,369 @@
1
+ import { join, resolve, dirname, extname } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { walkFiles, readFile } from '../util.js';
4
+ // ── Hallucinated imports ─────────────────────────────────────────────────────
5
+ const RESOLVE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs', '.json'];
6
+ function resolveRelativeImport(importPath, fromFile, cwd) {
7
+ // fromFile is relative to cwd
8
+ const fromDir = dirname(join(cwd, fromFile));
9
+ const base = resolve(fromDir, importPath);
10
+ // Try as-is
11
+ if (existsSync(base))
12
+ return true;
13
+ // Try with extensions appended
14
+ for (const ext of RESOLVE_EXTS) {
15
+ if (existsSync(base + ext))
16
+ return true;
17
+ }
18
+ // Try as directory with index
19
+ for (const ext of RESOLVE_EXTS) {
20
+ if (existsSync(join(base, 'index' + ext)))
21
+ return true;
22
+ }
23
+ // Handle ESM TypeScript pattern: ./foo.js → ./foo.ts (strip .js, try .ts/.tsx etc)
24
+ const baseExt = extname(base);
25
+ if (baseExt) {
26
+ const withoutExt = base.slice(0, -baseExt.length);
27
+ for (const ext of RESOLVE_EXTS) {
28
+ if (existsSync(withoutExt + ext))
29
+ return true;
30
+ }
31
+ // Also try as directory index
32
+ for (const ext of RESOLVE_EXTS) {
33
+ if (existsSync(join(withoutExt, 'index' + ext)))
34
+ return true;
35
+ }
36
+ }
37
+ return false;
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
+ }
66
+ function extractRelativeImports(source) {
67
+ const imports = [];
68
+ const lines = source.split('\n');
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const line = lines[i];
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
+ }
81
+ }
82
+ // require('./foo') — must be at statement level, not inside a string
83
+ const reqMatch = line.match(/require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
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
+ }
90
+ }
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
+ }
101
+ }
102
+ }
103
+ return imports;
104
+ }
105
+ function checkHallucinatedImports(cwd, files) {
106
+ const issues = [];
107
+ const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
108
+ for (const file of files) {
109
+ const ext = extname(file);
110
+ if (!sourceExts.has(ext))
111
+ continue;
112
+ if (file.includes('node_modules'))
113
+ continue;
114
+ const content = readFile(join(cwd, file));
115
+ if (!content)
116
+ continue;
117
+ const relImports = extractRelativeImports(content);
118
+ for (const imp of relImports) {
119
+ // Skip .js extensions pointing to .ts files (common in ESM TypeScript)
120
+ // The resolver already handles this
121
+ if (!resolveRelativeImport(imp.path, file, cwd)) {
122
+ issues.push({
123
+ severity: 'error',
124
+ message: `hallucinated import: "${imp.path}" does not resolve to any file`,
125
+ file,
126
+ line: imp.line,
127
+ fixable: false,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ return issues;
133
+ }
134
+ // ── Empty catch blocks ───────────────────────────────────────────────────────
135
+ function isTestFile(file) {
136
+ return /\.(test|spec)\.[jt]sx?$/.test(file) || file.includes('__tests__') || /^test[/\\]/.test(file);
137
+ }
138
+ function checkEmptyCatch(cwd, files) {
139
+ const issues = [];
140
+ const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
141
+ for (const file of files) {
142
+ if (!sourceExts.has(extname(file)))
143
+ continue;
144
+ // Skip test files — empty catches in tests are usually intentional (testing error paths)
145
+ if (isTestFile(file))
146
+ continue;
147
+ const content = readFile(join(cwd, file));
148
+ if (!content)
149
+ continue;
150
+ const lines = content.split('\n');
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i];
153
+ // single-line catch with param and empty body — error silently swallowed
154
+ if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
155
+ issues.push({
156
+ severity: 'error',
157
+ message: 'empty catch block — error silently swallowed',
158
+ file,
159
+ line: i + 1,
160
+ fixable: false,
161
+ fixHint: 'log or handle the error, or add a comment explaining why it is intentional',
162
+ });
163
+ continue;
164
+ }
165
+ // single-line catch without param and empty body
166
+ if (/catch\s*\{\s*\}/.test(line)) {
167
+ issues.push({
168
+ severity: 'error',
169
+ message: 'empty catch block — error silently swallowed',
170
+ file,
171
+ line: i + 1,
172
+ fixable: false,
173
+ fixHint: 'log or handle the error, or add a comment explaining why it is intentional',
174
+ });
175
+ continue;
176
+ }
177
+ // Multi-line: catch block that starts on this line — check if it's comment-only
178
+ const catchStart = line.match(/catch\s*(?:\([^)]*\))?\s*\{/);
179
+ if (catchStart) {
180
+ // Collect lines until matching }
181
+ let depth = 0;
182
+ let blockStart = -1;
183
+ for (let ci = line.indexOf('{'); ci < line.length; ci++) {
184
+ if (line[ci] === '{') {
185
+ depth++;
186
+ blockStart = ci;
187
+ break;
188
+ }
189
+ }
190
+ if (depth > 0) {
191
+ const blockLines = [line.slice(blockStart + 1)];
192
+ let j = i + 1;
193
+ while (j < lines.length && depth > 0) {
194
+ const l = lines[j];
195
+ for (const ch of l) {
196
+ if (ch === '{')
197
+ depth++;
198
+ else if (ch === '}')
199
+ depth--;
200
+ }
201
+ blockLines.push(l);
202
+ j++;
203
+ }
204
+ // Check if block body is only comments
205
+ const bodyText = blockLines.join('\n').replace(/\}$/, '').trim();
206
+ if (bodyText.length > 0 && /^(\s*(\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*$/.test(bodyText)) {
207
+ issues.push({
208
+ severity: 'warning',
209
+ message: 'catch block contains only comments — consider proper error handling',
210
+ file,
211
+ line: i + 1,
212
+ fixable: false,
213
+ });
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+ return issues;
220
+ }
221
+ // ── Stubbed tests ────────────────────────────────────────────────────────────
222
+ function checkStubbedTests(cwd, files) {
223
+ const issues = [];
224
+ const testExts = /\.(test|spec)\.[jt]sx?$/;
225
+ for (const file of files) {
226
+ if (!testExts.test(file))
227
+ continue;
228
+ const content = readFile(join(cwd, file));
229
+ if (!content)
230
+ continue;
231
+ const lines = content.split('\n');
232
+ for (let i = 0; i < lines.length; i++) {
233
+ const line = lines[i];
234
+ // Trivial assertions
235
+ if (/expect\s*\(\s*true\s*\)\s*\.toBe\s*\(\s*true\s*\)/.test(line)) {
236
+ issues.push({
237
+ severity: 'error',
238
+ message: 'stubbed test: trivial assertion expect(true).toBe(true)',
239
+ file,
240
+ line: i + 1,
241
+ fixable: false,
242
+ });
243
+ }
244
+ if (/expect\s*\(\s*1\s*\)\s*\.toBe\s*\(\s*1\s*\)/.test(line)) {
245
+ issues.push({
246
+ severity: 'error',
247
+ message: 'stubbed test: trivial assertion expect(1).toBe(1)',
248
+ file,
249
+ line: i + 1,
250
+ fixable: false,
251
+ });
252
+ }
253
+ // Empty test body: test('...', () => {}) or it('...', () => {})
254
+ if (/(?:test|it)\s*\(\s*['"`][^'"]+['"`]\s*,\s*(?:async\s*)?\(\s*\)\s*=>\s*\{\s*\}\s*\)/.test(line)) {
255
+ issues.push({
256
+ severity: 'error',
257
+ message: 'stubbed test: empty test body',
258
+ file,
259
+ line: i + 1,
260
+ fixable: false,
261
+ fixHint: 'add assertions or mark as test.todo()',
262
+ });
263
+ }
264
+ // it.skip without .todo — skipped test (always check regardless of other matches on this line)
265
+ if (/(?:it|test)\.skip\s*\(/.test(line) && !/\.todo\s*\(/.test(line)) {
266
+ issues.push({
267
+ severity: 'warning',
268
+ message: 'skipped test: use test.todo() instead of .skip for unimplemented tests',
269
+ file,
270
+ line: i + 1,
271
+ fixable: true,
272
+ fixHint: 'change .skip to .todo if not yet implemented',
273
+ });
274
+ }
275
+ }
276
+ }
277
+ return issues;
278
+ }
279
+ // ── Unhandled async (removed error handling) ─────────────────────────────────
280
+ function checkUnhandledAsync(cwd, files) {
281
+ const issues = [];
282
+ const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
283
+ for (const file of files) {
284
+ if (!sourceExts.has(extname(file)))
285
+ continue;
286
+ // Skip test files — test runners handle errors at the framework level
287
+ if (isTestFile(file))
288
+ continue;
289
+ const content = readFile(join(cwd, file));
290
+ if (!content)
291
+ continue;
292
+ const lines = content.split('\n');
293
+ let unhandledCount = 0;
294
+ for (let i = 0; i < lines.length; i++) {
295
+ const line = lines[i];
296
+ // await without try/catch context — detect standalone awaits
297
+ // We look for: const/let/var x = await or just await on its own, not inside try
298
+ const hasAwait = /^\s*(?:const|let|var)\s+\w.*=\s*await\s+/.test(line) || /^\s*await\s+/.test(line);
299
+ if (!hasAwait)
300
+ continue;
301
+ // Check context window — look for try { in surrounding lines
302
+ const contextStart = Math.max(0, i - 15);
303
+ const contextEnd = Math.min(lines.length - 1, i + 5);
304
+ const contextLines = lines.slice(contextStart, contextEnd + 1);
305
+ const contextText = contextLines.join('\n');
306
+ // Count try/catch blocks in context
307
+ const tryCount = (contextText.match(/\btry\s*\{/g) || []).length;
308
+ const catchCount = (contextText.match(/\bcatch\s*(?:\([^)]*\))?\s*\{/g) || []).length;
309
+ if (tryCount === 0 || catchCount === 0) {
310
+ // Also check for .catch() chained
311
+ const hasCatch = /\.catch\s*\(/.test(line) || (i + 1 < lines.length && /\.catch\s*\(/.test(lines[i + 1]));
312
+ if (!hasCatch) {
313
+ unhandledCount++;
314
+ if (unhandledCount <= 10) {
315
+ issues.push({
316
+ severity: 'warning',
317
+ message: 'unhandled async: await without try/catch',
318
+ file,
319
+ line: i + 1,
320
+ fixable: false,
321
+ fixHint: 'wrap in try/catch or chain .catch()',
322
+ });
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ return issues;
329
+ }
330
+ // ── Main check ───────────────────────────────────────────────────────────────
331
+ export async function checkIntegrity(cwd, ignore) {
332
+ const files = walkFiles(cwd, ignore);
333
+ const hallucinatedIssues = checkHallucinatedImports(cwd, files);
334
+ const emptyCatchIssues = checkEmptyCatch(cwd, files);
335
+ const stubbedTestIssues = checkStubbedTests(cwd, files);
336
+ const unhandledAsyncIssues = checkUnhandledAsync(cwd, files);
337
+ const allIssues = [
338
+ ...hallucinatedIssues,
339
+ ...emptyCatchIssues,
340
+ ...stubbedTestIssues,
341
+ ...unhandledAsyncIssues,
342
+ ];
343
+ // Scoring: start at 100, penalize per issue type
344
+ let score = 100;
345
+ score -= hallucinatedIssues.length * 10;
346
+ score -= emptyCatchIssues.filter(i => i.severity === 'error').length * 8;
347
+ score -= stubbedTestIssues.filter(i => i.severity === 'error').length * 5;
348
+ // Unhandled async capped at -30
349
+ const unhandledErrors = unhandledAsyncIssues.length;
350
+ score -= Math.min(30, unhandledErrors * 3);
351
+ score = Math.max(0, Math.round(score));
352
+ // Summary parts
353
+ const parts = [];
354
+ if (hallucinatedIssues.length > 0)
355
+ parts.push(`${hallucinatedIssues.length} hallucinated import${hallucinatedIssues.length !== 1 ? 's' : ''}`);
356
+ if (emptyCatchIssues.length > 0)
357
+ parts.push(`${emptyCatchIssues.length} empty catch${emptyCatchIssues.length !== 1 ? 'es' : ''}`);
358
+ if (stubbedTestIssues.length > 0)
359
+ parts.push(`${stubbedTestIssues.length} stubbed test${stubbedTestIssues.length !== 1 ? 's' : ''}`);
360
+ if (unhandledAsyncIssues.length > 0)
361
+ parts.push(`${unhandledAsyncIssues.length} unhandled async`);
362
+ return {
363
+ name: 'integrity',
364
+ score,
365
+ maxScore: 100,
366
+ issues: allIssues,
367
+ summary: parts.length === 0 ? 'no integrity issues found' : parts.join(', '),
368
+ };
369
+ }