@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,200 @@
|
|
|
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 { readdirSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { extname, join } from "node:path";
|
|
16
|
+
import { gradeFromScore } from "../types.js";
|
|
17
|
+
const MAX_FILE_TOKENS = 4000; // ~400 lines; beyond this LLMs lose mid-context info
|
|
18
|
+
const MAX_IMPORTS = 15; // files importing >15 modules are hard to reason about
|
|
19
|
+
const CHARS_PER_TOKEN = 3.5; // empirical average for code
|
|
20
|
+
export function runContext(cwd) {
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
const issues = [];
|
|
23
|
+
// Collect source files with imports
|
|
24
|
+
const files = [];
|
|
25
|
+
const dirs = ["src", "web/src"];
|
|
26
|
+
for (const dir of dirs) {
|
|
27
|
+
try {
|
|
28
|
+
collectFiles(join(cwd, dir), cwd, files);
|
|
29
|
+
}
|
|
30
|
+
catch { /* dir doesn't exist */ }
|
|
31
|
+
}
|
|
32
|
+
if (files.length === 0) {
|
|
33
|
+
return { name: "context", score: 100, grade: "A", details: { skipped: true, reason: "no source files" }, issues: [], duration: Date.now() - start };
|
|
34
|
+
}
|
|
35
|
+
let highTokenFiles = 0;
|
|
36
|
+
let heavyImportFiles = 0;
|
|
37
|
+
let circularDeps = 0;
|
|
38
|
+
let totalImports = 0;
|
|
39
|
+
let totalTokens = 0;
|
|
40
|
+
// ── 1. Token density per file ──
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
totalTokens += f.tokens;
|
|
43
|
+
if (f.tokens > MAX_FILE_TOKENS) {
|
|
44
|
+
highTokenFiles++;
|
|
45
|
+
issues.push({ severity: "warning", message: `~${f.tokens} tokens (>${MAX_FILE_TOKENS}) — large context cost for LLMs`, file: f.path, rule: "high-token-count" });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ── 2. Import count per file ──
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
totalImports += f.imports.length;
|
|
51
|
+
if (f.imports.length > MAX_IMPORTS) {
|
|
52
|
+
heavyImportFiles++;
|
|
53
|
+
issues.push({ severity: "warning", message: `${f.imports.length} imports (>${MAX_IMPORTS}) — consider splitting or co-locating`, file: f.path, rule: "heavy-imports" });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// ── 3. Circular dependency detection ──
|
|
57
|
+
const importGraph = new Map();
|
|
58
|
+
for (const f of files) {
|
|
59
|
+
const deps = new Set();
|
|
60
|
+
for (const imp of f.imports) {
|
|
61
|
+
// Resolve relative import to file path
|
|
62
|
+
const resolved = resolveImport(f.path, imp);
|
|
63
|
+
if (resolved && files.some((ff) => ff.path === resolved)) {
|
|
64
|
+
deps.add(resolved);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
importGraph.set(f.path, deps);
|
|
68
|
+
}
|
|
69
|
+
const cycles = findCycles(importGraph);
|
|
70
|
+
circularDeps = cycles.length;
|
|
71
|
+
for (const cycle of cycles.slice(0, 5)) {
|
|
72
|
+
issues.push({ severity: "error", message: `Circular dependency: ${cycle.join(" → ")}`, rule: "circular-dependency" });
|
|
73
|
+
}
|
|
74
|
+
if (cycles.length > 5) {
|
|
75
|
+
issues.push({ severity: "error", message: `...and ${cycles.length - 5} more circular dependencies`, rule: "circular-dependency" });
|
|
76
|
+
}
|
|
77
|
+
// ── 4. Self-containment heuristic ──
|
|
78
|
+
// Files with many imports and few exports are "context sinks" — hard to understand in isolation
|
|
79
|
+
let contextSinks = 0;
|
|
80
|
+
for (const f of files) {
|
|
81
|
+
const exportCount = (f.content.match(/\bexport\s+/g) || []).length;
|
|
82
|
+
if (f.imports.length > 8 && exportCount <= 1) {
|
|
83
|
+
contextSinks++;
|
|
84
|
+
issues.push({ severity: "warning", message: `${f.imports.length} imports but only ${exportCount} export — hard to understand in isolation`, file: f.path, rule: "context-sink" });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ── Score ──
|
|
88
|
+
const avgImports = files.length > 0 ? totalImports / files.length : 0;
|
|
89
|
+
const avgTokens = files.length > 0 ? totalTokens / files.length : 0;
|
|
90
|
+
const penalty = highTokenFiles * 5 + heavyImportFiles * 3 + circularDeps * 15 + contextSinks * 2;
|
|
91
|
+
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
92
|
+
return {
|
|
93
|
+
name: "context",
|
|
94
|
+
score,
|
|
95
|
+
grade: gradeFromScore(score),
|
|
96
|
+
details: {
|
|
97
|
+
filesScanned: files.length,
|
|
98
|
+
totalTokens,
|
|
99
|
+
avgTokensPerFile: Math.round(avgTokens),
|
|
100
|
+
avgImportsPerFile: Math.round(avgImports * 10) / 10,
|
|
101
|
+
highTokenFiles,
|
|
102
|
+
heavyImportFiles,
|
|
103
|
+
circularDeps,
|
|
104
|
+
contextSinks,
|
|
105
|
+
},
|
|
106
|
+
issues,
|
|
107
|
+
duration: Date.now() - start,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// ── Import parsing ──
|
|
111
|
+
function parseImports(content) {
|
|
112
|
+
const imports = [];
|
|
113
|
+
const regex = /import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
114
|
+
let match;
|
|
115
|
+
while ((match = regex.exec(content)) !== null) {
|
|
116
|
+
const path = match[1];
|
|
117
|
+
// Only count local imports (starting with . or /)
|
|
118
|
+
if (path.startsWith(".") || path.startsWith("/")) {
|
|
119
|
+
imports.push(path);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return imports;
|
|
123
|
+
}
|
|
124
|
+
function resolveImport(fromPath, importPath) {
|
|
125
|
+
// Simple resolution: join directory of fromPath with importPath
|
|
126
|
+
const dir = fromPath.includes("/") ? fromPath.replace(/\/[^/]+$/, "") : "";
|
|
127
|
+
let resolved = importPath;
|
|
128
|
+
if (importPath.startsWith("./")) {
|
|
129
|
+
resolved = dir ? `${dir}/${importPath.slice(2)}` : importPath.slice(2);
|
|
130
|
+
}
|
|
131
|
+
else if (importPath.startsWith("../")) {
|
|
132
|
+
const parts = dir.split("/");
|
|
133
|
+
parts.pop();
|
|
134
|
+
resolved = [...parts, importPath.slice(3)].join("/");
|
|
135
|
+
}
|
|
136
|
+
// Strip extension and try common ones
|
|
137
|
+
resolved = resolved.replace(/\.(js|ts|tsx|jsx)$/, "");
|
|
138
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx"];
|
|
139
|
+
for (const ext of extensions) {
|
|
140
|
+
if (resolved.endsWith(ext.replace(".", "")))
|
|
141
|
+
return resolved;
|
|
142
|
+
}
|
|
143
|
+
// Return with .ts as default assumption
|
|
144
|
+
return resolved + ".ts";
|
|
145
|
+
}
|
|
146
|
+
// ── Cycle detection (DFS) ──
|
|
147
|
+
function findCycles(graph) {
|
|
148
|
+
const cycles = [];
|
|
149
|
+
const visited = new Set();
|
|
150
|
+
const inStack = new Set();
|
|
151
|
+
const path = [];
|
|
152
|
+
function dfs(node) {
|
|
153
|
+
if (inStack.has(node)) {
|
|
154
|
+
// Found cycle — extract it
|
|
155
|
+
const cycleStart = path.indexOf(node);
|
|
156
|
+
if (cycleStart >= 0) {
|
|
157
|
+
cycles.push([...path.slice(cycleStart), node]);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (visited.has(node))
|
|
162
|
+
return;
|
|
163
|
+
visited.add(node);
|
|
164
|
+
inStack.add(node);
|
|
165
|
+
path.push(node);
|
|
166
|
+
const deps = graph.get(node);
|
|
167
|
+
if (deps) {
|
|
168
|
+
for (const dep of deps) {
|
|
169
|
+
dfs(dep);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
path.pop();
|
|
173
|
+
inStack.delete(node);
|
|
174
|
+
}
|
|
175
|
+
for (const node of graph.keys()) {
|
|
176
|
+
dfs(node);
|
|
177
|
+
}
|
|
178
|
+
return cycles;
|
|
179
|
+
}
|
|
180
|
+
// ── File collection ──
|
|
181
|
+
function collectFiles(dir, cwd, out) {
|
|
182
|
+
for (const entry of readdirSync(dir)) {
|
|
183
|
+
if (entry === "node_modules" || entry === "dist" || entry === ".git")
|
|
184
|
+
continue;
|
|
185
|
+
const full = join(dir, entry);
|
|
186
|
+
if (statSync(full).isDirectory()) {
|
|
187
|
+
collectFiles(full, cwd, out);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const ext = extname(entry);
|
|
191
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext) && !entry.includes(".test.") && !entry.includes(".spec.")) {
|
|
192
|
+
const content = readFileSync(full, "utf-8");
|
|
193
|
+
const relPath = full.replace(cwd + "/", "");
|
|
194
|
+
const imports = parseImports(content);
|
|
195
|
+
const tokens = Math.round(content.length / CHARS_PER_TOKEN);
|
|
196
|
+
out.push({ path: relPath, content, imports, tokens });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Coverage runner — runs tests with coverage and parses the summary. */
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
export function runCoverage(cwd, stack) {
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
if (stack.testRunner === "none") {
|
|
9
|
+
return {
|
|
10
|
+
name: "coverage",
|
|
11
|
+
score: 0,
|
|
12
|
+
grade: "F",
|
|
13
|
+
details: { skipped: true, reason: "no test runner" },
|
|
14
|
+
issues: [],
|
|
15
|
+
duration: Date.now() - start,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// Run tests with coverage
|
|
19
|
+
const cmd = stack.testRunner === "vitest"
|
|
20
|
+
? "npx vitest run --coverage 2>/dev/null || true"
|
|
21
|
+
: "npx jest --coverage --coverageReporters=json-summary 2>/dev/null || true";
|
|
22
|
+
run(cmd, cwd, 120_000);
|
|
23
|
+
// Look for coverage summary
|
|
24
|
+
const searchPaths = [
|
|
25
|
+
"coverage/coverage-summary.json",
|
|
26
|
+
"test-results/coverage/coverage-summary.json",
|
|
27
|
+
];
|
|
28
|
+
let summary = null;
|
|
29
|
+
for (const p of searchPaths) {
|
|
30
|
+
const full = join(cwd, p);
|
|
31
|
+
if (existsSync(full)) {
|
|
32
|
+
try {
|
|
33
|
+
summary = JSON.parse(readFileSync(full, "utf-8"));
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* parse failed */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!summary?.total) {
|
|
42
|
+
return {
|
|
43
|
+
name: "coverage",
|
|
44
|
+
score: 0,
|
|
45
|
+
grade: "F",
|
|
46
|
+
details: { skipped: true, reason: "no coverage data generated" },
|
|
47
|
+
issues: [],
|
|
48
|
+
duration: Date.now() - start,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const stmts = summary.total.statements?.pct || 0;
|
|
52
|
+
const lines = summary.total.lines?.pct || 0;
|
|
53
|
+
const branches = summary.total.branches?.pct || 0;
|
|
54
|
+
const functions = summary.total.functions?.pct || 0;
|
|
55
|
+
// Score is the average of all four metrics
|
|
56
|
+
const score = Math.round((stmts + lines + branches + functions) / 4);
|
|
57
|
+
return {
|
|
58
|
+
name: "coverage",
|
|
59
|
+
score,
|
|
60
|
+
grade: gradeFromScore(score),
|
|
61
|
+
details: { statements: stmts, lines, branches, functions },
|
|
62
|
+
issues: [],
|
|
63
|
+
duration: Date.now() - start,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Dependency health — vulnerabilities, outdated packages. */
|
|
2
|
+
import { gradeFromScore } from "../types.js";
|
|
3
|
+
import { run } from "./exec.js";
|
|
4
|
+
export function runDependencies(cwd, stack) {
|
|
5
|
+
const start = Date.now();
|
|
6
|
+
const issues = [];
|
|
7
|
+
const pm = stack.packageManager;
|
|
8
|
+
// Vulnerability audit
|
|
9
|
+
const auditCmd = pm === "pnpm"
|
|
10
|
+
? "pnpm audit --json"
|
|
11
|
+
: pm === "yarn"
|
|
12
|
+
? "yarn audit --json"
|
|
13
|
+
: "npm audit --json";
|
|
14
|
+
const auditResult = run(auditCmd + " 2>/dev/null || true", cwd);
|
|
15
|
+
let vulnCritical = 0, vulnHigh = 0, vulnModerate = 0, vulnLow = 0;
|
|
16
|
+
try {
|
|
17
|
+
const audit = JSON.parse(auditResult.stdout);
|
|
18
|
+
// npm audit format
|
|
19
|
+
if (audit.metadata?.vulnerabilities) {
|
|
20
|
+
const v = audit.metadata.vulnerabilities;
|
|
21
|
+
vulnCritical = v.critical || 0;
|
|
22
|
+
vulnHigh = v.high || 0;
|
|
23
|
+
vulnModerate = v.moderate || 0;
|
|
24
|
+
vulnLow = v.low || 0;
|
|
25
|
+
}
|
|
26
|
+
// pnpm audit format
|
|
27
|
+
if (audit.advisories) {
|
|
28
|
+
for (const adv of Object.values(audit.advisories)) {
|
|
29
|
+
if (adv.severity === "critical")
|
|
30
|
+
vulnCritical++;
|
|
31
|
+
else if (adv.severity === "high")
|
|
32
|
+
vulnHigh++;
|
|
33
|
+
else if (adv.severity === "moderate")
|
|
34
|
+
vulnModerate++;
|
|
35
|
+
else
|
|
36
|
+
vulnLow++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* audit parse failed — might be clean */
|
|
42
|
+
}
|
|
43
|
+
if (vulnCritical > 0)
|
|
44
|
+
issues.push({
|
|
45
|
+
severity: "error",
|
|
46
|
+
message: `${vulnCritical} critical vulnerabilities`,
|
|
47
|
+
});
|
|
48
|
+
if (vulnHigh > 0)
|
|
49
|
+
issues.push({
|
|
50
|
+
severity: "error",
|
|
51
|
+
message: `${vulnHigh} high vulnerabilities`,
|
|
52
|
+
});
|
|
53
|
+
if (vulnModerate > 0)
|
|
54
|
+
issues.push({
|
|
55
|
+
severity: "warning",
|
|
56
|
+
message: `${vulnModerate} moderate vulnerabilities`,
|
|
57
|
+
});
|
|
58
|
+
// Outdated check
|
|
59
|
+
const outdatedCmd = pm === "pnpm" ? "pnpm outdated --json" : "npm outdated --json";
|
|
60
|
+
const outdatedResult = run(outdatedCmd + " 2>/dev/null || true", cwd);
|
|
61
|
+
let outdatedCount = 0;
|
|
62
|
+
let majorOutdated = 0;
|
|
63
|
+
try {
|
|
64
|
+
const outdated = JSON.parse(outdatedResult.stdout);
|
|
65
|
+
// npm/pnpm format: object keyed by package name
|
|
66
|
+
for (const [, info] of Object.entries(outdated)) {
|
|
67
|
+
outdatedCount++;
|
|
68
|
+
const current = info.current || info.version || "";
|
|
69
|
+
const latest = info.latest || "";
|
|
70
|
+
if (current && latest && current.split(".")[0] !== latest.split(".")[0]) {
|
|
71
|
+
majorOutdated++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* no outdated data */
|
|
77
|
+
}
|
|
78
|
+
if (majorOutdated > 0)
|
|
79
|
+
issues.push({
|
|
80
|
+
severity: "warning",
|
|
81
|
+
message: `${majorOutdated} packages behind by a major version`,
|
|
82
|
+
});
|
|
83
|
+
// Score: -25 per critical, -15 per high, -5 per moderate, -1 per major outdated
|
|
84
|
+
const score = Math.max(0, Math.min(100, 100 -
|
|
85
|
+
vulnCritical * 25 -
|
|
86
|
+
vulnHigh * 15 -
|
|
87
|
+
vulnModerate * 5 -
|
|
88
|
+
majorOutdated * 1));
|
|
89
|
+
return {
|
|
90
|
+
name: "dependencies",
|
|
91
|
+
score,
|
|
92
|
+
grade: gradeFromScore(score),
|
|
93
|
+
details: {
|
|
94
|
+
vulnerabilities: {
|
|
95
|
+
critical: vulnCritical,
|
|
96
|
+
high: vulnHigh,
|
|
97
|
+
moderate: vulnModerate,
|
|
98
|
+
low: vulnLow,
|
|
99
|
+
},
|
|
100
|
+
outdated: outdatedCount,
|
|
101
|
+
majorOutdated,
|
|
102
|
+
},
|
|
103
|
+
issues,
|
|
104
|
+
duration: Date.now() - start,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Documentation check — README, JSDoc, code comments. */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
export function runDocs(cwd) {
|
|
6
|
+
const start = Date.now();
|
|
7
|
+
const issues = [];
|
|
8
|
+
let readmeScore = 0;
|
|
9
|
+
let exportDocScore = 0;
|
|
10
|
+
// Check README
|
|
11
|
+
const readmePath = join(cwd, "README.md");
|
|
12
|
+
if (!existsSync(readmePath)) {
|
|
13
|
+
issues.push({ severity: "error", message: "No README.md — project has no documentation", rule: "no-readme" });
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const readme = readFileSync(readmePath, "utf-8");
|
|
17
|
+
const lines = readme.split("\n").length;
|
|
18
|
+
if (lines < 5) {
|
|
19
|
+
issues.push({ severity: "warning", message: `README.md is only ${lines} lines — minimal documentation`, rule: "short-readme" });
|
|
20
|
+
readmeScore = 30;
|
|
21
|
+
}
|
|
22
|
+
else if (lines < 20) {
|
|
23
|
+
readmeScore = 60;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
readmeScore = 100;
|
|
27
|
+
}
|
|
28
|
+
// Check README sections
|
|
29
|
+
const hasInstall = /install|getting started|setup|usage/i.test(readme);
|
|
30
|
+
const hasDescription = readme.length > 100;
|
|
31
|
+
if (!hasInstall)
|
|
32
|
+
issues.push({ severity: "info", message: "README missing install/usage section", rule: "readme-no-install" });
|
|
33
|
+
if (!hasDescription)
|
|
34
|
+
issues.push({ severity: "warning", message: "README has very little content", rule: "readme-sparse" });
|
|
35
|
+
}
|
|
36
|
+
// Check exported function documentation
|
|
37
|
+
const files = [];
|
|
38
|
+
const dirs = ["src", "web/src"];
|
|
39
|
+
for (const dir of dirs) {
|
|
40
|
+
try {
|
|
41
|
+
collectFiles(join(cwd, dir), files);
|
|
42
|
+
}
|
|
43
|
+
catch { /* dir doesn't exist */ }
|
|
44
|
+
}
|
|
45
|
+
let totalExports = 0;
|
|
46
|
+
let documentedExports = 0;
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const content = readFileSync(file, "utf-8");
|
|
49
|
+
const lines = content.split("\n");
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i].trim();
|
|
52
|
+
if (line.startsWith("export function ") || line.startsWith("export async function ") || line.startsWith("export class ") || line.startsWith("export interface ")) {
|
|
53
|
+
totalExports++;
|
|
54
|
+
// Check if preceded by a JSDoc or // comment
|
|
55
|
+
const prevLine = i > 0 ? lines[i - 1].trim() : "";
|
|
56
|
+
if (prevLine.endsWith("*/") || prevLine.startsWith("//") || prevLine.startsWith("/**")) {
|
|
57
|
+
documentedExports++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (totalExports > 0) {
|
|
63
|
+
const pct = Math.round((documentedExports / totalExports) * 100);
|
|
64
|
+
exportDocScore = pct;
|
|
65
|
+
if (pct < 30) {
|
|
66
|
+
issues.push({ severity: "warning", message: `Only ${pct}% of exports have documentation (${documentedExports}/${totalExports})`, rule: "undocumented-exports" });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
exportDocScore = 100; // no exports = nothing to document
|
|
71
|
+
}
|
|
72
|
+
const score = Math.round(readmeScore * 0.5 + exportDocScore * 0.5);
|
|
73
|
+
return {
|
|
74
|
+
name: "docs",
|
|
75
|
+
score,
|
|
76
|
+
grade: gradeFromScore(score),
|
|
77
|
+
details: { readmeLines: existsSync(readmePath) ? readFileSync(readmePath, "utf-8").split("\n").length : 0, totalExports, documentedExports, documentedPct: totalExports > 0 ? Math.round((documentedExports / totalExports) * 100) + "%" : "n/a" },
|
|
78
|
+
issues,
|
|
79
|
+
duration: Date.now() - start,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function collectFiles(dir, out) {
|
|
83
|
+
for (const entry of readdirSync(dir)) {
|
|
84
|
+
if (entry === "node_modules" || entry === "dist")
|
|
85
|
+
continue;
|
|
86
|
+
const full = join(dir, entry);
|
|
87
|
+
if (statSync(full).isDirectory()) {
|
|
88
|
+
collectFiles(full, out);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const ext = extname(entry);
|
|
92
|
+
if ((ext === ".ts" || ext === ".tsx") && !entry.includes(".test.") && !entry.includes(".spec.")) {
|
|
93
|
+
out.push(full);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Code duplication detection — finds copy-pasted blocks. */
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
const MIN_LINES = 6; // minimum duplicate block size
|
|
6
|
+
const MIN_TOKENS = 50; // minimum token count for a duplicate
|
|
7
|
+
export function runDuplication(cwd) {
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const issues = [];
|
|
10
|
+
const files = [];
|
|
11
|
+
const dirs = ["src", "web/src"];
|
|
12
|
+
for (const dir of dirs) {
|
|
13
|
+
try {
|
|
14
|
+
collectFiles(join(cwd, dir), files);
|
|
15
|
+
}
|
|
16
|
+
catch { /* dir doesn't exist */ }
|
|
17
|
+
}
|
|
18
|
+
if (files.length < 2) {
|
|
19
|
+
return { name: "duplication", score: 100, grade: "A", details: { filesScanned: files.length, duplicates: 0 }, issues: [], duration: Date.now() - start };
|
|
20
|
+
}
|
|
21
|
+
// Simple line-based duplicate detection
|
|
22
|
+
// Build a map of normalized line hashes → locations
|
|
23
|
+
const lineMap = new Map();
|
|
24
|
+
let totalSourceLines = 0;
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const content = readFileSync(file, "utf-8");
|
|
27
|
+
const relPath = file.replace(cwd + "/", "");
|
|
28
|
+
const lines = content.split("\n");
|
|
29
|
+
totalSourceLines += lines.length;
|
|
30
|
+
for (let i = 0; i <= lines.length - MIN_LINES; i++) {
|
|
31
|
+
const block = lines
|
|
32
|
+
.slice(i, i + MIN_LINES)
|
|
33
|
+
.map((l) => l.trim())
|
|
34
|
+
.filter((l) => l.length > 0 && !l.startsWith("//") && !l.startsWith("*") && l !== "{" && l !== "}" && l !== "");
|
|
35
|
+
if (block.length < MIN_LINES - 2)
|
|
36
|
+
continue; // too many empty/trivial lines
|
|
37
|
+
const key = block.join("\n");
|
|
38
|
+
if (key.length < MIN_TOKENS)
|
|
39
|
+
continue;
|
|
40
|
+
const locs = lineMap.get(key) || [];
|
|
41
|
+
locs.push({ file: relPath, line: i + 1 });
|
|
42
|
+
lineMap.set(key, locs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Find blocks that appear in 2+ locations
|
|
46
|
+
const duplicates = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (const [_key, locs] of lineMap) {
|
|
49
|
+
if (locs.length < 2)
|
|
50
|
+
continue;
|
|
51
|
+
// Deduplicate: same file, adjacent lines are the same block
|
|
52
|
+
const unique = locs.filter((l, i) => i === 0 || l.file !== locs[i - 1].file || l.line > locs[i - 1].line + MIN_LINES);
|
|
53
|
+
if (unique.length < 2)
|
|
54
|
+
continue;
|
|
55
|
+
// Only report each pair once
|
|
56
|
+
for (let i = 0; i < unique.length - 1; i++) {
|
|
57
|
+
const a = unique[i];
|
|
58
|
+
const b = unique[i + 1];
|
|
59
|
+
const pairKey = `${a.file}:${a.line}-${b.file}:${b.line}`;
|
|
60
|
+
if (seen.has(pairKey))
|
|
61
|
+
continue;
|
|
62
|
+
seen.add(pairKey);
|
|
63
|
+
duplicates.push({ fileA: a.file, lineA: a.line, fileB: b.file, lineB: b.line, lines: MIN_LINES });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const d of duplicates.slice(0, 20)) {
|
|
67
|
+
issues.push({
|
|
68
|
+
severity: "warning",
|
|
69
|
+
message: `${MIN_LINES}-line duplicate block`,
|
|
70
|
+
file: `${d.fileA}:${d.lineA} ↔ ${d.fileB}:${d.lineB}`,
|
|
71
|
+
rule: "duplicate-code",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const dupPct = totalSourceLines > 0 ? Math.round((duplicates.length * MIN_LINES * 100) / totalSourceLines) : 0;
|
|
75
|
+
const score = Math.max(0, Math.min(100, 100 - dupPct * 3 - duplicates.length));
|
|
76
|
+
return {
|
|
77
|
+
name: "duplication",
|
|
78
|
+
score,
|
|
79
|
+
grade: gradeFromScore(score),
|
|
80
|
+
details: { filesScanned: files.length, totalSourceLines, duplicateBlocks: duplicates.length, duplicationPct: dupPct + "%" },
|
|
81
|
+
issues,
|
|
82
|
+
duration: Date.now() - start,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function collectFiles(dir, out) {
|
|
86
|
+
for (const entry of readdirSync(dir)) {
|
|
87
|
+
if (entry === "node_modules" || entry === "dist" || entry === ".git")
|
|
88
|
+
continue;
|
|
89
|
+
const full = join(dir, entry);
|
|
90
|
+
if (statSync(full).isDirectory()) {
|
|
91
|
+
collectFiles(full, out);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const ext = extname(entry);
|
|
95
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext) && !entry.includes(".test.") && !entry.includes(".spec.")) {
|
|
96
|
+
out.push(full);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Shared exec helper for runners. */
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
export function run(cmd, cwd, timeout = 60_000) {
|
|
4
|
+
try {
|
|
5
|
+
const stdout = execSync(cmd, {
|
|
6
|
+
cwd,
|
|
7
|
+
timeout,
|
|
8
|
+
encoding: "utf-8",
|
|
9
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
return { stdout, ok: true };
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
return { stdout: e.stdout || e.stderr || String(e), ok: false };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function runJSON(cmd, cwd, timeout = 60_000) {
|
|
18
|
+
const { stdout } = run(cmd, cwd, timeout);
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(stdout);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|