@vibecodeqa/cli 0.37.4 → 0.38.1

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/dist/cli.js CHANGED
@@ -227,7 +227,7 @@ async function writeOutputs(report, outputDir, flags) {
227
227
  writeFileSync(join(outputDir, "report.sarif"), generateSARIF(report));
228
228
  }
229
229
  }
230
- async function printResults(report, trend, flags, outputDir) {
230
+ async function printResults(report, trend, flags, outputDir, interactive) {
231
231
  const { score, grade, checks } = report;
232
232
  const totalIssues = checks.reduce((s, c) => s + c.issues.length, 0);
233
233
  if (flags.jsonOnly) {
@@ -248,15 +248,10 @@ async function printResults(report, trend, flags, outputDir) {
248
248
  console.log(formatTrend(trend, scores));
249
249
  }
250
250
  console.log("");
251
- console.log(` \x1b[2mReport: ${join(outputDir, "report/index.html")}\x1b[0m`);
252
- console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
253
- if (flags.badgeMode)
254
- console.log(` \x1b[2mBadge: ${join(outputDir, "badge.svg")}\x1b[0m`);
255
- if (flags.sarifMode)
256
- console.log(` \x1b[2mSARIF: ${join(outputDir, "report.sarif")}\x1b[0m`);
257
- console.log("");
258
- // Top actionable issues
259
- if (flags.topN > 0) {
251
+ // Top actionable issues. Explicit --top wins; otherwise show a few by default
252
+ // in an interactive terminal so the scan never dead-ends at a file path.
253
+ const effectiveTopN = flags.topN > 0 ? flags.topN : interactive ? 3 : 0;
254
+ if (effectiveTopN > 0) {
260
255
  const allIssues = checks.flatMap((c) => c.issues.map((iss) => ({ check: c.name, weight: getCheckMeta(c.name).weight, ...iss })));
261
256
  // Sort by: errors first, then by check weight (highest-impact first)
262
257
  allIssues.sort((a, b) => {
@@ -266,9 +261,11 @@ async function printResults(report, trend, flags, outputDir) {
266
261
  return sevDiff;
267
262
  return b.weight - a.weight;
268
263
  });
269
- const top = allIssues.slice(0, flags.topN);
264
+ const top = allIssues.slice(0, effectiveTopN);
270
265
  if (top.length > 0) {
271
- console.log(` \x1b[1mTop ${top.length} issues to fix:\x1b[0m`);
266
+ const more = allIssues.length - top.length;
267
+ const moreStr = more > 0 ? ` \x1b[2m(+${more} more)\x1b[0m` : "";
268
+ console.log(` \x1b[1mTop ${top.length} issues to fix:\x1b[0m${moreStr}`);
272
269
  for (const iss of top) {
273
270
  const sevColor = iss.severity === "error" ? "\x1b[31m" : iss.severity === "warning" ? "\x1b[33m" : "\x1b[2m";
274
271
  const sevChar = iss.severity[0].toUpperCase();
@@ -278,6 +275,37 @@ async function printResults(report, trend, flags, outputDir) {
278
275
  console.log("");
279
276
  }
280
277
  }
278
+ // Next steps: surface the weakest scored dimensions and how to dig into each.
279
+ // Skipped in CI (clean machine-readable-ish output) — interactive runs get the on-ramp.
280
+ if (!flags.ciMode) {
281
+ const weakest = checks
282
+ .filter((c) => {
283
+ const det = c.details;
284
+ return !det.skipped && !det.comingSoon && c.score < 70;
285
+ })
286
+ .sort((a, b) => a.score - b.score)
287
+ .slice(0, 3);
288
+ if (weakest.length > 0) {
289
+ console.log(" \x1b[1mWeakest areas:\x1b[0m");
290
+ for (const c of weakest) {
291
+ const gc = color(c.grade);
292
+ const label = getCheckMeta(c.name).label || c.name;
293
+ console.log(` ${gc}${c.grade}\x1b[0m \x1b[2m${String(c.score).padStart(3)}\x1b[0m ${label.padEnd(18)}\x1b[2m→ vcqa explain ${c.name}\x1b[0m`);
294
+ }
295
+ console.log("");
296
+ }
297
+ }
298
+ // Report paths + the interactive on-ramp.
299
+ console.log(` \x1b[2mReport: ${join(outputDir, "report/index.html")}\x1b[0m`);
300
+ console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
301
+ if (flags.badgeMode)
302
+ console.log(` \x1b[2mBadge: ${join(outputDir, "badge.svg")}\x1b[0m`);
303
+ if (flags.sarifMode)
304
+ console.log(` \x1b[2mSARIF: ${join(outputDir, "report.sarif")}\x1b[0m`);
305
+ if (!interactive && !flags.ciMode) {
306
+ console.log(` \x1b[2mExplore: \x1b[0m\x1b[1mvcqa monitor\x1b[0m\x1b[2m — live TUI to drill into issues & copy fix-prompts\x1b[0m`);
307
+ }
308
+ console.log("");
281
309
  }
282
310
  }
283
311
  async function handleUpload(report, cwd, jsonOnly) {
@@ -866,11 +894,14 @@ async function main() {
866
894
  };
867
895
  const trend = computeTrend(report, outputDir);
868
896
  await writeOutputs(report, outputDir, flags);
897
+ // Interactive = a real terminal session (not piped, CI, JSON, or watch). Gates the
898
+ // post-scan prompt and the default top-issues view.
899
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !quietMode && !ciMode && !watchMode;
869
900
  if (flags.markdownMode) {
870
901
  console.log(generateMarkdown(report, trend));
871
902
  }
872
903
  else {
873
- await printResults(report, trend, flags, outputDir);
904
+ await printResults(report, trend, flags, outputDir, interactive);
874
905
  }
875
906
  if (flags.annotations) {
876
907
  emitAnnotations(report);
@@ -900,7 +931,62 @@ async function main() {
900
931
  }
901
932
  if (watchMode) {
902
933
  await startWatch(cwd);
934
+ return;
935
+ }
936
+ // Interactive on-ramp: offer to open the live monitor or the HTML report.
937
+ if (interactive && !flags.uploadMode && !flags.prComment) {
938
+ await promptNextAction(cwd, outputDir);
939
+ }
940
+ }
941
+ /** Read a single keypress from a TTY, restoring stdin state afterward. */
942
+ function readKey() {
943
+ return new Promise((resolve) => {
944
+ const stdin = process.stdin;
945
+ const wasRaw = stdin.isRaw;
946
+ stdin.setRawMode?.(true);
947
+ stdin.resume();
948
+ stdin.once("data", (buf) => {
949
+ stdin.setRawMode?.(wasRaw ?? false);
950
+ stdin.pause();
951
+ resolve(buf.toString("utf-8"));
952
+ });
953
+ });
954
+ }
955
+ /** Open a file/URL with the OS default handler (detached). */
956
+ function openPath(target) {
957
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
958
+ import("node:child_process").then(({ spawn }) => {
959
+ try {
960
+ spawn(cmd, [target], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
961
+ }
962
+ catch {
963
+ /* opening is best-effort */
964
+ }
965
+ });
966
+ }
967
+ /** Post-scan prompt: [m] monitor · [o] open report · anything else quits. */
968
+ async function promptNextAction(cwd, outputDir) {
969
+ process.stdout.write(" \x1b[1m[m]\x1b[0m\x1b[2m monitor\x1b[0m \x1b[1m[o]\x1b[0m\x1b[2m open report\x1b[0m \x1b[1m[enter]\x1b[0m\x1b[2m quit\x1b[0m ");
970
+ let key;
971
+ try {
972
+ key = await readKey();
973
+ }
974
+ catch {
975
+ process.stdout.write("\n");
976
+ return;
977
+ }
978
+ process.stdout.write("\n");
979
+ const k = key.toLowerCase();
980
+ if (k === "m") {
981
+ const { startMonitor } = await import("./monitor.js");
982
+ await startMonitor(cwd);
983
+ }
984
+ else if (k === "o") {
985
+ const reportPath = join(outputDir, "report/index.html");
986
+ openPath(reportPath);
987
+ console.log(` \x1b[2mOpening ${reportPath}\x1b[0m`);
903
988
  }
989
+ // any other key (enter, q, ctrl-c, …) → quit
904
990
  }
905
991
  async function checkForUpdate(currentVersion) {
906
992
  try {
package/dist/monitor.js CHANGED
@@ -425,6 +425,9 @@ function MonitorApp({ cwd }) {
425
425
  { time: ts(), text: cached ? `Loaded cached scan: ${cached.grade} ${cached.score}/100` : `Monitoring ${basename(cwd)}...`, type: cached ? "scan" : "info" },
426
426
  ]);
427
427
  const [copied, setCopied] = useState(false);
428
+ const [showHelp, setShowHelp] = useState(false);
429
+ const [search, setSearch] = useState("");
430
+ const [searchActive, setSearchActive] = useState(false);
428
431
  const scanningRef = useRef(false);
429
432
  const prevScoreRef = useRef(cached ? cached.score : null);
430
433
  const addLog = useCallback((text, type = "info") => {
@@ -438,7 +441,16 @@ function MonitorApp({ cwd }) {
438
441
  const o = { error: 0, warning: 1, info: 2 };
439
442
  return (o[a.severity] ?? 2) - (o[b.severity] ?? 2);
440
443
  }), [state.checks]);
441
- const currentList = panel === "checks" ? activeChecks : allIssues;
444
+ // Issues filtered by the dashboard search query (matches message / check / file).
445
+ const displayIssues = useMemo(() => {
446
+ if (!search)
447
+ return allIssues;
448
+ const q = search.toLowerCase();
449
+ return allIssues.filter((i) => i.message.toLowerCase().includes(q) ||
450
+ i.check.toLowerCase().includes(q) ||
451
+ (typeof i.file === "string" && i.file.toLowerCase().includes(q)));
452
+ }, [allIssues, search]);
453
+ const currentList = panel === "checks" ? activeChecks : displayIssues;
442
454
  // Derived data for file views (memoized, used by both render + keyboard)
443
455
  const filesWithIssues = useMemo(() => {
444
456
  const map = new Map();
@@ -529,13 +541,57 @@ function MonitorApp({ cwd }) {
529
541
  }, [pendingCfg, monCfg]);
530
542
  // ── Keyboard — single handler for all views ──
531
543
  useInput((input, key) => {
544
+ // Ctrl-C always quits, even mid-search
545
+ if (key.ctrl && input === "c") {
546
+ exit();
547
+ return;
548
+ }
549
+ // Help overlay swallows all input until dismissed
550
+ if (showHelp) {
551
+ if (input === "?" || input === "q" || key.escape || key.return)
552
+ setShowHelp(false);
553
+ return;
554
+ }
555
+ // Search input mode (filters the dashboard Issues panel)
556
+ if (searchActive) {
557
+ if (key.escape) {
558
+ setSearch("");
559
+ setSearchActive(false);
560
+ setCursor(0);
561
+ return;
562
+ }
563
+ if (key.return) {
564
+ setSearchActive(false);
565
+ return;
566
+ }
567
+ if (key.backspace || key.delete) {
568
+ setSearch((s) => s.slice(0, -1));
569
+ setCursor(0);
570
+ return;
571
+ }
572
+ if (input && input.length === 1 && input >= " " && !key.ctrl && !key.meta) {
573
+ setSearch((s) => s + input);
574
+ setCursor(0);
575
+ }
576
+ return;
577
+ }
532
578
  // Quit from anywhere
533
- if (input === "q" || (key.ctrl && input === "c")) {
579
+ if (input === "q") {
534
580
  exit();
535
581
  return;
536
582
  }
583
+ // Help overlay: ? opens from any view
584
+ if (input === "?") {
585
+ setShowHelp(true);
586
+ return;
587
+ }
537
588
  // Esc: drill up one level or quit
538
589
  if (key.escape) {
590
+ if (mode.view === "dashboard" && search) {
591
+ setSearch("");
592
+ setCursor(0);
593
+ return;
594
+ }
539
595
  if (mode.view === "config") {
540
596
  setPendingCfg(null);
541
597
  setMode({ view: "dashboard" });
@@ -634,6 +690,12 @@ function MonitorApp({ cwd }) {
634
690
  }
635
691
  // ── Dashboard navigation ──
636
692
  if (mode.view === "dashboard") {
693
+ if (input === "/") {
694
+ setSearchActive(true);
695
+ setPanel("issues");
696
+ setCursor(0);
697
+ return;
698
+ }
637
699
  if (key.tab) {
638
700
  setPanel((p) => p === "checks" ? "issues" : "checks");
639
701
  setCursor(0);
@@ -648,8 +710,8 @@ function MonitorApp({ cwd }) {
648
710
  setCursor(0);
649
711
  return;
650
712
  }
651
- if (key.return && panel === "issues" && allIssues[cursor]) {
652
- setMode({ view: "check-detail", checkName: allIssues[cursor].check });
713
+ if (key.return && panel === "issues" && displayIssues[cursor]) {
714
+ setMode({ view: "check-detail", checkName: displayIssues[cursor].check });
653
715
  setCursor(0);
654
716
  return;
655
717
  }
@@ -740,6 +802,9 @@ function MonitorApp({ cwd }) {
740
802
  const proj = basename(cwd);
741
803
  const p = monCfg.panels;
742
804
  // ── Render views ──
805
+ if (showHelp) {
806
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { proj: proj, stack: stack, workspace: workspace, state: state }), _jsx(HelpOverlay, { height: rows - 3 }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "? or Esc to close" }) })] }));
807
+ }
743
808
  if (mode.view === "all-files") {
744
809
  return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { proj: proj, stack: stack, workspace: workspace, state: state }), _jsx(AllFilesView, { checks: state.checks, height: rows - 3, cursor: cursor }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Esc back \u00B7 \u2191\u2193 select \u00B7 Enter view file issues \u00B7 q quit" }) })] }));
745
810
  }
@@ -781,10 +846,42 @@ function MonitorApp({ cwd }) {
781
846
  return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { proj: proj, stack: stack, workspace: workspace, state: state }), _jsx(Box, { paddingX: 1, gap: 2, height: 1, children: state.scanCount > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "E " }), _jsx(Text, { color: "red", bold: true, children: errorCount })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "W " }), _jsx(Text, { color: "yellow", bold: true, children: warnCount })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "checks " }), _jsx(Text, { bold: true, children: activeChecks.length })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "scan " }), _jsxs(Text, { children: [state.duration, "ms"] })] }), state.scores.length >= 2 && _jsx(Text, { color: "cyan", children: spark(state.scores) })] })) }), _jsxs(Box, { height: bodyRows, children: [sidebarVisible && (_jsxs(Box, { flexDirection: "column", width: 26, children: [p.score && _jsx(ScorePanel, { state: state, height: scoreH }), p.checks && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panel === "checks" ? "magenta" : "gray", paddingX: 1, width: 24, height: checksH, overflowY: "hidden", children: [_jsxs(Text, { bold: true, color: "magenta", children: [" \u25C8 Checks ", panel === "checks" && _jsx(Text, { dimColor: true, children: "\u25C4" })] }), activeChecks.slice(0, checksH - 3).map((c, i) => {
782
847
  const sel = panel === "checks" && i === cursor;
783
848
  return (_jsxs(Text, { children: [_jsxs(Text, { color: sel ? "white" : gc(c.grade), children: [sel ? "▸" : " ", c.grade === "A" ? "●" : c.grade === "B" ? "◐" : "○", " "] }), _jsx(Text, { bold: sel, children: c.name.slice(0, 13).padEnd(13) }), _jsx(Text, { color: gc(c.grade), children: String(c.score).padStart(3) })] }, c.name));
784
- })] }))] })), mainVisible && (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [p.activity && _jsx(ActivityPanel, { log: log, height: activityH }), p.issues && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panel === "issues" ? "magenta" : "gray", paddingX: 1, height: issuesH, overflowY: "hidden", children: [_jsxs(Text, { bold: true, color: "magenta", children: [" ", "\u25C8 Issues (", allIssues.length, ") ", panel === "issues" && _jsx(Text, { dimColor: true, children: "\u25C4" })] }), allIssues.slice(0, issuesH - 3).map((iss, i) => {
849
+ })] }))] })), mainVisible && (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [p.activity && _jsx(ActivityPanel, { log: log, height: activityH }), p.issues && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panel === "issues" ? "magenta" : "gray", paddingX: 1, height: issuesH, overflowY: "hidden", children: [_jsxs(Text, { bold: true, color: "magenta", children: [" ", "\u25C8 Issues (", displayIssues.length, search ? `/${allIssues.length}` : "", ") ", panel === "issues" && _jsx(Text, { dimColor: true, children: "\u25C4" })] }), (searchActive || search) && (_jsxs(Text, { children: [" ", _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: searchActive ? "white" : "cyan", children: search }), searchActive && _jsx(Text, { color: "white", children: "\u258C" })] })), displayIssues.length === 0 && (_jsxs(Text, { color: search ? "yellow" : "green", children: [" ", search ? "No issues match." : state.scanCount > 0 ? "✓ No issues — clean scan!" : "Scanning…"] })), displayIssues.slice(0, issuesH - 3).map((iss, i) => {
785
850
  const sel = panel === "issues" && i === cursor;
786
851
  return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: sel ? "white" : "gray", children: sel ? "▸" : " " }), _jsxs(Text, { color: sc(iss.severity), bold: true, children: [iss.severity[0].toUpperCase(), " "] }), _jsxs(Text, { dimColor: true, children: [(iss.check || "").slice(0, 11).padEnd(11), " "] }), iss.file && _jsxs(Text, { color: "cyan", children: [basename(String(iss.file)).slice(0, 18).padEnd(18), " "] }), _jsx(Text, { children: iss.message.slice(0, 40) })] }, `${iss.check}-${iss.file || ""}-${iss.line || i}`));
