@safetnsr/vet 1.19.0 → 1.19.1

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.
@@ -9,5 +9,6 @@ export declare function buildCategories(checkMap: {
9
9
  deps: CheckResult[];
10
10
  architecture: CheckResult[];
11
11
  aiready: CheckResult[];
12
+ history: CheckResult[];
12
13
  }): CategoryResult[];
13
14
  export declare function buildVetResult(project: string, categories: CategoryResult[]): VetResult;
@@ -16,12 +16,13 @@ export function toGrade(score) {
16
16
  }
17
17
  // ── Category weights ─────────────────────────────────────────────────────────
18
18
  const WEIGHTS = {
19
- security: 0.25,
20
- integrity: 0.25,
21
- debt: 0.20,
19
+ security: 0.20,
20
+ integrity: 0.20,
21
+ debt: 0.15,
22
22
  deps: 0.10,
23
23
  architecture: 0.10,
24
24
  aiready: 0.10,
25
+ history: 0.15,
25
26
  };
26
27
  // ── Scoring floor for non-security checks ────────────────────────────────────
27
28
  const SECURITY_CHECKS = new Set(['scan', 'secrets', 'permissions', 'owasp']);
@@ -68,7 +69,7 @@ function completenessMultiplier(categories) {
68
69
  // ── Group checks into categories ─────────────────────────────────────────────
69
70
  export function buildCategories(checkMap) {
70
71
  const categories = [];
71
- for (const name of ['security', 'integrity', 'debt', 'deps', 'architecture', 'aiready']) {
72
+ for (const name of ['security', 'integrity', 'debt', 'deps', 'architecture', 'aiready', 'history']) {
72
73
  const checks = checkMap[name];
73
74
  if (!checks || checks.length === 0)
74
75
  continue;
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkHotspots(cwd: string): Promise<CheckResult>;
@@ -0,0 +1,228 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { walkFiles, readFile, c } from '../util.js';
4
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.py', '.go', '.rs', '.java']);
5
+ function isSourceFile(f) {
6
+ const dot = f.lastIndexOf('.');
7
+ return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
8
+ }
9
+ function getGitChurn(cwd, months = 6) {
10
+ const churn = new Map();
11
+ try {
12
+ const since = `--since="${months} months ago"`;
13
+ // Get commit count + authors per file
14
+ const log = execSync(`git log ${since} --format="%H %ae" --name-only --no-merges 2>/dev/null`, { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, timeout: 15_000 });
15
+ let currentAuthor = '';
16
+ const fileAuthors = new Map();
17
+ const fileCommits = new Map();
18
+ for (const line of log.split('\n')) {
19
+ if (!line.trim())
20
+ continue;
21
+ if (/^[0-9a-f]{40}\s/.test(line)) {
22
+ currentAuthor = line.split(' ').slice(1).join(' ');
23
+ continue;
24
+ }
25
+ const file = line.trim();
26
+ fileCommits.set(file, (fileCommits.get(file) || 0) + 1);
27
+ if (!fileAuthors.has(file))
28
+ fileAuthors.set(file, new Set());
29
+ fileAuthors.get(file).add(currentAuthor);
30
+ }
31
+ // Get lines changed per file
32
+ const numstat = execSync(`git log ${since} --numstat --format="" --no-merges 2>/dev/null`, { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, timeout: 15_000 });
33
+ const fileLinesChanged = new Map();
34
+ for (const line of numstat.split('\n')) {
35
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
36
+ if (!match)
37
+ continue;
38
+ const added = parseInt(match[1], 10);
39
+ const removed = parseInt(match[2], 10);
40
+ const file = match[3];
41
+ fileLinesChanged.set(file, (fileLinesChanged.get(file) || 0) + added + removed);
42
+ }
43
+ for (const [file, commits] of fileCommits) {
44
+ churn.set(file, {
45
+ file,
46
+ commits,
47
+ authors: fileAuthors.get(file)?.size || 1,
48
+ linesChanged: fileLinesChanged.get(file) || 0,
49
+ });
50
+ }
51
+ }
52
+ catch {
53
+ // Not a git repo or git not available
54
+ }
55
+ return churn;
56
+ }
57
+ function getTemporalCoupling(cwd, months = 6) {
58
+ const couplings = [];
59
+ try {
60
+ const since = `--since="${months} months ago"`;
61
+ const log = execSync(`git log ${since} --format="COMMIT" --name-only --no-merges 2>/dev/null`, { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, timeout: 15_000 });
62
+ // Parse commits into file sets
63
+ const commits = [];
64
+ let current = [];
65
+ for (const line of log.split('\n')) {
66
+ if (line === 'COMMIT') {
67
+ if (current.length > 0)
68
+ commits.push(current);
69
+ current = [];
70
+ }
71
+ else if (line.trim()) {
72
+ const f = line.trim();
73
+ if (isSourceFile(f))
74
+ current.push(f);
75
+ }
76
+ }
77
+ if (current.length > 0)
78
+ commits.push(current);
79
+ // Count co-changes (files that appear in same commit)
80
+ const pairCount = new Map();
81
+ const fileCommitCount = new Map();
82
+ for (const files of commits) {
83
+ if (files.length > 20)
84
+ continue; // skip huge commits (refactors/renames)
85
+ for (const f of files) {
86
+ fileCommitCount.set(f, (fileCommitCount.get(f) || 0) + 1);
87
+ }
88
+ for (let i = 0; i < files.length; i++) {
89
+ for (let j = i + 1; j < files.length; j++) {
90
+ const key = [files[i], files[j]].sort().join('::');
91
+ pairCount.set(key, (pairCount.get(key) || 0) + 1);
92
+ }
93
+ }
94
+ }
95
+ // Find strong couplings
96
+ for (const [key, count] of pairCount) {
97
+ if (count < 3)
98
+ continue; // minimum 3 co-changes
99
+ const [f1, f2] = key.split('::');
100
+ const minCommits = Math.min(fileCommitCount.get(f1) || 1, fileCommitCount.get(f2) || 1);
101
+ const strength = count / minCommits;
102
+ if (strength > 0.5) { // >50% of the time they change together
103
+ couplings.push({ file1: f1, file2: f2, cochanges: count, couplingStrength: strength });
104
+ }
105
+ }
106
+ couplings.sort((a, b) => b.couplingStrength - a.couplingStrength);
107
+ }
108
+ catch {
109
+ // Not a git repo
110
+ }
111
+ return couplings;
112
+ }
113
+ // ── Complexity proxy: indentation depth ─────────────────────────────────────
114
+ function getIndentationComplexity(content) {
115
+ const lines = content.split('\n');
116
+ let totalDepth = 0;
117
+ let maxDepth = 0;
118
+ let measured = 0;
119
+ for (const line of lines) {
120
+ if (!line.trim())
121
+ continue;
122
+ const match = line.match(/^(\s+)/);
123
+ if (match) {
124
+ const depth = match[1].includes('\t')
125
+ ? match[1].split('\t').length - 1
126
+ : Math.floor(match[1].length / 2);
127
+ totalDepth += depth;
128
+ if (depth > maxDepth)
129
+ maxDepth = depth;
130
+ }
131
+ measured++;
132
+ }
133
+ return measured > 0 ? totalDepth / measured : 0;
134
+ }
135
+ // ── Main check ───────────────────────────────────────────────────────────────
136
+ export async function checkHotspots(cwd) {
137
+ const issues = [];
138
+ const t0 = Date.now();
139
+ const churn = getGitChurn(cwd);
140
+ if (churn.size === 0) {
141
+ return { name: 'hotspots', score: 100, maxScore: 100, summary: 'no git history', issues: [] };
142
+ }
143
+ const allFiles = walkFiles(cwd);
144
+ const sourceFiles = allFiles.filter(f => isSourceFile(f));
145
+ // Calculate complexity for each file (indentation-based, fast)
146
+ const fileComplexity = new Map();
147
+ for (const file of sourceFiles) {
148
+ const content = readFile(join(cwd, file));
149
+ if (!content)
150
+ continue;
151
+ fileComplexity.set(file, getIndentationComplexity(content));
152
+ }
153
+ const hotspots = [];
154
+ for (const [file, ch] of churn) {
155
+ const complexity = fileComplexity.get(file);
156
+ if (complexity === undefined)
157
+ continue;
158
+ // Normalize: risk = log(commits) × complexity
159
+ const risk = Math.log2(ch.commits + 1) * complexity;
160
+ hotspots.push({ file, commits: ch.commits, complexity, risk, authors: ch.authors });
161
+ }
162
+ hotspots.sort((a, b) => b.risk - a.risk);
163
+ // Top hotspots are issues
164
+ const topHotspots = hotspots.slice(0, 5);
165
+ for (const hs of topHotspots) {
166
+ if (hs.risk < 5)
167
+ continue; // skip low-risk files
168
+ issues.push({
169
+ severity: hs.risk > 20 ? 'warning' : 'info',
170
+ message: `hotspot: ${hs.file} — ${hs.commits} commits, complexity ${hs.complexity.toFixed(1)}, risk score ${hs.risk.toFixed(1)}${hs.authors > 3 ? `, ${hs.authors} authors` : ''}`,
171
+ file: hs.file,
172
+ fixable: false,
173
+ fixHint: 'high-churn complex files are bug magnets — prioritize refactoring and add tests',
174
+ });
175
+ }
176
+ // ── Temporal coupling ─────────────────────────────────────────────────────
177
+ const couplings = getTemporalCoupling(cwd);
178
+ // Filter out obvious couplings (same directory, test+source)
179
+ const interestingCouplings = couplings.filter(cp => {
180
+ const dir1 = cp.file1.split('/').slice(0, -1).join('/');
181
+ const dir2 = cp.file2.split('/').slice(0, -1).join('/');
182
+ // Same directory coupling is expected
183
+ if (dir1 === dir2)
184
+ return false;
185
+ // Test+source coupling is expected
186
+ if (cp.file1.includes('test') || cp.file2.includes('test'))
187
+ return false;
188
+ return true;
189
+ });
190
+ for (const cp of interestingCouplings.slice(0, 3)) {
191
+ issues.push({
192
+ severity: 'info',
193
+ message: `temporal coupling: ${cp.file1} ↔ ${cp.file2} change together ${Math.round(cp.couplingStrength * 100)}% of the time (${cp.cochanges} co-changes) — possible hidden dependency`,
194
+ file: cp.file1,
195
+ fixable: false,
196
+ fixHint: 'investigate if these files share a concept that should be co-located or abstracted',
197
+ });
198
+ }
199
+ // ── Multi-author hotfiles ─────────────────────────────────────────────────
200
+ const manyAuthors = hotspots.filter(h => h.authors >= 5).slice(0, 3);
201
+ for (const ma of manyAuthors) {
202
+ issues.push({
203
+ severity: 'info',
204
+ message: `knowledge diffusion: ${ma.file} touched by ${ma.authors} authors — high bus factor risk if not well-documented`,
205
+ file: ma.file,
206
+ fixable: false,
207
+ fixHint: 'ensure this file has clear documentation and tests — many people modify it',
208
+ });
209
+ }
210
+ const elapsed = Date.now() - t0;
211
+ // ── Scoring ───────────────────────────────────────────────────────────────
212
+ // Score based on how many high-risk hotspots exist relative to codebase size
213
+ const highRiskCount = hotspots.filter(h => h.risk > 20).length;
214
+ const riskRatio = sourceFiles.length > 0 ? highRiskCount / sourceFiles.length : 0;
215
+ const hotspotScore = Math.max(25, Math.round(100 - riskRatio * 500));
216
+ // Temporal coupling penalty
217
+ const couplingPenalty = Math.min(30, interestingCouplings.length * 5);
218
+ const score = Math.max(25, hotspotScore - couplingPenalty);
219
+ const parts = [];
220
+ parts.push(`${churn.size} files in git history, ${elapsed}ms`);
221
+ if (topHotspots.length > 0 && topHotspots[0].risk > 5) {
222
+ parts.push(c.yellow + `top hotspot: ${topHotspots[0].file.split('/').pop()} (risk ${topHotspots[0].risk.toFixed(0)})` + c.reset);
223
+ }
224
+ if (interestingCouplings.length > 0) {
225
+ parts.push(`${interestingCouplings.length} temporal couplings`);
226
+ }
227
+ return { name: 'hotspots', score, maxScore: 100, summary: parts.join(', '), issues };
228
+ }
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ import { checkArchitecture } from './checks/architecture.js';
17
17
  import { checkAIReady } from './checks/aiready.js';
18
18
  import { checkDeep } from './checks/deep.js';
19
19
  import { checkSemantic } from './checks/semantic.js';
20
+ import { checkHotspots } from './checks/hotspots.js';
20
21
  import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
21
22
  import { checkMemory } from './checks/memory.js';
22
23
  import { checkVerify } from './checks/verify.js';
@@ -325,7 +326,7 @@ async function runChecks() {
325
326
  }
326
327
  }
327
328
  // Run ALL independent checks in parallel
328
- const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult,] = await Promise.all([
329
+ const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult,] = await Promise.all([
329
330
  withTimeout('scan', () => checkScan(cwd)),
330
331
  withTimeout('secrets', () => checkSecrets(cwd)),
331
332
  withTimeout('config', () => checkConfig(cwd, ignore)),
@@ -351,6 +352,7 @@ async function runChecks() {
351
352
  withTimeout('aiready', () => checkAIReady(cwd)),
352
353
  withTimeout('deep', () => checkDeep(cwd), 60_000),
353
354
  withTimeout('semantic', () => checkSemantic(cwd), 60_000),
355
+ withTimeout('hotspots', () => checkHotspots(cwd), 30_000),
354
356
  ]);
355
357
  // Git-dependent checks (diff + history) — parallel with each other
356
358
  const [diffResult, historyResult] = await Promise.all([
@@ -366,6 +368,7 @@ async function runChecks() {
366
368
  deps: [depsResult],
367
369
  architecture: [architectureResult],
368
370
  aiready: [aireadyResult, deepResult, semanticResult],
371
+ history: [hotspotsResult],
369
372
  });
370
373
  }
371
374
  catch (e) {
package/dist/scorer.d.ts CHANGED
@@ -6,5 +6,6 @@ export interface CheckMap {
6
6
  deps: CheckResult[];
7
7
  architecture: CheckResult[];
8
8
  aiready: CheckResult[];
9
+ history: CheckResult[];
9
10
  }
10
11
  export declare function score(project: string, checkMap: CheckMap): VetResult;
package/dist/types.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface Issue {
14
14
  fixHint?: string;
15
15
  }
16
16
  export interface CategoryResult {
17
- name: 'security' | 'integrity' | 'debt' | 'deps' | 'architecture' | 'aiready';
17
+ name: 'security' | 'integrity' | 'debt' | 'deps' | 'architecture' | 'aiready' | 'history';
18
18
  score: number;
19
19
  weight: number;
20
20
  checks: CheckResult[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {