@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/dist/check-meta.d.ts +15 -0
  4. package/dist/check-meta.js +166 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.js +140 -0
  7. package/dist/detect.d.ts +8 -0
  8. package/dist/detect.js +67 -0
  9. package/dist/fs-utils.d.ts +23 -0
  10. package/dist/fs-utils.js +77 -0
  11. package/dist/report/html.d.ts +12 -0
  12. package/dist/report/html.js +400 -0
  13. package/dist/runners/architecture.d.ts +28 -0
  14. package/dist/runners/architecture.js +272 -0
  15. package/dist/runners/complexity.d.ts +3 -0
  16. package/dist/runners/complexity.js +152 -0
  17. package/dist/runners/confusion.d.ts +16 -0
  18. package/dist/runners/confusion.js +198 -0
  19. package/dist/runners/context.d.ts +15 -0
  20. package/dist/runners/context.js +200 -0
  21. package/dist/runners/coverage.d.ts +3 -0
  22. package/dist/runners/coverage.js +65 -0
  23. package/dist/runners/dependencies.d.ts +3 -0
  24. package/dist/runners/dependencies.js +106 -0
  25. package/dist/runners/docs.d.ts +3 -0
  26. package/dist/runners/docs.js +97 -0
  27. package/dist/runners/duplication.d.ts +3 -0
  28. package/dist/runners/duplication.js +100 -0
  29. package/dist/runners/exec.d.ts +6 -0
  30. package/dist/runners/exec.js +25 -0
  31. package/dist/runners/lint.d.ts +3 -0
  32. package/dist/runners/lint.js +78 -0
  33. package/dist/runners/secrets.d.ts +3 -0
  34. package/dist/runners/secrets.js +108 -0
  35. package/dist/runners/security.d.ts +3 -0
  36. package/dist/runners/security.js +121 -0
  37. package/dist/runners/standards.d.ts +3 -0
  38. package/dist/runners/standards.js +153 -0
  39. package/dist/runners/structure.d.ts +3 -0
  40. package/dist/runners/structure.js +110 -0
  41. package/dist/runners/testing.d.ts +12 -0
  42. package/dist/runners/testing.js +401 -0
  43. package/dist/runners/tests.d.ts +3 -0
  44. package/dist/runners/tests.js +54 -0
  45. package/dist/runners/type-safety.d.ts +3 -0
  46. package/dist/runners/type-safety.js +74 -0
  47. package/dist/runners/types-check.d.ts +3 -0
  48. package/dist/runners/types-check.js +44 -0
  49. package/dist/score.d.ts +6 -0
  50. package/dist/score.js +19 -0
  51. package/dist/trend.d.ts +19 -0
  52. package/dist/trend.js +63 -0
  53. package/dist/types.d.ts +40 -0
  54. package/dist/types.js +12 -0
  55. 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,3 @@
1
+ /** Coverage runner — runs tests with coverage and parses the summary. */
2
+ import type { CheckResult, StackInfo } from "../types.js";
3
+ export declare function runCoverage(cwd: string, stack: StackInfo): CheckResult;
@@ -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,3 @@
1
+ /** Dependency health — vulnerabilities, outdated packages. */
2
+ import type { CheckResult, StackInfo } from "../types.js";
3
+ export declare function runDependencies(cwd: string, stack: StackInfo): CheckResult;
@@ -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,3 @@
1
+ /** Documentation check — README, JSDoc, code comments. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runDocs(cwd: string): CheckResult;
@@ -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,3 @@
1
+ /** Code duplication detection — finds copy-pasted blocks. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runDuplication(cwd: string): CheckResult;
@@ -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,6 @@
1
+ /** Shared exec helper for runners. */
2
+ export declare function run(cmd: string, cwd: string, timeout?: number): {
3
+ stdout: string;
4
+ ok: boolean;
5
+ };
6
+ export declare function runJSON<T>(cmd: string, cwd: string, timeout?: number): T | null;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ /** Lint check — auto-detects biome or eslint. */
2
+ import type { CheckResult, StackInfo } from "../types.js";
3
+ export declare function runLint(cwd: string, stack: StackInfo): CheckResult;