sapper-ai 0.5.0 → 0.6.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.
Files changed (43) hide show
  1. package/dist/auth.d.ts +11 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +102 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +316 -32
  6. package/dist/harden.d.ts +28 -0
  7. package/dist/harden.d.ts.map +1 -0
  8. package/dist/harden.js +309 -0
  9. package/dist/mcp/jsonc.d.ts +3 -0
  10. package/dist/mcp/jsonc.d.ts.map +1 -0
  11. package/dist/mcp/jsonc.js +119 -0
  12. package/dist/mcp/wrapConfig.d.ts +22 -0
  13. package/dist/mcp/wrapConfig.d.ts.map +1 -0
  14. package/dist/mcp/wrapConfig.js +192 -0
  15. package/dist/policyYaml.d.ts +3 -0
  16. package/dist/policyYaml.d.ts.map +1 -0
  17. package/dist/policyYaml.js +27 -0
  18. package/dist/postinstall.d.ts.map +1 -1
  19. package/dist/postinstall.js +11 -2
  20. package/dist/quarantine.d.ts +13 -0
  21. package/dist/quarantine.d.ts.map +1 -0
  22. package/dist/quarantine.js +22 -0
  23. package/dist/report.d.ts.map +1 -1
  24. package/dist/report.js +1061 -59
  25. package/dist/scan.d.ts +15 -0
  26. package/dist/scan.d.ts.map +1 -1
  27. package/dist/scan.js +179 -178
  28. package/dist/utils/env.d.ts +3 -0
  29. package/dist/utils/env.d.ts.map +1 -0
  30. package/dist/utils/env.js +25 -0
  31. package/dist/utils/format.d.ts +22 -0
  32. package/dist/utils/format.d.ts.map +1 -0
  33. package/dist/utils/format.js +97 -0
  34. package/dist/utils/fs.d.ts +7 -0
  35. package/dist/utils/fs.d.ts.map +1 -0
  36. package/dist/utils/fs.js +47 -0
  37. package/dist/utils/repoRoot.d.ts +2 -0
  38. package/dist/utils/repoRoot.d.ts.map +1 -0
  39. package/dist/utils/repoRoot.js +20 -0
  40. package/dist/utils/semver.d.ts +2 -0
  41. package/dist/utils/semver.d.ts.map +1 -0
  42. package/dist/utils/semver.js +7 -0
  43. package/package.json +5 -7
package/dist/report.js CHANGED
@@ -45,8 +45,11 @@ body {
45
45
  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
46
46
  background: var(--bg-primary);
47
47
  color: var(--text-primary);
48
+ line-height: 1.45;
48
49
  }
49
50
 
51
+ button, input { font: inherit; }
52
+
50
53
  header {
51
54
  position: sticky;
52
55
  top: 0;
@@ -69,6 +72,16 @@ header {
69
72
  font-size: 12px;
70
73
  color: var(--text-secondary);
71
74
  text-align: right;
75
+ min-width: 0;
76
+ }
77
+
78
+ .meta-scope {
79
+ display: inline-block;
80
+ max-width: 520px;
81
+ vertical-align: bottom;
82
+ white-space: nowrap;
83
+ overflow: hidden;
84
+ text-overflow: ellipsis;
72
85
  }
73
86
 
74
87
  #theme-toggle {
@@ -87,33 +100,54 @@ header {
87
100
  }
88
101
 
89
102
  .summary {
90
- display: grid;
91
- grid-template-columns: repeat(4, minmax(0, 1fr));
92
- gap: 12px;
103
+ display: flex;
104
+ flex-wrap: nowrap;
105
+ gap: 10px;
106
+ overflow-x: auto;
107
+ overflow-y: hidden;
108
+ padding-bottom: 4px;
109
+ scrollbar-gutter: stable;
110
+ -webkit-overflow-scrolling: touch;
111
+ }
112
+
113
+ .summary:focus-visible {
114
+ outline: 2px solid var(--accent);
115
+ outline-offset: 4px;
116
+ border-radius: 16px;
93
117
  }
94
118
 
95
119
  .metric-card {
96
120
  background: var(--bg-secondary);
97
121
  border: 1px solid var(--border);
98
122
  border-radius: 14px;
99
- padding: 12px;
123
+ padding: 10px 12px;
124
+ flex: 1 0 150px;
125
+ min-width: 150px;
100
126
  }
101
127
 
102
128
  .metric-card .label {
103
129
  display: block;
104
- font-size: 12px;
130
+ font-size: 11px;
105
131
  color: var(--text-secondary);
132
+ white-space: nowrap;
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
106
135
  }
107
136
 
108
137
  .metric-card .value {
109
138
  display: block;
110
- margin-top: 6px;
111
- font-size: 20px;
139
+ margin-top: 4px;
140
+ font-size: 18px;
112
141
  font-variant-numeric: tabular-nums;
142
+ white-space: nowrap;
113
143
  }
114
144
 
115
145
  .metric-card .value.danger { color: var(--risk-critical); }
116
146
 
147
+ .metric-card[data-metric="coverage"] .value {
148
+ font-size: 16px;
149
+ }
150
+
117
151
  .chart {
118
152
  margin-top: 12px;
119
153
  background: var(--bg-secondary);
@@ -181,28 +215,140 @@ header {
181
215
  gap: 8px;
182
216
  }
183
217
 
218
+ .toggle.inline { margin-top: 0; }
219
+
220
+ .tree-actions {
221
+ margin-top: 10px;
222
+ display: flex;
223
+ flex-wrap: wrap;
224
+ align-items: center;
225
+ gap: 8px;
226
+ }
227
+
228
+ .mini-btn {
229
+ border: 1px solid var(--border);
230
+ background: transparent;
231
+ color: var(--text-primary);
232
+ padding: 6px 10px;
233
+ border-radius: 999px;
234
+ cursor: pointer;
235
+ font-size: 12px;
236
+ }
237
+
238
+ .mini-btn:hover { background: var(--bg-tertiary); }
239
+
240
+ .mini-btn:focus-visible,
241
+ #theme-toggle:focus-visible,
242
+ #tree-search:focus-visible,
243
+ #tree summary:focus-visible,
244
+ .file-btn:focus-visible,
245
+ .match-btn:focus-visible {
246
+ outline: 2px solid var(--accent);
247
+ outline-offset: 2px;
248
+ }
249
+
250
+ .tree-root {
251
+ margin-top: 10px;
252
+ font-size: 12px;
253
+ color: var(--text-secondary);
254
+ word-break: break-all;
255
+ }
256
+
257
+ .tree-empty {
258
+ padding: 10px;
259
+ border: 1px dashed var(--border);
260
+ border-radius: 12px;
261
+ color: var(--text-secondary);
262
+ font-size: 12px;
263
+ }
264
+
184
265
  #tree {
185
266
  padding: 10px;
186
267
  overflow: auto;
187
268
  flex: 1;
188
269
  }
189
270
 
190
- details {
271
+ #tree details {
191
272
  border-radius: 10px;
192
273
  }
193
- summary {
274
+
275
+ #tree details > summary::-webkit-details-marker { display: none; }
276
+
277
+ #tree summary {
194
278
  cursor: pointer;
195
279
  padding: 8px 10px;
196
280
  border-radius: 10px;
197
281
  color: var(--text-primary);
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 8px;
285
+ list-style: none;
286
+ }
287
+
288
+ #tree summary:hover { background: var(--bg-tertiary); }
289
+
290
+ .tree-icon {
291
+ width: 16px;
292
+ height: 16px;
293
+ display: inline-flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ flex: 0 0 auto;
297
+ color: var(--text-muted);
298
+ }
299
+ .tree-icon svg { width: 16px; height: 16px; fill: currentColor; }
300
+ .tree-spacer { width: 14px; height: 14px; flex: 0 0 auto; }
301
+
302
+ #tree details > summary .chev {
303
+ width: 14px;
304
+ height: 14px;
305
+ display: inline-flex;
306
+ align-items: center;
307
+ justify-content: center;
308
+ border-radius: 6px;
309
+ color: var(--text-secondary);
310
+ font-size: 12px;
311
+ flex: 0 0 auto;
312
+ transition: transform 140ms ease;
313
+ }
314
+
315
+ #tree details[open] > summary .chev { transform: rotate(90deg); }
316
+
317
+ #tree details > summary .dirname { flex: 1 1 auto; }
318
+
319
+ #tree details > summary .dir-badge {
320
+ font-size: 11px;
321
+ padding: 2px 8px;
322
+ border-radius: 999px;
323
+ border: 1px solid var(--border);
324
+ color: var(--text-secondary);
325
+ }
326
+
327
+ #tree details > summary .dir-badge.warn {
328
+ border-color: rgba(245, 158, 11, 0.6);
329
+ color: #f59e0b;
330
+ }
331
+
332
+ #tree details > summary .dir-badge.danger {
333
+ border-color: rgba(239, 68, 68, 0.65);
334
+ color: #ef4444;
335
+ }
336
+
337
+ #tree details > div {
338
+ margin-left: 14px;
339
+ padding-left: 10px;
340
+ border-left: 1px solid var(--border);
341
+ }
342
+
343
+ #tree > div > details > div {
344
+ margin-left: 0;
198
345
  }
