@safetnsr/vet 0.5.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.d.ts +2 -0
- package/dist/checks/debt.js +373 -0
- 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 +77 -44
- 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
|
};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { join, basename } from 'node:path';
|
|
2
|
+
import { walkFiles, readFile } from '../util.js';
|
|
3
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
4
|
+
const SOURCE_EXTS = new Set(['.ts', '.js', '.tsx', '.jsx']);
|
|
5
|
+
function isSourceFile(f) {
|
|
6
|
+
const dot = f.lastIndexOf('.');
|
|
7
|
+
return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
|
|
8
|
+
}
|
|
9
|
+
function isTestFile(f) {
|
|
10
|
+
return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || f.startsWith('test/') || f.startsWith('test\\');
|
|
11
|
+
}
|
|
12
|
+
function isEntryFile(f) {
|
|
13
|
+
const b = basename(f);
|
|
14
|
+
return /^(cli|main|index)\.[jt]sx?$/.test(b);
|
|
15
|
+
}
|
|
16
|
+
function isBarrelFile(f) {
|
|
17
|
+
const b = basename(f);
|
|
18
|
+
return /^index\.[jt]sx?$/.test(b);
|
|
19
|
+
}
|
|
20
|
+
/** Normalize a function body for comparison */
|
|
21
|
+
function normalize(body) {
|
|
22
|
+
let s = body;
|
|
23
|
+
// Replace string literals
|
|
24
|
+
s = s.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, '"S"');
|
|
25
|
+
// Replace number literals (but not in identifiers)
|
|
26
|
+
s = s.replace(/\b\d+\.?\d*\b/g, '0');
|
|
27
|
+
// Strip whitespace
|
|
28
|
+
s = s.replace(/\s+/g, '');
|
|
29
|
+
// Collapse variable names to single char (simple: replace camelCase identifiers)
|
|
30
|
+
s = s.replace(/\b[a-z][a-zA-Z0-9]{3,}\b/g, 'V');
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
/** Simple string hash */
|
|
34
|
+
function simpleHash(s) {
|
|
35
|
+
let h = 0;
|
|
36
|
+
for (let i = 0; i < s.length; i++) {
|
|
37
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
38
|
+
}
|
|
39
|
+
return h.toString(36);
|
|
40
|
+
}
|
|
41
|
+
/** Similarity ratio between two strings (0-1) */
|
|
42
|
+
function similarity(a, b) {
|
|
43
|
+
if (a === b)
|
|
44
|
+
return 1;
|
|
45
|
+
const longer = a.length >= b.length ? a : b;
|
|
46
|
+
const shorter = a.length >= b.length ? b : a;
|
|
47
|
+
if (longer.length === 0)
|
|
48
|
+
return 1;
|
|
49
|
+
// Count matching characters in sequence
|
|
50
|
+
let matches = 0;
|
|
51
|
+
const used = new Array(longer.length).fill(false);
|
|
52
|
+
for (let i = 0; i < shorter.length; i++) {
|
|
53
|
+
for (let j = 0; j < longer.length; j++) {
|
|
54
|
+
if (!used[j] && shorter[i] === longer[j]) {
|
|
55
|
+
matches++;
|
|
56
|
+
used[j] = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return matches / longer.length;
|
|
62
|
+
}
|
|
63
|
+
/** Extract function bodies with brace matching */
|
|
64
|
+
function extractBraceBody(source, startIdx) {
|
|
65
|
+
let idx = source.indexOf('{', startIdx);
|
|
66
|
+
if (idx === -1)
|
|
67
|
+
return null;
|
|
68
|
+
let depth = 0;
|
|
69
|
+
const start = idx;
|
|
70
|
+
for (let i = idx; i < source.length; i++) {
|
|
71
|
+
if (source[i] === '{')
|
|
72
|
+
depth++;
|
|
73
|
+
else if (source[i] === '}') {
|
|
74
|
+
depth--;
|
|
75
|
+
if (depth === 0)
|
|
76
|
+
return source.substring(start + 1, i);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/** Get line number for a character index */
|
|
82
|
+
function lineAt(source, idx) {
|
|
83
|
+
let line = 1;
|
|
84
|
+
for (let i = 0; i < idx && i < source.length; i++) {
|
|
85
|
+
if (source[i] === '\n')
|
|
86
|
+
line++;
|
|
87
|
+
}
|
|
88
|
+
return line;
|
|
89
|
+
}
|
|
90
|
+
/** Extract all named functions from source */
|
|
91
|
+
function extractFunctions(source, file) {
|
|
92
|
+
const fns = [];
|
|
93
|
+
// Named function declarations: function name(...)
|
|
94
|
+
const funcDeclRe = /\bfunction\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\([^)]*\)[^{]*/g;
|
|
95
|
+
let match;
|
|
96
|
+
while ((match = funcDeclRe.exec(source)) !== null) {
|
|
97
|
+
const body = extractBraceBody(source, match.index + match[0].length - 1);
|
|
98
|
+
if (body && body.trim().length > 10) {
|
|
99
|
+
const norm = normalize(body);
|
|
100
|
+
fns.push({
|
|
101
|
+
name: match[1],
|
|
102
|
+
body,
|
|
103
|
+
normalized: norm,
|
|
104
|
+
hash: simpleHash(norm),
|
|
105
|
+
file,
|
|
106
|
+
line: lineAt(source, match.index),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Arrow function assignments: const/let/var name = (...) => { ... }
|
|
111
|
+
// Also: export const name = (...) => { ... }
|
|
112
|
+
const arrowRe = /\b(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]*?)?\s*=>\s*\{/g;
|
|
113
|
+
while ((match = arrowRe.exec(source)) !== null) {
|
|
114
|
+
const braceStart = source.indexOf('{', match.index + match[0].length - 1);
|
|
115
|
+
const body = extractBraceBody(source, braceStart);
|
|
116
|
+
if (body && body.trim().length > 10) {
|
|
117
|
+
const norm = normalize(body);
|
|
118
|
+
fns.push({
|
|
119
|
+
name: match[1],
|
|
120
|
+
body,
|
|
121
|
+
normalized: norm,
|
|
122
|
+
hash: simpleHash(norm),
|
|
123
|
+
file,
|
|
124
|
+
line: lineAt(source, match.index),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return fns;
|
|
129
|
+
}
|
|
130
|
+
// ── A) Near-duplicate detection ──────────────────────────────────────────────
|
|
131
|
+
function findDuplicates(allFuncs) {
|
|
132
|
+
const issues = [];
|
|
133
|
+
const groups = new Map();
|
|
134
|
+
// Group by hash first (exact normalized match)
|
|
135
|
+
for (const fn of allFuncs) {
|
|
136
|
+
const existing = groups.get(fn.hash) || [];
|
|
137
|
+
existing.push(fn);
|
|
138
|
+
groups.set(fn.hash, existing);
|
|
139
|
+
}
|
|
140
|
+
const reported = new Set();
|
|
141
|
+
// Exact duplicates
|
|
142
|
+
for (const [, group] of groups) {
|
|
143
|
+
if (group.length < 2)
|
|
144
|
+
continue;
|
|
145
|
+
// Deduplicate by name+file
|
|
146
|
+
const key = group.map(f => `${f.file}:${f.name}`).sort().join('|');
|
|
147
|
+
if (reported.has(key))
|
|
148
|
+
continue;
|
|
149
|
+
reported.add(key);
|
|
150
|
+
const locations = group.map(f => `${f.name} (${f.file}:${f.line})`).join(', ');
|
|
151
|
+
issues.push({
|
|
152
|
+
severity: 'warning',
|
|
153
|
+
message: `near-duplicate functions: ${locations}`,
|
|
154
|
+
file: group[0].file,
|
|
155
|
+
line: group[0].line,
|
|
156
|
+
fixable: true,
|
|
157
|
+
fixHint: 'extract shared logic into a single function',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Similarity check for non-exact matches
|
|
161
|
+
const singles = allFuncs.filter(fn => {
|
|
162
|
+
const g = groups.get(fn.hash);
|
|
163
|
+
return !g || g.length < 2;
|
|
164
|
+
});
|
|
165
|
+
for (let i = 0; i < singles.length; i++) {
|
|
166
|
+
for (let j = i + 1; j < singles.length; j++) {
|
|
167
|
+
const a = singles[i];
|
|
168
|
+
const b = singles[j];
|
|
169
|
+
// Skip very short normalized bodies
|
|
170
|
+
if (a.normalized.length < 30 || b.normalized.length < 30)
|
|
171
|
+
continue;
|
|
172
|
+
const sim = similarity(a.normalized, b.normalized);
|
|
173
|
+
if (sim > 0.85) {
|
|
174
|
+
const key = [a.file + ':' + a.name, b.file + ':' + b.name].sort().join('|');
|
|
175
|
+
if (reported.has(key))
|
|
176
|
+
continue;
|
|
177
|
+
reported.add(key);
|
|
178
|
+
issues.push({
|
|
179
|
+
severity: 'warning',
|
|
180
|
+
message: `similar functions (${Math.round(sim * 100)}%): ${a.name} (${a.file}:${a.line}) and ${b.name} (${b.file}:${b.line})`,
|
|
181
|
+
file: a.file,
|
|
182
|
+
line: a.line,
|
|
183
|
+
fixable: true,
|
|
184
|
+
fixHint: 'consider merging or extracting shared logic',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return issues;
|
|
190
|
+
}
|
|
191
|
+
// ── B) Orphaned exports ──────────────────────────────────────────────────────
|
|
192
|
+
function findOrphanedExports(cwd, files) {
|
|
193
|
+
const issues = [];
|
|
194
|
+
const sourceFiles = files.filter(f => isSourceFile(f) && !isTestFile(f));
|
|
195
|
+
// Collect all named exports
|
|
196
|
+
const exports = [];
|
|
197
|
+
for (const file of sourceFiles) {
|
|
198
|
+
if (isBarrelFile(file) || isEntryFile(file))
|
|
199
|
+
continue;
|
|
200
|
+
const content = readFile(join(cwd, file));
|
|
201
|
+
if (!content)
|
|
202
|
+
continue;
|
|
203
|
+
const lines = content.split('\n');
|
|
204
|
+
for (let i = 0; i < lines.length; i++) {
|
|
205
|
+
const line = lines[i];
|
|
206
|
+
// export function name
|
|
207
|
+
const funcMatch = line.match(/^export\s+(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
208
|
+
if (funcMatch) {
|
|
209
|
+
exports.push({ name: funcMatch[1], file, line: i + 1 });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// export const/let/var name
|
|
213
|
+
const constMatch = line.match(/^export\s+(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
214
|
+
if (constMatch) {
|
|
215
|
+
exports.push({ name: constMatch[1], file, line: i + 1 });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// export { name, name2 } — but skip type exports
|
|
219
|
+
if (/^export\s+type\s/.test(line))
|
|
220
|
+
continue;
|
|
221
|
+
const braceMatch = line.match(/^export\s*\{([^}]+)\}/);
|
|
222
|
+
if (braceMatch) {
|
|
223
|
+
const names = braceMatch[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
if (name === 'default' || name === 'type')
|
|
226
|
+
continue;
|
|
227
|
+
exports.push({ name, file, line: i + 1 });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Scan all files for imports of each name
|
|
233
|
+
const allContent = [];
|
|
234
|
+
for (const file of sourceFiles) {
|
|
235
|
+
const content = readFile(join(cwd, file));
|
|
236
|
+
if (content)
|
|
237
|
+
allContent.push(content);
|
|
238
|
+
}
|
|
239
|
+
const allText = allContent.join('\n');
|
|
240
|
+
for (const exp of exports) {
|
|
241
|
+
// Check if name appears in import statements across all files
|
|
242
|
+
// import { name } from or import { x, name } from or import { name as y }
|
|
243
|
+
const importPattern = new RegExp(`import\\s+[^;]*\\b${exp.name}\\b[^;]*from\\s+`, 'm');
|
|
244
|
+
if (!importPattern.test(allText)) {
|
|
245
|
+
issues.push({
|
|
246
|
+
severity: 'warning',
|
|
247
|
+
message: `orphaned export: "${exp.name}" is exported but never imported`,
|
|
248
|
+
file: exp.file,
|
|
249
|
+
line: exp.line,
|
|
250
|
+
fixable: true,
|
|
251
|
+
fixHint: 'remove the export keyword or delete the function',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return issues;
|
|
256
|
+
}
|
|
257
|
+
// ── C) Wrapper pass-throughs ─────────────────────────────────────────────────
|
|
258
|
+
function findWrappers(allFuncs) {
|
|
259
|
+
const issues = [];
|
|
260
|
+
for (const fn of allFuncs) {
|
|
261
|
+
const trimmed = fn.body.trim();
|
|
262
|
+
// return someFn(args) or return someFn(...args)
|
|
263
|
+
if (/^return\s+[a-zA-Z_$][a-zA-Z0-9_$.]*\s*\([^)]*\)\s*;?\s*$/.test(trimmed)) {
|
|
264
|
+
issues.push({
|
|
265
|
+
severity: 'info',
|
|
266
|
+
message: `wrapper pass-through: ${fn.name} just delegates to another function`,
|
|
267
|
+
file: fn.file,
|
|
268
|
+
line: fn.line,
|
|
269
|
+
fixable: true,
|
|
270
|
+
fixHint: 'call the inner function directly instead',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return issues;
|
|
275
|
+
}
|
|
276
|
+
// ── D) Naming drift ─────────────────────────────────────────────────────────
|
|
277
|
+
function findNamingDrift(allFuncs) {
|
|
278
|
+
const issues = [];
|
|
279
|
+
// Common prefixes that indicate the same action
|
|
280
|
+
const actionPrefixes = ['get', 'fetch', 'load', 'retrieve', 'find', 'query', 'read', 'create', 'make', 'build', 'generate', 'set', 'update', 'save', 'write', 'delete', 'remove', 'destroy', 'handle', 'process', 'parse', 'format', 'validate', 'check', 'verify', 'is', 'has', 'can', 'should', 'init', 'setup', 'configure', 'start', 'stop', 'enable', 'disable', 'show', 'hide', 'render', 'display', 'transform', 'convert', 'map', 'filter', 'reduce', 'sort', 'merge', 'split', 'join', 'send', 'emit', 'dispatch', 'trigger', 'on', 'listen', 'subscribe', 'publish', 'notify', 'log', 'print', 'debug', 'warn', 'error'];
|
|
281
|
+
// Extract suffix groups: for each function name, find its suffix after stripping known prefixes
|
|
282
|
+
const suffixMap = new Map();
|
|
283
|
+
for (const fn of allFuncs) {
|
|
284
|
+
const name = fn.name;
|
|
285
|
+
for (const prefix of actionPrefixes) {
|
|
286
|
+
if (name.length > prefix.length && name.startsWith(prefix) && name[prefix.length] === name[prefix.length].toUpperCase()) {
|
|
287
|
+
const suffix = name.substring(prefix.length);
|
|
288
|
+
const existing = suffixMap.get(suffix) || [];
|
|
289
|
+
// Avoid duplicate entries
|
|
290
|
+
if (!existing.some(e => e.name === name)) {
|
|
291
|
+
existing.push({ prefix, name, file: fn.file });
|
|
292
|
+
suffixMap.set(suffix, existing);
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const [suffix, entries] of suffixMap) {
|
|
299
|
+
const uniquePrefixes = new Set(entries.map(e => e.prefix));
|
|
300
|
+
if (uniquePrefixes.size >= 3) {
|
|
301
|
+
const names = entries.map(e => e.name).join(', ');
|
|
302
|
+
issues.push({
|
|
303
|
+
severity: 'info',
|
|
304
|
+
message: `naming drift: ${uniquePrefixes.size} prefixes for "${suffix}": ${names}`,
|
|
305
|
+
fixable: false,
|
|
306
|
+
fixHint: 'standardize on one prefix pattern',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return issues;
|
|
311
|
+
}
|
|
312
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
313
|
+
export async function checkDebt(cwd, ignore) {
|
|
314
|
+
const allFiles = walkFiles(cwd, ignore);
|
|
315
|
+
const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f));
|
|
316
|
+
if (sourceFiles.length === 0) {
|
|
317
|
+
return {
|
|
318
|
+
name: 'debt',
|
|
319
|
+
score: 100,
|
|
320
|
+
maxScore: 100,
|
|
321
|
+
issues: [],
|
|
322
|
+
summary: 'no source files to analyze',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// Extract all functions
|
|
326
|
+
const allFuncs = [];
|
|
327
|
+
for (const file of sourceFiles) {
|
|
328
|
+
const content = readFile(join(cwd, file));
|
|
329
|
+
if (!content)
|
|
330
|
+
continue;
|
|
331
|
+
allFuncs.push(...extractFunctions(content, file));
|
|
332
|
+
}
|
|
333
|
+
const issues = [];
|
|
334
|
+
// A) Duplicates
|
|
335
|
+
const dupIssues = findDuplicates(allFuncs);
|
|
336
|
+
issues.push(...dupIssues);
|
|
337
|
+
// B) Orphaned exports
|
|
338
|
+
const orphanIssues = findOrphanedExports(cwd, allFiles);
|
|
339
|
+
issues.push(...orphanIssues);
|
|
340
|
+
// C) Wrappers
|
|
341
|
+
const wrapperIssues = findWrappers(allFuncs);
|
|
342
|
+
issues.push(...wrapperIssues);
|
|
343
|
+
// D) Naming drift
|
|
344
|
+
const driftIssues = findNamingDrift(allFuncs);
|
|
345
|
+
issues.push(...driftIssues);
|
|
346
|
+
// ── Scoring ──────────────────────────────────────────────────────────────
|
|
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
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
354
|
+
const parts = [];
|
|
355
|
+
if (dupIssues.length > 0)
|
|
356
|
+
parts.push(`${dupIssues.length} duplicate${dupIssues.length !== 1 ? 's' : ''}`);
|
|
357
|
+
if (orphanIssues.length > 0)
|
|
358
|
+
parts.push(`${orphanIssues.length} orphaned export${orphanIssues.length !== 1 ? 's' : ''}`);
|
|
359
|
+
if (wrapperIssues.length > 0)
|
|
360
|
+
parts.push(`${wrapperIssues.length} wrapper${wrapperIssues.length !== 1 ? 's' : ''}`);
|
|
361
|
+
if (driftIssues.length > 0)
|
|
362
|
+
parts.push(`${driftIssues.length} naming drift`);
|
|
363
|
+
const summary = parts.length === 0
|
|
364
|
+
? `${sourceFiles.length} files analyzed, no technical debt found`
|
|
365
|
+
: `${sourceFiles.length} files: ${parts.join(', ')}`;
|
|
366
|
+
return {
|
|
367
|
+
name: 'debt',
|
|
368
|
+
score: finalScore,
|
|
369
|
+
maxScore: 100,
|
|
370
|
+
issues,
|
|
371
|
+
summary,
|
|
372
|
+
};
|
|
373
|
+
}
|
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
|
};
|