@safetnsr/vet 1.19.0 → 1.19.2
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/dist/categories.d.ts +1 -0
- package/dist/categories.js +5 -4
- package/dist/checks/clones.d.ts +2 -0
- package/dist/checks/clones.js +172 -0
- package/dist/checks/hotspots.d.ts +2 -0
- package/dist/checks/hotspots.js +228 -0
- package/dist/cli.js +7 -2
- package/dist/scorer.d.ts +1 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/categories.d.ts
CHANGED
|
@@ -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;
|
package/dist/categories.js
CHANGED
|
@@ -16,12 +16,13 @@ export function toGrade(score) {
|
|
|
16
16
|
}
|
|
17
17
|
// ── Category weights ─────────────────────────────────────────────────────────
|
|
18
18
|
const WEIGHTS = {
|
|
19
|
-
security: 0.
|
|
20
|
-
integrity: 0.
|
|
21
|
-
debt: 0.
|
|
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,172 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { walkFiles, readFile, c } from '../util.js';
|
|
4
|
+
const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
|
|
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__') || /(?:^|[/\\])tests?[/\\]/.test(f);
|
|
11
|
+
}
|
|
12
|
+
function isExampleFile(f) {
|
|
13
|
+
return /(?:^|[/\\])(?:examples?|templates?|fixtures?|demos?)[/\\]/.test(f);
|
|
14
|
+
}
|
|
15
|
+
// ── Token normalization ─────────────────────────────────────────────────────
|
|
16
|
+
// Strip comments, normalize whitespace, replace identifiers with placeholders
|
|
17
|
+
// This makes structurally identical code match even with different variable names
|
|
18
|
+
function normalizeTokens(code) {
|
|
19
|
+
// Remove single-line comments
|
|
20
|
+
let normalized = code.replace(/\/\/.*$/gm, '');
|
|
21
|
+
// Remove multi-line comments
|
|
22
|
+
normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
23
|
+
// Remove string literals (replace with placeholder)
|
|
24
|
+
normalized = normalized.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, '"S"');
|
|
25
|
+
// Normalize whitespace
|
|
26
|
+
normalized = normalized.replace(/\s+/g, ' ').trim();
|
|
27
|
+
// Normalize numbers (replace with placeholder)
|
|
28
|
+
normalized = normalized.replace(/\b\d+\.?\d*\b/g, '0');
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
const MIN_CHUNK_LINES = 6; // minimum lines for a clone to matter
|
|
32
|
+
function extractChunks(file, content, windowSize) {
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
const chunks = [];
|
|
35
|
+
for (let i = 0; i <= lines.length - windowSize; i++) {
|
|
36
|
+
const rawSlice = lines.slice(i, i + windowSize);
|
|
37
|
+
// Skip chunks that are mostly empty or imports
|
|
38
|
+
const meaningful = rawSlice.filter(l => {
|
|
39
|
+
const t = l.trim();
|
|
40
|
+
return t && !t.startsWith('import ') && !t.startsWith('export ') && t !== '{' && t !== '}' && t !== ');';
|
|
41
|
+
});
|
|
42
|
+
if (meaningful.length < windowSize * 0.5)
|
|
43
|
+
continue;
|
|
44
|
+
const normalized = normalizeTokens(rawSlice.join('\n'));
|
|
45
|
+
if (normalized.length < 40)
|
|
46
|
+
continue; // too short after normalization
|
|
47
|
+
const hash = createHash('md5').update(normalized).digest('hex');
|
|
48
|
+
chunks.push({
|
|
49
|
+
file,
|
|
50
|
+
startLine: i + 1,
|
|
51
|
+
endLine: i + windowSize,
|
|
52
|
+
hash,
|
|
53
|
+
raw: rawSlice.join('\n'),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return chunks;
|
|
57
|
+
}
|
|
58
|
+
export async function checkClones(cwd) {
|
|
59
|
+
const allFiles = walkFiles(cwd);
|
|
60
|
+
const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f) && !isExampleFile(f));
|
|
61
|
+
if (sourceFiles.length < 2) {
|
|
62
|
+
return { name: 'clones', score: 100, maxScore: 100, summary: 'too few files', issues: [] };
|
|
63
|
+
}
|
|
64
|
+
const t0 = Date.now();
|
|
65
|
+
const issues = [];
|
|
66
|
+
// Single window size — use the largest to reduce noise
|
|
67
|
+
const WINDOW_SIZE = 10;
|
|
68
|
+
const hashMap = new Map();
|
|
69
|
+
for (const file of sourceFiles) {
|
|
70
|
+
const content = readFile(join(cwd, file));
|
|
71
|
+
if (!content)
|
|
72
|
+
continue;
|
|
73
|
+
const chunks = extractChunks(file, content, WINDOW_SIZE);
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
if (!hashMap.has(chunk.hash))
|
|
76
|
+
hashMap.set(chunk.hash, []);
|
|
77
|
+
hashMap.get(chunk.hash).push(chunk);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Find cross-file duplicates, pick ONE representative per file per clone
|
|
81
|
+
const allCloneGroups = [];
|
|
82
|
+
for (const [hash, chunks] of hashMap) {
|
|
83
|
+
if (chunks.length < 2)
|
|
84
|
+
continue;
|
|
85
|
+
// Group by file, pick earliest occurrence per file
|
|
86
|
+
const byFile = new Map();
|
|
87
|
+
for (const chunk of chunks) {
|
|
88
|
+
const existing = byFile.get(chunk.file);
|
|
89
|
+
if (!existing || chunk.startLine < existing.startLine) {
|
|
90
|
+
byFile.set(chunk.file, chunk);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Cross-file only
|
|
94
|
+
if (byFile.size < 2)
|
|
95
|
+
continue;
|
|
96
|
+
const reps = [...byFile.values()];
|
|
97
|
+
allCloneGroups.push({
|
|
98
|
+
hash,
|
|
99
|
+
locations: reps.map(r => ({ file: r.file, startLine: r.startLine, endLine: r.endLine })),
|
|
100
|
+
lineCount: WINDOW_SIZE,
|
|
101
|
+
sample: reps[0].raw.slice(0, 200),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Deduplicate overlapping clones: group by file-set, merge overlapping line ranges
|
|
105
|
+
// Sort by number of files (more widespread clones first), then by earliest line
|
|
106
|
+
allCloneGroups.sort((a, b) => b.locations.length - a.locations.length || a.locations[0].startLine - b.locations[0].startLine);
|
|
107
|
+
const coveredRanges = new Map();
|
|
108
|
+
const filteredGroups = [];
|
|
109
|
+
for (const group of allCloneGroups) {
|
|
110
|
+
// Check if the first location is already substantially covered
|
|
111
|
+
const firstLoc = group.locations[0];
|
|
112
|
+
const covered = coveredRanges.get(firstLoc.file);
|
|
113
|
+
if (covered) {
|
|
114
|
+
let overlapCount = 0;
|
|
115
|
+
for (let line = firstLoc.startLine; line <= firstLoc.endLine; line++) {
|
|
116
|
+
if (covered.has(line))
|
|
117
|
+
overlapCount++;
|
|
118
|
+
}
|
|
119
|
+
// Skip if >50% of lines already reported
|
|
120
|
+
if (overlapCount > group.lineCount * 0.5)
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
filteredGroups.push(group);
|
|
124
|
+
// Mark all locations as covered
|
|
125
|
+
for (const loc of group.locations) {
|
|
126
|
+
if (!coveredRanges.has(loc.file))
|
|
127
|
+
coveredRanges.set(loc.file, new Set());
|
|
128
|
+
const set = coveredRanges.get(loc.file);
|
|
129
|
+
for (let line = loc.startLine; line <= loc.endLine; line++) {
|
|
130
|
+
set.add(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Report top clones
|
|
135
|
+
const topClones = filteredGroups.slice(0, 10);
|
|
136
|
+
for (const clone of topClones) {
|
|
137
|
+
const locs = clone.locations.slice(0, 3);
|
|
138
|
+
const locStr = locs.map(l => `${l.file}:${l.startLine}`).join(', ');
|
|
139
|
+
issues.push({
|
|
140
|
+
severity: clone.lineCount >= 15 ? 'warning' : 'info',
|
|
141
|
+
message: `duplicated ${clone.lineCount}-line block across ${clone.locations.length} files: ${locStr}`,
|
|
142
|
+
file: clone.locations[0].file,
|
|
143
|
+
line: clone.locations[0].startLine,
|
|
144
|
+
fixable: true,
|
|
145
|
+
fixHint: 'extract into a shared function or module',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const elapsed = Date.now() - t0;
|
|
149
|
+
// ── Scoring ───────────────────────────────────────────────────────────────
|
|
150
|
+
// Total duplicated lines as % of codebase
|
|
151
|
+
const totalSourceLines = sourceFiles.reduce((sum, f) => {
|
|
152
|
+
const content = readFile(join(cwd, f));
|
|
153
|
+
return sum + (content ? content.split('\n').length : 0);
|
|
154
|
+
}, 0);
|
|
155
|
+
// Count unique duplicated lines from covered ranges
|
|
156
|
+
let duplicatedLines = 0;
|
|
157
|
+
for (const lines of coveredRanges.values()) {
|
|
158
|
+
duplicatedLines += lines.size;
|
|
159
|
+
}
|
|
160
|
+
const duplicationRate = totalSourceLines > 0 ? duplicatedLines / totalSourceLines : 0;
|
|
161
|
+
const score = Math.max(25, Math.round(100 - duplicationRate * 400));
|
|
162
|
+
const parts = [];
|
|
163
|
+
parts.push(`${sourceFiles.length} files scanned in ${elapsed}ms`);
|
|
164
|
+
if (filteredGroups.length > 0) {
|
|
165
|
+
parts.push(c.yellow + `${filteredGroups.length} clone groups` + c.reset);
|
|
166
|
+
parts.push(`${duplicatedLines} duplicated lines (${(duplicationRate * 100).toFixed(1)}%)`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
parts.push('no cross-file clones detected');
|
|
170
|
+
}
|
|
171
|
+
return { name: 'clones', score, maxScore: 100, summary: parts.join(', '), issues };
|
|
172
|
+
}
|
|
@@ -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,8 @@ 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';
|
|
21
|
+
import { checkClones } from './checks/clones.js';
|
|
20
22
|
import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
|
|
21
23
|
import { checkMemory } from './checks/memory.js';
|
|
22
24
|
import { checkVerify } from './checks/verify.js';
|
|
@@ -325,7 +327,7 @@ async function runChecks() {
|
|
|
325
327
|
}
|
|
326
328
|
}
|
|
327
329
|
// 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([
|
|
330
|
+
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, clonesResult,] = await Promise.all([
|
|
329
331
|
withTimeout('scan', () => checkScan(cwd)),
|
|
330
332
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
331
333
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -351,6 +353,8 @@ async function runChecks() {
|
|
|
351
353
|
withTimeout('aiready', () => checkAIReady(cwd)),
|
|
352
354
|
withTimeout('deep', () => checkDeep(cwd), 60_000),
|
|
353
355
|
withTimeout('semantic', () => checkSemantic(cwd), 60_000),
|
|
356
|
+
withTimeout('hotspots', () => checkHotspots(cwd), 30_000),
|
|
357
|
+
withTimeout('clones', () => checkClones(cwd), 60_000),
|
|
354
358
|
]);
|
|
355
359
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
356
360
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -362,10 +366,11 @@ async function runChecks() {
|
|
|
362
366
|
return score(cwd, {
|
|
363
367
|
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
|
|
364
368
|
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
|
|
365
|
-
debt: [readyResult, historyResult, debtResult, bloatResult],
|
|
369
|
+
debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult],
|
|
366
370
|
deps: [depsResult],
|
|
367
371
|
architecture: [architectureResult],
|
|
368
372
|
aiready: [aireadyResult, deepResult, semanticResult],
|
|
373
|
+
history: [hotspotsResult],
|
|
369
374
|
});
|
|
370
375
|
}
|
|
371
376
|
catch (e) {
|
package/dist/scorer.d.ts
CHANGED
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[];
|