iterate-plugin 2.9.4 → 2.11.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.
@@ -333,6 +333,27 @@ const ITERATE_CSS = `
333
333
  .iterate-switch[data-on] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
334
334
  .iterate-switch[data-on] .iterate-switch-knob { transform: translateX(18px); background: #FFFFFF; }
335
335
 
336
+ /* Shared keyboard focus ring for every iterate interactive control */
337
+ .iterate-btn:focus-visible, .iterate-vbtn:focus-visible, .iterate-batch-btn:focus-visible,
338
+ .iterate-filter-select:focus-visible, .iterate-filter-search:focus-visible,
339
+ .iterate-finding:focus-visible, .iterate-switch:focus-visible {
340
+ outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px;
341
+ }
342
+
343
+ /* Dashboard empty/onboarding state */
344
+ .iterate-dashboard-empty { opacity: 0.75; }
345
+ .iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
346
+
347
+ /* Convergence-completed progress fill */
348
+ .iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
349
+
350
+ /* Batch scope segmented control */
351
+ .iterate-batch-scope { opacity: 0.6; }
352
+ .iterate-batch-scope-on { opacity: 1; border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-label-primary); }
353
+
354
+ /* Overflow dimension chip */
355
+ .iterate-dim-more { opacity: 0.7; font-style: italic; }
356
+
336
357
  /* Button variants */
337
358
  .iterate-btn[data-ghost] { background: transparent; }
338
359
  .iterate-btn[data-danger] { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); color: var(--dsw-alias-state-error-primary); background: transparent; }
@@ -402,16 +423,15 @@ function removeStorageByPrefix(prefix: string): number {
402
423
  return removed
403
424
  }
404
425
 
405
- /** Copy text to the clipboard, returning whether it succeeded. */
406
- function copyText(text: string): boolean {
426
+ /** Copy text to the clipboard, resolving to whether it actually succeeded. */
427
+ function copyText(text: string): Promise<boolean> {
407
428
  if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
408
- navigator.clipboard.writeText(text).then(
429
+ return navigator.clipboard.writeText(text).then(
409
430
  () => true,
410
431
  () => false,
411
432
  )
412
- return true
413
433
  }
414
- return false
434
+ return Promise.resolve(false)
415
435
  }
416
436
 
417
437
  /** Literal severity keys recognized by SEVERITY_LABEL / SEVERITY_COLOR. */
@@ -523,7 +543,14 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
523
543
  style: { height: `${Math.max(4, Math.round((p.count / max) * 24))}px` },
524
544
  }),
525
545
  )
526
- return React.createElement('div', { className: 'iterate-trend', title: '各轮发现数量趋势' }, ...bars)
546
+ // Accessible summary: the per-round counts are otherwise invisible to
547
+ // assistive tech (mouse-only title divs).
548
+ const summary = points.map((p) => `Round ${p.round}: ${p.count}`).join(', ')
549
+ return React.createElement('div', {
550
+ className: 'iterate-trend',
551
+ role: 'img',
552
+ 'aria-label': `各轮发现数量趋势:${summary}`,
553
+ }, ...bars)
527
554
  }
528
555
 
529
556
  /** Dashboard: live convergence strip above the composer.
@@ -549,7 +576,16 @@ function ConvergenceDashboard(props: SlotProps) {
549
576
  setPulseKey((k) => k + 1)
550
577
  }, [report && hashReport(report) + ':' + getCurrentRound(report)])
551
578
 
552
- if (!report) return null
579
+ if (!report) {
580
+ // Empty/onboarding state: first-time users otherwise see nothing and have
581
+ // no idea the plugin exists or how to start.
582
+ return React.createElement(
583
+ 'div',
584
+ { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard iterate-dashboard-empty' },
585
+ React.createElement('span', { className: 'iterate-round-badge' }, 'iterate'),
586
+ React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。试试「review this project」或「/iterate review-only」'),
587
+ )
588
+ }
553
589
 
554
590
  const round = getCurrentRound(report)
555
591
  const total = getTotalRounds(report)
@@ -577,13 +613,25 @@ function ConvergenceDashboard(props: SlotProps) {
577
613
  }, `附件图片 ${String(imageCount)}`)
578
614
  : null
579
615
 
580
- const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
616
+ const dimNames = Object.keys(dims)
617
+ const dimBadges = dimNames.slice(0, 6).map((dim) =>
581
618
  React.createElement(
582
619
  'span',
583
620
  { key: dim, className: 'iterate-dim-badge' },
584
621
  `${dim} · ${(dims[dim]?.length ?? 0)}`,
585
622
  ),
586
623
  )
624
+ // Don't silently drop dimensions: surface the overflow as a +N chip.
625
+ const overflow = dimNames.length - 6
626
+ if (overflow > 0) {
627
+ dimBadges.push(
628
+ React.createElement(
629
+ 'span',
630
+ { key: '+more', className: 'iterate-dim-badge iterate-dim-more', title: dimNames.slice(6).join(', ') },
631
+ `+${overflow} 更多`,
632
+ ),
633
+ )
634
+ }
587
635
 
588
636
  // Fix-count badge: show a running "fixes applied" metric when the report
589
637
  // carries a number (normal mode only — threaded through `fixedCount`).
@@ -599,6 +647,23 @@ function ConvergenceDashboard(props: SlotProps) {
599
647
  }, `${String(fixCount)} fixes`)
600
648
  : null
601
649
 
650
+ // Convergence is the payoff of the review — make it visible on the
651
+ // persistent dashboard, not only in the transient 3.6s capsule.
652
+ const converged = report.convergence && report.convergence.converged === true
653
+ const convChip = converged
654
+ ? React.createElement('span', {
655
+ className: 'iterate-chip-resume',
656
+ key: 'converged',
657
+ title: '审查已收敛:最后一轮未发现新问题',
658
+ }, '✓ 已收敛')
659
+ : null
660
+
661
+ const sevMetric = (key: 'critical' | 'high' | 'medium' | 'low', label: string) =>
662
+ React.createElement('span', { className: 'iterate-metric', key, title: label },
663
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR[key] } }),
664
+ `${label} ${String(stats[key])}`,
665
+ )
666
+
602
667
  return React.createElement(
603
668
  'div',
604
669
  { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
@@ -608,20 +673,16 @@ function ConvergenceDashboard(props: SlotProps) {
608
673
  `Round ${round} / ${total}`,
609
674
  ),
610
675
  React.createElement('div', { className: 'iterate-progress' },
611
- React.createElement('div', { className: 'iterate-progress-fill', style: { width: `${progress}%` } }),
612
- ),
613
- React.createElement('span', { className: 'iterate-metric' },
614
- React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.critical } }),
615
- stats.critical,
616
- ),
617
- React.createElement('span', { className: 'iterate-metric' },
618
- React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.high } }),
619
- stats.high,
620
- ),
621
- React.createElement('span', { className: 'iterate-metric' },
622
- React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
623
- stats.medium,
676
+ React.createElement('div', {
677
+ className: converged ? 'iterate-progress-fill iterate-progress-fill-done' : 'iterate-progress-fill',
678
+ style: { width: `${progress}%` },
679
+ }),
624
680
  ),
681
+ convChip,
682
+ sevMetric('critical', 'CRIT'),
683
+ sevMetric('high', 'HIGH'),
684
+ sevMetric('medium', 'MED'),
685
+ sevMetric('low', 'LOW'),
625
686
  fixBadge,
626
687
  resumeChip,
627
688
  imageChip,
@@ -731,14 +792,15 @@ function TriagePanel(props: SlotProps) {
731
792
  const [selected, setSelected] = React.useState<number | null>(null)
732
793
  const [selectAll, setSelectAll] = React.useState(false)
733
794
 
734
- /** Persist + return the next verdicts state. */
735
- const persistVerdicts = (next: Record<string, 'keep' | 'skip' | 'ignore'>) => {
736
- if (storage) storage.set(storageKey, JSON.stringify(next))
737
- return next
738
- }
795
+ /** Persist the verdicts to localStorage whenever they change. Kept OUT of
796
+ * the state updater (updaters must stay pure React may double-invoke them
797
+ * under StrictMode, and a storage throw must not surface during render). */
798
+ React.useEffect(() => {
799
+ if (storage) storage.set(storageKey, JSON.stringify(verdicts))
800
+ }, [storageKey, verdicts])
739
801
 
740
802
  const setVerdict = (index: number, verdict: 'keep' | 'skip' | 'ignore') => {
741
- setVerdicts((prev) => persistVerdicts({ ...prev, [String(index)]: verdict }))
803
+ setVerdicts((prev) => ({ ...prev, [String(index)]: verdict }))
742
804
  }
743
805
 
744
806
  // ── Filtering (visible findings + their original indices) ───────────────
@@ -757,13 +819,10 @@ function TriagePanel(props: SlotProps) {
757
819
  const allIndices = allVerdictKeys(verdicts)
758
820
  const batchTarget = selectAll ? allIndices : indices
759
821
  const applyBatch = (verdict: 'keep' | 'skip' | 'ignore') => {
760
- setVerdicts((prev) => persistVerdicts(batchSetVerdict(prev, batchTarget, verdict)))
761
- }
762
- const applyBatchAll = (verdict: 'keep' | 'skip' | 'ignore') => {
763
- setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, verdict)))
822
+ setVerdicts((prev) => batchSetVerdict(prev, batchTarget, verdict))
764
823
  }
765
824
  const doResetVerdicts = () => {
766
- setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, 'keep')))
825
+ setVerdicts((prev) => setAllVerdicts(prev, 'keep'))
767
826
  setSelectAll(false)
768
827
  }
769
828
 
@@ -772,8 +831,13 @@ function TriagePanel(props: SlotProps) {
772
831
  const doc = typeof document !== 'undefined' ? document : null
773
832
  if (!doc) return
774
833
  const onKeyDown = (ev: KeyboardEvent) => {
834
+ // Never hijack modified shortcuts (Cmd/Ctrl/Alt combos like Cmd+A
835
+ // select-all) or keystrokes typed into an editable surface (the composer
836
+ // may be a contenteditable div, not a textarea).
837
+ if (ev.metaKey || ev.ctrlKey || ev.altKey) return
775
838
  const t = ev.target as HTMLElement | null
776
839
  if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
840
+ if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
777
841
  const verdict = keyToVerdict(ev.key)
778
842
  if (verdict && selected !== null && indices.includes(selected)) {
779
843
  ev.preventDefault()
@@ -807,10 +871,16 @@ function TriagePanel(props: SlotProps) {
807
871
  const doCopyYaml = () => {
808
872
  const yaml = toKnownIntentionalYaml(ignored)
809
873
  if (!yaml) return
810
- const ok = copyText(yaml)
811
- setCopied(true)
812
- setTimeout(() => setCopied(false), 1600)
813
- if (!ok) setPayload(yaml)
874
+ copyText(yaml).then((ok) => {
875
+ if (ok) {
876
+ setCopied(true)
877
+ setTimeout(() => setCopied(false), 1600)
878
+ } else {
879
+ // Copy failed (permissions/unsupported) — reveal the payload as a
880
+ // manual-copy fallback instead of claiming success.
881
+ setPayload(yaml)
882
+ }
883
+ })
814
884
  }
815
885
 
816
886
  const doBuildInstruction = () => {
@@ -841,7 +911,14 @@ function TriagePanel(props: SlotProps) {
841
911
  key: String(index),
842
912
  className: 'iterate-finding',
843
913
  'data-selected': isSelected ? '' : undefined,
914
+ role: 'option',
915
+ 'aria-selected': isSelected,
916
+ tabIndex: 0,
844
917
  onClick: () => setSelected(index),
918
+ onFocus: () => setSelected(index),
919
+ onKeyDown: (e: React.KeyboardEvent) => {
920
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(index) }
921
+ },
845
922
  },
846
923
  React.createElement('div', { className: 'iterate-finding-meta' },
847
924
  React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(severity) } }),
@@ -861,17 +938,17 @@ function TriagePanel(props: SlotProps) {
861
938
 
862
939
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
863
940
  React.createElement('div', { className: 'iterate-triage-head' },
864
- React.createElement('span', {}, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
941
+ React.createElement('span', { role: 'heading', 'aria-level': 3 }, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
865
942
  React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择'),
866
943
  ),
867
944
  React.createElement('div', { className: 'iterate-filter' },
868
- React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSeverityFilter(e.target.value), title: '按严重度筛选' },
945
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSeverityFilter(e.target.value), 'aria-label': '按严重度筛选' },
869
946
  React.createElement('option', { value: '' }, '全部严重度'),
870
947
  ...options.severities.map((s) =>
871
948
  React.createElement('option', { key: s.value, value: s.value }, `${severityLabel(s.value)} (${s.count})`),
872
949
  ),
873
950
  ),
874
- React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDimensionFilter(e.target.value), title: '按维度筛选' },
951
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDimensionFilter(e.target.value), 'aria-label': '按维度筛选' },
875
952
  React.createElement('option', { value: '' }, '全部维度'),
876
953
  ...options.dimensions.map((d) =>
877
954
  React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
@@ -881,6 +958,7 @@ function TriagePanel(props: SlotProps) {
881
958
  className: 'iterate-filter-search',
882
959
  type: 'search',
883
960
  placeholder: '搜索文件 / 摘要…',
961
+ 'aria-label': '搜索文件或摘要',
884
962
  value: filter.search,
885
963
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => setSearchFilter(e.target.value),
886
964
  }),
@@ -891,26 +969,35 @@ function TriagePanel(props: SlotProps) {
891
969
  ),
892
970
  ),
893
971
  React.createElement('div', { className: 'iterate-batch' },
894
- React.createElement('span', { className: 'iterate-batch-label' }, '批量:'),
895
- React.createElement('label', { className: 'iterate-batch-check', title: '勾选后批量按钮作用于全部 findings,否则仅当前可见' },
896
- React.createElement('input', { type: 'checkbox', checked: selectAll, onChange: (e) => setSelectAll(e.target.checked) }),
897
- selectAll ? `全部 ${allIndices.length}` : '全选',
898
- ),
899
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, '全部 y'),
900
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, '全部 n'),
901
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, '全部 a'),
902
- React.createElement('span', { className: 'iterate-batch-label', style: { marginLeft: 8 } }, '全部:'),
903
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('keep') }, 'y'),
904
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('skip') }, 'n'),
905
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('ignore') }, 'a'),
972
+ React.createElement('span', { className: 'iterate-batch-label' }, '批量作用于:'),
973
+ React.createElement('button', {
974
+ className: selectAll ? 'iterate-batch-btn iterate-batch-scope' : 'iterate-batch-btn iterate-batch-scope iterate-batch-scope-on',
975
+ onClick: () => setSelectAll(false),
976
+ title: '批量按钮仅作用于当前筛选可见的 findings',
977
+ }, `可见 ${indices.length}`),
978
+ React.createElement('button', {
979
+ className: selectAll ? 'iterate-batch-btn iterate-batch-scope iterate-batch-scope-on' : 'iterate-batch-btn iterate-batch-scope',
980
+ onClick: () => setSelectAll(true),
981
+ title: '批量按钮作用于全部 findings',
982
+ }, `全部 ${allIndices.length}`),
983
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, 'y'),
984
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, 'n'),
985
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, 'a'),
906
986
  React.createElement('button', { className: 'iterate-batch-btn', onClick: doResetVerdicts, title: '把所有判定恢复为默认 y(修复)' }, '重置'),
907
987
  ),
908
988
  ...rows,
909
989
  React.createElement('div', { className: 'iterate-triage-foot' },
910
990
  React.createElement('span', {}, `y ${counts.keep} · n ${counts.skip} · a ${counts.ignore} · 待写回 known_intentional:${ignoredCount} 条`),
911
991
  React.createElement('span', { style: { display: 'flex', gap: 6 } },
912
- React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyYaml }, copied ? '已复制' : '复制 known_intentional'),
913
- React.createElement('button', { className: 'iterate-btn', onClick: doBuildInstruction }, '生成应用指令'),
992
+ React.createElement('button', {
993
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined,
994
+ onClick: doCopyYaml, disabled: ignoredCount === 0,
995
+ title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '复制 known_intentional YAML',
996
+ }, copied ? '已复制' : `复制 known_intentional${ignoredCount > 0 ? `(${ignoredCount})` : ''}`),
997
+ React.createElement('button', {
998
+ className: 'iterate-btn', onClick: doBuildInstruction, disabled: ignoredCount === 0,
999
+ title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '生成 iterate_triage 应用指令',
1000
+ }, '生成应用指令'),
914
1001
  ),
915
1002
  ),
916
1003
  payload
@@ -1031,10 +1118,11 @@ function SettingsPanel(_props: SlotProps) {
1031
1118
  setTimeout(() => setter(false), 1600)
1032
1119
  }
1033
1120
 
1034
- /** Copy a guide/status block; keep the copy button state in sync. */
1121
+ /** Copy a guide/status block; flash success only when it actually copied. */
1035
1122
  const doCopy = (text: string, slot: 'guide' | 'status') => {
1036
- copyText(text)
1037
- flashCopied(slot)
1123
+ copyText(text).then((ok) => {
1124
+ if (ok) flashCopied(slot)
1125
+ })
1038
1126
  }
1039
1127
 
1040
1128
  /** Two-step destroy guard: first click arms, second click clears. */
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join, resolve, sep } from 'node:path'
4
4
  import yaml from 'js-yaml'
@@ -75,6 +75,10 @@ export function mergeConfig(
75
75
  const out: Record<string, unknown> = { ...base }
76
76
  for (const [key, value] of Object.entries(override)) {
77
77
  if (value === undefined) continue
78
+ // Prototype-pollution guard: a YAML `__proto__`/`constructor`/`prototype`
79
+ // key must never be plain-assigned — js-yaml stores __proto__ as an own
80
+ // data property, and `out[key] = value` would invoke the __proto__ setter.
81
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
78
82
  const baseValue = out[key]
79
83
  if (
80
84
  baseValue &&
@@ -242,7 +246,13 @@ function effectiveCwd(sessionCwd?: string): string {
242
246
  if (encoded && encoded.startsWith('--') && encoded.endsWith('--')) {
243
247
  try {
244
248
  const decoded = decodeURIComponent(encoded.slice(2, -2).replace(/~/g, '%'))
245
- if (decoded && decoded.startsWith(sep)) return decoded
249
+ // The workspace encoding drops the leading root separator (`/Volumes/…`
250
+ // → `Volumes-…`), so re-attach it when absent. `~<hex>` → `%<hex>` is
251
+ // the documented percent spelling; '-' doubles as the '/' separator, so
252
+ // literal dashes in a path cannot round-trip — verify the result exists
253
+ // and fall through otherwise.
254
+ const candidate = decoded && !decoded.startsWith(sep) ? sep + decoded : decoded
255
+ if (candidate && candidate.startsWith(sep) && existsSync(candidate)) return candidate
246
256
  } catch {
247
257
  // malformed encoding — fall through to cwd
248
258
  }
@@ -10,7 +10,7 @@
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
12
 
13
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import yaml from 'js-yaml'
16
16
 
@@ -169,12 +169,14 @@ export function writeConfigFile(
169
169
  try {
170
170
  writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8')
171
171
  } catch (err) {
172
+ let rollbackError = ''
172
173
  try {
173
174
  if (backupPath) copyFileSync(backupPath, configPath)
174
- } catch {
175
- // Rollback failure is reported, never swallowed silently.
175
+ else if (existsSync(configPath)) rmSync(configPath, { force: true })
176
+ } catch (rbErr) {
177
+ rollbackError = `; rollback also failed: ${String(rbErr)}`
176
178
  }
177
- return { ok: false, error: `failed to write config: ${String(err)}` }
179
+ return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` }
178
180
  }
