@vibecodeqa/cli 0.38.0 → 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
- const graph = buildGraph(files);
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 — delegates to jscpd when available, falls back to built-in line-hash. */
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 — delegates to jscpd when available, falls back to built-in line-hash. */
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
- const MIN_LINES = 6; // minimum duplicate block size
8
- const MIN_TOKENS = 50; // minimum token count for a duplicate
9
- export function runDuplication(cwd) {
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
- // Fallback: built-in line-hash detection
19
- const issues = [];
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: sourceFiles.length, duplicates: 0, tool: "built-in" },
39
+ details: { filesScanned: files.length, duplicates: 0, tool: "built-in" },
27
40
  issues: [],
28
41
  duration: Date.now() - start,
29
42
  };
30
43
  }
31
- // Simple line-based duplicate detection
32
- // Build a map of normalized line hashes → locations
33
- const lineMap = new Map();
34
- let totalSourceLines = 0;
35
- for (const sf of sourceFiles) {
36
- const lines = sf.content.split("\n");
37
- totalSourceLines += lines.length;
38
- for (let i = 0; i <= lines.length - MIN_LINES; i++) {
39
- const block = lines
40
- .slice(i, i + MIN_LINES)
41
- .map((l) => l.trim())
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: sourceFiles.length,
106
- totalSourceLines,
107
- duplicateBlocks: duplicates.length,
108
- duplicationPct: `${dupPct}%`,
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 6 --min-tokens 50 --reporters json --output "${tmpDir}" --ignore "${ignores}" --silent 2>/dev/null || true`, cwd, 30_000);
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, 20)) {
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} \u2194 ${fileB}:${d.secondFile?.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
- // jscpd token-level detection is more aggressive than line-hash
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,
@@ -3,6 +3,30 @@
3
3
  * This runner catches patterns beyond what the plugin covers. */
4
4
  import { getProductionFiles, readDeps } from "../fs-utils.js";
5
5
  import { gradeFromScore } from "../types.js";
6
+ /** True when the `.map()` callback starting at line `i` actually returns JSX.
7
+ * `after` is the line text from `.map(` onward, right-trimmed. Distinguishes JSX
8
+ * returns from data maps (`=> ({...})`, `=> fn(...)`), TS generics, and comparisons. */
9
+ function mapCallbackReturnsJsx(after, lines, i) {
10
+ if (/=>\s*<[A-Za-z]/.test(after))
11
+ return true; // inline: => <Tag
12
+ if (/=>\s*\($/.test(after)) {
13
+ // multiline arrow body: => ( followed by JSX on the next non-empty line
14
+ return /^<[A-Za-z]/.test((lines[i + 1] || "").trim());
15
+ }
16
+ if (/=>\s*\{$/.test(after)) {
17
+ // block body: JSX iff it returns `<Tag` or `(` then `<Tag`
18
+ for (let j = i; j < Math.min(i + 12, lines.length); j++) {
19
+ const lt = (lines[j] || "").trim();
20
+ if (/return\s*<[A-Za-z]/.test(lt))
21
+ return true;
22
+ if (/return\s*\($/.test(lt))
23
+ return /^<[A-Za-z]/.test((lines[j + 1] || "").trim());
24
+ if (j > i && /^return\b/.test(lt))
25
+ return false; // returns a non-JSX value
26
+ }
27
+ }
28
+ return false;
29
+ }
6
30
  export function runReact(cwd, stack) {
7
31
  const start = Date.now();
8
32
  if (stack.framework !== "react") {
@@ -68,11 +92,13 @@ export function runReact(cwd, stack) {
68
92
  rule: "conditional-hook",
69
93
  });
70
94
  }
71
- // 2. Missing key in .map() returning JSX
72
- if (/\.map\s*\(/.test(trimmed)) {
73
- // Look ahead for JSX return without key
74
- const mapBlock = lines.slice(i, Math.min(i + 10, lines.length)).join("\n");
75
- if (/<\w/.test(mapBlock) && !mapBlock.includes("key=") && !mapBlock.includes("key:")) {
95
+ // 2. Missing key in .map() returning JSX. Only flag genuine JSX returns —
96
+ // not data maps, TS generics, or comparisons (see mapCallbackReturnsJsx).
97
+ const mapIdx = trimmed.indexOf(".map(");
98
+ if (mapIdx !== -1 && mapCallbackReturnsJsx(trimmed.slice(mapIdx).trimEnd(), lines, i)) {
99
+ // Inspect just the JSX head for a key enough to cover the opening element.
100
+ const head = lines.slice(i, Math.min(i + 8, lines.length)).join("\n");
101
+ if (!head.includes("key=") && !head.includes("key:")) {
76
102
  missingKeys++;
77
103
  issues.push({ severity: "error", message: "JSX in .map() without key prop", file: f.path, line: i + 1, rule: "missing-key" });
78
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.38.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
  }