iterate-plugin 2.3.7 → 2.5.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/lib/parse.js ADDED
@@ -0,0 +1,790 @@
1
+ /**
2
+ * lib/parse.js — Pure logic for iterate client UI.
3
+ *
4
+ * Framework-agnostic, DOM-free, single-file, testable with Node.js assert.
5
+ * Every function is exported for unit test coverage.
6
+ *
7
+ * @module iterate-ui/parse
8
+ */
9
+
10
+ // ─── Constants ───────────────────────────────────────────────────────────────
11
+
12
+ /** Severity ordering (lowest index = most severe). */
13
+ export const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low']
14
+
15
+ /** Severity labels (short form for badges). */
16
+ export const SEVERITY_LABEL = {
17
+ critical: 'CRIT',
18
+ high: 'HIGH',
19
+ medium: 'MED',
20
+ low: 'LOW',
21
+ }
22
+
23
+ /** Severity colors (CSS-compatible). */
24
+ export const SEVERITY_COLOR = {
25
+ critical: '#ef4444',
26
+ high: '#f97316',
27
+ medium: '#eab308',
28
+ low: '#6b7280',
29
+ }
30
+
31
+ // ─── ReviewReport detection ──────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Check whether `obj` is a valid ReviewReport-like object.
35
+ * The minimum requirement: an object with `convergence` (object),
36
+ * `findings` (array), and `rounds` (array).
37
+ *
38
+ * @param {unknown} obj
39
+ * @returns {obj is Record<string, unknown>}
40
+ */
41
+ export function isReviewReport(obj) {
42
+ if (!obj || typeof obj !== 'object') return false
43
+ const o = /** @type {Record<string, unknown>} */ (obj)
44
+ return (
45
+ typeof o.convergence === 'object' &&
46
+ o.convergence !== null &&
47
+ Array.isArray(o.findings) &&
48
+ Array.isArray(o.rounds)
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Deep-scan an object tree for the first ReviewReport.
54
+ *
55
+ * - Uses a `seen` Set to avoid circular references.
56
+ * - Respects `maxDepth` (default 20) to cap stack depth.
57
+ * - Returns the first Report found (breadth-first precedence), or null.
58
+ *
59
+ * @param {unknown} obj
60
+ * @param {Set<unknown>} [seen]
61
+ * @param {number} [maxDepth=20]
62
+ * @returns {Record<string, unknown> | null}
63
+ */
64
+ export function findReportInObject(obj, seen, maxDepth = 20) {
65
+ if (maxDepth <= 0) return null
66
+ if (!obj || typeof obj !== 'object') return null
67
+
68
+ const s = seen || new Set()
69
+ if (s.has(obj)) return null
70
+ s.add(obj)
71
+
72
+ // Check self
73
+ if (isReviewReport(obj)) return /** @type {Record<string, unknown>} */ (obj)
74
+
75
+ // Check arrays first (breadth-first within a node)
76
+ if (Array.isArray(obj)) {
77
+ for (const item of obj) {
78
+ const found = findReportInObject(item, s, maxDepth - 1)
79
+ if (found) return found
80
+ }
81
+ return null
82
+ }
83
+
84
+ // Check object values
85
+ const o = /** @type {Record<string, unknown>} */ (obj)
86
+ for (const key of Object.keys(o)) {
87
+ const val = o[key]
88
+ if (val && typeof val === 'object') {
89
+ // Check leaf values that are arrays or objects
90
+ const found = findReportInObject(val, s, maxDepth - 1)
91
+ if (found) return found
92
+ }
93
+ }
94
+
95
+ return null
96
+ }
97
+
98
+ /**
99
+ * Scan a session snapshot (or any object) for the latest iterate_review tool
100
+ * call result that contains a ReviewReport. Prefers the most recent one.
101
+ *
102
+ * @param {unknown} session
103
+ * @returns {Record<string, unknown> | null}
104
+ */
105
+ export function scanSessionForReport(session) {
106
+ if (!session || typeof session !== 'object') return null
107
+
108
+ // Try direct find first
109
+ const direct = findReportInObject(session)
110
+ if (direct) return direct
111
+
112
+ // Try common session structures
113
+ const s = /** @type {Record<string, unknown>} */ (session)
114
+
115
+ // Common pattern: session.toolCalls[].result.report
116
+ if (Array.isArray(s.toolCalls)) {
117
+ const calls = /** @type {Array<Record<string, unknown>>} */ (s.toolCalls)
118
+ for (let i = calls.length - 1; i >= 0; i--) {
119
+ const call = calls[i]
120
+ if (!call) continue
121
+ if (call.tool === 'iterate_review' || String(call.tool ?? '').endsWith('iterate_review')) {
122
+ const result = call.result
123
+ if (result && typeof result === 'object') {
124
+ const r = /** @type {Record<string, unknown>} */ (result)
125
+ if (r.report && typeof r.report === 'object') {
126
+ return /** @type {Record<string, unknown>} */ (r.report)
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // Common pattern: session.messages[].tool_calls[].function.arguments
134
+ if (Array.isArray(s.messages)) {
135
+ const msgs = /** @type {Array<Record<string, unknown>>} */ (s.messages)
136
+ for (let i = msgs.length - 1; i >= 0; i--) {
137
+ const msg = msgs[i]
138
+ if (!msg || !Array.isArray(msg.tool_calls)) continue
139
+ const calls = /** @type {Array<Record<string, unknown>>} */ (msg.tool_calls)
140
+ for (const call of calls) {
141
+ if (!call) continue
142
+ const fn = call.function
143
+ if (fn && typeof fn === 'object') {
144
+ const f = /** @type {Record<string, unknown>} */ (fn)
145
+ if (String(f.name ?? '').endsWith('iterate_review')) {
146
+ // Try to parse arguments
147
+ try {
148
+ const args = JSON.parse(String(f.arguments ?? '{}'))
149
+ const found = findReportInObject(args)
150
+ if (found) return found
151
+ } catch {
152
+ // Not JSON, skip
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ return null
161
+ }
162
+
163
+ // ─── Normalization ───────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Normalize a ReviewReport, filling in missing optional fields with computed
167
+ * defaults. Never mutates the input.
168
+ *
169
+ * @param {Record<string, unknown>} report
170
+ * @returns {Record<string, unknown>}
171
+ */
172
+ export function normalizeReport(report) {
173
+ const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
174
+ const rounds = /** @type {Array<unknown>} */ (report.rounds ?? [])
175
+ const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
176
+
177
+ // Normalize convergence
178
+ const totalRounds =
179
+ typeof convergence.totalRounds === 'number'
180
+ ? convergence.totalRounds
181
+ : rounds.length
182
+
183
+ const normalizedConvergence = {
184
+ totalRounds,
185
+ findingsByRound: Array.isArray(convergence.findingsByRound)
186
+ ? convergence.findingsByRound
187
+ : rounds.map((r) => {
188
+ const rr = /** @type {Record<string, unknown>} */ (r)
189
+ return Array.isArray(rr?.findings) ? rr.findings.length : 0
190
+ }),
191
+ converged: convergence.converged === true,
192
+ stoppedReason: convergence.stoppedReason ?? (rounds.length < totalRounds ? 'converged' : 'max_rounds_reached'),
193
+ }
194
+
195
+ // Compute summary if missing. Always build a NEW object so the input's
196
+ // summary (or any other field) is never mutated.
197
+ let summary = report.summary
198
+ if (!summary || typeof summary !== 'object') {
199
+ summary = computeSummaryFromFindings(findings)
200
+ } else {
201
+ const s = /** @type {Record<string, unknown>} */ (summary)
202
+ const computed = computeSummaryFromFindings(findings)
203
+ summary = {
204
+ totalFindings: typeof s.totalFindings === 'number' ? s.totalFindings : findings.length,
205
+ critical: typeof s.critical === 'number' ? s.critical : computed.critical,
206
+ high: typeof s.high === 'number' ? s.high : computed.high,
207
+ medium: typeof s.medium === 'number' ? s.medium : computed.medium,
208
+ low: typeof s.low === 'number' ? s.low : computed.low,
209
+ byDimension: s.byDimension && typeof s.byDimension === 'object'
210
+ ? s.byDimension
211
+ : computed.byDimension,
212
+ }
213
+ }
214
+
215
+ return {
216
+ mode: report.mode ?? 'dry-run',
217
+ goal: report.goal ?? '',
218
+ dimensions: Array.isArray(report.dimensions) ? report.dimensions : [],
219
+ maxReviewRounds: report.maxReviewRounds ?? totalRounds,
220
+ rounds,
221
+ findings,
222
+ convergence: normalizedConvergence,
223
+ summary,
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Compute summary stats from findings array.
229
+ *
230
+ * @param {Array<Record<string, unknown>>} findings
231
+ * @returns {{ totalFindings: number, critical: number, high: number, medium: number, low: number, byDimension: Record<string, number> }}
232
+ */
233
+ function computeSummaryFromFindings(findings) {
234
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 }
235
+ /** @type {Record<string, number>} */
236
+ const byDimension = {}
237
+
238
+ for (const f of findings) {
239
+ const sev = String(f.severity ?? 'low')
240
+ if (sev in counts) counts[sev]++
241
+ const dim = String(f.dimension ?? 'unknown')
242
+ byDimension[dim] = (byDimension[dim] ?? 0) + 1
243
+ }
244
+
245
+ return {
246
+ totalFindings: findings.length,
247
+ critical: counts.critical,
248
+ high: counts.high,
249
+ medium: counts.medium,
250
+ low: counts.low,
251
+ byDimension,
252
+ }
253
+ }
254
+
255
+ export { computeSummaryFromFindings }
256
+
257
+ // ─── Convergence helpers ─────────────────────────────────────────────────────
258
+
259
+ /**
260
+ * Compute progress percentage (0-100) from a normalized report.
261
+ *
262
+ * @param {Record<string, unknown>} report
263
+ * @returns {number}
264
+ */
265
+ export function computeConvergenceProgress(report) {
266
+ const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
267
+ const totalRounds = typeof convergence.totalRounds === 'number'
268
+ ? convergence.totalRounds
269
+ : 1
270
+ const currentRounds = /** @type {Array<unknown>} */ (report.rounds ?? []).length
271
+ // Guard against an empty report (totalRounds <= 0) producing NaN.
272
+ if (!(totalRounds > 0)) return 0
273
+ return Math.min(100, Math.round((currentRounds / totalRounds) * 100))
274
+ }
275
+
276
+ /**
277
+ * Get the current round number (1-indexed) from a report.
278
+ *
279
+ * @param {Record<string, unknown>} report
280
+ * @returns {number}
281
+ */
282
+ export function getCurrentRound(report) {
283
+ return (/** @type {Array<unknown>} */ (report.rounds ?? [])).length
284
+ }
285
+
286
+ /**
287
+ * Get the total round count (max) from a report.
288
+ *
289
+ * @param {Record<string, unknown>} report
290
+ * @returns {number}
291
+ */
292
+ export function getTotalRounds(report) {
293
+ const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
294
+ return typeof convergence.totalRounds === 'number'
295
+ ? convergence.totalRounds
296
+ : 1
297
+ }
298
+
299
+ // ─── Severity stats ──────────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Count findings by severity. Returns an object with `critical`, `high`,
303
+ * `medium`, `low` keys.
304
+ *
305
+ * @param {Record<string, unknown>} report
306
+ * @returns {{ critical: number, high: number, medium: number, low: number }}
307
+ */
308
+ export function severityStats(report) {
309
+ const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
310
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 }
311
+ for (const f of findings) {
312
+ const sev = String(f.severity ?? 'low')
313
+ if (sev in counts) counts[sev]++
314
+ }
315
+ return counts
316
+ }
317
+
318
+ // ─── Dimension grouping ──────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Group findings by dimension. Returns a Record<string, Array<finding>>.
322
+ *
323
+ * @param {Record<string, unknown>} report
324
+ * @returns {Record<string, Array<Record<string, unknown>>>}
325
+ */
326
+ export function groupByDimension(report) {
327
+ const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
328
+ /** @type {Record<string, Array<Record<string, unknown>>>} */
329
+ const groups = {}
330
+ for (const f of findings) {
331
+ const dim = String(f.dimension ?? 'unknown')
332
+ if (!groups[dim]) groups[dim] = []
333
+ groups[dim].push(f)
334
+ }
335
+ return groups
336
+ }
337
+
338
+ // ─── Triage state ────────────────────────────────────────────────────────────
339
+
340
+ /** Triage verdict values */
341
+ export const TRIAGE_VERDICTS = /** @type {const} */ (['keep', 'skip', 'ignore'])
342
+
343
+ /**
344
+ * Build initial triage state for a report. Each finding gets a default verdict
345
+ * of 'keep'. Returns a Map where key = finding index (string), value = verdict.
346
+ *
347
+ * @param {Record<string, unknown>} report
348
+ * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
349
+ */
350
+ export function buildTriageState(report) {
351
+ const findings = /** @type {Array<unknown>} */ (report.findings ?? [])
352
+ /** @type {Record<string, 'keep' | 'skip' | 'ignore'>} */
353
+ const state = {}
354
+ for (let i = 0; i < findings.length; i++) {
355
+ state[String(i)] = 'keep'
356
+ }
357
+ return state
358
+ }
359
+
360
+ // ─── Report hashing (for localStorage key) ────────────────────────────────────
361
+
362
+ /**
363
+ * Create a deterministic hash string from a report's key fields.
364
+ * Used as localStorage key for persisting triage verdicts.
365
+ *
366
+ * @param {Record<string, unknown>} report
367
+ * @returns {string}
368
+ */
369
+ export function hashReport(report) {
370
+ const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
371
+ const totalRounds = String(convergence.totalRounds ?? '')
372
+ const findingsCount = String((/** @type {Array<unknown>} */ (report.findings ?? [])).length)
373
+ const firstFinding = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])[0]
374
+ const firstSummary = firstFinding ? String(firstFinding.summary ?? '') : ''
375
+ const mode = String(report.mode ?? '')
376
+ // Use mode + totalRounds + findingsCount + first 20 chars of first finding summary
377
+ return `iterate-triage-${mode}-${totalRounds}-${findingsCount}-${firstSummary.slice(0, 20)}`
378
+ }
379
+
380
+ // ─── Known-intentional YAML builder ──────────────────────────────────────────
381
+
382
+ /**
383
+ * Convert triage entries with verdict 'ignore' to a YAML-compatible text
384
+ * snippet for known_intentional entries.
385
+ *
386
+ * @param {Array<{ file: string, line?: number, dimension: string, reason: string }>} entries
387
+ * @returns {string}
388
+ */
389
+ export function toKnownIntentionalYaml(entries) {
390
+ if (!entries || entries.length === 0) return ''
391
+
392
+ const lines = ['known_intentional:']
393
+ for (const e of entries) {
394
+ lines.push(` - file: ${JSON.stringify(e.file)}`)
395
+ if (e.line !== undefined && e.line > 0) {
396
+ lines.push(` line: ${e.line}`)
397
+ }
398
+ lines.push(` dimension: ${JSON.stringify(e.dimension)}`)
399
+ lines.push(` reason: ${JSON.stringify(e.reason)}`)
400
+ }
401
+ return lines.join('\n')
402
+ }
403
+
404
+ /**
405
+ * Build a text instruction that the user can paste to the model to trigger
406
+ * `iterate_triage` tool call. Works even if the user hasn't yet configured
407
+ * an `iterate_triage` tool — the instruction tells the model what to do.
408
+ *
409
+ * @param {Array<{ file: string, line?: number, dimension: string, reason: string }>} entries
410
+ * @returns {string}
411
+ */
412
+ export function buildApplyInstruction(entries) {
413
+ if (!entries || entries.length === 0) return ''
414
+
415
+ const payload = JSON.stringify(
416
+ {
417
+ operation: 'apply',
418
+ entries: entries.map((e) => ({
419
+ file: e.file,
420
+ ...(e.line !== undefined ? { line: e.line } : {}),
421
+ dimension: e.dimension,
422
+ reason: e.reason,
423
+ })),
424
+ },
425
+ null,
426
+ 2,
427
+ )
428
+
429
+ return (
430
+ `Please call \`iterate_triage\` with the following payload to apply the triage verdicts:\n\n` +
431
+ `\`\`\`json\n${payload}\n\`\`\``
432
+ )
433
+ }
434
+
435
+ /**
436
+ * Collect ignored entries from triage state + findings, returning the
437
+ * structured data ready for `iterate_triage` tool call.
438
+ *
439
+ * @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
440
+ * @param {Array<Record<string, unknown>>} findings
441
+ * @returns {Array<{ file: string, line?: number, dimension: string, reason: string }>}
442
+ */
443
+ export function collectIgnoredEntries(triageState, findings) {
444
+ const entries = []
445
+ for (const [idx, verdict] of Object.entries(triageState)) {
446
+ if (verdict !== 'ignore') continue
447
+ const finding = findings[Number(idx)]
448
+ if (!finding) continue
449
+ entries.push({
450
+ file: String(finding.file ?? ''),
451
+ ...(typeof finding.line === 'number' && finding.line > 0
452
+ ? { line: finding.line }
453
+ : {}),
454
+ dimension: String(finding.dimension ?? ''),
455
+ reason: String(finding.summary ?? ''),
456
+ })
457
+ }
458
+ return entries
459
+ }
460
+
461
+ // ─── Finding filtering ──────────────────────────────────────────────────────
462
+
463
+ /**
464
+ * Normalize a caller-supplied filter into a stable shape.
465
+ * Unknown severity values are dropped; search is lower-cased + trimmed.
466
+ *
467
+ * @param {{ severities?: string[], dimensions?: string[], search?: string } | null | undefined} filter
468
+ * @returns {{ severities: string[], dimensions: string[], search: string }}
469
+ */
470
+ export function normalizeFindingFilter(filter) {
471
+ const f = filter && typeof filter === 'object' ? filter : {}
472
+ const severities = Array.isArray(f.severities)
473
+ ? f.severities.filter((s) => SEVERITY_ORDER.includes(String(s)))
474
+ : []
475
+ const dimensions = Array.isArray(f.dimensions)
476
+ ? f.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
477
+ : []
478
+ const search = typeof f.search === 'string' ? f.search.trim().toLowerCase() : ''
479
+ return { severities, dimensions, search }
480
+ }
481
+
482
+ /**
483
+ * Whether a single finding matches a normalized filter.
484
+ * An empty filter matches everything.
485
+ *
486
+ * @param {Record<string, unknown>} finding
487
+ * @param {{ severities: string[], dimensions: string[], search: string }} filter
488
+ * @returns {boolean}
489
+ */
490
+ export function findingMatches(finding, filter) {
491
+ const f = normalizeFindingFilter(filter)
492
+ const sev = String(finding.severity ?? 'low')
493
+ if (f.severities.length > 0 && !f.severities.includes(sev)) return false
494
+ const dim = String(finding.dimension ?? '')
495
+ if (f.dimensions.length > 0 && !f.dimensions.includes(dim)) return false
496
+ if (f.search) {
497
+ const haystack = [
498
+ String(finding.file ?? ''),
499
+ String(finding.summary ?? ''),
500
+ String(finding.dimension ?? ''),
501
+ String(finding.suggested_fix ?? ''),
502
+ ].join(' ').toLowerCase()
503
+ if (haystack.indexOf(f.search) < 0) return false
504
+ }
505
+ return true
506
+ }
507
+
508
+ /**
509
+ * Filter a findings array, returning only the matches.
510
+ *
511
+ * @param {Array<Record<string, unknown>>} findings
512
+ * @param {{ severities?: string[], dimensions?: string[], search?: string } | null | undefined} filter
513
+ * @returns {Array<Record<string, unknown>>}
514
+ */
515
+ export function filterFindings(findings, filter) {
516
+ const f = normalizeFindingFilter(filter)
517
+ return (Array.isArray(findings) ? findings : []).filter((finding) => findingMatches(finding, f))
518
+ }
519
+
520
+ /**
521
+ * Filter a findings array, returning the matches together with their ORIGINAL
522
+ * indices. Batch operations act on these indices so the triage state (keyed by
523
+ * original index) stays consistent even when some findings are hidden.
524
+ *
525
+ * @param {Array<Record<string, unknown>>} findings
526
+ * @param {{ severities?: string[], dimensions?: string[], search?: string } | null | undefined} filter
527
+ * @returns {{ filtered: Array<Record<string, unknown>>, indices: number[] }}
528
+ */
529
+ export function filterFindingsWithIndices(findings, filter) {
530
+ const f = normalizeFindingFilter(filter)
531
+ const list = Array.isArray(findings) ? findings : []
532
+ const filtered = []
533
+ const indices = []
534
+ for (let i = 0; i < list.length; i++) {
535
+ if (findingMatches(list[i], f)) {
536
+ filtered.push(list[i])
537
+ indices.push(i)
538
+ }
539
+ }
540
+ return { filtered, indices }
541
+ }
542
+
543
+ /**
544
+ * Build the severity + dimension filter options with per-option counts, so the
545
+ * UI can render chips/selects and show how many findings each filters down to.
546
+ *
547
+ * @param {Array<Record<string, unknown>>} findings
548
+ * @returns {{ severities: Array<{ value: string, count: number }>, dimensions: Array<{ value: string, count: number }> }}
549
+ */
550
+ export function buildFilterOptions(findings) {
551
+ const list = Array.isArray(findings) ? findings : []
552
+ const severities = SEVERITY_ORDER.map((value) => ({ value, count: 0 }))
553
+ /** @type {Record<string, number>} */
554
+ const dimCounts = {}
555
+ for (const f of list) {
556
+ const sev = String(f.severity ?? 'low')
557
+ const sv = severities.find((s) => s.value === sev)
558
+ if (sv) sv.count++
559
+ const dim = String(f.dimension ?? 'unknown')
560
+ dimCounts[dim] = (dimCounts[dim] ?? 0) + 1
561
+ }
562
+ return {
563
+ severities: severities.map((s) => ({ ...s })),
564
+ dimensions: Object.keys(dimCounts).map((value) => ({ value, count: dimCounts[value] })),
565
+ }
566
+ }
567
+
568
+ // ─── Triage batch operations ────────────────────────────────────────────────
569
+
570
+ /**
571
+ * Count how many findings carry each verdict.
572
+ *
573
+ * @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
574
+ * @returns {{ keep: number, skip: number, ignore: number }}
575
+ */
576
+ export function countVerdicts(triageState) {
577
+ const counts = { keep: 0, skip: 0, ignore: 0 }
578
+ for (const v of Object.values(triageState ?? {})) {
579
+ if (v === 'keep' || v === 'skip' || v === 'ignore') counts[v]++
580
+ }
581
+ return counts
582
+ }
583
+
584
+ /**
585
+ * Set the verdict for a list of finding indices. Returns a NEW state
586
+ * (the input is never mutated).
587
+ *
588
+ * @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
589
+ * @param {number[]} indices
590
+ * @param {'keep' | 'skip' | 'ignore'} verdict
591
+ * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
592
+ */
593
+ export function batchSetVerdict(triageState, indices, verdict) {
594
+ if (verdict !== 'keep' && verdict !== 'skip' && verdict !== 'ignore') return triageState
595
+ if (!Array.isArray(indices) || indices.length === 0) return triageState
596
+ const next = { ...triageState }
597
+ for (const idx of indices) {
598
+ if (typeof idx === 'number' && Number.isInteger(idx) && idx >= 0) {
599
+ next[String(idx)] = verdict
600
+ }
601
+ }
602
+ return next
603
+ }
604
+
605
+ /**
606
+ * Set the verdict for ALL findings (or only the given index whitelist).
607
+ *
608
+ * @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
609
+ * @param {'keep' | 'skip' | 'ignore'} verdict
610
+ * @param {number[]} [indices]
611
+ * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
612
+ */
613
+ export function setAllVerdicts(triageState, verdict, indices) {
614
+ const targets = Array.isArray(indices)
615
+ ? indices
616
+ : Object.keys(triageState ?? {}).map(Number)
617
+ return batchSetVerdict(triageState, targets, verdict)
618
+ }
619
+
620
+ // ─── History & trend ────────────────────────────────────────────────────────
621
+
622
+ /**
623
+ * Per-round finding counts (including severity breakdown), oldest first.
624
+ * Derived from `report.rounds`.
625
+ *
626
+ * @param {Record<string, unknown>} report
627
+ * @returns {Array<{ round: number, count: number, critical: number, high: number, medium: number, low: number }>}
628
+ */
629
+ export function buildRoundHistory(report) {
630
+ const rounds = Array.isArray(report.rounds) ? report.rounds : []
631
+ return rounds.map((r) => {
632
+ const rr = /** @type {Record<string, unknown>} */ (r)
633
+ const findings = Array.isArray(rr.findings) ? rr.findings : []
634
+ const sev = severityStats({ findings })
635
+ return {
636
+ round: typeof rr.round === 'number' ? rr.round : 0,
637
+ count: findings.length,
638
+ critical: sev.critical,
639
+ high: sev.high,
640
+ medium: sev.medium,
641
+ low: sev.low,
642
+ }
643
+ })
644
+ }
645
+
646
+ /**
647
+ * Findings-by-round trend points. Prefers the explicit
648
+ * `convergence.findingsByRound` when present, otherwise derives from rounds.
649
+ *
650
+ * @param {Record<string, unknown>} report
651
+ * @returns {Array<{ round: number, count: number }>}
652
+ */
653
+ export function buildFindingTrend(report) {
654
+ const conv = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
655
+ if (Array.isArray(conv.findingsByRound)) {
656
+ return conv.findingsByRound.map((n, i) => ({ round: i + 1, count: typeof n === 'number' ? n : 0 }))
657
+ }
658
+ return buildRoundHistory(report).map((h) => ({ round: h.round, count: h.count }))
659
+ }
660
+
661
+ /**
662
+ * Trend metrics for the dashboard chart + summary line.
663
+ *
664
+ * @param {Record<string, unknown>} report
665
+ * @returns {{ points: Array<{ round: number, count: number }>, total: number, firstRound: number, lastRound: number, reductionPercent: number, converged: boolean }}
666
+ */
667
+ export function computeTrendMetrics(report) {
668
+ const conv = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
669
+ const points = buildFindingTrend(report)
670
+ const total = points.reduce((sum, p) => sum + p.count, 0)
671
+ const firstRound = points.length > 0 ? points[0].count : 0
672
+ const lastRound = points.length > 0 ? points[points.length - 1].count : 0
673
+ const reductionPercent = firstRound > 0 ? Math.round(((firstRound - lastRound) / firstRound) * 100) : 0
674
+ return {
675
+ points,
676
+ total,
677
+ firstRound,
678
+ lastRound,
679
+ reductionPercent,
680
+ converged: conv.converged === true,
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Peak count among trend points (for chart scaling). Never returns 0 so the
686
+ * chart always has a sane baseline.
687
+ *
688
+ * @param {Array<{ round: number, count: number }>} points
689
+ * @returns {number}
690
+ */
691
+ export function trendMax(points) {
692
+ let max = 1
693
+ for (const p of Array.isArray(points) ? points : []) {
694
+ if (typeof p.count === 'number' && p.count > max) max = p.count
695
+ }
696
+ return max
697
+ }
698
+
699
+ // ─── Completion notification ────────────────────────────────────────────────
700
+
701
+ /**
702
+ * One-line completion summary for notifications ("已收敛 / 已达最大轮数").
703
+ *
704
+ * @param {Record<string, unknown>} report
705
+ * @returns {string}
706
+ */
707
+ export function buildCompletionSummary(report) {
708
+ const conv = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
709
+ const rounds = getCurrentRound(report)
710
+ const total = getTotalRounds(report)
711
+ const stats = severityStats(report)
712
+ const converged = conv.converged === true
713
+ const reason = converged ? '已收敛' : `已达最大轮数 ${total}`
714
+ const totalFindings = stats.critical + stats.high + stats.medium + stats.low
715
+ return `iterate 评审完成 · ${rounds}/${total} 轮 · ${totalFindings} 项发现 · ${reason}`
716
+ }
717
+
718
+ // ─── Config edit guidance ───────────────────────────────────────────────────
719
+
720
+ /**
721
+ * Editable config fields (key + label + hint), used by the settings guide.
722
+ * @type {Array<{ key: string, label: string, hint: string }>}
723
+ */
724
+ export const CONFIG_EDIT_FIELDS = [
725
+ { key: 'goal', label: '目标', hint: '一句话描述本次迭代目标(字符串)' },
726
+ { key: 'dimensions', label: '审查维度', hint: '数组,如 ["correctness","security"]' },
727
+ { key: 'max_rounds', label: '最大轮数', hint: '正整数' },
728
+ { key: 'review.scope', label: '审查范围', hint: '"full" 或 "changed-only"' },
729
+ { key: 'atomic.max_lines', label: '原子修复上限行数', hint: '正整数' },
730
+ { key: 'git.push_per_round', label: '每轮推送', hint: 'true / false' },
731
+ ]
732
+
733
+ /**
734
+ * Static copy-paste config editing guide (shown in the settings page).
735
+ *
736
+ * @returns {string}
737
+ */
738
+ export function buildConfigEditGuide() {
739
+ const lines = [
740
+ 'iterate 配置编辑指引',
741
+ '---------------------',
742
+ '配置文件:项目根目录 iterate.config.yaml。',
743
+ '',
744
+ '可编辑字段:',
745
+ ...CONFIG_EDIT_FIELDS.map((f) => `- ${f.key}(${f.label}):${f.hint}`),
746
+ '',
747
+ '让模型帮你改:',
748
+ '1. 调用 iterate_config({ operation: "read" }) 查看当前配置;',
749
+ '2. 说明想改的字段,例如「把 max_rounds 改成 5,dimensions 只保留 correctness 和 security」;',
750
+ '3. 模型会调用 iterate_config({ operation: "write", updates: {...} }) 写入,写入前自动备份,失败自动回滚。',
751
+ ]
752
+ return lines.join('\n')
753
+ }
754
+
755
+ /**
756
+ * Build a copy-paste instruction for a desired config change. The user picks
757
+ * the fields they want to change; the resulting text is meant to be pasted to
758
+ * the model to trigger an `iterate_config` write.
759
+ *
760
+ * @param {Record<string, unknown>} desiredChanges
761
+ * @returns {string}
762
+ */
763
+ export function buildConfigEditInstruction(desiredChanges) {
764
+ const payload = JSON.stringify({ operation: 'write', updates: desiredChanges }, null, 2)
765
+ return `请调用 \`iterate_config\` 写入以下配置更新:\n\n\`\`\`json\n${payload}\n\`\`\``
766
+ }
767
+
768
+ /**
769
+ * Keyboard shortcut → triage verdict mapping (used by the triage panel).
770
+ * @type {Record<string, 'keep' | 'skip' | 'ignore'>}
771
+ */
772
+ export const VERDICT_SHORTCUTS = {
773
+ y: 'keep',
774
+ Y: 'keep',
775
+ n: 'skip',
776
+ N: 'skip',
777
+ a: 'ignore',
778
+ A: 'ignore',
779
+ }
780
+
781
+ /**
782
+ * Map a keyboard event key to a triage verdict, or null when the key is not a
783
+ * triage shortcut.
784
+ *
785
+ * @param {string} key
786
+ * @returns {'keep' | 'skip' | 'ignore' | null}
787
+ */
788
+ export function keyToVerdict(key) {
789
+ return VERDICT_SHORTCUTS[key] ?? null
790
+ }