179
181
 
180
182
  return { ok: true, backupPath }
package/src/evidence.ts CHANGED
@@ -23,13 +23,21 @@
23
23
  * filesystem half (`verifyFinding`) to stay unit-testable without touching disk.
24
24
  */
25
25
 
26
- import { existsSync, readFileSync } from 'node:fs'
26
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
27
27
  import { resolve, sep } from 'node:path'
28
28
  import type { ReviewFinding } from './types.ts'
29
29
 
30
30
  /** Sentinel for whole-file findings (line 0 or omitted means the whole file). */
31
31
  export const WHOLE_FILE_LINE = 0
32
32
 
33
+ /**
34
+ * Hard cap on a single evidence file read. `verifyFinding` only needs the
35
+ * line count + a NUL check; reading an unbounded file (or a device file
36
+ * reached through a symlink) is a memory/hang hazard, so anything larger is
37
+ * treated as not line-addressable.
38
+ */
39
+ const MAX_EVIDENCE_BYTES = 10 * 1024 * 1024
40
+
33
41
  export type EvidenceError = 'file_not_found' | 'line_out_of_range'
34
42
 
35
43
  /** Per-finding attestation result. */
@@ -79,6 +87,22 @@ export function resolveWithin(root: string, rel: string): string | null {
79
87
  return resolved
80
88
  }