787
- }), allIssues.length > issuesH - 3 && _jsxs(Text, { dimColor: true, children: [" +", allIssues.length - (issuesH - 3), " more"] })] }))] })), !sidebarVisible && !mainVisible && (_jsx(Box, { height: bodyRows, justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "All panels hidden. Press c to configure." }) }))] }), _jsx(Box, { paddingX: 1, justifyContent: "space-between", children: _jsx(Text, { dimColor: true, children: "Tab panel \u00B7 \u2191\u2193 Enter Esc \u00B7 r scan \u00B7 f files \u00B7 g git \u00B7 t trends \u00B7 c config \u00B7 q" }) })] }));
852
+ }), displayIssues.length > issuesH - 3 && _jsxs(Text, { dimColor: true, children: [" +", displayIssues.length - (issuesH - 3), " more"] })] }))] })), !sidebarVisible && !mainVisible && (_jsx(Box, { height: bodyRows, justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "All panels hidden. Press c to configure." }) }))] }), _jsx(Box, { paddingX: 1, justifyContent: "space-between", children: _jsx(Text, { dimColor: true, children: "Tab \u00B7 \u2191\u2193 Enter Esc \u00B7 / search \u00B7 r scan \u00B7 f files \u00B7 g git \u00B7 t trends \u00B7 c config \u00B7 ? help \u00B7 q" }) })] }));
853
+ }
854
+ function HelpOverlay({ height }) {
855
+ const groups = [
856
+ {
857
+ title: "Navigate",
858
+ keys: [
859
+ ["↑ ↓", "move selection"],
860
+ ["Enter", "drill in (check → issue → source)"],
861
+ ["Tab", "switch Checks / Issues panel"],
862
+ ["Esc", "back up one level (or clear search)"],
863
+ ["q · Ctrl-C", "quit"],
864
+ ],
865
+ },
866
+ {
867
+ title: "Views",
868
+ keys: [
869
+ ["r", "re-scan now"],
870
+ ["f", "all files by issue count"],
871
+ ["g", "git-changed files"],
872
+ ["t", "score trends"],
873
+ ["c", "config (thresholds, panels)"],
874
+ ],
875
+ },
876
+ {
877
+ title: "Issues",
878
+ keys: [
879
+ ["/", "search / filter issues"],
880
+ ["y", "copy an AI fix-prompt to clipboard"],
881
+ ],
882
+ },
883
+ ];
884
+ return (_jsxs(Box, { flexDirection: "column", height: height, paddingX: 2, paddingY: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "magenta", children: "\u25C8 Keyboard shortcuts" }), _jsx(Text, { children: " " }), groups.map((g) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: g.title }), g.keys.map(([k, desc]) => (_jsxs(Text, { children: [_jsx(Text, { color: "white", bold: true, children: ` ${k}`.padEnd(16) }), _jsx(Text, { dimColor: true, children: desc })] }, k)))] }, g.title)))] }));
788
885
  }
789
886
  function Header({ proj, stack, workspace, state }) {
790
887
  return (_jsxs(Box, { paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: "magenta", bold: true, children: "vcqa monitor" }), _jsxs(Text, { dimColor: true, children: [" v", VERSION] })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: proj }), _jsxs(Text, { dimColor: true, children: [" ", stack.language, "/", stack.framework, workspace.isMonorepo ? ` · ${workspace.tool}` : ""] })] }), _jsxs(Text, { children: [state.score > 0 && _jsxs(Text, { color: gc(state.grade), bold: true, children: [state.grade, " ", state.score, " "] }), _jsx(Text, { dimColor: true, children: state.scanning ? "⟳ scanning" : "● watching" })] })] }));
@@ -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.37.4",
3
+ "version": "0.38.1",
4
4
  "description": "Code health scanner for the AI coding era. 25 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {