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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/config-loader.js +14 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/skill-prompt.js +45 -15
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/lib/client.js +152 -80
- package/lib/parse.js +26 -17
- package/package.json +3 -3
- package/src/client/index.ts +144 -56
- package/src/config-loader.ts +12 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/skill-prompt.ts +45 -15
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/types.ts +12 -0
package/src/client/index.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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)
|
|
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
|
|
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', {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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),
|
|
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),
|
|
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('
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
React.createElement('button', {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
React.createElement('button', { className: 'iterate-batch-btn', onClick: () =>
|
|
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', {
|
|
913
|
-
|
|
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;
|
|
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
|
-
|
|
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. */
|
package/src/config-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/config-write.ts
CHANGED
|
@@ -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
|
-
|
|
175
|
-
|
|
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
|
|
43
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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))
|
package/src/meta-review.ts
CHANGED
|
@@ -70,8 +70,13 @@ export interface FinalReviewReport {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
package/src/method-scope.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|