@safetnsr/vet 0.5.0 → 0.6.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.
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkDebt(cwd: string, ignore: string[]): Promise<CheckResult>;
@@ -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: 10,
320
+ maxScore: 10,
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(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);
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: 10,
370
+ issues,
371
+ summary,
372
+ };
373
+ }
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { checkHistory } from './checks/history.js';
10
10
  import { checkScan } from './checks/scan.js';
11
11
  import { checkSecrets } from './checks/secrets.js';
12
12
  import { checkDeps } from './checks/deps.js';
13
+ import { checkDebt } from './checks/debt.js';
13
14
  import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
14
15
  import { score } from './scorer.js';
15
16
  import { reportPretty, reportJSON } from './reporter.js';
@@ -50,6 +51,7 @@ if (flags.has('--help') || flags.has('-h')) {
50
51
  secrets leaked secrets in build output and .env files
51
52
  receipt last agent session audit (informational)
52
53
  deps phantom/hallucinated dependency detection
54
+ debt AI-generated technical debt patterns
53
55
 
54
56
  ${c.dim}options:${c.reset}
55
57
  --ci CI mode (exit 1 if score < threshold)
@@ -122,7 +124,7 @@ if (isFix) {
122
124
  process.exit(0);
123
125
  }
124
126
  async function runChecks() {
125
- const allChecks = ['ready', 'diff', 'models', 'config', 'history', 'scan', 'secrets', 'receipt', 'deps'];
127
+ const allChecks = ['ready', 'diff', 'models', 'config', 'history', 'scan', 'secrets', 'receipt', 'deps', 'debt'];
126
128
  const enabledChecks = config.checks || allChecks;
127
129
  const results = [];
128
130
  // ready and models are async (try rich subpackages first, fallback to built-in)
@@ -144,6 +146,8 @@ async function runChecks() {
144
146
  results.push(await checkReceipt(cwd));
145
147
  if (enabledChecks.includes('deps'))
146
148
  results.push(await checkDeps(cwd));
149
+ if (enabledChecks.includes('debt'))
150
+ results.push(await checkDebt(cwd, ignore));
147
151
  return score(cwd, results);
148
152
  }
149
153
  // --watch mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "vet your AI-generated code — one command, six checks, zero config",
5
5
  "type": "module",
6
6
  "bin": {