199
- summary:hover { background: var(--bg-tertiary); }
200
346
 
201
347
  .file-btn {
202
348
  width: 100%;
203
349
  text-align: left;
204
350
  display: flex;
205
- align-items: center;
351
+ align-items: flex-start;
206
352
  gap: 10px;
207
353
  padding: 8px 10px;
208
354
  border: 1px solid transparent;
@@ -219,6 +365,27 @@ summary:hover { background: var(--bg-tertiary); }
219
365
  border-left: 3px solid var(--accent);
220
366
  }
221
367
 
368
+ .file-btn .fname {
369
+ flex: 1 1 auto;
370
+ min-width: 0;
371
+ display: flex;
372
+ flex-direction: column;
373
+ gap: 2px;
374
+ }
375
+
376
+ .file-btn .fname > span {
377
+ overflow: hidden;
378
+ text-overflow: ellipsis;
379
+ white-space: nowrap;
380
+ }
381
+
382
+ .file-btn .fname .sub {
383
+ font-size: 11px;
384
+ color: var(--text-secondary);
385
+ }
386
+
387
+ .tree-spacer { width: 14px; flex: 0 0 auto; }
388
+
222
389
  .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
223
390
  .dot.critical { background: var(--risk-critical); }
224
391
  .dot.high { background: var(--risk-high); }
@@ -232,6 +399,7 @@ summary:hover { background: var(--bg-tertiary); }
232
399
  }
233
400
 
234
401
  .file-header { padding-bottom: 12px; border-bottom: 1px solid var(--border); }
402
+ .file-path-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
235
403
  .file-path { font-size: 12px; color: var(--text-secondary); word-break: break-all; }
236
404
  .file-name { margin-top: 6px; font-size: 18px; font-weight: 700; }
237
405
  .metrics { margin-top: 10px; display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); }
@@ -242,6 +410,7 @@ summary:hover { background: var(--bg-tertiary); }
242
410
  .section { margin-top: 14px; }
243
411
  .section h3 { margin: 0 0 8px; font-size: 13px; color: var(--text-secondary); }
244
412
  .patterns ul { margin: 0; padding-left: 18px; }
413
+ .reasons ul { margin: 0; padding-left: 18px; }
245
414
 
