@vibecodeqa/cli 0.38.1 → 0.39.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.
|
@@ -23,5 +23,5 @@ export interface ArchGraph {
|
|
|
23
23
|
godModules: string[];
|
|
24
24
|
orphans: string[];
|
|
25
25
|
}
|
|
26
|
-
export declare function runArchitecture(cwd: string, workspace?: WorkspaceInfo): CheckResult
|
|
26
|
+
export declare function runArchitecture(cwd: string, workspace?: WorkspaceInfo): Promise<CheckResult>;
|
|
27
27
|
export { generateArchSVG, generateDSM, generateLayerDiagram, generatePackageDiagram, generateSequenceDiagram } from "./diagrams.js";
|
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
* 6. Layer violations (optional: detect cross-layer imports)
|
|
10
10
|
* 7. SVG architecture diagram
|
|
11
11
|
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
12
13
|
import { basename, dirname, extname } from "node:path";
|
|
14
|
+
import { cruise } from "dependency-cruiser";
|
|
13
15
|
import { getProductionFiles } from "../fs-utils.js";
|
|
14
16
|
import { gradeFromScore } from "../types.js";
|
|
15
17
|
import { generateContainerDiagram } from "./diagrams.js";
|
|
16
|
-
export function runArchitecture(cwd, workspace) {
|
|
18
|
+
export async function runArchitecture(cwd, workspace) {
|
|
17
19
|
const start = Date.now();
|
|
18
20
|
const issues = [];
|
|
19
21
|
const files = getProductionFiles(cwd);
|
|
@@ -27,7 +29,12 @@ export function runArchitecture(cwd, workspace) {
|
|
|
27
29
|
duration: Date.now() - start,
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
|
-
|
|
32
|
+
// Prefer dependency-cruiser (real module resolution incl. tsconfig paths) and
|
|
33
|
+
// fall back to the built-in resolver when it can't cover the project (e.g. SFC-
|
|
34
|
+
// heavy or monorepos where cross-package imports use bare specifiers).
|
|
35
|
+
const cruised = await buildGraphViaCruise(cwd, files);
|
|
36
|
+
const graph = cruised ?? buildGraph(files);
|
|
37
|
+
const tool = cruised ? "dependency-cruiser" : "built-in";
|
|
31
38
|
// ── Circular dependencies ──
|
|
32
39
|
const cycles = findCycles(graph.nodes);
|
|
33
40
|
for (const cycle of cycles.slice(0, 5)) {
|
|
@@ -142,12 +149,77 @@ export function runArchitecture(cwd, workspace) {
|
|
|
142
149
|
graph: graphData,
|
|
143
150
|
containerSvg: generateContainerDiagram(cwd),
|
|
144
151
|
assessment,
|
|
152
|
+
tool,
|
|
145
153
|
},
|
|
146
154
|
issues,
|
|
147
155
|
duration: Date.now() - start,
|
|
148
156
|
};
|
|
149
157
|
}
|
|
150
158
|
// ── Graph building ──
|
|
159
|
+
/** Build the import graph with dependency-cruiser (real module resolution,
|
|
160
|
+
* tsconfig path aliases, transitive TS deps). Returns null — so the caller
|
|
161
|
+
* falls back to the built-in resolver — if it errors or covers too little of
|
|
162
|
+
* the project (e.g. .vue/.svelte SFCs it can't resolve, or monorepo bare imports). */
|
|
163
|
+
async function buildGraphViaCruise(cwd, files) {
|
|
164
|
+
// .vue/.svelte single-file components need our SFC-aware resolver; dependency-cruiser
|
|
165
|
+
// doesn't resolve them without a bundler plugin, so hand those projects to the built-in.
|
|
166
|
+
if (files.some((f) => f.ext === ".vue" || f.ext === ".svelte"))
|
|
167
|
+
return null;
|
|
168
|
+
const fileSet = new Set(files.map((f) => f.path));
|
|
169
|
+
const roots = [...new Set(files.map((f) => f.path.split("/")[0]).filter(Boolean))];
|
|
170
|
+
if (roots.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
const prevCwd = process.cwd();
|
|
173
|
+
try {
|
|
174
|
+
process.chdir(cwd); // dependency-cruiser resolves relative to process.cwd()
|
|
175
|
+
const options = {
|
|
176
|
+
doNotFollow: { path: "node_modules" },
|
|
177
|
+
exclude: { path: "(\\.test\\.|\\.spec\\.|/node_modules/|/dist/|/\\.vibe-check/)" },
|
|
178
|
+
tsPreCompilationDeps: true,
|
|
179
|
+
};
|
|
180
|
+
if (existsSync("tsconfig.json"))
|
|
181
|
+
options.tsConfig = { fileName: "tsconfig.json" };
|
|
182
|
+
const result = await cruise(roots, options);
|
|
183
|
+
const output = result.output;
|
|
184
|
+
if (typeof output === "string")
|
|
185
|
+
return null;
|
|
186
|
+
const modules = output.modules ?? [];
|
|
187
|
+
const nodes = new Map();
|
|
188
|
+
for (const m of modules) {
|
|
189
|
+
if (fileSet.has(m.source)) {
|
|
190
|
+
nodes.set(m.source, { path: m.source, imports: [], importedBy: [], dir: dirname(m.source), exports: 0 });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const m of modules) {
|
|
194
|
+
const node = nodes.get(m.source);
|
|
195
|
+
if (!node)
|
|
196
|
+
continue;
|
|
197
|
+
const seen = new Set();
|
|
198
|
+
for (const d of m.dependencies ?? []) {
|
|
199
|
+
if (d.coreModule || !d.resolved || d.resolved === m.source)
|
|
200
|
+
continue;
|
|
201
|
+
if (!fileSet.has(d.resolved) || seen.has(d.resolved))
|
|
202
|
+
continue;
|
|
203
|
+
seen.add(d.resolved);
|
|
204
|
+
node.imports.push(d.resolved);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const [path, node] of nodes) {
|
|
208
|
+
for (const imp of node.imports)
|
|
209
|
+
nodes.get(imp)?.importedBy.push(path);
|
|
210
|
+
}
|
|
211
|
+
// Too little coverage → the built-in resolver is a better bet for this project.
|
|
212
|
+
if (nodes.size < files.length * 0.5)
|
|
213
|
+
return null;
|
|
214
|
+
return { nodes };
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
process.chdir(prevCwd);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
151
223
|
function buildGraph(files) {
|
|
152
224
|
const filePaths = new Set(files.map((f) => f.path));
|
|
153
225
|
const nodes = new Map();
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
/** Code duplication
|
|
1
|
+
/** Code duplication.
|
|
2
|
+
*
|
|
3
|
+
* - If the project depends on jscpd, shell out to it (opt-in, richest output).
|
|
4
|
+
* - Otherwise run jscpd's detection engine (@jscpd/core — battle-tested Rabin-Karp
|
|
5
|
+
* matching + maximal-clone extension) over our own lightweight tokenizer. We
|
|
6
|
+
* supply the tokens (zero heavy language-grammar dependency) and let the mature
|
|
7
|
+
* engine do the matching/merging/statistics.
|
|
8
|
+
*
|
|
9
|
+
* Catches Type-1/2 clones (exact, modulo formatting), like jscpd's default. */
|
|
2
10
|
import type { CheckResult } from "../types.js";
|
|
3
|
-
export declare function runDuplication(cwd: string): CheckResult
|
|
11
|
+
export declare function runDuplication(cwd: string): Promise<CheckResult>;
|
|
12
|
+
interface Token {
|
|
13
|
+
text: string;
|
|
14
|
+
line: number;
|
|
15
|
+
endLine: number;
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
/** Lex source into tokens, skipping whitespace and comments. */
|
|
20
|
+
export declare function tokenize(content: string): Token[];
|
|
21
|
+
/** Blank out import / re-export statements (preserving line numbers) before
|
|
22
|
+
* tokenizing. Identical import headers aren't refactorable duplication. */
|
|
23
|
+
export declare function stripImports(content: string): string;
|
|
24
|
+
export {};
|
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
/** Code duplication
|
|
1
|
+
/** Code duplication.
|
|
2
|
+
*
|
|
3
|
+
* - If the project depends on jscpd, shell out to it (opt-in, richest output).
|
|
4
|
+
* - Otherwise run jscpd's detection engine (@jscpd/core — battle-tested Rabin-Karp
|
|
5
|
+
* matching + maximal-clone extension) over our own lightweight tokenizer. We
|
|
6
|
+
* supply the tokens (zero heavy language-grammar dependency) and let the mature
|
|
7
|
+
* engine do the matching/merging/statistics.
|
|
8
|
+
*
|
|
9
|
+
* Catches Type-1/2 clones (exact, modulo formatting), like jscpd's default. */
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
2
11
|
import { readFileSync, rmdirSync, unlinkSync } from "node:fs";
|
|
3
12
|
import { join } from "node:path";
|
|
13
|
+
import { Detector, getDefaultOptions, MemoryStore, Statistic, } from "@jscpd/core";
|
|
4
14
|
import { getProductionFiles, readDeps } from "../fs-utils.js";
|
|
5
15
|
import { gradeFromScore } from "../types.js";
|
|
6
16
|
import { run } from "./exec.js";
|
|
7
|
-
|
|
8
|
-
const MIN_TOKENS = 50;
|
|
9
|
-
|
|
17
|
+
// jscpd-parity thresholds: ≥ MIN_TOKENS consecutive matching tokens AND ≥ MIN_LINES lines.
|
|
18
|
+
const MIN_TOKENS = 50;
|
|
19
|
+
const MIN_LINES = 6;
|
|
20
|
+
const MAX_ISSUES = 20;
|
|
21
|
+
// One synthetic "format" for every file so clones are detected across all sources
|
|
22
|
+
// (jscpd only compares within a format; we want cross-file/cross-extension matches).
|
|
23
|
+
const FORMAT = "src";
|
|
24
|
+
export async function runDuplication(cwd) {
|
|
10
25
|
const start = Date.now();
|
|
11
26
|
// Try jscpd if it's an explicit project dependency (opt-in, not auto-npx)
|
|
12
27
|
const deps = readDeps(cwd);
|
|
@@ -15,108 +30,244 @@ export function runDuplication(cwd) {
|
|
|
15
30
|
jscpdResult.duration = Date.now() - start;
|
|
16
31
|
return jscpdResult;
|
|
17
32
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const sourceFiles = getProductionFiles(cwd);
|
|
21
|
-
if (sourceFiles.length < 2) {
|
|
33
|
+
const files = getProductionFiles(cwd);
|
|
34
|
+
if (files.length < 2) {
|
|
22
35
|
return {
|
|
23
36
|
name: "duplication",
|
|
24
37
|
score: 100,
|
|
25
38
|
grade: "A",
|
|
26
|
-
details: { filesScanned:
|
|
39
|
+
details: { filesScanned: files.length, duplicates: 0, tool: "built-in" },
|
|
27
40
|
issues: [],
|
|
28
41
|
duration: Date.now() - start,
|
|
29
42
|
};
|
|
30
43
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.filter((l) => l.length > 0 &&
|
|
43
|
-
!l.startsWith("//") &&
|
|
44
|
-
!l.startsWith("*") &&
|
|
45
|
-
!l.startsWith("import ") &&
|
|
46
|
-
!l.startsWith("export {") &&
|
|
47
|
-
l !== "{" &&
|
|
48
|
-
l !== "}" &&
|
|
49
|
-
l !== "");
|
|
50
|
-
if (block.length < MIN_LINES - 2)
|
|
51
|
-
continue; // too many empty/trivial lines
|
|
52
|
-
const key = block.join("\n");
|
|
53
|
-
if (key.length < MIN_TOKENS)
|
|
54
|
-
continue;
|
|
55
|
-
const locs = lineMap.get(key) || [];
|
|
56
|
-
locs.push({ file: sf.path, line: i + 1 });
|
|
57
|
-
lineMap.set(key, locs);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
// Find blocks that appear in 2+ locations
|
|
61
|
-
const duplicates = [];
|
|
62
|
-
const seen = new Set();
|
|
63
|
-
for (const [key, locs] of lineMap) {
|
|
64
|
-
if (locs.length < 2)
|
|
65
|
-
continue;
|
|
66
|
-
// Deduplicate: same file, adjacent lines are the same block
|
|
67
|
-
const unique = locs.filter((l, i) => i === 0 || l.file !== locs[i - 1].file || l.line > locs[i - 1].line + MIN_LINES);
|
|
68
|
-
if (unique.length < 2)
|
|
69
|
-
continue;
|
|
70
|
-
// Only report each pair once
|
|
71
|
-
for (let i = 0; i < unique.length - 1; i++) {
|
|
72
|
-
const a = unique[i];
|
|
73
|
-
const b = unique[i + 1];
|
|
74
|
-
const pairKey = `${a.file}:${a.line}-${b.file}:${b.line}`;
|
|
75
|
-
if (seen.has(pairKey))
|
|
76
|
-
continue;
|
|
77
|
-
seen.add(pairKey);
|
|
78
|
-
duplicates.push({ fileA: a.file, lineA: a.line, fileB: b.file, lineB: b.line, lines: MIN_LINES, content: key });
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
for (const d of duplicates.slice(0, 20)) {
|
|
82
|
-
// Show first 3 lines of the duplicated content, truncate at word boundary
|
|
83
|
-
const lines = d.content.split("\n").slice(0, 3);
|
|
84
|
-
const preview = lines.join(" \u2502 "); // use │ separator
|
|
85
|
-
const maxLen = 120;
|
|
86
|
-
const truncated = preview.length > maxLen ? `${preview.slice(0, preview.lastIndexOf(" ", maxLen) || maxLen)}...` : preview;
|
|
87
|
-
// First line of content is the best search string
|
|
88
|
-
const searchSnippet = d.content.split("\n")[0];
|
|
89
|
-
issues.push({
|
|
90
|
-
severity: "warning",
|
|
91
|
-
message: `Duplicate (${d.lines} lines): ${truncated}`,
|
|
92
|
-
file: `${d.fileA}:${d.lineA} ↔ ${d.fileB}:${d.lineB}`,
|
|
93
|
-
rule: "duplicate-code",
|
|
94
|
-
snippet: searchSnippet,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
const dupPct = totalSourceLines > 0 ? Math.round((duplicates.length * MIN_LINES * 100) / totalSourceLines) : 0;
|
|
98
|
-
// Score based on duplication percentage (not absolute count)
|
|
99
|
-
const score = Math.max(0, Math.min(100, 100 - dupPct * 5));
|
|
44
|
+
const { clones, percentage, totalLines } = await detectWithCore(files);
|
|
45
|
+
const score = scoreFromPct(percentage);
|
|
46
|
+
// Rank biggest clones first; stable tie-break for deterministic output.
|
|
47
|
+
clones.sort((a, b) => b.lines - a.lines || a.fileA.localeCompare(b.fileA) || a.lineA - b.lineA);
|
|
48
|
+
const issues = clones.slice(0, MAX_ISSUES).map((c) => ({
|
|
49
|
+
severity: "warning",
|
|
50
|
+
message: `Duplicate (${c.lines} lines): ${c.snippet.slice(0, 100)}`,
|
|
51
|
+
file: `${c.fileA}:${c.lineA} ↔ ${c.fileB}:${c.lineB}`,
|
|
52
|
+
rule: "duplicate-code",
|
|
53
|
+
snippet: c.snippet,
|
|
54
|
+
}));
|
|
100
55
|
return {
|
|
101
56
|
name: "duplication",
|
|
102
57
|
score,
|
|
103
58
|
grade: gradeFromScore(score),
|
|
104
59
|
details: {
|
|
105
|
-
filesScanned:
|
|
106
|
-
totalSourceLines,
|
|
107
|
-
duplicateBlocks:
|
|
108
|
-
duplicationPct: `${
|
|
60
|
+
filesScanned: files.length,
|
|
61
|
+
totalSourceLines: totalLines,
|
|
62
|
+
duplicateBlocks: clones.length,
|
|
63
|
+
duplicationPct: `${percentage}%`,
|
|
109
64
|
tool: "built-in",
|
|
110
65
|
},
|
|
111
66
|
issues,
|
|
112
67
|
duration: Date.now() - start,
|
|
113
68
|
};
|
|
114
69
|
}
|
|
70
|
+
/** Run @jscpd/core over our tokens. Files are fed sequentially into one Detector
|
|
71
|
+
* sharing a store, so later files match earlier ones (cross-file clones). */
|
|
72
|
+
async function detectWithCore(files) {
|
|
73
|
+
const options = { ...getDefaultOptions(), minTokens: MIN_TOKENS, minLines: MIN_LINES, maxLines: 100_000 };
|
|
74
|
+
const detector = new Detector(new VcqaTokenizer(), new MemoryStore(), undefined, options);
|
|
75
|
+
const statistic = new Statistic();
|
|
76
|
+
const handlers = statistic.subscribe();
|
|
77
|
+
for (const event of Object.keys(handlers)) {
|
|
78
|
+
const handler = handlers[event];
|
|
79
|
+
if (handler)
|
|
80
|
+
detector.on(event, handler);
|
|
81
|
+
}
|
|
82
|
+
const clones = [];
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
const found = await detector.detect(f.path, f.content, FORMAT);
|
|
85
|
+
for (const c of found) {
|
|
86
|
+
const a = c.duplicationA; // the current file
|
|
87
|
+
const b = c.duplicationB; // the earlier match
|
|
88
|
+
const lines = Math.max(1, a.end.line - a.start.line + 1);
|
|
89
|
+
const snippet = (f.content.split("\n")[a.start.line - 1] ?? "").trim();
|
|
90
|
+
clones.push({ fileA: a.sourceId, lineA: a.start.line, fileB: b.sourceId, lineB: b.start.line, lines, snippet });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const stat = statistic.getStatistic();
|
|
94
|
+
const percentage = Math.round((stat.total.percentage ?? 0) * 10) / 10;
|
|
95
|
+
return { clones, percentage, totalLines: stat.total.lines ?? 0 };
|
|
96
|
+
}
|
|
97
|
+
const isWord = (ch) => (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") || ch === "_" || ch === "$";
|
|
98
|
+
/** Consume a string/template literal, returning its full text. One token, so the
|
|
99
|
+
* detector never matches *inside* a string; text retained so distinct strings differ. */
|
|
100
|
+
function scanString(content, cur) {
|
|
101
|
+
const quote = content[cur.i];
|
|
102
|
+
const start = cur.i;
|
|
103
|
+
const n = content.length;
|
|
104
|
+
cur.i++;
|
|
105
|
+
while (cur.i < n && content[cur.i] !== quote) {
|
|
106
|
+
if (content[cur.i] === "\\") {
|
|
107
|
+
if (content[cur.i + 1] === "\n")
|
|
108
|
+
cur.line++;
|
|
109
|
+
cur.i += 2;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (content[cur.i] === "\n")
|
|
113
|
+
cur.line++;
|
|
114
|
+
cur.i++;
|
|
115
|
+
}
|
|
116
|
+
const text = content.slice(start, Math.min(cur.i + 1, n));
|
|
117
|
+
cur.i++; // skip closing quote
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
/** Consume a block comment, advancing past the closing star-slash. */
|
|
121
|
+
function scanBlockComment(content, cur) {
|
|
122
|
+
const n = content.length;
|
|
123
|
+
cur.i += 2;
|
|
124
|
+
while (cur.i < n && !(content[cur.i] === "*" && content[cur.i + 1] === "/")) {
|
|
125
|
+
if (content[cur.i] === "\n")
|
|
126
|
+
cur.line++;
|
|
127
|
+
cur.i++;
|
|
128
|
+
}
|
|
129
|
+
cur.i += 2;
|
|
130
|
+
}
|
|
131
|
+
/** Lex source into tokens, skipping whitespace and comments. */
|
|
132
|
+
export function tokenize(content) {
|
|
133
|
+
const tokens = [];
|
|
134
|
+
const n = content.length;
|
|
135
|
+
const cur = { i: 0, line: 1 };
|
|
136
|
+
while (cur.i < n) {
|
|
137
|
+
const c = content[cur.i];
|
|
138
|
+
if (c === "\n") {
|
|
139
|
+
cur.line++;
|
|
140
|
+
cur.i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
144
|
+
cur.i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (c === "/" && content[cur.i + 1] === "/") {
|
|
148
|
+
while (cur.i < n && content[cur.i] !== "\n")
|
|
149
|
+
cur.i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (c === "/" && content[cur.i + 1] === "*") {
|
|
153
|
+
scanBlockComment(content, cur);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const startLine = cur.line;
|
|
157
|
+
const startPos = cur.i;
|
|
158
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
159
|
+
const text = scanString(content, cur);
|
|
160
|
+
tokens.push({ text, line: startLine, endLine: cur.line, start: startPos, end: cur.i });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (isWord(c)) {
|
|
164
|
+
let j = cur.i + 1;
|
|
165
|
+
while (j < n && isWord(content[j]))
|
|
166
|
+
j++;
|
|
167
|
+
tokens.push({ text: content.slice(cur.i, j), line: startLine, endLine: startLine, start: cur.i, end: j });
|
|
168
|
+
cur.i = j;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
tokens.push({ text: c, line: startLine, endLine: startLine, start: startPos, end: cur.i + 1 }); // punctuation
|
|
172
|
+
cur.i++;
|
|
173
|
+
}
|
|
174
|
+
return tokens;
|
|
175
|
+
}
|
|
176
|
+
/** Blank out import / re-export statements (preserving line numbers) before
|
|
177
|
+
* tokenizing. Identical import headers aren't refactorable duplication. */
|
|
178
|
+
export function stripImports(content) {
|
|
179
|
+
const lines = content.split("\n");
|
|
180
|
+
let cont = false; // inside a multi-line import
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
const t = lines[i].trim();
|
|
183
|
+
if (cont) {
|
|
184
|
+
lines[i] = "";
|
|
185
|
+
if (/from\s*['"][^'"]*['"]\s*;?\s*$/.test(t) || /;\s*$/.test(t))
|
|
186
|
+
cont = false;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (/^import\b/.test(t)) {
|
|
190
|
+
const done = /from\s*['"][^'"]*['"]\s*;?\s*$/.test(t) || /^import\s*['"][^'"]*['"]\s*;?\s*$/.test(t);
|
|
191
|
+
lines[i] = "";
|
|
192
|
+
if (!done)
|
|
193
|
+
cont = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (/^export\s*(\*|\{[^}]*\})\s*from\s*['"]/.test(t))
|
|
197
|
+
lines[i] = "";
|
|
198
|
+
}
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
function toIToken(t, format) {
|
|
202
|
+
return {
|
|
203
|
+
type: "code",
|
|
204
|
+
value: t.text,
|
|
205
|
+
length: t.text.length,
|
|
206
|
+
format,
|
|
207
|
+
range: [t.start, t.end],
|
|
208
|
+
loc: { start: { line: t.line, column: 0, position: t.start }, end: { line: t.endLine, column: 0, position: t.end } },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** Bridges our tokens into the @jscpd/core engine: hashes rolling MIN_TOKENS-token
|
|
212
|
+
* windows into map frames the Rabin-Karp detector matches against. */
|
|
213
|
+
class VcqaTokenizer {
|
|
214
|
+
generateMaps(id, data, format, options) {
|
|
215
|
+
const w = options.minTokens ?? MIN_TOKENS;
|
|
216
|
+
const toks = tokenize(stripImports(data)).map((t) => toIToken(t, format));
|
|
217
|
+
const frames = [];
|
|
218
|
+
for (let i = 0; i + w <= toks.length; i++) {
|
|
219
|
+
const id2 = createHash("md5").update(toks.slice(i, i + w).map((t) => t.value).join("")).digest("hex");
|
|
220
|
+
frames.push({ id: id2, sourceId: id, start: toks[i], end: toks[i + w - 1] });
|
|
221
|
+
}
|
|
222
|
+
return [new VcqaTokensMap(id, format, data, toks.length, frames)];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
class VcqaTokensMap {
|
|
226
|
+
id;
|
|
227
|
+
format;
|
|
228
|
+
data;
|
|
229
|
+
tokensCount;
|
|
230
|
+
frames;
|
|
231
|
+
p = 0;
|
|
232
|
+
constructor(id, format, data, tokensCount, frames) {
|
|
233
|
+
this.id = id;
|
|
234
|
+
this.format = format;
|
|
235
|
+
this.data = data;
|
|
236
|
+
this.tokensCount = tokensCount;
|
|
237
|
+
this.frames = frames;
|
|
238
|
+
}
|
|
239
|
+
getFormat() { return this.format; }
|
|
240
|
+
getId() { return this.id; }
|
|
241
|
+
getLinesCount() { return this.data.split("\n").length; }
|
|
242
|
+
getTokensCount() { return this.tokensCount; }
|
|
243
|
+
next() {
|
|
244
|
+
if (this.p < this.frames.length)
|
|
245
|
+
return { value: this.frames[this.p++], done: false };
|
|
246
|
+
return { value: false, done: true };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Shared scoring band (used by both built-in and jscpd paths).
|
|
250
|
+
* Industry benchmarks: <5% great, 5-10% good, 10-20% acceptable, >20% needs work. */
|
|
251
|
+
function scoreFromPct(dupPct) {
|
|
252
|
+
if (dupPct <= 5)
|
|
253
|
+
return 100;
|
|
254
|
+
if (dupPct <= 10)
|
|
255
|
+
return 90;
|
|
256
|
+
if (dupPct <= 20)
|
|
257
|
+
return 75;
|
|
258
|
+
if (dupPct <= 30)
|
|
259
|
+
return 60;
|
|
260
|
+
if (dupPct <= 40)
|
|
261
|
+
return 45;
|
|
262
|
+
if (dupPct <= 50)
|
|
263
|
+
return 30;
|
|
264
|
+
return Math.max(10, Math.round(30 - (dupPct - 50)));
|
|
265
|
+
}
|
|
115
266
|
function tryJscpd(cwd) {
|
|
116
267
|
// jscpd writes JSON to a file, not stdout. Use a temp output dir.
|
|
117
268
|
const tmpDir = join(cwd, ".vibe-check", "jscpd-tmp");
|
|
118
269
|
const ignores = "node_modules/**,dist/**,build/**,.vibe-check/**,coverage/**,.next/**,.nuxt/**,**/*.json,**/*.lock,**/*.yaml,**/*.md";
|
|
119
|
-
run(`npx jscpd . --min-lines
|
|
270
|
+
run(`npx jscpd . --min-lines ${MIN_LINES} --min-tokens ${MIN_TOKENS} --reporters json --output "${tmpDir}" --ignore "${ignores}" --silent 2>/dev/null || true`, cwd, 30_000);
|
|
120
271
|
const reportPath = join(tmpDir, "jscpd-report.json");
|
|
121
272
|
let rawData;
|
|
122
273
|
try {
|
|
@@ -139,33 +290,19 @@ function tryJscpd(cwd) {
|
|
|
139
290
|
return null;
|
|
140
291
|
const issues = [];
|
|
141
292
|
const clones = data.duplicates || [];
|
|
142
|
-
for (const d of clones.slice(0,
|
|
293
|
+
for (const d of clones.slice(0, MAX_ISSUES)) {
|
|
143
294
|
const fileA = d.firstFile?.name || "?";
|
|
144
295
|
const fileB = d.secondFile?.name || "?";
|
|
145
296
|
const lines = d.lines || 0;
|
|
146
297
|
issues.push({
|
|
147
298
|
severity: "warning",
|
|
148
299
|
message: `Duplicate (${lines} lines)`,
|
|
149
|
-
file: `${fileA}:${d.firstFile?.start}
|
|
300
|
+
file: `${fileA}:${d.firstFile?.start} ↔ ${fileB}:${d.secondFile?.start}`,
|
|
150
301
|
rule: "duplicate-code",
|
|
151
302
|
});
|
|
152
303
|
}
|
|
153
304
|
const dupPct = Math.round((data.statistics.total?.percentage || 0) * 100) / 100;
|
|
154
|
-
|
|
155
|
-
// Industry benchmarks: <10% good, 10-20% acceptable, 20-40% needs work, >40% poor
|
|
156
|
-
const score = dupPct <= 5
|
|
157
|
-
? 100
|
|
158
|
-
: dupPct <= 10
|
|
159
|
-
? 90
|
|
160
|
-
: dupPct <= 20
|
|
161
|
-
? 75
|
|
162
|
-
: dupPct <= 30
|
|
163
|
-
? 60
|
|
164
|
-
: dupPct <= 40
|
|
165
|
-
? 45
|
|
166
|
-
: dupPct <= 50
|
|
167
|
-
? 30
|
|
168
|
-
: Math.max(10, Math.round(30 - (dupPct - 50)));
|
|
305
|
+
const score = scoreFromPct(dupPct);
|
|
169
306
|
return {
|
|
170
307
|
name: "duplication",
|
|
171
308
|
score,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibecodeqa/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Code health scanner for the AI coding era. 25 checks, zero config, full report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"vitest": "^4.1.6"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
+
"@jscpd/core": "^4.2.4",
|
|
57
|
+
"dependency-cruiser": "^17.4.3",
|
|
56
58
|
"ink": "^5.2.1",
|
|
57
59
|
"react": "^18.3.1"
|
|
58
60
|
}
|