@safetnsr/vet 0.6.0 → 1.0.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 +2 -1
- package/dist/categories.d.ts +9 -0
- package/dist/categories.js +79 -0
- package/dist/checks/config.js +8 -8
- package/dist/checks/debt.js +9 -9
- package/dist/checks/deps.js +5 -5
- package/dist/checks/diff.js +4 -4
- package/dist/checks/history.js +4 -4
- package/dist/checks/integrity.d.ts +2 -0
- package/dist/checks/integrity.js +317 -0
- package/dist/checks/map.d.ts +25 -0
- package/dist/checks/map.js +256 -0
- package/dist/checks/models.js +6 -6
- package/dist/checks/owasp.d.ts +2 -0
- package/dist/checks/owasp.js +794 -0
- package/dist/checks/ready.js +7 -7
- package/dist/checks/receipt.js +5 -5
- package/dist/checks/scan.js +3 -3
- package/dist/checks/secrets.js +4 -4
- package/dist/cli.js +76 -47
- package/dist/reporter.d.ts +1 -0
- package/dist/reporter.js +56 -25
- package/dist/scorer.d.ts +7 -1
- package/dist/scorer.js +4 -14
- package/dist/types.d.ts +11 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# vet
|
|
2
2
|
|
|
3
|
-
vet your AI-generated code. one command,
|
|
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
|
+
}
|
package/dist/checks/config.js
CHANGED
|
@@ -162,8 +162,8 @@ export function checkConfig(cwd, ignore) {
|
|
|
162
162
|
});
|
|
163
163
|
return {
|
|
164
164
|
name: 'config',
|
|
165
|
-
score:
|
|
166
|
-
maxScore:
|
|
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 :
|
|
196
|
-
const finalScore = Math.max(0, Math.min(
|
|
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
|
|
202
|
-
maxScore:
|
|
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
|
};
|
package/dist/checks/debt.js
CHANGED
|
@@ -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:
|
|
320
|
-
maxScore:
|
|
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(
|
|
348
|
-
const orphanPenalty = Math.min(
|
|
349
|
-
const wrapperPenalty = Math.min(
|
|
350
|
-
const driftPenalty = Math.min(
|
|
351
|
-
const rawScore =
|
|
352
|
-
const finalScore = Math.max(0, Math.round(rawScore
|
|
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:
|
|
369
|
+
maxScore: 100,
|
|
370
370
|
issues,
|
|
371
371
|
summary,
|
|
372
372
|
};
|
package/dist/checks/deps.js
CHANGED
|
@@ -155,8 +155,8 @@ export async function checkDeps(cwd) {
|
|
|
155
155
|
if (!hasPkgJson) {
|
|
156
156
|
return {
|
|
157
157
|
name: 'deps',
|
|
158
|
-
score:
|
|
159
|
-
maxScore:
|
|
158
|
+
score: 100,
|
|
159
|
+
maxScore: 100,
|
|
160
160
|
issues: [],
|
|
161
161
|
summary: 'no package.json found',
|
|
162
162
|
};
|
|
@@ -252,8 +252,8 @@ export async function checkDeps(cwd) {
|
|
|
252
252
|
// ── Scoring ────────────────────────────────────────────────────────────────
|
|
253
253
|
const errors = issues.filter(i => i.severity === 'error').length;
|
|
254
254
|
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
255
|
-
const rawScore =
|
|
256
|
-
const finalScore = Math.max(0, Math.min(
|
|
255
|
+
const rawScore = 100 - (errors * 30) - (warnings * 10);
|
|
256
|
+
const finalScore = Math.max(0, Math.min(100, rawScore));
|
|
257
257
|
// ── Summary ────────────────────────────────────────────────────────────────
|
|
258
258
|
const parts = [];
|
|
259
259
|
if (errors > 0)
|
|
@@ -269,7 +269,7 @@ export async function checkDeps(cwd) {
|
|
|
269
269
|
return {
|
|
270
270
|
name: 'deps',
|
|
271
271
|
score: finalScore,
|
|
272
|
-
maxScore:
|
|
272
|
+
maxScore: 100,
|
|
273
273
|
issues,
|
|
274
274
|
summary,
|
|
275
275
|
};
|
package/dist/checks/diff.js
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
|
171
|
-
maxScore:
|
|
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`
|
package/dist/checks/history.js
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
|
73
|
-
maxScore:
|
|
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,317 @@
|
|
|
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 extractRelativeImports(source) {
|
|
40
|
+
const imports = [];
|
|
41
|
+
const lines = source.split('\n');
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
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 });
|
|
48
|
+
}
|
|
49
|
+
// require('./foo')
|
|
50
|
+
const reqMatch = line.match(/require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
|
|
51
|
+
if (reqMatch) {
|
|
52
|
+
imports.push({ path: reqMatch[1], line: i + 1 });
|
|
53
|
+
}
|
|
54
|
+
// import('./foo')
|
|
55
|
+
const dynMatch = line.match(/import\s*\(\s*['"](\.[^'"]+)['"]\s*\)/);
|
|
56
|
+
if (dynMatch) {
|
|
57
|
+
imports.push({ path: dynMatch[1], line: i + 1 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return imports;
|
|
61
|
+
}
|
|
62
|
+
function checkHallucinatedImports(cwd, files) {
|
|
63
|
+
const issues = [];
|
|
64
|
+
const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const ext = extname(file);
|
|
67
|
+
if (!sourceExts.has(ext))
|
|
68
|
+
continue;
|
|
69
|
+
if (file.includes('node_modules'))
|
|
70
|
+
continue;
|
|
71
|
+
const content = readFile(join(cwd, file));
|
|
72
|
+
if (!content)
|
|
73
|
+
continue;
|
|
74
|
+
const relImports = extractRelativeImports(content);
|
|
75
|
+
for (const imp of relImports) {
|
|
76
|
+
// Skip .js extensions pointing to .ts files (common in ESM TypeScript)
|
|
77
|
+
// The resolver already handles this
|
|
78
|
+
if (!resolveRelativeImport(imp.path, file, cwd)) {
|
|
79
|
+
issues.push({
|
|
80
|
+
severity: 'error',
|
|
81
|
+
message: `hallucinated import: "${imp.path}" does not resolve to any file`,
|
|
82
|
+
file,
|
|
83
|
+
line: imp.line,
|
|
84
|
+
fixable: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return issues;
|
|
90
|
+
}
|
|
91
|
+
// ── Empty catch blocks ───────────────────────────────────────────────────────
|
|
92
|
+
function checkEmptyCatch(cwd, files) {
|
|
93
|
+
const issues = [];
|
|
94
|
+
const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
if (!sourceExts.has(extname(file)))
|
|
97
|
+
continue;
|
|
98
|
+
const content = readFile(join(cwd, file));
|
|
99
|
+
if (!content)
|
|
100
|
+
continue;
|
|
101
|
+
const lines = content.split('\n');
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
const line = lines[i];
|
|
104
|
+
// catch(e) {} or catch(err) {} — empty catch
|
|
105
|
+
if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
|
|
106
|
+
issues.push({
|
|
107
|
+
severity: 'error',
|
|
108
|
+
message: 'empty catch block — error silently swallowed',
|
|
109
|
+
file,
|
|
110
|
+
line: i + 1,
|
|
111
|
+
fixable: false,
|
|
112
|
+
fixHint: 'log or handle the error, or add a comment explaining why it is intentional',
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// catch {} (no param)
|
|
117
|
+
if (/catch\s*\{\s*\}/.test(line)) {
|
|
118
|
+
issues.push({
|
|
119
|
+
severity: 'error',
|
|
120
|
+
message: 'empty catch block — error silently swallowed',
|
|
121
|
+
file,
|
|
122
|
+
line: i + 1,
|
|
123
|
+
fixable: false,
|
|
124
|
+
fixHint: 'log or handle the error, or add a comment explaining why it is intentional',
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Multi-line: catch block that starts on this line — check if it's comment-only
|
|
129
|
+
const catchStart = line.match(/catch\s*(?:\([^)]*\))?\s*\{/);
|
|
130
|
+
if (catchStart) {
|
|
131
|
+
// Collect lines until matching }
|
|
132
|
+
let depth = 0;
|
|
133
|
+
let blockStart = -1;
|
|
134
|
+
for (let ci = line.indexOf('{'); ci < line.length; ci++) {
|
|
135
|
+
if (line[ci] === '{') {
|
|
136
|
+
depth++;
|
|
137
|
+
blockStart = ci;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (depth > 0) {
|
|
142
|
+
const blockLines = [line.slice(blockStart + 1)];
|
|
143
|
+
let j = i + 1;
|
|
144
|
+
while (j < lines.length && depth > 0) {
|
|
145
|
+
const l = lines[j];
|
|
146
|
+
for (const ch of l) {
|
|
147
|
+
if (ch === '{')
|
|
148
|
+
depth++;
|
|
149
|
+
else if (ch === '}')
|
|
150
|
+
depth--;
|
|
151
|
+
}
|
|
152
|
+
blockLines.push(l);
|
|
153
|
+
j++;
|
|
154
|
+
}
|
|
155
|
+
// Check if block body is only comments
|
|
156
|
+
const bodyText = blockLines.join('\n').replace(/\}$/, '').trim();
|
|
157
|
+
if (bodyText.length > 0 && /^(\s*(\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*$/.test(bodyText)) {
|
|
158
|
+
issues.push({
|
|
159
|
+
severity: 'warning',
|
|
160
|
+
message: 'catch block contains only comments — consider proper error handling',
|
|
161
|
+
file,
|
|
162
|
+
line: i + 1,
|
|
163
|
+
fixable: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return issues;
|
|
171
|
+
}
|
|
172
|
+
// ── Stubbed tests ────────────────────────────────────────────────────────────
|
|
173
|
+
function checkStubbedTests(cwd, files) {
|
|
174
|
+
const issues = [];
|
|
175
|
+
const testExts = /\.(test|spec)\.[jt]sx?$/;
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
if (!testExts.test(file))
|
|
178
|
+
continue;
|
|
179
|
+
const content = readFile(join(cwd, file));
|
|
180
|
+
if (!content)
|
|
181
|
+
continue;
|
|
182
|
+
const lines = content.split('\n');
|
|
183
|
+
for (let i = 0; i < lines.length; i++) {
|
|
184
|
+
const line = lines[i];
|
|
185
|
+
// Trivial assertions
|
|
186
|
+
if (/expect\s*\(\s*true\s*\)\s*\.toBe\s*\(\s*true\s*\)/.test(line)) {
|
|
187
|
+
issues.push({
|
|
188
|
+
severity: 'error',
|
|
189
|
+
message: 'stubbed test: trivial assertion expect(true).toBe(true)',
|
|
190
|
+
file,
|
|
191
|
+
line: i + 1,
|
|
192
|
+
fixable: false,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (/expect\s*\(\s*1\s*\)\s*\.toBe\s*\(\s*1\s*\)/.test(line)) {
|
|
196
|
+
issues.push({
|
|
197
|
+
severity: 'error',
|
|
198
|
+
message: 'stubbed test: trivial assertion expect(1).toBe(1)',
|
|
199
|
+
file,
|
|
200
|
+
line: i + 1,
|
|
201
|
+
fixable: false,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Empty test body: test('...', () => {}) or it('...', () => {})
|
|
205
|
+
if (/(?:test|it)\s*\(\s*['"`][^'"]+['"`]\s*,\s*(?:async\s*)?\(\s*\)\s*=>\s*\{\s*\}\s*\)/.test(line)) {
|
|
206
|
+
issues.push({
|
|
207
|
+
severity: 'error',
|
|
208
|
+
message: 'stubbed test: empty test body',
|
|
209
|
+
file,
|
|
210
|
+
line: i + 1,
|
|
211
|
+
fixable: false,
|
|
212
|
+
fixHint: 'add assertions or mark as test.todo()',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// it.skip without .todo — skipped test (always check regardless of other matches on this line)
|
|
216
|
+
if (/(?:it|test)\.skip\s*\(/.test(line) && !/\.todo\s*\(/.test(line)) {
|
|
217
|
+
issues.push({
|
|
218
|
+
severity: 'warning',
|
|
219
|
+
message: 'skipped test: use test.todo() instead of .skip for unimplemented tests',
|
|
220
|
+
file,
|
|
221
|
+
line: i + 1,
|
|
222
|
+
fixable: true,
|
|
223
|
+
fixHint: 'change .skip to .todo if not yet implemented',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return issues;
|
|
229
|
+
}
|
|
230
|
+
// ── Unhandled async (removed error handling) ─────────────────────────────────
|
|
231
|
+
function checkUnhandledAsync(cwd, files) {
|
|
232
|
+
const issues = [];
|
|
233
|
+
const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
if (!sourceExts.has(extname(file)))
|
|
236
|
+
continue;
|
|
237
|
+
const content = readFile(join(cwd, file));
|
|
238
|
+
if (!content)
|
|
239
|
+
continue;
|
|
240
|
+
const lines = content.split('\n');
|
|
241
|
+
let unhandledCount = 0;
|
|
242
|
+
for (let i = 0; i < lines.length; i++) {
|
|
243
|
+
const line = lines[i];
|
|
244
|
+
// await without try/catch context — detect standalone awaits
|
|
245
|
+
// We look for: const/let/var x = await or just await on its own, not inside try
|
|
246
|
+
const hasAwait = /^\s*(?:const|let|var)\s+\w.*=\s*await\s+/.test(line) || /^\s*await\s+/.test(line);
|
|
247
|
+
if (!hasAwait)
|
|
248
|
+
continue;
|
|
249
|
+
// Check context window — look for try { in surrounding lines
|
|
250
|
+
const contextStart = Math.max(0, i - 15);
|
|
251
|
+
const contextEnd = Math.min(lines.length - 1, i + 5);
|
|
252
|
+
const contextLines = lines.slice(contextStart, contextEnd + 1);
|
|
253
|
+
const contextText = contextLines.join('\n');
|
|
254
|
+
// Count try/catch blocks in context
|
|
255
|
+
const tryCount = (contextText.match(/\btry\s*\{/g) || []).length;
|
|
256
|
+
const catchCount = (contextText.match(/\bcatch\s*(?:\([^)]*\))?\s*\{/g) || []).length;
|
|
257
|
+
if (tryCount === 0 || catchCount === 0) {
|
|
258
|
+
// Also check for .catch() chained
|
|
259
|
+
const hasCatch = /\.catch\s*\(/.test(line) || (i + 1 < lines.length && /\.catch\s*\(/.test(lines[i + 1]));
|
|
260
|
+
if (!hasCatch) {
|
|
261
|
+
unhandledCount++;
|
|
262
|
+
if (unhandledCount <= 10) {
|
|
263
|
+
issues.push({
|
|
264
|
+
severity: 'warning',
|
|
265
|
+
message: 'unhandled async: await without try/catch',
|
|
266
|
+
file,
|
|
267
|
+
line: i + 1,
|
|
268
|
+
fixable: false,
|
|
269
|
+
fixHint: 'wrap in try/catch or chain .catch()',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return issues;
|
|
277
|
+
}
|
|
278
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
279
|
+
export async function checkIntegrity(cwd, ignore) {
|
|
280
|
+
const files = walkFiles(cwd, ignore);
|
|
281
|
+
const hallucinatedIssues = checkHallucinatedImports(cwd, files);
|
|
282
|
+
const emptyCatchIssues = checkEmptyCatch(cwd, files);
|
|
283
|
+
const stubbedTestIssues = checkStubbedTests(cwd, files);
|
|
284
|
+
const unhandledAsyncIssues = checkUnhandledAsync(cwd, files);
|
|
285
|
+
const allIssues = [
|
|
286
|
+
...hallucinatedIssues,
|
|
287
|
+
...emptyCatchIssues,
|
|
288
|
+
...stubbedTestIssues,
|
|
289
|
+
...unhandledAsyncIssues,
|
|
290
|
+
];
|
|
291
|
+
// Scoring: start at 100, penalize per issue type
|
|
292
|
+
let score = 100;
|
|
293
|
+
score -= hallucinatedIssues.length * 10;
|
|
294
|
+
score -= emptyCatchIssues.filter(i => i.severity === 'error').length * 8;
|
|
295
|
+
score -= stubbedTestIssues.filter(i => i.severity === 'error').length * 5;
|
|
296
|
+
// Unhandled async capped at -30
|
|
297
|
+
const unhandledErrors = unhandledAsyncIssues.length;
|
|
298
|
+
score -= Math.min(30, unhandledErrors * 3);
|
|
299
|
+
score = Math.max(0, Math.round(score));
|
|
300
|
+
// Summary parts
|
|
301
|
+
const parts = [];
|
|
302
|
+
if (hallucinatedIssues.length > 0)
|
|
303
|
+
parts.push(`${hallucinatedIssues.length} hallucinated import${hallucinatedIssues.length !== 1 ? 's' : ''}`);
|
|
304
|
+
if (emptyCatchIssues.length > 0)
|
|
305
|
+
parts.push(`${emptyCatchIssues.length} empty catch${emptyCatchIssues.length !== 1 ? 'es' : ''}`);
|
|
306
|
+
if (stubbedTestIssues.length > 0)
|
|
307
|
+
parts.push(`${stubbedTestIssues.length} stubbed test${stubbedTestIssues.length !== 1 ? 's' : ''}`);
|
|
308
|
+
if (unhandledAsyncIssues.length > 0)
|
|
309
|
+
parts.push(`${unhandledAsyncIssues.length} unhandled async`);
|
|
310
|
+
return {
|
|
311
|
+
name: 'integrity',
|
|
312
|
+
score,
|
|
313
|
+
maxScore: 100,
|
|
314
|
+
issues: allIssues,
|
|
315
|
+
summary: parts.length === 0 ? 'no integrity issues found' : parts.join(', '),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
export declare const AGENT_CONFIG_FILES: string[];
|
|
3
|
+
export declare function parseAgentConfigs(cwd: string): string[];
|
|
4
|
+
export declare function extractRefs(content: string, cwd: string): string[];
|
|
5
|
+
export type VisibilityTier = 'config' | 'visible' | 'invisible';
|
|
6
|
+
export interface ClassifiedFile {
|
|
7
|
+
path: string;
|
|
8
|
+
tier: VisibilityTier;
|
|
9
|
+
}
|
|
10
|
+
export declare function classifyFiles(cwd: string, configPaths: string[], refs: string[]): ClassifiedFile[];
|
|
11
|
+
export interface MapResult {
|
|
12
|
+
config: string[];
|
|
13
|
+
visible: string[];
|
|
14
|
+
invisible: string[];
|
|
15
|
+
stats: {
|
|
16
|
+
total: number;
|
|
17
|
+
visible_pct: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare function checkMap(cwd: string): Promise<CheckResult & {
|
|
21
|
+
mapData: MapResult;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function renderMapReport(result: CheckResult & {
|
|
24
|
+
mapData: MapResult;
|
|
25
|
+
}, asJson: boolean): string;
|