81
89
 
90
+ /** True when `candidate` is `root` itself or lexically inside `root`. */
91
+ function isWithin(root: string, candidate: string): boolean {
92
+ if (candidate === root) return true
93
+ const prefix = root.endsWith(sep) ? root : root + sep
94
+ return candidate.startsWith(prefix)
95
+ }
96
+
97
+ /** best-effort realpath; falls back to the lexical path on any failure. */
98
+ function safeRealpath(p: string): string {
99
+ try {
100
+ return realpathSync(p)
101
+ } catch {
102
+ return p
103
+ }
104
+ }
105
+
82
106
  /**
83
107
  * Pure check that `line` (if anchored) exists in `text`.
84
108
  * Whole-file findings (undefined/0) are always bounds-valid.
@@ -116,6 +140,50 @@ export function verifyFinding(
116
140
  }
117
141
  }
118
142
 
143
+ // Symlink containment: resolveWithin is lexical only, but existsSync /
144
+ // readFileSync follow symlinks. Verify the REAL path stays inside the REAL
145
+ // project root so a finding path can never read (or line-count) a file
146
+ // outside the project via a symlinked directory or file.
147
+ const rootReal = safeRealpath(root)
148
+ const real = safeRealpath(resolved)
149
+ if (!isWithin(rootReal, real)) {
150
+ return {
151
+ file: relFile,
152
+ line,
153
+ lineTotal: null,
154
+ resolvedPath: resolved,
155
+ verified: false,
156
+ error: 'file_not_found',
157
+ }
158
+ }
159
+
160
+ // Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
161
+ // multi-GB file is not a line-addressable text target. statSync follows
162
+ // symlinks, so a link to a device still lands here and is rejected.
163
+ let st
164
+ try {
165
+ st = statSync(resolved)
166
+ } catch {
167
+ return {
168
+ file: relFile,
169
+ line,
170
+ lineTotal: null,
171
+ resolvedPath: resolved,
172
+ verified: false,
173
+ error: 'file_not_found',
174
+ }
175
+ }
176
+ if (!st.isFile() || st.size > MAX_EVIDENCE_BYTES) {
177
+ return {
178
+ file: relFile,
179
+ line,
180
+ lineTotal: null,
181
+ resolvedPath: resolved,
182
+ verified: false,
183
+ error: 'line_out_of_range',
184
+ }
185
+ }
186
+
119
187
  let raw: Buffer
120
188
  try {
121
189
  raw = readFileSync(resolved)
package/src/git-scope.ts CHANGED
@@ -38,14 +38,30 @@ export interface GitScopeResult {
38
38
  }
39
39
 
40
40
  /**
41
- * Parse `git diff --name-only` stdout into a list of relative paths.
42
- * Pure: strips blank lines, trims whitespace, drops quotes (git can quote
43
- * paths with special characters).
41
+ * Parse `git diff --name-only -z` stdout into a list of relative paths.
42
+ * Pure. NUL-delimited mode is machine-safe (handles any filename); when no
43
+ * NUL is present (callers that did not pass -z) fall back to newline-split
44
+ * with C-style quote/escape unescaping for core.quotePath output.
44
45
  */
