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/client.js +852 -0
- package/lib/parse.js +790 -0
- package/package.json +11 -4
- package/src/config-write.ts +181 -0
- package/src/index.ts +13 -4
- package/src/paths.ts +38 -0
- package/src/review.ts +13 -6
- package/src/skill-prompt.ts +117 -24
- package/src/tools/checkpoint.ts +285 -0
- package/src/tools/config.ts +64 -6
- package/src/tools/decision-log.ts +14 -4
- package/src/tools/fix.ts +565 -0
- package/src/tools/triage.ts +370 -0
- package/src/types.ts +64 -0
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
|
+
}
|