246
415
  pre {
247
416
  margin: 0;
@@ -258,6 +427,74 @@ code {
258
427
  white-space: pre;
259
428
  }
260
429
 
430
+ mark.hl {
431
+ color: inherit;
432
+ border-radius: 6px;
433
+ padding: 0 2px;
434
+ text-decoration-line: underline;
435
+ text-decoration-style: wavy;
436
+ text-decoration-thickness: 2px;
437
+ text-underline-offset: 3px;
438
+ background: linear-gradient(to bottom, transparent 62%, rgba(245, 158, 11, 0.14) 62%);
439
+ text-decoration-color: rgba(245, 158, 11, 0.85);
440
+ }
441
+
442
+ mark.hl[data-sev="high"] {
443
+ background: linear-gradient(to bottom, transparent 62%, rgba(239, 68, 68, 0.14) 62%);
444
+ text-decoration-color: rgba(239, 68, 68, 0.9);
445
+ }
446
+
447
+ mark.hl[data-sev="medium"] {
448
+ background: linear-gradient(to bottom, transparent 62%, rgba(245, 158, 11, 0.14) 62%);
449
+ text-decoration-color: rgba(245, 158, 11, 0.85);
450
+ }
451
+
452
+ mark.hl.active {
453
+ text-decoration-thickness: 3px;
454
+ filter: saturate(1.2);
455
+ }
456
+
457
+ .match-list { display: flex; flex-direction: column; gap: 6px; }
458
+ .match-btn {
459
+ text-align: left;
460
+ border: 1px solid var(--border);
461
+ background: transparent;
462
+ color: var(--text-primary);
463
+ padding: 8px 10px;
464
+ border-radius: 10px;
465
+ cursor: pointer;
466
+ font-size: 12px;
467
+ display: flex;
468
+ align-items: baseline;
469
+ justify-content: space-between;
470
+ gap: 10px;
471
+ }
472
+ .match-btn:hover { background: var(--bg-tertiary); }
473
+ .loc { color: var(--text-secondary); font-size: 11px; font-variant-numeric: tabular-nums; }
474
+
475
+ .muted { color: var(--text-secondary); font-size: 12px; margin: 8px 0 0; }
476
+
477
+ .match-context-list { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; }
478
+ .match-context {
479
+ border: 1px solid var(--border);
480
+ background: var(--bg-secondary);
481
+ border-radius: 14px;
482
+ padding: 10px;
483
+ }
484
+ .match-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
485
+ .match-needle { font-size: 12px; color: var(--text-secondary); margin-top: 4px; word-break: break-word; }
486
+ .match-context pre { margin-top: 8px; }
487
+
488
+ .file-btn:focus-visible,
489
+ #tree summary:focus-visible,
490
+ #tree-search:focus-visible,
491
+ #theme-toggle:focus-visible,
492
+ .mini-btn:focus-visible,
493
+ .match-btn:focus-visible {
494
+ outline: 2px solid var(--accent);
495
+ outline-offset: 2px;
496
+ }
497
+
261
498
  @media (max-width: 1024px) {
262
499
  .main { grid-template-columns: 40% 60%; }
263
500
  }
@@ -265,15 +502,78 @@ code {
265
502
  @media (max-width: 768px) {
266
503
  header { height: auto; padding: 12px 14px; gap: 10px; flex-wrap: wrap; }
267
504
  .container { padding: 14px; }
268
- .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
505
+ .summary { gap: 8px; }
506
+ .metric-card { flex-basis: 140px; min-width: 140px; }
269
507
  .main { grid-template-columns: 1fr; }
270
508
  #tree { max-height: 260px; }
271
509
  }
272
510
 
511
+ @media (prefers-contrast: more) {
512
+ :root, [data-theme="dark"] {
513
+ --border: #4b4b4b;
514
+ --text-secondary: #d1d1d1;
515
+ --text-muted: #a0a0a0;
516
+ }
517
+ [data-theme="light"] {
518
+ --border: #111827;
519
+ --text-secondary: #374151;
520
+ --text-muted: #4b5563;
521
+ }
522
+ }
523
+
524
+ @media (forced-colors: active) {
525
+ .metric-card,
526
+ .panel,
527
+ pre,
528
+ #theme-toggle,
529
+ #tree-search,
530
+ .mini-btn {
531
+ border-color: CanvasText;
532
+ }
533
+ .metric-card .value.danger { color: CanvasText; }
534
+ .badge,
535
+ .badge.block,
536
+ .badge.allow {
537
+ border-color: CanvasText;
538
+ color: CanvasText;
539
+ }
540
+ .file-btn.active {
541
+ outline: 2px solid Highlight;
542
+ outline-offset: 2px;
543
+ border-color: Highlight;
544
+ border-left-color: Highlight;
545
+ }
546
+ }
547
+
273
548
  @media print {
274
549
  * { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
275
550
  header { position: static; }
276
551
  .main { grid-template-columns: 1fr; }
552
+ .summary { overflow: visible; flex-wrap: wrap; padding-bottom: 0; }
553
+ .metric-card { min-width: auto; flex: 1 1 220px; }
554
+ .metric-card .label,
555
+ .metric-card .value {
556
+ white-space: normal;
557
+ overflow: visible;
558
+ text-overflow: clip;
559
+ }
560
+ .meta-scope { max-width: none; white-space: normal; }
561
+ #tree,
562
+ .detail-inner,
563
+ pre {
564
+ overflow: visible;
565
+ max-height: none;
566
+ }
567
+ pre { white-space: pre-wrap; }
568
+ code { white-space: pre-wrap; overflow-wrap: anywhere; }
569
+ #theme-toggle,
570
+ .controls,
571
+ .tree-actions,
572
+ #tree-search,
573
+ .mini-btn,
574
+ .toggle {
575
+ display: none !important;
576
+ }
277
577
  }
278
578
  `.trim();
279
579
  }
@@ -281,6 +581,75 @@ function generateJs() {
281
581
  return `
282
582
  const el = (sel) => document.querySelector(sel);
283
583
 
584
+ const ICON_FOLDER =
585
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 4l2 2h8a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2h6z"/></svg>';
586
+ const ICON_FILE =
587
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm0 2.5L19.5 10H14V4.5z"/></svg>';
588
+
589
+ function prefersReducedMotion() {
590
+ return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
591
+ }
592
+
593
+ function normalizePath(p) {
594
+ return String(p || '').replace(/\\\\/g, '/');
595
+ }
596
+
597
+ function computeBasePath(findings, targetStr) {
598
+ const paths = [];
599
+ for (const f of findings || []) {
600
+ const fp = normalizePath(f && f.filePath);
601
+ if (fp) paths.push(fp);
602
+ }
603
+ if (paths.length === 0) return '';
604
+
605
+ const targets = String(targetStr || '')
606
+ .split(',')
607
+ .map((s) => s.trim())
608
+ .filter(Boolean)
609
+ .map(normalizePath);
610
+
611
+ if (targets.length === 1) {
612
+ const t = targets[0].replace(/\\/+$/, '');
613
+ if (t && paths.every((p) => p === t || p.startsWith(t + '/'))) {
614
+ return t + '/';
615
+ }
616
+ }
617
+
618
+ const segs = paths.map((p) => p.split('/').filter(Boolean));
619
+ let prefix = segs[0].slice();
620
+ for (let i = 1; i < segs.length; i++) {
621
+ const s = segs[i];
622
+ let j = 0;
623
+ while (j < prefix.length && j < s.length && prefix[j] === s[j]) j += 1;
624
+ prefix = prefix.slice(0, j);
625
+ if (prefix.length === 0) break;
626
+ }
627
+
628
+ if (prefix.length < 2) return '';
629
+ const lead = paths[0].startsWith('/') ? '/' : '';
630
+ return lead + prefix.join('/') + '/';
631
+ }
632
+
633
+ function toDisplayPath(filePath, basePath) {
634
+ const full = normalizePath(filePath);
635
+ const base = normalizePath(basePath);
636
+ if (base && full.startsWith(base)) {
637
+ return full.slice(base.length).replace(/^\\/+/, '');
638
+ }
639
+ return full.replace(/^\\/+/, '');
640
+ }
641
+
642
+ function fileDisplayInfo(finding, basePath) {
643
+ const fullPath = String(finding && finding.filePath ? finding.filePath : '');
644
+ const normalizedFull = normalizePath(fullPath);
645
+ const displayPath = toDisplayPath(normalizedFull, basePath);
646
+ const displayName =
647
+ displayPath.split('/').filter(Boolean).pop() ||
648
+ normalizedFull.split('/').filter(Boolean).pop() ||
649
+ normalizedFull;
650
+ return { displayPath, displayName };
651
+ }
652
+
284
653
  function escapeHtml(text) {
285
654
  return String(text)
286
655
  .replace(/&/g, '&amp;')
@@ -304,11 +673,39 @@ function debounce(fn, ms) {
304
673
  };
305
674
  }
306
675
 
307
- function buildFileTree(findings) {
308
- const root = { files: [], dirs: new Map() };
676
+ function getDirOpenState() {
677
+ if (!window.__DIR_OPEN) window.__DIR_OPEN = {};
678
+ return window.__DIR_OPEN;
679
+ }
680
+
681
+ function buildFileTree(findings, basePath) {
682
+ function mkNode(name) {
683
+ return {
684
+ name,
685
+ files: [],
686
+ dirs: new Map(),
687
+ stats: { total: 0, threats: 0, maxRisk: 0 },
688
+ };
689
+ }
690
+
691
+ function bump(node, finding) {
692
+ const r = Number(finding && finding.risk);
693
+ const risk = Number.isFinite(r) ? r : 0;
694
+ node.stats.total += 1;
695
+ if (risk >= 0.5) node.stats.threats += 1;
696
+ if (risk > node.stats.maxRisk) node.stats.maxRisk = risk;
697
+ }
698
+
699
+ const root = mkNode('');
309
700
  for (const f of findings) {
310
- const parts = String(f.filePath).split('/').filter(Boolean);
701
+ const info = fileDisplayInfo(f, basePath);
702
+ f.__displayPath = info.displayPath;
703
+ f.__displayName = info.displayName;
704
+
705
+ const parts = String(info.displayPath).split('/').filter(Boolean);
706
+ if (parts.length === 0) continue;
311
707
  let node = root;
708
+ bump(node, f);
312
709
  for (let i = 0; i < parts.length; i++) {
313
710
  const part = parts[i];
314
711
  const isFile = i === parts.length - 1;
@@ -316,51 +713,113 @@ function buildFileTree(findings) {
316
713
  node.files.push(f);
317
714
  } else {
318
715
  if (!node.dirs.has(part)) {
319
- node.dirs.set(part, { name: part, files: [], dirs: new Map() });
716
+ node.dirs.set(part, mkNode(part));
320
717
  }
321
718
  node = node.dirs.get(part);
719
+ bump(node, f);
322
720
  }
323
721
  }
324
722
  }
325
723
  return root;
326
724
  }
327
725
 
328
- function renderTreeNode(node, opts) {
726
+ function renderTreeNode(node, opts, parentPath) {
329
727
  const container = document.createElement('div');
330
728
 
331
729
  const dirEntries = Array.from(node.dirs.values()).sort((a, b) => a.name.localeCompare(b.name));
332
730
  for (const dir of dirEntries) {
333
731
  const details = document.createElement('details');
334
- details.open = true;
732
+ const dirPath = (parentPath ? parentPath + '/' : '') + String(dir.name);
733
+ details.setAttribute('data-dir', dirPath);
734
+ const openState = getDirOpenState();
735
+ if (opts && opts.query) {
736
+ details.open = true;
737
+ } else if (Object.prototype.hasOwnProperty.call(openState, dirPath)) {
738
+ details.open = !!openState[dirPath];
739
+ } else {
740
+ details.open = parentPath === '';
741
+ }
742
+ details.addEventListener('toggle', () => {
743
+ const state = getDirOpenState();
744
+ state[dirPath] = details.open;
745
+ });
746
+
335
747
  const summary = document.createElement('summary');
336
- summary.textContent = dir.name;
748
+ summary.title = dirPath;
749
+
750
+ const chev = document.createElement('span');
751
+ chev.className = 'chev';
752
+ chev.textContent = '>';
753
+ summary.appendChild(chev);
754
+
755
+ const icon = document.createElement('span');
756
+ icon.className = 'tree-icon';
757
+ icon.innerHTML = ICON_FOLDER;
758
+ summary.appendChild(icon);
759
+
760
+ const dirname = document.createElement('span');
761
+ dirname.className = 'dirname';
762
+ dirname.textContent = String(dir.name);
763
+ summary.appendChild(dirname);
764
+
765
+ const badge = document.createElement('span');
766
+ const threats = Number(dir.stats && dir.stats.threats) || 0;
767
+ const total = Number(dir.stats && dir.stats.total) || 0;
768
+ const maxRisk = Number(dir.stats && dir.stats.maxRisk) || 0;
769
+ badge.className = 'dir-badge' + (maxRisk >= 0.8 ? ' danger' : threats > 0 ? ' warn' : '');
770
+ badge.title = threats > 0 ? (threats + ' threats') : (total + ' files');
771
+ badge.textContent = threats > 0 ? (threats + '/' + total) : String(total);
772
+ summary.appendChild(badge);
773
+
337
774
  details.appendChild(summary);
338
- details.appendChild(renderTreeNode(dir, opts));
775
+ details.appendChild(renderTreeNode(dir, opts, dirPath));
339
776
  container.appendChild(details);
340
777
  }
341
778
 
342
- const files = node.files.slice().sort((a, b) => String(a.filePath).localeCompare(String(b.filePath)));
779
+ const files = node.files
780
+ .slice()
781
+ .sort((a, b) => String(a.__displayPath || a.filePath).localeCompare(String(b.__displayPath || b.filePath)));
343
782
  for (const f of files) {
344
- const threat = Number(f.risk) >= 0.5;
345
- if (opts.threatsOnly && !threat) continue;
346
- if (opts.query) {
347
- const name = String(f.filePath).split('/').pop() || '';
348
- if (!name.toLowerCase().includes(opts.query.toLowerCase())) continue;
349
- }
350
-
351
783
  const btn = document.createElement('button');
352
784
  btn.className = 'file-btn';
353
785
  btn.type = 'button';
354
786
  btn.setAttribute('data-file', String(f.filePath));
355
787
  btn.setAttribute('role', 'treeitem');
356
788
  btn.tabIndex = -1;
789
+ btn.title = String(f.__displayPath || f.filePath);
790
+
791
+ const spacer = document.createElement('span');
792
+ spacer.className = 'tree-spacer';
793
+ btn.appendChild(spacer);
357
794
 
358
795
  const dot = document.createElement('span');
359
796
  dot.className = 'dot ' + riskLevel(Number(f.risk));
360
797
  btn.appendChild(dot);
361
798
 
799
+ const icon = document.createElement('span');
800
+ icon.className = 'tree-icon';
801
+ icon.innerHTML = ICON_FILE;
802
+ btn.appendChild(icon);
803
+
362
804
  const label = document.createElement('span');
363
- label.textContent = String(f.filePath).split('/').pop() || String(f.filePath);
805
+ label.className = 'fname';
806
+ const displayName =
807
+ String(f.__displayName || '').trim() ||
808
+ String(f.filePath).split('/').pop() ||
809
+ String(f.filePath);
810
+
811
+ const base = document.createElement('span');
812
+ base.textContent = displayName;
813
+ label.appendChild(base);
814
+
815
+ const dp = String(f.__displayPath || '');
816
+ const parent = dp.split('/').slice(0, -1).join('/');
817
+ if (parent) {
818
+ const sub = document.createElement('span');
819
+ sub.className = 'sub';
820
+ sub.textContent = parent;
821
+ label.appendChild(sub);
822
+ }
364
823
  btn.appendChild(label);
365
824
 
366
825
  btn.addEventListener('click', () => handleFileClick(String(f.filePath)));
@@ -370,32 +829,367 @@ function renderTreeNode(node, opts) {
370
829
  return container;
371
830
  }
372
831
 
832
+ function uniqStrings(values) {
833
+ const out = [];
834
+ const seen = new Set();
835
+ for (const v of values) {
836
+ const s = String(v || '').trim();
837
+ if (!s) continue;
838
+ const k = s.toLowerCase();
839
+ if (seen.has(k)) continue;
840
+ seen.add(k);
841
+ out.push(s);
842
+ }
843
+ return out;
844
+ }
845
+
846
+ function extractQuotedSegments(text) {
847
+ const out = [];
848
+ const s = String(text || '');
849
+ const re = /\\x60([^\\x60]{3,120})\\x60|"([^"]{3,120})"|'([^']{3,120})'/g;
850
+ let m;
851
+ while ((m = re.exec(s))) {
852
+ out.push(m[1] || m[2] || m[3] || '');
853
+ }
854
+ return out;
855
+ }
856
+
857
+ function buildHighlightNeedles(patterns, reasons) {
858
+ const needles = [];
859
+
860
+ const add = (value) => {
861
+ const s = String(value || '').trim();
862
+ if (!s) return;
863
+ needles.push(s);
864
+ };
865
+
866
+ for (const p of patterns || []) {
867
+ add(p);
868
+ const parts = String(p || '').split(/[^a-zA-Z0-9_-]+/).filter(Boolean);
869
+ for (const part of parts) {
870
+ if (part.length >= 6 || /[\\d_-]/.test(part)) add(part);
871
+ }
872
+ }
873
+
874
+ for (const r of reasons || []) {
875
+ for (const q of extractQuotedSegments(r)) add(q);
876
+ }
877
+
878
+ const unique = uniqStrings(needles).filter((s) => s.length >= 3 && s.length <= 160);
879
+ return unique.slice(0, 24);
880
+ }
881
+
882
+ function indexToLineCol(text, index) {
883
+ const s = String(text || '');
884
+ const n = Math.max(0, Math.min(s.length, Number(index) || 0));
885
+ let line = 1;
886
+ let col = 1;
887
+ for (let i = 0; i < n; i++) {
888
+ if (s.charCodeAt(i) === 10) {
889
+ line += 1;
890
+ col = 1;
891
+ } else {
892
+ col += 1;
893
+ }
894
+ }
895
+ return { line, col };
896
+ }
897
+
898
+ function normalizeRuleMatches(ruleMatches) {
899
+ const out = [];
900
+ const seen = new Set();
901
+ for (const m of ruleMatches || []) {
902
+ if (!m || typeof m !== 'object') continue;
903
+ const label = typeof m.label === 'string' ? m.label : '';
904
+ const severity = m.severity === 'high' ? 'high' : 'medium';
905
+ const matchText = typeof m.matchText === 'string' ? m.matchText : '';
906
+ const context = typeof m.context === 'string' ? m.context : '';
907
+ if (!label || !matchText) continue;
908
+ const key = label + '\\n' + matchText.toLowerCase();
909
+ if (seen.has(key)) continue;
910
+ seen.add(key);
911
+ out.push({ label, severity, matchText, context });
912
+ if (out.length >= 24) break;
913
+ }
914
+ return out;
915
+ }
916
+
917
+ function highlightPlainText(text, needle) {
918
+ const s = String(text || '');
919
+ const n = String(needle || '').trim();
920
+ if (!s) return '';
921
+ if (!n) return escapeHtml(s);
922
+
923
+ const lower = s.toLowerCase();
924
+ const nl = n.toLowerCase();
925
+
926
+ let html = '';
927
+ let pos = 0;
928
+ let from = 0;
929
+ let count = 0;
930
+ const MAX = 8;
931
+
932
+ while (count < MAX) {
933
+ const idx = lower.indexOf(nl, from);
934
+ if (idx === -1) break;
935
+ html += escapeHtml(s.slice(pos, idx));
936
+ html += '<mark class="hl">' + escapeHtml(s.slice(idx, idx + nl.length)) + '</mark>';
937
+ pos = idx + nl.length;
938
+ from = pos;
939
+ count += 1;
940
+ }
941
+
942
+ html += escapeHtml(s.slice(pos));
943
+ return html;
944
+ }
945
+
946
+ function highlightSnippet(snippet, ruleMatches, patterns, reasons) {
947
+ const text = String(snippet || '');
948
+ const rms = normalizeRuleMatches(ruleMatches);
949
+ const missing = [];
950
+
951
+ if (rms.length > 0) {
952
+ if (text.length === 0) {
953
+ for (const rm of rms) {
954
+ missing.push({
955
+ label: rm.label,
956
+ severity: rm.severity,
957
+ matchText: rm.matchText,
958
+ contextHtml: highlightPlainText(rm.context || '', rm.matchText),
959
+ });
960
+ }
961
+ return { html: escapeHtml(text), matches: [], missing };
962
+ }
963
+
964
+ const lower = text.toLowerCase();
965
+ const occ = [];
966
+ const found = new Set();
967
+ const MAX_MATCHES = 160;
968
+ const MAX_PER_NEEDLE = 24;
969
+
970
+ for (const rm of rms) {
971
+ const needle = String(rm.matchText || '').trim();
972
+ if (!needle || needle.length < 3 || needle.length > 200) continue;
973
+ const nl = needle.toLowerCase();
974
+ let from = 0;
975
+ let count = 0;
976
+ while (count < MAX_PER_NEEDLE && occ.length < MAX_MATCHES) {
977
+ const idx = lower.indexOf(nl, from);
978
+ if (idx === -1) break;
979
+ occ.push({ start: idx, end: idx + nl.length, label: rm.label, severity: rm.severity });
980
+ found.add(rm.label + '\\n' + nl);
981
+ from = idx + Math.max(1, nl.length);
982
+ count += 1;
983
+ }
984
+ if (occ.length >= MAX_MATCHES) break;
985
+ }
986
+
987
+ for (const rm of rms) {
988
+ const nl = String(rm.matchText || '').trim().toLowerCase();
989
+ if (!nl) continue;
990
+ const key = rm.label + '\\n' + nl;
991
+ if (found.has(key)) continue;
992
+ missing.push({
993
+ label: rm.label,
994
+ severity: rm.severity,
995
+ matchText: rm.matchText,
996
+ contextHtml: highlightPlainText(rm.context || '', rm.matchText),
997
+ });
998
+ if (missing.length >= 24) break;
999
+ }
1000
+
1001
+ if (occ.length === 0) {
1002
+ return { html: escapeHtml(text), matches: [], missing };
1003
+ }
1004
+
1005
+ occ.sort((a, b) => a.start - b.start || b.end - a.end);
1006
+
1007
+ const merged = [];
1008
+ for (const o of occ) {
1009
+ const last = merged[merged.length - 1];
1010
+ if (!last || o.start > last.end) {
1011
+ merged.push({ start: o.start, end: o.end, labels: [o.label], severity: o.severity });
1012
+ continue;
1013
+ }
1014
+ if (o.end > last.end) last.end = o.end;
1015
+ if (last.labels.indexOf(o.label) === -1) last.labels.push(o.label);
1016
+ if (o.severity === 'high') last.severity = 'high';
1017
+ }
1018
+
1019
+ let html = '';
1020
+ let pos = 0;
1021
+ const matches = [];
1022
+ for (let i = 0; i < merged.length; i++) {
1023
+ const r = merged[i];
1024
+ html += escapeHtml(text.slice(pos, r.start));
1025
+ const id = 'm' + i;
1026
+ const seg = text.slice(r.start, r.end);
1027
+ const labelText = Array.isArray(r.labels) ? r.labels.slice(0, 4).join(', ') : '';
1028
+ const labelAttr = labelText ? (' data-label=\"' + escapeHtml(labelText) + '\" title=\"' + escapeHtml(labelText) + '\"') : '';
1029
+ const sevAttr = r.severity ? (' data-sev=\"' + escapeHtml(String(r.severity)) + '\"') : '';
1030
+ html += '<mark class=\"hl\" id=\"' + id + '\"' + labelAttr + sevAttr + '>' + escapeHtml(seg) + '</mark>';
1031
+
1032
+ const lc = indexToLineCol(text, r.start);
1033
+ const previewBase = seg.replace(/\\s+/g, ' ').trim().slice(0, 70);
1034
+ const preview = labelText ? ('[' + labelText + '] ' + previewBase) : previewBase;
1035
+ matches.push({ id, start: r.start, loc: lc.line + ':' + lc.col, preview });
1036
+ pos = r.end;
1037
+ }
1038
+ html += escapeHtml(text.slice(pos));
1039
+
1040
+ return { html, matches, missing };
1041
+ }
1042
+
1043
+ // Fallback for older scan results without ruleMatches.
1044
+ const needles = buildHighlightNeedles(patterns, reasons);
1045
+ if (needles.length === 0 || text.length === 0) {
1046
+ return { html: escapeHtml(text), matches: [], missing: [] };
1047
+ }
1048
+
1049
+ const lower = text.toLowerCase();
1050
+ const ranges = [];
1051
+ const MAX_MATCHES = 120;
1052
+ const MAX_PER_NEEDLE = 20;
1053
+
1054
+ for (const needle of needles) {
1055
+ const n = String(needle || '').toLowerCase();
1056
+ if (!n) continue;
1057
+ let from = 0;
1058
+ let count = 0;
1059
+ while (count < MAX_PER_NEEDLE && ranges.length < MAX_MATCHES) {
1060
+ const idx = lower.indexOf(n, from);
1061
+ if (idx === -1) break;
1062
+ ranges.push({ start: idx, end: idx + n.length });
1063
+ from = idx + Math.max(1, n.length);
1064
+ count += 1;
1065
+ }
1066
+ if (ranges.length >= MAX_MATCHES) break;
1067
+ }
1068
+
1069
+ if (ranges.length === 0) {
1070
+ return { html: escapeHtml(text), matches: [], missing: [] };
1071
+ }
1072
+
1073
+ ranges.sort((a, b) => a.start - b.start || b.end - a.end);
1074
+
1075
+ const merged = [];
1076
+ for (const r of ranges) {
1077
+ const last = merged[merged.length - 1];
1078
+ if (!last || r.start > last.end) {
1079
+ merged.push({ start: r.start, end: r.end });
1080
+ continue;
1081
+ }
1082
+ if (r.end > last.end) last.end = r.end;
1083
+ }
1084
+
1085
+ let html = '';
1086
+ let pos = 0;
1087
+ const matches = [];
1088
+ for (let i = 0; i < merged.length; i++) {
1089
+ const r = merged[i];
1090
+ html += escapeHtml(text.slice(pos, r.start));
1091
+ const id = 'm' + i;
1092
+ const seg = text.slice(r.start, r.end);
1093
+ html += '<mark class=\"hl\" id=\"' + id + '\">' + escapeHtml(seg) + '</mark>';
1094
+
1095
+ const lc = indexToLineCol(text, r.start);
1096
+ const preview = seg.replace(/\\s+/g, ' ').trim().slice(0, 80);
1097
+ matches.push({ id, start: r.start, loc: lc.line + ':' + lc.col, preview });
1098
+ pos = r.end;
1099
+ }
1100
+ html += escapeHtml(text.slice(pos));
1101
+
1102
+ return { html, matches, missing: [] };
1103
+ }
1104
+
1105
+ function setActiveHighlight(detail, mark) {
1106
+ if (!detail || !mark) return;
1107
+ detail.querySelectorAll('mark.hl.active').forEach((m) => m.classList.remove('active'));
1108
+ mark.classList.add('active');
1109
+ }
1110
+
373
1111
  function handleFileClick(filePath) {
374
1112
  const finding = (SCAN_DATA.findings || []).find((f) => f.filePath === filePath);
375
1113
  if (!finding) return;
376
1114
 
1115
+ window.__ACTIVE_FILE = filePath;
1116
+
377
1117
  document.querySelectorAll('.file-btn').forEach((b) => b.classList.remove('active'));
378
- const active = document.querySelector('.file-btn[data-file="' + CSS.escape(filePath) + '"]');
379
- if (active) active.classList.add('active');
1118
+ let active = null;
1119
+ document.querySelectorAll('.file-btn').forEach((b) => {
1120
+ if (b.getAttribute('data-file') === filePath) active = b;
1121
+ });
1122
+ if (active) {
1123
+ active.classList.add('active');
1124
+ try { active.scrollIntoView({ block: 'nearest' }); } catch {}
1125
+ }
380
1126
 
381
1127
  const detail = el('#detail');
382
- const name = String(filePath).split('/').pop() || filePath;
1128
+ if (!detail) return;
1129
+
1130
+ const basePath = String(window.__TREE_STATE && window.__TREE_STATE.basePath ? window.__TREE_STATE.basePath : '');
1131
+ const info = fileDisplayInfo(finding, basePath);
1132
+ const displayPath = String(info.displayPath || filePath);
1133
+ const name = String(info.displayName || normalizePath(filePath).split('/').pop() || filePath);
383
1134
  const patterns = Array.isArray(finding.patterns) ? finding.patterns : [];
1135
+ const reasons = Array.isArray(finding.reasons) ? finding.reasons : [];
384
1136
  const detectors = Array.isArray(finding.detectors) ? finding.detectors : [];
1137
+ const ruleMatches = Array.isArray(finding.ruleMatches) ? finding.ruleMatches : [];
385
1138
  const badgeClass = finding.action === 'block' ? 'block' : 'allow';
386
1139
  const badgeText = String(finding.action || '').toUpperCase();
387
1140
 
388
1141
  const patternsHtml = patterns.length
389
1142
  ? patterns.map((p) => '<li>' + escapeHtml(p) + '</li>').join('')
390
1143
  : '<li>None</li>';
1144
+ const reasonsHtml = reasons.length
1145
+ ? reasons.map((r) => '<li>' + escapeHtml(r) + '</li>').join('')
1146
+ : '<li>None</li>';
391
1147
  const detectorsHtml = detectors.length
392
1148
  ? detectors.map((d) => '<span class="badge">' + escapeHtml(d) + '</span>').join('')
393
1149
  : '<span class="badge">none</span>';
394
1150
 
1151
+ const hl = highlightSnippet(finding.snippet || '', ruleMatches, patterns, reasons);
1152
+ const matchButtonsHtml = hl.matches.length
1153
+ ? hl.matches.map((m, i) =>
1154
+ '<button class="match-btn" type="button" data-jump="' + m.id + '">' +
1155
+ '<span>' + escapeHtml(String(i + 1) + '. ' + (m.preview || 'match')) + '</span>' +
1156
+ '<span class="loc">' + escapeHtml(m.loc) + '</span>' +
1157
+ '</button>'
1158
+ ).join('')
1159
+ : '';
1160
+
1161
+ const missingHtml = hl.missing && hl.missing.length
1162
+ ? '<div class="match-context-list">' +
1163
+ hl.missing.map((m) =>
1164
+ '<div class="match-context">' +
1165
+ '<div class="match-meta">' +
1166
+ '<span>' + escapeHtml(String(m.label || 'match')) + '</span>' +
1167
+ '<span class="loc">' + escapeHtml(String(m.severity || '')) + '</span>' +
1168
+ '</div>' +
1169
+ (m.matchText ? '<div class="match-needle">' + escapeHtml(String(m.matchText)) + '</div>' : '') +
1170
+ '<pre><code>' + String(m.contextHtml || '') + '</code></pre>' +
1171
+ '</div>'
1172
+ ).join('') +
1173
+ '</div>'
1174
+ : '';
1175
+
1176
+ const matchesSectionHtml =
1177
+ (matchButtonsHtml || missingHtml)
1178
+ ? '<div class="section matches">' +
1179
+ '<h3>Matches</h3>' +
1180
+ (matchButtonsHtml
1181
+ ? '<div class="match-list">' + matchButtonsHtml + '</div>'
1182
+ : '<p class="muted">No matches found in snippet (it may be truncated). See contexts below.</p>') +
1183
+ missingHtml +
1184
+ '</div>'
1185
+ : '';
1186
+
395
1187
  detail.innerHTML =
396
1188
  '<div class="detail-inner">' +
397
1189
  '<div class="file-header">' +
398
- '<div class="file-path">' + escapeHtml(filePath) + '</div>' +
1190
+ '<div class="file-path-row">' +
1191
+ '<div class="file-path" title="' + escapeHtml(filePath) + '">' + escapeHtml(displayPath) + '</div>' +
1192
+ '</div>' +
399
1193
  '<div class="file-name">' + escapeHtml(name) + '</div>' +
400
1194
  '<div class="metrics">' +
401
1195
  '<span>Risk: <span class="badge">' + Number(finding.risk).toFixed(2) + '</span></span>' +
@@ -407,9 +1201,14 @@ function handleFileClick(filePath) {
407
1201
  '<h3>Detected Patterns</h3>' +
408
1202
  '<ul>' + patternsHtml + '</ul>' +
409
1203
  '</div>' +
1204
+ '<div class="section reasons">' +
1205
+ '<h3>Reasons</h3>' +
1206
+ '<ul>' + reasonsHtml + '</ul>' +
1207
+ '</div>' +
1208
+ matchesSectionHtml +
410
1209
  '<div class="section snippet">' +
411
1210
  '<h3>Code Snippet</h3>' +
412
- '<pre><code>' + escapeHtml(finding.snippet || '') + '</code></pre>' +
1211
+ '<pre><code>' + hl.html + '</code></pre>' +
413
1212
  '</div>' +
414
1213
  '<div class="section detectors">' +
415
1214
  '<h3>Detectors</h3>' +
@@ -420,6 +1219,31 @@ function handleFileClick(filePath) {
420
1219
  '<p>' + escapeHtml(finding.aiAnalysis || '') + '</p>' +
421
1220
  '</div>' +
422
1221
  '</div>';
1222
+
1223
+ const inner = detail.querySelector('.detail-inner');
1224
+ if (inner) {
1225
+ inner.scrollTop = 0;
1226
+ inner.scrollLeft = 0;
1227
+ }
1228
+
1229
+ const firstMark = detail.querySelector('mark.hl');
1230
+ if (firstMark) setActiveHighlight(detail, firstMark);
1231
+
1232
+ try {
1233
+ const next = '#file=' + encodeURIComponent(filePath);
1234
+ if (location.hash !== next) location.hash = next;
1235
+ } catch {}
1236
+
1237
+ const autoJump = el('#auto-jump');
1238
+ if (
1239
+ autoJump &&
1240
+ autoJump.checked &&
1241
+ window.matchMedia &&
1242
+ window.matchMedia('(max-width: 768px)').matches
1243
+ ) {
1244
+ try { detail.focus({ preventScroll: true }); } catch {}
1245
+ try { detail.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' }); } catch {}
1246
+ }
423
1247
  }
424
1248
 
425
1249
  function handleSearch(query) {
@@ -484,12 +1308,14 @@ function renderChart() {
484
1308
  }
485
1309
 
486
1310
  function renderTree(partial) {
487
- const state = window.__TREE_STATE || { query: '', riskFilter: null };
1311
+ const state = window.__TREE_STATE || { query: '', riskFilter: null, basePath: '' };
488
1312
  window.__TREE_STATE = Object.assign({}, state, partial);
489
1313
 
490
1314
  const findings = Array.isArray(SCAN_DATA.findings) ? SCAN_DATA.findings : [];
491
1315
  const threatsOnlyEl = el('#threats-only');
492
1316
  const threatsOnly = threatsOnlyEl ? !!threatsOnlyEl.checked : true;
1317
+ const query = String(window.__TREE_STATE.query || '').trim().toLowerCase();
1318
+ const basePath = String(window.__TREE_STATE.basePath || '');
493
1319
 
494
1320
  let visible = findings;
495
1321
  if (window.__TREE_STATE.riskFilter) {
@@ -503,32 +1329,96 @@ function renderTree(partial) {
503
1329
  });
504
1330
  }
505
1331
 
506
- const treeRoot = buildFileTree(visible);
1332
+ if (threatsOnly) {
1333
+ visible = visible.filter((f) => Number(f.risk) >= 0.5);
1334
+ }
1335
+
1336
+ for (const f of visible) {
1337
+ const info = fileDisplayInfo(f, basePath);
1338
+ f.__displayPath = info.displayPath;
1339
+ f.__displayName = info.displayName;
1340
+ }
1341
+
1342
+ if (query) {
1343
+ visible = visible.filter((f) => {
1344
+ const dp = String(f.__displayPath || '').toLowerCase();
1345
+ const dn = String(f.__displayName || '').toLowerCase();
1346
+ return dp.includes(query) || dn.includes(query);
1347
+ });
1348
+ }
1349
+
507
1350
  const tree = el('#tree');
508
1351
  if (!tree) return;
509
1352
  tree.innerHTML = '';
510
- const query = window.__TREE_STATE.query || '';
511
- const node = renderTreeNode(treeRoot, { query, threatsOnly });
512
- tree.appendChild(node);
1353
+ if (visible.length === 0) {
1354
+ tree.innerHTML = '<div class="tree-empty">No files match the current filters.</div>';
1355
+ } else {
1356
+ const treeRoot = buildFileTree(visible, basePath);
1357
+ const node = renderTreeNode(treeRoot, { query }, '');
1358
+ tree.appendChild(node);
1359
+
1360
+ const first = tree.querySelector('.file-btn');
1361
+ if (first) first.tabIndex = 0;
1362
+ }
513
1363
 
514
- const first = tree.querySelector('.file-btn');
515
- if (first) first.tabIndex = 0;
1364
+ const root = el('#tree-root');
1365
+ if (root) {
1366
+ const parts = [];
1367
+ const baseLabel = String(basePath || '').replace(/\\/+$/, '');
1368
+ if (baseLabel) parts.push('Root: ' + baseLabel);
1369
+ else if (SCAN_DATA && SCAN_DATA.target) parts.push('Target: ' + String(SCAN_DATA.target));
1370
+ parts.push('Showing: ' + String(visible.length) + '/' + String(findings.length));
1371
+ if (window.__TREE_STATE.riskFilter) parts.push('Risk: ' + String(window.__TREE_STATE.riskFilter));
1372
+ if (threatsOnly) parts.push('Threats only');
1373
+ if (query) parts.push('Search: ' + query);
1374
+ root.textContent = parts.join(' | ');
1375
+ root.hidden = parts.length === 0;
1376
+ }
516
1377
  }
517
1378
 
518
1379
  function installKeyboardNav() {
519
1380
  document.addEventListener('keydown', (e) => {
1381
+ const target = e.target && e.target.nodeType === 1 ? e.target : null;
1382
+ const tag = target && target.tagName ? String(target.tagName).toUpperCase() : '';
1383
+ const isTyping = tag === 'INPUT' || tag === 'TEXTAREA' || (target && target.isContentEditable);
1384
+
1385
+ if (!isTyping && e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
1386
+ const search = el('#tree-search');
1387
+ if (search) {
1388
+ e.preventDefault();
1389
+ search.focus();
1390
+ if (search.select) search.select();
1391
+ }
1392
+ return;
1393
+ }
1394
+
1395
+ if (e.key === 'Escape') {
1396
+ const search = el('#tree-search');
1397
+ if (search && typeof search.value === 'string' && search.value.length > 0) {
1398
+ e.preventDefault();
1399
+ search.value = '';
1400
+ handleSearch('');
1401
+ try { search.focus(); } catch {}
1402
+ }
1403
+ return;
1404
+ }
1405
+
1406
+ if (isTyping) return;
1407
+
520
1408
  const items = Array.from(document.querySelectorAll('.file-btn'));
521
1409
  if (items.length === 0) return;
522
1410
  const active = document.activeElement;
523
1411
  const idx = items.indexOf(active);
524
1412
 
525
1413
  if (e.key === 'ArrowDown') {
1414
+ if (idx === -1) return;
526
1415
  e.preventDefault();
527
1416
  const next = items[Math.min(items.length - 1, Math.max(0, idx + 1))] || items[0];
528
1417
  next.focus();
529
1418
  return;
530
1419
  }
531
1420
  if (e.key === 'ArrowUp') {
1421
+ if (idx === -1) return;
532
1422
  e.preventDefault();
533
1423
  const next = items[Math.max(0, idx - 1)] || items[0];
534
1424
  next.focus();
@@ -561,13 +1451,99 @@ function bootstrap() {
561
1451
  const threatsOnly = el('#threats-only');
562
1452
  if (threatsOnly) threatsOnly.addEventListener('change', handleThreatsOnlyToggle);
563
1453
 
1454
+ const findings = Array.isArray(SCAN_DATA.findings) ? SCAN_DATA.findings : [];
1455
+ const basePath = computeBasePath(findings, SCAN_DATA && SCAN_DATA.target);
1456
+ const state = window.__TREE_STATE || { query: '', riskFilter: null };
1457
+ window.__TREE_STATE = Object.assign({}, state, { basePath });
1458
+
1459
+ const autoJump = el('#auto-jump');
1460
+ if (autoJump) {
1461
+ try {
1462
+ const saved = localStorage.getItem('sapper-auto-jump');
1463
+ if (saved === '0' || saved === '1') autoJump.checked = saved === '1';
1464
+ } catch {}
1465
+
1466
+ autoJump.addEventListener('change', () => {
1467
+ try { localStorage.setItem('sapper-auto-jump', autoJump.checked ? '1' : '0'); } catch {}
1468
+ });
1469
+ }
1470
+
1471
+ function setAllDirsOpen(open) {
1472
+ const state = getDirOpenState();
1473
+ document.querySelectorAll('#tree details').forEach((d) => {
1474
+ d.open = open;
1475
+ const dir = d.getAttribute('data-dir');
1476
+ if (dir) state[dir] = open;
1477
+ });
1478
+ }
1479
+
1480
+ const expandAll = el('#expand-all');
1481
+ if (expandAll) expandAll.addEventListener('click', () => setAllDirsOpen(true));
1482
+
1483
+ const collapseAll = el('#collapse-all');
1484
+ if (collapseAll) collapseAll.addEventListener('click', () => setAllDirsOpen(false));
1485
+
1486
+ try {
1487
+ const findings = Array.isArray(SCAN_DATA && SCAN_DATA.findings) ? SCAN_DATA.findings : [];
1488
+ const basePath = computeBasePath(findings, SCAN_DATA && SCAN_DATA.target);
1489
+ const state = window.__TREE_STATE || { query: '', riskFilter: null, basePath: '' };
1490
+ if (!state.basePath) state.basePath = basePath;
1491
+ window.__TREE_STATE = state;
1492
+ } catch {}
1493
+
564
1494
  renderChart();
565
1495
  renderTree({ query: '' });
566
1496
  installKeyboardNav();
567
1497
 
568
1498
  const detail = el('#detail');
569
1499
  if (detail) {
1500
+ detail.addEventListener('click', (e) => {
1501
+ const t = e.target && e.target.nodeType === 1 ? e.target : null;
1502
+ if (!t) return;
1503
+
1504
+ const btn = t.closest ? t.closest('.match-btn') : null;
1505
+ if (btn) {
1506
+ const id = btn.getAttribute('data-jump');
1507
+ if (!id) return;
1508
+ const mark = document.getElementById(id);
1509
+ if (!mark) return;
1510
+ setActiveHighlight(detail, mark);
1511
+ try {
1512
+ mark.scrollIntoView({
1513
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
1514
+ block: 'center',
1515
+ inline: 'nearest',
1516
+ });
1517
+ } catch {
1518
+ try { mark.scrollIntoView(); } catch {}
1519
+ }
1520
+ return;
1521
+ }
1522
+
1523
+ if (t.matches && t.matches('mark.hl')) {
1524
+ setActiveHighlight(detail, t);
1525
+ }
1526
+ });
1527
+
570
1528
  detail.innerHTML = '<div class="detail-inner"><div class="file-path">Select a file to view details</div></div>';
1529
+
1530
+ try {
1531
+ const h = String(location.hash || '');
1532
+ if (h.startsWith('#file=')) {
1533
+ const fp = decodeURIComponent(h.slice('#file='.length));
1534
+ if (fp) handleFileClick(fp);
1535
+ }
1536
+ } catch {}
1537
+
1538
+ window.addEventListener('hashchange', () => {
1539
+ try {
1540
+ const h = String(location.hash || '');
1541
+ if (!h.startsWith('#file=')) return;
1542
+ const fp = decodeURIComponent(h.slice('#file='.length));
1543
+ if (!fp || window.__ACTIVE_FILE === fp) return;
1544
+ handleFileClick(fp);
1545
+ } catch {}
1546
+ });
571
1547
  }
572
1548
  }
573
1549
 
@@ -578,32 +1554,52 @@ function renderHeader(result) {
578
1554
  return `
579
1555
  <header>
580
1556
  <div class="logo">SapperAI Scan Report</div>
581
- <div class="meta">Scanned: ${escapeHtml(result.timestamp)} | Scope: ${escapeHtml(result.scope)}</div>
1557
+ <div class="meta">Scanned: ${escapeHtml(result.timestamp)} | Scope: <span class="meta-scope" title="${escapeHtml(result.scope)}">${escapeHtml(result.scope)}</span></div>
582
1558
  <button id="theme-toggle" type="button">Dark/Light</button>
583
1559
  </header>
584
1560
  `.trim();
585
1561
  }
586
1562
  function renderSummary(result) {
587
- const total = result.summary.totalFiles;
588
- const threats = result.summary.threats;
1563
+ const total = result.summary?.totalFiles ?? 0;
1564
+ const eligible = result.summary?.eligibleFiles ?? 0;
1565
+ const scanned = result.summary?.scannedFiles ?? 0;
1566
+ const threats = result.summary?.threats ?? 0;
589
1567
  const maxRisk = result.findings.reduce((m, f) => Math.max(m, f.risk), 0);
1568
+ const coverageEligible = eligible > 0 ? (scanned / eligible) * 100 : 0;
1569
+ const coverageTotal = total > 0 ? (scanned / total) * 100 : 0;
1570
+ const coverageValue = `${coverageEligible.toFixed(1)}% / ${coverageTotal.toFixed(2)}%`;
1571
+ const coverageTitle = eligible > 0 && total > 0
1572
+ ? `Coverage: ${coverageEligible.toFixed(1)}% eligible (${scanned.toLocaleString()}/${eligible.toLocaleString()}) · ${coverageTotal.toFixed(2)}% total (${scanned.toLocaleString()}/${total.toLocaleString()})`
1573
+ : 'Coverage: N/A';
590
1574
  return `
591
- <section class="summary">
592
- <div class="metric-card">
593
- <span class="label">Total Files</span>
594
- <span class="value">${total.toLocaleString()}</span>
1575
+ <section class="summary" tabindex="0" role="region" aria-label="Scan summary metrics">
1576
+ <div class="metric-card" data-metric="total">
1577
+ <span class="label" title="Total files">Total</span>
1578
+ <span class="value" title="${total.toLocaleString()}">${total.toLocaleString()}</span>
1579
+ </div>
1580
+ <div class="metric-card" data-metric="eligible">
1581
+ <span class="label" title="Eligible (Config-like)">Eligible</span>
1582
+ <span class="value" title="${eligible.toLocaleString()}">${eligible.toLocaleString()}</span>
1583
+ </div>
1584
+ <div class="metric-card" data-metric="scanned">
1585
+ <span class="label" title="Scanned files">Scanned</span>
1586
+ <span class="value" title="${scanned.toLocaleString()}">${scanned.toLocaleString()}</span>
1587
+ </div>
1588
+ <div class="metric-card" data-metric="coverage">
1589
+ <span class="label" title="Coverage (eligible / total)">Coverage</span>
1590
+ <span class="value" title="${escapeHtml(coverageTitle)}" aria-label="${escapeHtml(coverageTitle)}">${coverageValue}</span>
595
1591
  </div>
596
- <div class="metric-card">
597
- <span class="label">Threats</span>
598
- <span class="value danger">${threats.toLocaleString()}</span>
1592
+ <div class="metric-card" data-metric="threats">
1593
+ <span class="label" title="Threats">Threats</span>
1594
+ <span class="value danger" title="${threats.toLocaleString()}">${threats.toLocaleString()}</span>
599
1595
  </div>
600
- <div class="metric-card">
601
- <span class="label">Max Risk</span>
602
- <span class="value">${maxRisk.toFixed(2)}</span>
1596
+ <div class="metric-card" data-metric="maxRisk">
1597
+ <span class="label" title="Max risk">Max risk</span>
1598
+ <span class="value" title="${maxRisk.toFixed(2)}">${maxRisk.toFixed(2)}</span>
603
1599
  </div>
604
- <div class="metric-card">
605
- <span class="label">AI Scan</span>
606
- <span class="value">${result.ai ? 'Enabled' : 'Disabled'}</span>
1600
+ <div class="metric-card" data-metric="ai">
1601
+ <span class="label" title="AI scan">AI scan</span>
1602
+ <span class="value" title="${result.ai ? 'On' : 'Off'}">${result.ai ? 'On' : 'Off'}</span>
607
1603
  </div>
608
1604
  </section>
609
1605
 
@@ -620,11 +1616,17 @@ function renderMainContent(result) {
620
1616
  <aside class="panel file-tree">
621
1617
  <div class="controls">
622
1618
  <input type="text" placeholder="Search files..." id="tree-search" />
623
- <label class="toggle"><input type="checkbox" id="threats-only" checked /> Threats only (${threatsCount})</label>
1619
+ <div class="tree-actions">
1620
+ <label class="toggle inline"><input type="checkbox" id="threats-only" checked /> Threats only (${threatsCount})</label>
1621
+ <label class="toggle inline"><input type="checkbox" id="auto-jump" checked /> Auto-jump to details</label>
1622
+ <button class="mini-btn" type="button" id="expand-all">Expand all</button>
1623
+ <button class="mini-btn" type="button" id="collapse-all">Collapse all</button>
1624
+ </div>
1625
+ <div class="tree-root" id="tree-root" hidden></div>
624
1626
  </div>
625
1627
  <div id="tree" role="tree"></div>
626
1628
  </aside>
627
- <main class="panel detail-panel" id="detail"></main>
1629
+ <main class="panel detail-panel" id="detail" tabindex="-1"></main>
628
1630
  </section>
629
1631
  `.trim();
630
1632
  }