45
46
  export function parseChangedFiles(stdout: string): string[] {
47
+ if (stdout.includes('\0')) {
48
+ return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0)
49
+ }
46
50
  return stdout
47
51
  .split('\n')
48
- .map((line) => line.trim().replace(/^"|"$/g, ''))
52
+ .map((line) => {
53
+ const trimmed = line.trim()
54
+ // git core.quotePath wraps paths with special characters in "..."; the
55
+ // content uses C-style escapes (\" \\ \t \n and \ooo octal for non-ASCII).
56
+ const quoted = trimmed.match(/^"(.*)"$/)
57
+ if (!quoted) return trimmed
58
+ return quoted[1]!
59
+ .replace(/\\"/g, '"')
60
+ .replace(/\\\\/g, '\\')
61
+ .replace(/\\t/g, '\t')
62
+ .replace(/\\n/g, '\n')
63
+ .replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)))
64
+ })
49
65
  .filter((line) => line.length > 0)
50
66
  }
51
67
 
@@ -118,9 +134,21 @@ export async function resolveChangedFiles(
118
134
  root: string,
119
135
  targetBranch: string,
120
136
  ): Promise<GitScopeResult> {
121
- const { ok, stdout, stderr } = await runGit(['diff', '--name-only', targetBranch, '--'], root)
137
+ // Option-injection guard: a branch name starting with '-' would be parsed by
138
+ // git as an option (e.g. --output=...), not a ref. Reject it outright.
139
+ if (typeof targetBranch !== 'string' || targetBranch.trim() === '' || targetBranch.startsWith('-')) {
140
+ return {
141
+ scope: 'full',
142
+ changedFiles: [],
143
+ fallbackToFull: true,
144
+ error: `invalid target branch "${String(targetBranch)}"`,
145
+ }
146
+ }
147
+ // -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
148
+ // non-ASCII), and never confused with option-like content.
149
+ const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root)
122
150
  if (!ok) {
123
- const reason = stderr.trim() || `git diff --name-only ${targetBranch} failed`
151
+ const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`
124
152
  return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason }
125
153
  }
126
154
  const existing = filterExistingFiles(root, parseChangedFiles(stdout))
@@ -70,8 +70,13 @@ export interface FinalReviewReport {
70
70
  }
71
71
  }
72
72
 
73
- /** Number of distinct consistency checks performed by `metaReviewReport`. */
74
- export const META_REVIEW_CHECKS = 6
73
+ /**
74
+ * Number of distinct consistency checks performed by `metaReviewReport`.
75
+ * The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
76
+ * SORT_ORDER, CONVERGENCE_SUM, CONVERGENCE_FLAG, ROUND_NUMBER, ROUND_EMPTY,
77
+ * ROUND_GAP.
78
+ */
79
+ export const META_REVIEW_CHECKS = 10
75
80
 
76
81
  /**
77
82
  * How many uncovered scope files are listed in a COVERAGE_GAP hint before the
@@ -266,14 +271,23 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
266
271
  )
267
272
  }
268
273
  }
269
- for (let i = 1; i <= rounds.length; i++) {
270
- if (!seenRounds.has(i)) {
271
- add(
272
- 'ROUND_GAP',
273
- 'medium',
274
- `Round ${i} is missing from the round sequence`,
275
- `rounds present: ${[...seenRounds].sort((a, b) => a - b).join(', ') || 'none'}.`,
276
- )
274
+ // ROUND_GAP: only flag gaps WITHIN the range of actually-present round
275
+ // numbers. Non-contiguous starts (e.g. a resumed run beginning at round 5)
276
+ // and arbitrary round numbering are supported by the aggregate engine, so
277
+ // missing 1..N prefixes are NOT defects. Checks min..max of present rounds.
278
+ const present = [...seenRounds].sort((a, b) => a - b)
279
+ if (present.length > 0) {
280
+ const min = present[0]!
281
+ const max = present[present.length - 1]!
282
+ for (let i = min; i <= max; i++) {
283
+ if (!seenRounds.has(i)) {
284
+ add(
285
+ 'ROUND_GAP',
286
+ 'medium',
287
+ `Round ${i} is missing from the round sequence`,
288
+ `rounds present: ${present.join(', ')}.`,
289
+ )
290
+ }
277
291
  }
278
292
  }
279
293
 
@@ -96,6 +96,8 @@ const SIGNATURE_PATTERNS: Array<{ kind: string; re: RegExp; nameIndex: number }>
96
96
  export function collectMethodSignatures(text: string): MethodSignature[] {
97
97
  const lines = text.split('\n')
98
98
  const out: MethodSignature[] = []
99
+ // Set lookup instead of scanning the growing array (O(S²) → O(S)).
100
+ const seen = new Set<string>()
99
101
  for (let i = 0; i < lines.length; i++) {
100
102
  const raw = lines[i]!
101
103
  const line = i + 1
@@ -105,7 +107,9 @@ export function collectMethodSignatures(text: string): MethodSignature[] {
105
107
  const name = m[p.nameIndex]
106
108
  if (!name || RESERVED_WORDS.has(name) || CALLABLE_NOISE.has(name)) continue
107
109
  // Avoid two patterns claiming the same line (e.g. TS method + arrow).
108
- if (out.some((s) => s.line === line && s.name === name)) break
110
+ const key = `${line}|${name}`
111
+ if (seen.has(key)) break
112
+ seen.add(key)
109
113
  out.push({ name, line })
110
114
  break
111
115
  }