@vibecodeqa/cli 0.9.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/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/check-meta.d.ts +15 -0
- package/dist/check-meta.js +166 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +140 -0
- package/dist/detect.d.ts +8 -0
- package/dist/detect.js +67 -0
- package/dist/fs-utils.d.ts +23 -0
- package/dist/fs-utils.js +77 -0
- package/dist/report/html.d.ts +12 -0
- package/dist/report/html.js +400 -0
- package/dist/runners/architecture.d.ts +28 -0
- package/dist/runners/architecture.js +272 -0
- package/dist/runners/complexity.d.ts +3 -0
- package/dist/runners/complexity.js +152 -0
- package/dist/runners/confusion.d.ts +16 -0
- package/dist/runners/confusion.js +198 -0
- package/dist/runners/context.d.ts +15 -0
- package/dist/runners/context.js +200 -0
- package/dist/runners/coverage.d.ts +3 -0
- package/dist/runners/coverage.js +65 -0
- package/dist/runners/dependencies.d.ts +3 -0
- package/dist/runners/dependencies.js +106 -0
- package/dist/runners/docs.d.ts +3 -0
- package/dist/runners/docs.js +97 -0
- package/dist/runners/duplication.d.ts +3 -0
- package/dist/runners/duplication.js +100 -0
- package/dist/runners/exec.d.ts +6 -0
- package/dist/runners/exec.js +25 -0
- package/dist/runners/lint.d.ts +3 -0
- package/dist/runners/lint.js +78 -0
- package/dist/runners/secrets.d.ts +3 -0
- package/dist/runners/secrets.js +108 -0
- package/dist/runners/security.d.ts +3 -0
- package/dist/runners/security.js +121 -0
- package/dist/runners/standards.d.ts +3 -0
- package/dist/runners/standards.js +153 -0
- package/dist/runners/structure.d.ts +3 -0
- package/dist/runners/structure.js +110 -0
- package/dist/runners/testing.d.ts +12 -0
- package/dist/runners/testing.js +401 -0
- package/dist/runners/tests.d.ts +3 -0
- package/dist/runners/tests.js +54 -0
- package/dist/runners/type-safety.d.ts +3 -0
- package/dist/runners/type-safety.js +74 -0
- package/dist/runners/types-check.d.ts +3 -0
- package/dist/runners/types-check.js +44 -0
- package/dist/score.d.ts +6 -0
- package/dist/score.js +19 -0
- package/dist/trend.d.ts +19 -0
- package/dist/trend.js +63 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +12 -0
- package/package.json +53 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/** Architecture analysis — import graph, circular deps, coupling metrics, god modules.
|
|
2
|
+
*
|
|
3
|
+
* Produces:
|
|
4
|
+
* 1. Import graph (adjacency list)
|
|
5
|
+
* 2. Circular dependency detection
|
|
6
|
+
* 3. Fan-in / fan-out metrics per file (coupling)
|
|
7
|
+
* 4. God modules (imported by >50% of files)
|
|
8
|
+
* 5. Orphan files (not imported by anyone, not an entrypoint)
|
|
9
|
+
* 6. Layer violations (optional: detect cross-layer imports)
|
|
10
|
+
* 7. SVG architecture diagram
|
|
11
|
+
*/
|
|
12
|
+
import { basename, dirname, extname } from "node:path";
|
|
13
|
+
import { gradeFromScore } from "../types.js";
|
|
14
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
15
|
+
export function runArchitecture(cwd) {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
const issues = [];
|
|
18
|
+
const files = getProductionFiles(cwd);
|
|
19
|
+
if (files.length < 2) {
|
|
20
|
+
return { name: "architecture", score: 100, grade: "A", details: { skipped: true, reason: "fewer than 2 source files" }, issues: [], duration: Date.now() - start };
|
|
21
|
+
}
|
|
22
|
+
const graph = buildGraph(files);
|
|
23
|
+
// ── Circular dependencies ──
|
|
24
|
+
const cycles = findCycles(graph.nodes);
|
|
25
|
+
for (const cycle of cycles.slice(0, 5)) {
|
|
26
|
+
issues.push({ severity: "error", message: `Circular: ${cycle.map(short).join(" → ")}`, rule: "circular-dep" });
|
|
27
|
+
}
|
|
28
|
+
if (cycles.length > 5) {
|
|
29
|
+
issues.push({ severity: "error", message: `...and ${cycles.length - 5} more cycles`, rule: "circular-dep" });
|
|
30
|
+
}
|
|
31
|
+
// ── God modules (imported by >50% of files) ──
|
|
32
|
+
const threshold = Math.max(3, Math.floor(files.length * 0.5));
|
|
33
|
+
const godModules = [];
|
|
34
|
+
for (const [path, node] of graph.nodes) {
|
|
35
|
+
if (node.importedBy.length >= threshold) {
|
|
36
|
+
godModules.push(path);
|
|
37
|
+
issues.push({ severity: "warning", message: `God module: imported by ${node.importedBy.length}/${files.length} files — consider splitting`, file: path, rule: "god-module" });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// ── Orphan files (not imported by anyone) ──
|
|
41
|
+
const entrypoints = new Set(["index.ts", "index.tsx", "main.ts", "main.tsx", "cli.ts", "App.tsx", "App.ts"]);
|
|
42
|
+
const orphans = [];
|
|
43
|
+
for (const [path, node] of graph.nodes) {
|
|
44
|
+
const isEntry = entrypoints.has(basename(path));
|
|
45
|
+
if (node.importedBy.length === 0 && !isEntry) {
|
|
46
|
+
orphans.push(path);
|
|
47
|
+
issues.push({ severity: "warning", message: `Orphan: not imported by any file (dead module?)`, file: path, rule: "orphan-module" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ── High fan-out (file imports too many modules) ──
|
|
51
|
+
let highFanOut = 0;
|
|
52
|
+
for (const [path, node] of graph.nodes) {
|
|
53
|
+
if (node.imports.length > 10) {
|
|
54
|
+
highFanOut++;
|
|
55
|
+
issues.push({ severity: "warning", message: `High fan-out: imports ${node.imports.length} modules — hard to test in isolation`, file: path, rule: "high-fan-out" });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── High fan-in + fan-out (connector files) ──
|
|
59
|
+
let connectors = 0;
|
|
60
|
+
for (const [path, node] of graph.nodes) {
|
|
61
|
+
if (node.imports.length > 5 && node.importedBy.length > 5) {
|
|
62
|
+
connectors++;
|
|
63
|
+
issues.push({ severity: "warning", message: `Connector: ${node.imports.length} imports, ${node.importedBy.length} importers — high coupling`, file: path, rule: "connector-module" });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ── Score ──
|
|
67
|
+
const penalty = cycles.length * 15 + godModules.length * 5 + orphans.length * 2 + highFanOut * 3 + connectors * 4;
|
|
68
|
+
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
69
|
+
// ── Build details with graph data for visualization ──
|
|
70
|
+
const graphData = {};
|
|
71
|
+
for (const [path, node] of graph.nodes) {
|
|
72
|
+
graphData[path] = { imports: node.imports, importedBy: node.importedBy, dir: node.dir };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
name: "architecture",
|
|
76
|
+
score,
|
|
77
|
+
grade: gradeFromScore(score),
|
|
78
|
+
details: {
|
|
79
|
+
totalModules: graph.nodes.size,
|
|
80
|
+
circularDeps: cycles.length,
|
|
81
|
+
godModules: godModules.length,
|
|
82
|
+
orphans: orphans.length,
|
|
83
|
+
highFanOut,
|
|
84
|
+
connectors,
|
|
85
|
+
graph: graphData,
|
|
86
|
+
},
|
|
87
|
+
issues,
|
|
88
|
+
duration: Date.now() - start,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// ── Graph building ──
|
|
92
|
+
function buildGraph(files) {
|
|
93
|
+
const filePaths = new Set(files.map((f) => f.path));
|
|
94
|
+
const nodes = new Map();
|
|
95
|
+
// Initialize nodes
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
nodes.set(f.path, {
|
|
98
|
+
path: f.path,
|
|
99
|
+
imports: [],
|
|
100
|
+
importedBy: [],
|
|
101
|
+
dir: dirname(f.path),
|
|
102
|
+
exports: (f.content.match(/\bexport\s+/g) || []).length,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Parse imports and build edges
|
|
106
|
+
for (const f of files) {
|
|
107
|
+
const imports = parseImports(f.content);
|
|
108
|
+
const node = nodes.get(f.path);
|
|
109
|
+
for (const imp of imports) {
|
|
110
|
+
const resolved = resolveImport(f.path, imp, filePaths);
|
|
111
|
+
if (resolved && resolved !== f.path) {
|
|
112
|
+
node.imports.push(resolved);
|
|
113
|
+
const target = nodes.get(resolved);
|
|
114
|
+
if (target)
|
|
115
|
+
target.importedBy.push(f.path);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { nodes };
|
|
120
|
+
}
|
|
121
|
+
function parseImports(content) {
|
|
122
|
+
const imports = [];
|
|
123
|
+
const regex = /import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
124
|
+
let match;
|
|
125
|
+
while ((match = regex.exec(content)) !== null) {
|
|
126
|
+
if (match[1].startsWith("."))
|
|
127
|
+
imports.push(match[1]);
|
|
128
|
+
}
|
|
129
|
+
return imports;
|
|
130
|
+
}
|
|
131
|
+
function resolveImport(fromPath, importPath, knownFiles) {
|
|
132
|
+
const fromDir = dirname(fromPath);
|
|
133
|
+
let resolved = importPath;
|
|
134
|
+
if (importPath.startsWith("./")) {
|
|
135
|
+
resolved = fromDir ? `${fromDir}/${importPath.slice(2)}` : importPath.slice(2);
|
|
136
|
+
}
|
|
137
|
+
else if (importPath.startsWith("../")) {
|
|
138
|
+
const parts = fromDir.split("/");
|
|
139
|
+
let imp = importPath;
|
|
140
|
+
while (imp.startsWith("../")) {
|
|
141
|
+
parts.pop();
|
|
142
|
+
imp = imp.slice(3);
|
|
143
|
+
}
|
|
144
|
+
resolved = [...parts, imp].filter(Boolean).join("/");
|
|
145
|
+
}
|
|
146
|
+
// Strip .js/.ts extension
|
|
147
|
+
resolved = resolved.replace(/\.(js|ts|tsx|jsx)$/, "");
|
|
148
|
+
// Try known extensions
|
|
149
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
150
|
+
if (knownFiles.has(resolved + ext))
|
|
151
|
+
return resolved + ext;
|
|
152
|
+
}
|
|
153
|
+
// Try index
|
|
154
|
+
for (const ext of [".ts", ".tsx"]) {
|
|
155
|
+
if (knownFiles.has(resolved + "/index" + ext))
|
|
156
|
+
return resolved + "/index" + ext;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// ── Cycle detection (DFS with path tracking) ──
|
|
161
|
+
function findCycles(nodes) {
|
|
162
|
+
const cycles = [];
|
|
163
|
+
const visited = new Set();
|
|
164
|
+
const inStack = new Set();
|
|
165
|
+
const path = [];
|
|
166
|
+
const seen = new Set(); // dedup cycles
|
|
167
|
+
function dfs(node) {
|
|
168
|
+
if (inStack.has(node)) {
|
|
169
|
+
const cycleStart = path.indexOf(node);
|
|
170
|
+
if (cycleStart >= 0) {
|
|
171
|
+
const cycle = path.slice(cycleStart).map(short);
|
|
172
|
+
const key = [...cycle].sort().join(",");
|
|
173
|
+
if (!seen.has(key)) {
|
|
174
|
+
seen.add(key);
|
|
175
|
+
cycles.push([...cycle, short(node)]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (visited.has(node))
|
|
181
|
+
return;
|
|
182
|
+
visited.add(node);
|
|
183
|
+
inStack.add(node);
|
|
184
|
+
path.push(node);
|
|
185
|
+
const n = nodes.get(node);
|
|
186
|
+
if (n) {
|
|
187
|
+
for (const dep of n.imports) {
|
|
188
|
+
dfs(dep);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
path.pop();
|
|
192
|
+
inStack.delete(node);
|
|
193
|
+
}
|
|
194
|
+
for (const node of nodes.keys()) {
|
|
195
|
+
dfs(node);
|
|
196
|
+
}
|
|
197
|
+
return cycles;
|
|
198
|
+
}
|
|
199
|
+
function short(path) {
|
|
200
|
+
return basename(path, extname(path));
|
|
201
|
+
}
|
|
202
|
+
// ── SVG Architecture Diagram ──
|
|
203
|
+
export function generateArchSVG(details) {
|
|
204
|
+
const graph = details.graph;
|
|
205
|
+
if (!graph || Object.keys(graph).length === 0)
|
|
206
|
+
return "";
|
|
207
|
+
const nodes = Object.entries(graph);
|
|
208
|
+
const nodeCount = nodes.length;
|
|
209
|
+
if (nodeCount > 40)
|
|
210
|
+
return ""; // too many nodes to render meaningfully
|
|
211
|
+
// Group by directory
|
|
212
|
+
const dirs = new Map();
|
|
213
|
+
for (const [path, info] of nodes) {
|
|
214
|
+
const dir = info.dir || ".";
|
|
215
|
+
const arr = dirs.get(dir) || [];
|
|
216
|
+
arr.push(path);
|
|
217
|
+
dirs.set(dir, arr);
|
|
218
|
+
}
|
|
219
|
+
const W = 800, padding = 40;
|
|
220
|
+
const dirEntries = [...dirs.entries()];
|
|
221
|
+
const dirWidth = (W - padding * 2) / Math.max(dirEntries.length, 1);
|
|
222
|
+
// Position nodes
|
|
223
|
+
const positions = new Map();
|
|
224
|
+
let dirIdx = 0;
|
|
225
|
+
for (const [_d, paths] of dirEntries) {
|
|
226
|
+
const x0 = padding + dirIdx * dirWidth + dirWidth / 2;
|
|
227
|
+
for (let i = 0; i < paths.length; i++) {
|
|
228
|
+
const y = padding + 50 + i * 35;
|
|
229
|
+
positions.set(paths[i], { x: x0, y });
|
|
230
|
+
}
|
|
231
|
+
dirIdx++;
|
|
232
|
+
}
|
|
233
|
+
const H = Math.max(300, padding * 2 + 50 + Math.max(...[...dirs.values()].map((p) => p.length)) * 35 + 20);
|
|
234
|
+
// Draw edges
|
|
235
|
+
let edges = "";
|
|
236
|
+
for (const [path, info] of nodes) {
|
|
237
|
+
const from = positions.get(path);
|
|
238
|
+
if (!from)
|
|
239
|
+
continue;
|
|
240
|
+
for (const imp of info.imports) {
|
|
241
|
+
const to = positions.get(imp);
|
|
242
|
+
if (!to)
|
|
243
|
+
continue;
|
|
244
|
+
const color = info.dir !== graph[imp]?.dir ? "#ef444440" : "#818cf825";
|
|
245
|
+
edges += `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${color}" stroke-width="1.5"/>`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Draw directory groups
|
|
249
|
+
let groups = "";
|
|
250
|
+
dirIdx = 0;
|
|
251
|
+
for (const [dName, paths] of dirEntries) {
|
|
252
|
+
const x = padding + dirIdx * dirWidth;
|
|
253
|
+
const h = paths.length * 35 + 20;
|
|
254
|
+
groups += `<rect x="${x + 5}" y="${padding + 30}" width="${dirWidth - 10}" height="${h}" rx="8" fill="#14141a" stroke="#23232a"/>`;
|
|
255
|
+
groups += `<text x="${x + dirWidth / 2}" y="${padding + 22}" text-anchor="middle" fill="#6b7280" font-size="10" font-weight="600">${dName === "." ? "root" : dName.split("/").pop()}</text>`;
|
|
256
|
+
dirIdx++;
|
|
257
|
+
}
|
|
258
|
+
// Draw nodes
|
|
259
|
+
let nodesSvg = "";
|
|
260
|
+
for (const [path] of nodes) {
|
|
261
|
+
const pos = positions.get(path);
|
|
262
|
+
if (!pos)
|
|
263
|
+
continue;
|
|
264
|
+
const name = basename(path, extname(path));
|
|
265
|
+
const info = graph[path];
|
|
266
|
+
const fanIn = info.importedBy.length;
|
|
267
|
+
const size = Math.min(8, 3 + fanIn);
|
|
268
|
+
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${size}" fill="#818cf8"/>`;
|
|
269
|
+
nodesSvg += `<text x="${pos.x + size + 4}" y="${pos.y + 3}" fill="#e5e5e5" font-size="9">${name}</text>`;
|
|
270
|
+
}
|
|
271
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px;background:#09090b;border-radius:8px;border:1px solid #1e1e24">${groups}${edges}${nodesSvg}</svg>`;
|
|
272
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** Complexity analysis — counts lines, functions, and cognitive complexity via AST-free heuristics. */
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
const MAX_FUNCTION_LINES = 60;
|
|
6
|
+
const MAX_COMPLEXITY = 15;
|
|
7
|
+
export function runComplexity(cwd) {
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const issues = [];
|
|
10
|
+
const functions = [];
|
|
11
|
+
// Find all TS/JS source files (not tests, not node_modules)
|
|
12
|
+
const srcDirs = ["src", "web/src"];
|
|
13
|
+
const files = [];
|
|
14
|
+
for (const dir of srcDirs) {
|
|
15
|
+
try {
|
|
16
|
+
collectFiles(join(cwd, dir), files);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* dir doesn't exist */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
let totalLines = 0;
|
|
23
|
+
const totalFiles = files.length;
|
|
24
|
+
let longFunctions = 0;
|
|
25
|
+
let complexFunctions = 0;
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const content = readFileSync(file, "utf-8");
|
|
28
|
+
const lines = content.split("\n");
|
|
29
|
+
totalLines += lines.length;
|
|
30
|
+
// Simple heuristic: find function boundaries and measure complexity
|
|
31
|
+
const funcs = extractFunctions(content, file.replace(cwd + "/", ""));
|
|
32
|
+
for (const f of funcs) {
|
|
33
|
+
functions.push(f);
|
|
34
|
+
if (f.lines > MAX_FUNCTION_LINES) {
|
|
35
|
+
longFunctions++;
|
|
36
|
+
issues.push({
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `${f.name}: ${f.lines} lines (max ${MAX_FUNCTION_LINES})`,
|
|
39
|
+
file: f.file,
|
|
40
|
+
rule: "long-function",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (f.complexity > MAX_COMPLEXITY) {
|
|
44
|
+
complexFunctions++;
|
|
45
|
+
issues.push({
|
|
46
|
+
severity: "warning",
|
|
47
|
+
message: `${f.name}: complexity ${f.complexity} (max ${MAX_COMPLEXITY})`,
|
|
48
|
+
file: f.file,
|
|
49
|
+
rule: "high-complexity",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Score: start at 100, -3 per long function, -5 per complex function
|
|
55
|
+
const score = Math.max(0, Math.min(100, 100 - longFunctions * 3 - complexFunctions * 5));
|
|
56
|
+
return {
|
|
57
|
+
name: "complexity",
|
|
58
|
+
score,
|
|
59
|
+
grade: gradeFromScore(score),
|
|
60
|
+
details: {
|
|
61
|
+
totalFiles,
|
|
62
|
+
totalLines,
|
|
63
|
+
longFunctions,
|
|
64
|
+
complexFunctions,
|
|
65
|
+
functionCount: functions.length,
|
|
66
|
+
},
|
|
67
|
+
issues,
|
|
68
|
+
duration: Date.now() - start,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function collectFiles(dir, out) {
|
|
72
|
+
for (const entry of readdirSync(dir)) {
|
|
73
|
+
if (entry === "node_modules" || entry === "dist" || entry === ".git")
|
|
74
|
+
continue;
|
|
75
|
+
const full = join(dir, entry);
|
|
76
|
+
const stat = statSync(full);
|
|
77
|
+
if (stat.isDirectory()) {
|
|
78
|
+
collectFiles(full, out);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const ext = extname(entry);
|
|
82
|
+
if ((ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") &&
|
|
83
|
+
!entry.includes(".test.") &&
|
|
84
|
+
!entry.includes(".spec.")) {
|
|
85
|
+
out.push(full);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Simple heuristic function extraction — not a full AST parser but good enough for metrics. */
|
|
91
|
+
function extractFunctions(content, filePath) {
|
|
92
|
+
const funcs = [];
|
|
93
|
+
const lines = content.split("\n");
|
|
94
|
+
// Track brace nesting
|
|
95
|
+
let funcStart = -1;
|
|
96
|
+
let funcName = "";
|
|
97
|
+
let braceCount = 0;
|
|
98
|
+
for (let i = 0; i < lines.length; i++) {
|
|
99
|
+
const line = lines[i];
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
// Detect function start
|
|
102
|
+
if (funcStart === -1) {
|
|
103
|
+
const match = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/) ||
|
|
104
|
+
trimmed.match(/^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/) ||
|
|
105
|
+
trimmed.match(/^(?:private|public|protected)?\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*\w[^{]*)?\{/) ||
|
|
106
|
+
trimmed.match(/^(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/);
|
|
107
|
+
if (match) {
|
|
108
|
+
funcStart = i;
|
|
109
|
+
funcName = match[1] || "anonymous";
|
|
110
|
+
braceCount = 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Track brace depth
|
|
114
|
+
if (funcStart !== -1) {
|
|
115
|
+
for (const ch of line) {
|
|
116
|
+
if (ch === "{")
|
|
117
|
+
braceCount++;
|
|
118
|
+
if (ch === "}")
|
|
119
|
+
braceCount--;
|
|
120
|
+
}
|
|
121
|
+
if (braceCount <= 0 && i > funcStart) {
|
|
122
|
+
const funcLines = i - funcStart + 1;
|
|
123
|
+
const funcContent = lines.slice(funcStart, i + 1).join("\n");
|
|
124
|
+
funcs.push({
|
|
125
|
+
file: filePath,
|
|
126
|
+
name: funcName,
|
|
127
|
+
lines: funcLines,
|
|
128
|
+
complexity: measureComplexity(funcContent),
|
|
129
|
+
});
|
|
130
|
+
funcStart = -1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return funcs;
|
|
135
|
+
}
|
|
136
|
+
/** Heuristic cognitive complexity — counts nesting and branching. */
|
|
137
|
+
function measureComplexity(code) {
|
|
138
|
+
let complexity = 0;
|
|
139
|
+
const lines = code.split("\n");
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
const trimmed = line.trim();
|
|
142
|
+
// +1 for each branch/loop keyword
|
|
143
|
+
if (/\b(if|else if|else|switch|for|while|do|catch|&&|\|\||[?]:)\b/.test(trimmed)) {
|
|
144
|
+
complexity++;
|
|
145
|
+
}
|
|
146
|
+
// +1 for ternary
|
|
147
|
+
if (/\?.*:/.test(trimmed) && !trimmed.startsWith("//")) {
|
|
148
|
+
complexity++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return complexity;
|
|
152
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Confusion Index — measures naming ambiguity that causes LLMs to misunderstand or edit the wrong code.
|
|
2
|
+
*
|
|
3
|
+
* Research backing:
|
|
4
|
+
* - "When Names Disappear" (arXiv:2510.03178): GPT-4o drops 28.6 pts on summarization with obfuscated names
|
|
5
|
+
* - "How Does Naming Affect LLMs?" (Wang et al. 2024): LLMs heavily rely on well-defined names
|
|
6
|
+
* - Linguistic Antipatterns (Arnaoudova et al.): 17-pattern catalog, 69% devs perceive as harmful
|
|
7
|
+
* - "Variable Naming Impact" (2024): descriptive names = 0.874 semantic similarity vs 0.802 obfuscated
|
|
8
|
+
*
|
|
9
|
+
* Four sub-checks:
|
|
10
|
+
* 1. File name confusability (near-identical or synonym filenames)
|
|
11
|
+
* 2. Generic function/variable names (process, handle, data, result, etc.)
|
|
12
|
+
* 3. Export name collisions (same name exported from multiple files)
|
|
13
|
+
* 4. Ambiguous abbreviations (auth, config, ctx, etc. outside conventional scope)
|
|
14
|
+
*/
|
|
15
|
+
import type { CheckResult } from "../types.js";
|
|
16
|
+
export declare function runConfusion(cwd: string): CheckResult;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Confusion Index — measures naming ambiguity that causes LLMs to misunderstand or edit the wrong code.
|
|
2
|
+
*
|
|
3
|
+
* Research backing:
|
|
4
|
+
* - "When Names Disappear" (arXiv:2510.03178): GPT-4o drops 28.6 pts on summarization with obfuscated names
|
|
5
|
+
* - "How Does Naming Affect LLMs?" (Wang et al. 2024): LLMs heavily rely on well-defined names
|
|
6
|
+
* - Linguistic Antipatterns (Arnaoudova et al.): 17-pattern catalog, 69% devs perceive as harmful
|
|
7
|
+
* - "Variable Naming Impact" (2024): descriptive names = 0.874 semantic similarity vs 0.802 obfuscated
|
|
8
|
+
*
|
|
9
|
+
* Four sub-checks:
|
|
10
|
+
* 1. File name confusability (near-identical or synonym filenames)
|
|
11
|
+
* 2. Generic function/variable names (process, handle, data, result, etc.)
|
|
12
|
+
* 3. Export name collisions (same name exported from multiple files)
|
|
13
|
+
* 4. Ambiguous abbreviations (auth, config, ctx, etc. outside conventional scope)
|
|
14
|
+
*/
|
|
15
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { basename, extname, join } from "node:path";
|
|
17
|
+
import { gradeFromScore } from "../types.js";
|
|
18
|
+
// ── Pattern dictionaries ──
|
|
19
|
+
const SYNONYM_PAIRS = [
|
|
20
|
+
["utils", "helpers"], ["util", "helper"],
|
|
21
|
+
["types", "interfaces"], ["types", "models"], ["interfaces", "models"],
|
|
22
|
+
["constants", "config"], ["constants", "settings"], ["config", "settings"],
|
|
23
|
+
["service", "controller"], ["service", "handler"], ["controller", "handler"],
|
|
24
|
+
["store", "state"], ["context", "provider"],
|
|
25
|
+
];
|
|
26
|
+
const GENERIC_NAMES = new Set([
|
|
27
|
+
"process", "handle", "run", "execute", "do", "perform", "make",
|
|
28
|
+
"get", "set", "update", "create", "delete", "remove", "add",
|
|
29
|
+
"data", "result", "item", "value", "info", "temp", "obj",
|
|
30
|
+
"stuff", "thing", "ret", "val", "res", "output", "input",
|
|
31
|
+
"response", "request", "payload", "body", "args", "params",
|
|
32
|
+
"list", "arr", "map", "dict", "collection",
|
|
33
|
+
"callback", "cb", "fn", "func", "handler",
|
|
34
|
+
]);
|
|
35
|
+
const AMBIGUOUS_ABBREVS = {
|
|
36
|
+
auth: "authentication OR authorization",
|
|
37
|
+
config: "which configuration?",
|
|
38
|
+
env: "environment (runtime? deployment? variables?)",
|
|
39
|
+
db: "database (connection? schema? query builder?)",
|
|
40
|
+
msg: "message (type? content? handler?)",
|
|
41
|
+
ctx: "context (React? request? app?)",
|
|
42
|
+
err: "error (type? instance? handler?)",
|
|
43
|
+
mgr: "manager (of what?)",
|
|
44
|
+
svc: "service (which service?)",
|
|
45
|
+
srv: "server (HTTP? WebSocket? instance?)",
|
|
46
|
+
req: "request (HTTP? specific type?)",
|
|
47
|
+
res: "response or result?",
|
|
48
|
+
cmd: "command (CLI? pattern? string?)",
|
|
49
|
+
evt: "event (DOM? custom? type?)",
|
|
50
|
+
ref: "reference (React ref? DB ref? pointer?)",
|
|
51
|
+
};
|
|
52
|
+
export function runConfusion(cwd) {
|
|
53
|
+
const start = Date.now();
|
|
54
|
+
const issues = [];
|
|
55
|
+
const files = [];
|
|
56
|
+
const dirs = ["src", "web/src"];
|
|
57
|
+
for (const dir of dirs) {
|
|
58
|
+
try {
|
|
59
|
+
collectFiles(join(cwd, dir), cwd, files);
|
|
60
|
+
}
|
|
61
|
+
catch { /* dir doesn't exist */ }
|
|
62
|
+
}
|
|
63
|
+
if (files.length === 0) {
|
|
64
|
+
return { name: "confusion", score: 100, grade: "A", details: { skipped: true, reason: "no source files" }, issues: [], duration: Date.now() - start };
|
|
65
|
+
}
|
|
66
|
+
let fileConfusability = 0;
|
|
67
|
+
let genericNames = 0;
|
|
68
|
+
let exportCollisions = 0;
|
|
69
|
+
let ambiguousAbbrevs = 0;
|
|
70
|
+
// ── 1. File name confusability ──
|
|
71
|
+
for (let i = 0; i < files.length; i++) {
|
|
72
|
+
for (let j = i + 1; j < files.length; j++) {
|
|
73
|
+
const a = files[i].base;
|
|
74
|
+
const b = files[j].base;
|
|
75
|
+
// Near-identical (Levenshtein ≤ 2)
|
|
76
|
+
if (a !== b && levenshtein(a, b) <= 2) {
|
|
77
|
+
fileConfusability++;
|
|
78
|
+
issues.push({ severity: "warning", message: `Similar filenames: ${a} ↔ ${b} (edit distance ${levenshtein(a, b)})`, file: files[i].path, rule: "similar-filename" });
|
|
79
|
+
}
|
|
80
|
+
// Synonym pairs
|
|
81
|
+
for (const [s1, s2] of SYNONYM_PAIRS) {
|
|
82
|
+
if ((a.includes(s1) && b.includes(s2)) || (a.includes(s2) && b.includes(s1))) {
|
|
83
|
+
fileConfusability++;
|
|
84
|
+
issues.push({ severity: "warning", message: `Synonym filenames: ${a} ↔ ${b} (${s1}/${s2} are interchangeable — pick one convention)`, file: files[i].path, rule: "synonym-filename" });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ── 2. Generic function/variable names ──
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
const lines = f.content.split("\n");
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i].trim();
|
|
95
|
+
if (line.startsWith("//") || line.startsWith("*"))
|
|
96
|
+
continue;
|
|
97
|
+
// Match exported function names
|
|
98
|
+
const funcMatch = line.match(/^export\s+(?:async\s+)?function\s+(\w+)/);
|
|
99
|
+
if (funcMatch && GENERIC_NAMES.has(funcMatch[1].toLowerCase())) {
|
|
100
|
+
genericNames++;
|
|
101
|
+
issues.push({ severity: "warning", message: `Generic export name: ${funcMatch[1]}() — not descriptive enough for LLM comprehension`, file: f.path, line: i + 1, rule: "generic-name" });
|
|
102
|
+
}
|
|
103
|
+
// Match standalone variable assignments with generic names
|
|
104
|
+
const varMatch = line.match(/^(?:export\s+)?(?:const|let)\s+(\w+)\s*=/);
|
|
105
|
+
if (varMatch && GENERIC_NAMES.has(varMatch[1].toLowerCase()) && varMatch[1].length <= 6) {
|
|
106
|
+
genericNames++;
|
|
107
|
+
issues.push({ severity: "warning", message: `Generic variable: ${varMatch[1]} — use a descriptive name`, file: f.path, line: i + 1, rule: "generic-name" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ── 3. Export name collisions ──
|
|
112
|
+
const exportMap = new Map();
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
for (const exp of f.exports) {
|
|
115
|
+
const arr = exportMap.get(exp) || [];
|
|
116
|
+
arr.push(f.path);
|
|
117
|
+
exportMap.set(exp, arr);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const [name, paths] of exportMap) {
|
|
121
|
+
if (paths.length > 1) {
|
|
122
|
+
exportCollisions++;
|
|
123
|
+
issues.push({ severity: "error", message: `Export collision: "${name}" exported from ${paths.length} files — LLMs may reference the wrong one`, file: paths.join(", "), rule: "export-collision" });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// ── 4. Ambiguous abbreviations in filenames and exports ──
|
|
127
|
+
for (const f of files) {
|
|
128
|
+
const nameParts = f.base.split(/[-_./]/).map((s) => s.toLowerCase());
|
|
129
|
+
for (const part of nameParts) {
|
|
130
|
+
if (AMBIGUOUS_ABBREVS[part]) {
|
|
131
|
+
ambiguousAbbrevs++;
|
|
132
|
+
issues.push({ severity: "warning", message: `Ambiguous abbreviation "${part}" in filename — could mean: ${AMBIGUOUS_ABBREVS[part]}`, file: f.path, rule: "ambiguous-abbreviation" });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ── Score ──
|
|
137
|
+
const penalty = fileConfusability * 5 + genericNames * 1 + exportCollisions * 10 + ambiguousAbbrevs * 2;
|
|
138
|
+
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
139
|
+
return {
|
|
140
|
+
name: "confusion",
|
|
141
|
+
score,
|
|
142
|
+
grade: gradeFromScore(score),
|
|
143
|
+
details: { fileConfusability, genericNames, exportCollisions, ambiguousAbbrevs, filesScanned: files.length },
|
|
144
|
+
issues,
|
|
145
|
+
duration: Date.now() - start,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// ── Helpers ──
|
|
149
|
+
function levenshtein(a, b) {
|
|
150
|
+
const m = a.length, n = b.length;
|
|
151
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
152
|
+
for (let i = 0; i <= m; i++)
|
|
153
|
+
dp[i][0] = i;
|
|
154
|
+
for (let j = 0; j <= n; j++)
|
|
155
|
+
dp[0][j] = j;
|
|
156
|
+
for (let i = 1; i <= m; i++) {
|
|
157
|
+
for (let j = 1; j <= n; j++) {
|
|
158
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return dp[m][n];
|
|
162
|
+
}
|
|
163
|
+
function extractExports(content) {
|
|
164
|
+
const exports = [];
|
|
165
|
+
const patterns = [
|
|
166
|
+
/export\s+(?:async\s+)?function\s+(\w+)/g,
|
|
167
|
+
/export\s+(?:const|let|var)\s+(\w+)/g,
|
|
168
|
+
/export\s+class\s+(\w+)/g,
|
|
169
|
+
/export\s+interface\s+(\w+)/g,
|
|
170
|
+
/export\s+type\s+(\w+)/g,
|
|
171
|
+
];
|
|
172
|
+
for (const pat of patterns) {
|
|
173
|
+
let match;
|
|
174
|
+
while ((match = pat.exec(content)) !== null) {
|
|
175
|
+
exports.push(match[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return exports;
|
|
179
|
+
}
|
|
180
|
+
function collectFiles(dir, cwd, out) {
|
|
181
|
+
for (const entry of readdirSync(dir)) {
|
|
182
|
+
if (entry === "node_modules" || entry === "dist" || entry === ".git")
|
|
183
|
+
continue;
|
|
184
|
+
const full = join(dir, entry);
|
|
185
|
+
if (statSync(full).isDirectory()) {
|
|
186
|
+
collectFiles(full, cwd, out);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const ext = extname(entry);
|
|
190
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext) && !entry.includes(".test.") && !entry.includes(".spec.")) {
|
|
191
|
+
const content = readFileSync(full, "utf-8");
|
|
192
|
+
const relPath = full.replace(cwd + "/", "");
|
|
193
|
+
const base = basename(entry, ext);
|
|
194
|
+
out.push({ path: relPath, base, content, exports: extractExports(content) });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Context Locality — measures how self-contained code is for LLM consumption.
|
|
2
|
+
*
|
|
3
|
+
* Research backing:
|
|
4
|
+
* - "Lost in the Middle" (Liu et al. 2023): 30%+ accuracy drop for mid-context info
|
|
5
|
+
* - "Context Rot" (Chroma 2025): all frontier models degrade with input length
|
|
6
|
+
* - "Codified Context" (Vassilev 2025): 108K-line codebase needs 24.2% context overhead
|
|
7
|
+
*
|
|
8
|
+
* Sub-checks:
|
|
9
|
+
* 1. Import depth — how many files does each file transitively depend on?
|
|
10
|
+
* 2. Token density — estimated tokens per file (high = expensive for LLM context)
|
|
11
|
+
* 3. File self-containment — ratio of local symbols to imported symbols
|
|
12
|
+
* 4. Circular dependencies — import cycles that confuse navigation
|
|
13
|
+
*/
|
|
14
|
+
import type { CheckResult } from "../types.js";
|
|
15
|
+
export declare function runContext(cwd: string): CheckResult;
|