iterate-plugin 3.8.0 → 3.9.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/src/index.ts CHANGED
@@ -2,16 +2,18 @@
2
2
  * iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
3
3
  *
4
4
  * Architecture:
5
- * - The plugin registers 17 tools (14 original + 3 v3.0 quality command center tools)
5
+ * - The plugin registers 18 tools (14 original + 3 v3.0 quality command center
6
+ * tools + 1 v3.9 report export)
6
7
  * Original: config, validate, decision-log, context, review, triage, fix, diff,
7
8
  * rollback, checkpoint, status, history, prune, transcript
8
9
  * v3.0: experience, quality_gate, defense_events
10
+ * v3.9: report
9
11
  * - The plugin injects a system prompt section teaching the iterate workflow pattern
10
12
  * - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
11
13
  * - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
12
- * - Subagents use the 17 tools to do real work (read config, run validation, log decisions,
14
+ * - Subagents use the 18 tools to do real work (read config, run validation, log decisions,
13
15
  * review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript,
14
- * query experience bank, check quality gates, query defense events)
16
+ * query experience bank, check quality gates, query defense events, export a report)
15
17
  * - A `tools/pre-execute` hook gates destructive iterate calls behind human approval
16
18
  * (F8 observatory approval policy: ask / deny / allow).
17
19
  *
@@ -23,7 +25,7 @@
23
25
  *
24
26
  * Key files:
25
27
  * - src/index.ts — Plugin entry: register tools + inject skill prompt
26
- * - src/tools/ — 17 tool implementations (14 original + 3 v3.0)
28
+ * - src/tools/ — 18 tool implementations (14 original + 3 v3.0 + 1 v3.9)
27
29
  * - src/config-loader.ts — YAML config loading
28
30
  * - src/types.ts — Shared types
29
31
  */
@@ -43,6 +45,7 @@ import { registerTranscriptTool } from './tools/transcript.ts'
43
45
  import { registerExperienceBankTool } from './tools/experience-bank.ts'
44
46
  import { registerQualityGateTool } from './tools/quality-gate.ts'
45
47
  import { registerDefenseEventsTool } from './tools/defense-events.ts'
48
+ import { registerReportTool } from './tools/report.ts'
46
49
  import { registerSessionHooks } from './session-hooks.ts'
47
50
  import { registerLiveCapture } from './live.ts'
48
51
  import { installWorkingDirectoryContext } from './working-directory.ts'
@@ -92,6 +95,8 @@ export function apply(ctx: Context): void {
92
95
  ['iterate_experience', () => registerExperienceBankTool(ctx)],
93
96
  ['iterate_quality_gate', () => registerQualityGateTool(ctx)],
94
97
  ['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
98
+ // v3.9: PR-ready / CI-ready report export
99
+ ['iterate_report', () => registerReportTool(ctx)],
95
100
  ]
96
101
  for (const [toolName, register] of toolRegistrations) {
97
102
  try {
@@ -0,0 +1,578 @@
1
+ /**
2
+ * src/report-render.ts — pure renderers for the portable iterate run report.
3
+ *
4
+ * ONE data model, TWO consumers that must never disagree:
5
+ * - the `iterate_report` tool (server side), and
6
+ * - the observatory's "导出报告 / 导出 SARIF" buttons (client side),
7
+ * both call {@link renderRunReportMarkdown} / {@link renderRunReportSarif} on
8
+ * the same normalized {@link RunReportData}.
9
+ *
10
+ * This module is deliberately dependency-free and DOM-free: it is compiled to
11
+ * `dist/` for the server AND inlined into `lib/client.js` by esbuild. It must
12
+ * import NOTHING — importing `lib/parse.js` would drag a file outside `src/`
13
+ * into the server's tsc program and scatter the emit into `dist/src/**`. Every
14
+ * extraction is tolerant: a hand-edited manifest can only ever produce an empty
15
+ * section, never a thrown export.
16
+ *
17
+ * Formats:
18
+ * - **markdown** — a PR-pasteable run report (header, quality gate, findings,
19
+ * fixes, validations, defense events, experience bank).
20
+ * - **sarif** — SARIF 2.1.0 for CI / GitHub Code Scanning (ruleId =
21
+ * review dimension, severity → error | warning | note, file+line
22
+ * locations, suggested fix attached).
23
+ */
24
+
25
+ /** Report formats accepted by the tool and the export buttons. */
26
+ export const REPORT_FORMATS = ['markdown', 'sarif'] as const
27
+
28
+ /** Severity ordering (lowest index = most severe). */
29
+ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'] as const
30
+
31
+ /** One normalized finding. */
32
+ export interface ReportFinding {
33
+ dimension: string
34
+ file: string
35
+ line: number
36
+ severity: string
37
+ summary: string
38
+ failureScenario: string
39
+ suggestedFix: string
40
+ isAtomic: boolean
41
+ acknowledged: boolean
42
+ }
43
+
44
+ /** One normalized applied fix. */
45
+ export interface ReportFix {
46
+ round: number
47
+ file: string
48
+ summary: string
49
+ linesAdded: number
50
+ linesRemoved: number
51
+ success: boolean
52
+ }
53
+
54
+ /** One normalized validation outcome. */
55
+ export interface ReportValidation {
56
+ round: number
57
+ command: string
58
+ exitCode: number | null
59
+ allowed: boolean
60
+ rejectReason: string
61
+ }
62
+
63
+ /** The one shape both renderers consume. */
64
+ export interface RunReportData {
65
+ project: string
66
+ goal: string
67
+ mode: string | null
68
+ taskMode: string | null
69
+ round: number
70
+ maxRounds: number
71
+ active: boolean
72
+ stoppedReason: string | null
73
+ updatedAt: string
74
+ convergence: number[]
75
+ findings: ReportFinding[]
76
+ severityCounts: { critical: number; high: number; medium: number; low: number }
77
+ fixes: ReportFix[]
78
+ validations: ReportValidation[]
79
+ qualityGate: Record<string, unknown> | null
80
+ experienceBank: Record<string, unknown> | null
81
+ defenseEvents: Record<string, unknown> | null
82
+ }
83
+
84
+ /** Options accepted by both renderers. */
85
+ export interface ReportRenderOptions {
86
+ lang?: 'zh' | 'en'
87
+ now?: string
88
+ toolVersion?: string
89
+ informationUri?: string
90
+ }
91
+
92
+ /** Human labels for the report, keyed by language. */
93
+ const REPORT_LABELS: Record<'en' | 'zh', Record<string, string>> = {
94
+ en: {
95
+ title: 'iterate run report',
96
+ project: 'Project',
97
+ goal: 'Goal',
98
+ mode: 'Mode',
99
+ status: 'Status',
100
+ round: 'Round',
101
+ updated: 'Updated',
102
+ converged: 'converged',
103
+ running: 'running',
104
+ stopped: 'stopped',
105
+ qualityGate: 'Quality gate',
106
+ overall: 'Overall',
107
+ verification: 'Verification',
108
+ dimension: 'Dimension',
109
+ qstatus: 'Status',
110
+ score: 'Score',
111
+ findingsCount: 'Findings',
112
+ fixed: 'Fixed',
113
+ convergence: 'Convergence',
114
+ findings: 'Findings',
115
+ severity: 'Severity',
116
+ location: 'Location',
117
+ summary: 'Summary',
118
+ defensiveType: 'Type',
119
+ none: '_none_',
120
+ fixes: 'Fixes',
121
+ file: 'File',
122
+ delta: 'Δ',
123
+ result: 'Result',
124
+ applied: 'applied',
125
+ failed: 'failed',
126
+ validations: 'Validations',
127
+ command: 'Command',
128
+ exit: 'Exit',
129
+ allowed: 'Allowed',
130
+ yes: 'yes',
131
+ no: 'no',
132
+ defenseEvents: 'Defense events',
133
+ experienceBank: 'Experience bank',
134
+ hits: 'hits',
135
+ entries: 'entries',
136
+ generatedAt: 'Generated at',
137
+ notRecorded: 'not recorded',
138
+ },
139
+ zh: {
140
+ title: 'iterate 运行报告',
141
+ project: '项目',
142
+ goal: '目标',
143
+ mode: '模式',
144
+ status: '状态',
145
+ round: '轮次',
146
+ updated: '更新时间',
147
+ converged: '已收敛',
148
+ running: '运行中',
149
+ stopped: '已结束',
150
+ qualityGate: '质量门禁',
151
+ overall: '总体',
152
+ verification: '验证',
153
+ dimension: '维度',
154
+ qstatus: '状态',
155
+ score: '得分',
156
+ findingsCount: '发现数',
157
+ fixed: '已修复',
158
+ convergence: '收敛率',
159
+ findings: '发现',
160
+ severity: '严重度',
161
+ location: '位置',
162
+ summary: '摘要',
163
+ defensiveType: '类型',
164
+ none: '无',
165
+ fixes: '修复',
166
+ file: '文件',
167
+ delta: '变更',
168
+ result: '结果',
169
+ applied: '成功',
170
+ failed: '失败',
171
+ validations: '验证',
172
+ command: '命令',
173
+ exit: '退出码',
174
+ allowed: '已授权',
175
+ yes: '是',
176
+ no: '否',
177
+ defenseEvents: '防御事件',
178
+ experienceBank: '经验银行',
179
+ hits: '命中',
180
+ entries: '条目',
181
+ generatedAt: '生成时间',
182
+ notRecorded: '未记录',
183
+ },
184
+ }
185
+
186
+ function reportLabels(lang: 'zh' | 'en'): Record<string, string> {
187
+ return lang === 'en' ? REPORT_LABELS.en : REPORT_LABELS.zh
188
+ }
189
+
190
+ /** Proxy-safe object read (never lets a hostile getter trap throw). */
191
+ function get(source: unknown, key: string): unknown {
192
+ if (!source || typeof source !== 'object') return undefined
193
+ try {
194
+ return (source as Record<string, unknown>)[key]
195
+ } catch {
196
+ return undefined
197
+ }
198
+ }
199
+
200
+ function asString(value: unknown): string {
201
+ return typeof value === 'string' ? value : ''
202
+ }
203
+
204
+ function asNumber(value: unknown): number {
205
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
206
+ }
207
+
208
+ function asArray(value: unknown): unknown[] {
209
+ return Array.isArray(value) ? value : []
210
+ }
211
+
212
+ function asObject(value: unknown): Record<string, unknown> | null {
213
+ return value && typeof value === 'object' && !Array.isArray(value)
214
+ ? (value as Record<string, unknown>)
215
+ : null
216
+ }
217
+
218
+ /** Escape a value for a Markdown table cell / inline text. */
219
+ function mdCell(value: unknown): string {
220
+ const s = value === null || value === undefined ? '' : String(value)
221
+ return s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim()
222
+ }
223
+
224
+ /** Severity of a finding, defaulting to the least-severe bucket. */
225
+ function findingSeverity(f: unknown): string {
226
+ const s = get(f, 'severity')
227
+ return typeof s === 'string' && (SEVERITY_ORDER as readonly string[]).includes(s) ? s : 'low'
228
+ }
229
+
230
+ /** Repo-relative POSIX uri for SARIF artifact locations. */
231
+ function sarifUri(file: unknown): string {
232
+ const s = typeof file === 'string' ? file.trim() : ''
233
+ if (!s) return ''
234
+ return s.replace(/\\/g, '/').replace(/^\.\//, '')
235
+ }
236
+
237
+ /** SARIF result level for an iterate severity. */
238
+ export function sarifLevel(severity: string): 'error' | 'warning' | 'note' {
239
+ if (severity === 'critical' || severity === 'high') return 'error'
240
+ if (severity === 'medium') return 'warning'
241
+ return 'note'
242
+ }
243
+
244
+ /**
245
+ * Normalize a manifest (+ the pulled disk artifacts) into ONE shape. Pure;
246
+ * tolerates junk at every field.
247
+ */
248
+ export function buildRunReportData(
249
+ manifest: unknown,
250
+ extra?: { qualityGate?: unknown; experienceBank?: unknown; defenseEvents?: unknown } | null,
251
+ ): RunReportData {
252
+ const src = asObject(manifest) ?? {}
253
+ const ex = asObject(extra)
254
+ const qg = asObject(get(ex, 'qualityGate'))
255
+ const bank = asObject(get(ex, 'experienceBank'))
256
+ const defense = asObject(get(ex, 'defenseEvents'))
257
+
258
+ const findings: ReportFinding[] = asArray(get(src, 'findings')).map((f) => ({
259
+ dimension: asString(get(f, 'dimension')),
260
+ file: asString(get(f, 'file')),
261
+ line: asNumber(get(f, 'line')),
262
+ severity: findingSeverity(f),
263
+ summary: asString(get(f, 'summary')),
264
+ failureScenario: asString(get(f, 'failure_scenario')),
265
+ suggestedFix: asString(get(f, 'suggested_fix')),
266
+ isAtomic: get(f, 'is_atomic') === true,
267
+ acknowledged: get(f, 'acknowledged') === true,
268
+ }))
269
+ findings.sort(
270
+ (a, b) =>
271
+ (SEVERITY_ORDER as readonly string[]).indexOf(a.severity) -
272
+ (SEVERITY_ORDER as readonly string[]).indexOf(b.severity),
273
+ )
274
+
275
+ const fixes: ReportFix[] = asArray(get(src, 'fixes')).map((f) => ({
276
+ round: asNumber(get(f, 'round')),
277
+ file: asString(get(f, 'file')),
278
+ summary: asString(get(f, 'summary')),
279
+ linesAdded: asNumber(get(f, 'linesAdded')),
280
+ linesRemoved: asNumber(get(f, 'linesRemoved')),
281
+ success: get(f, 'success') !== false,
282
+ }))
283
+
284
+ const validations: ReportValidation[] = asArray(get(src, 'validations')).map((v) => ({
285
+ round: asNumber(get(v, 'round')),
286
+ command: asString(get(v, 'command')),
287
+ exitCode: typeof get(v, 'exitCode') === 'number' ? asNumber(get(v, 'exitCode')) : null,
288
+ allowed: get(v, 'allowed') === true,
289
+ rejectReason: asString(get(v, 'rejectReason')),
290
+ }))
291
+
292
+ const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 }
293
+ for (const f of findings) {
294
+ const key = f.severity as keyof typeof severityCounts
295
+ if (key in severityCounts) severityCounts[key] += 1
296
+ }
297
+
298
+ const mode = get(src, 'mode')
299
+ const taskMode = get(src, 'taskMode')
300
+ const stoppedReason = get(src, 'stoppedReason')
301
+
302
+ return {
303
+ project: asString(get(src, 'project')),
304
+ goal: asString(get(src, 'goal')),
305
+ mode: mode === 'normal' || mode === 'dry-run' ? mode : null,
306
+ taskMode: taskMode === 'code' || taskMode === 'iterate' ? taskMode : null,
307
+ round: asNumber(get(src, 'round')),
308
+ maxRounds: asNumber(get(src, 'maxRounds')),
309
+ active: get(src, 'active') !== false,
310
+ stoppedReason: typeof stoppedReason === 'string' && stoppedReason ? stoppedReason : null,
311
+ updatedAt: asString(get(src, 'updatedAt')),
312
+ convergence: asArray(get(src, 'convergence')).filter(
313
+ (n): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0,
314
+ ),
315
+ findings,
316
+ severityCounts,
317
+ fixes,
318
+ validations,
319
+ qualityGate: qg,
320
+ experienceBank: bank,
321
+ defenseEvents: defense,
322
+ }
323
+ }
324
+
325
+ /** One-line human status for the report header. */
326
+ function reportStatus(data: RunReportData, labels: Record<string, string>): string {
327
+ if (data.active === true) return labels.running!
328
+ if (data.stoppedReason) return `${labels.stopped} · ${data.stoppedReason}`
329
+ return labels.stopped!
330
+ }
331
+
332
+ /** Render a PR-pasteable Markdown run report. */
333
+ export function renderRunReportMarkdown(
334
+ manifest: unknown,
335
+ extra?: { qualityGate?: unknown; experienceBank?: unknown; defenseEvents?: unknown } | null,
336
+ opts?: ReportRenderOptions,
337
+ ): string {
338
+ const data = buildRunReportData(manifest, extra)
339
+ const o = opts ?? {}
340
+ const labels = reportLabels(o.lang === 'en' ? 'en' : 'zh')
341
+ const when = o.now && o.now ? o.now : new Date().toISOString()
342
+ const lines: string[] = []
343
+
344
+ lines.push(`# ${labels.title}`)
345
+ lines.push('')
346
+ lines.push(`- **${labels.project}**: \`${mdCell(data.project || labels.notRecorded)}\``)
347
+ if (data.goal) lines.push(`- **${labels.goal}**: ${mdCell(data.goal)}`)
348
+ lines.push(
349
+ `- **${labels.mode}**: ${mdCell(data.mode || labels.notRecorded)}${data.taskMode ? ` · task_mode: ${data.taskMode}` : ''}`,
350
+ )
351
+ const roundText = data.maxRounds > 0 ? `${data.round}/${data.maxRounds}` : String(data.round)
352
+ lines.push(`- **${labels.round}**: ${mdCell(roundText)} · **${labels.status}**: ${mdCell(reportStatus(data, labels))}`)
353
+ lines.push(`- **${labels.updated}**: ${mdCell(data.updatedAt || when)}`)
354
+ lines.push(`- **${labels.generatedAt}**: ${mdCell(when)}`)
355
+ lines.push('')
356
+
357
+ // ── Quality gate ──
358
+ const qg = data.qualityGate
359
+ if (qg) {
360
+ lines.push(`## ${labels.qualityGate}`)
361
+ lines.push('')
362
+ const status = typeof qg.overallStatus === 'string' ? qg.overallStatus.toUpperCase() : 'PENDING'
363
+ lines.push(`- **${labels.overall}**: ${mdCell(status)} (${labels.score} ${asNumber(qg.overallScore)})`)
364
+ lines.push(
365
+ `- **${labels.verification}**: ${asNumber(qg.passedChecks)}/${asNumber(qg.totalChecks)} (${asNumber(qg.verificationPassRate)}%)`,
366
+ )
367
+ if (typeof qg.failReason === 'string' && qg.failReason) {
368
+ lines.push(`- ${labels.status}: ${mdCell(qg.failReason)}`)
369
+ }
370
+ lines.push('')
371
+ const dims = asArray(qg.dimensions)
372
+ if (dims.length > 0) {
373
+ lines.push(
374
+ `| ${labels.dimension} | ${labels.qstatus} | ${labels.score} | ${labels.findingsCount} | ${labels.fixed} | ${labels.convergence} |`,
375
+ )
376
+ lines.push('| --- | --- | --- | --- | --- | --- |')
377
+ for (const d of dims) {
378
+ lines.push(
379
+ `| ${mdCell(get(d, 'dimension'))} | ${mdCell(get(d, 'status'))} | ${mdCell(get(d, 'score'))} | ${mdCell(get(d, 'findingsCount'))} | ${mdCell(get(d, 'fixedCount'))} | ${mdCell(get(d, 'convergenceRate'))}% |`,
380
+ )
381
+ }
382
+ lines.push('')
383
+ }
384
+ }
385
+
386
+ // ── Findings ──
387
+ lines.push(`## ${labels.findings} (${data.findings.length})`)
388
+ lines.push('')
389
+ if (data.findings.length === 0) {
390
+ lines.push(labels.none!)
391
+ } else {
392
+ lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`)
393
+ lines.push('| --- | --- | --- | --- |')
394
+ for (const f of data.findings) {
395
+ const loc = f.file ? `${f.file}${f.line > 1 ? `:${f.line}` : ''}` : ''
396
+ lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`)
397
+ }
398
+ }
399
+ lines.push('')
400
+
401
+ // ── Fixes ──
402
+ lines.push(`## ${labels.fixes} (${data.fixes.length})`)
403
+ lines.push('')
404
+ if (data.fixes.length === 0) {
405
+ lines.push(labels.none!)
406
+ } else {
407
+ lines.push(`| # | ${labels.file} | ${labels.delta} | ${labels.result} | ${labels.summary} |`)
408
+ lines.push('| --- | --- | --- | --- | --- |')
409
+ for (const f of data.fixes) {
410
+ lines.push(
411
+ `| ${mdCell(f.round)} | ${mdCell(f.file)} | ${mdCell(`+${f.linesAdded}/-${f.linesRemoved}`)} | ${f.success ? labels.applied : labels.failed} | ${mdCell(f.summary)} |`,
412
+ )
413
+ }
414
+ }
415
+ lines.push('')
416
+
417
+ // ── Validations ──
418
+ if (data.validations.length > 0) {
419
+ lines.push(`## ${labels.validations}`)
420
+ lines.push('')
421
+ lines.push(`| # | ${labels.command} | ${labels.exit} | ${labels.allowed} |`)
422
+ lines.push('| --- | --- | --- | --- |')
423
+ for (const v of data.validations) {
424
+ lines.push(
425
+ `| ${mdCell(v.round)} | \`${mdCell(v.command)}\` | ${v.exitCode === null ? '—' : mdCell(v.exitCode)} | ${v.allowed ? labels.yes : labels.no} |`,
426
+ )
427
+ }
428
+ lines.push('')
429
+ }
430
+
431
+ // ── Defense events ──
432
+ const deEvents = asArray(get(data.defenseEvents, 'events'))
433
+ if (deEvents.length > 0) {
434
+ lines.push(`## ${labels.defenseEvents} (${deEvents.length})`)
435
+ lines.push('')
436
+ lines.push(`| ${labels.severity} | ${labels.defensiveType} | ${labels.round} | ${labels.summary} |`)
437
+ lines.push('| --- | --- | --- | --- |')
438
+ for (const e of deEvents) {
439
+ lines.push(
440
+ `| ${mdCell(get(e, 'severity'))} | ${mdCell(get(e, 'type'))} | ${mdCell(get(e, 'round'))} | ${mdCell(get(e, 'description') || get(e, 'outcome'))} |`,
441
+ )
442
+ }
443
+ lines.push('')
444
+ }
445
+
446
+ // ── Experience bank ──
447
+ const entries = asArray(get(data.experienceBank, 'entries'))
448
+ if (entries.length > 0) {
449
+ const totalHits = asNumber(get(data.experienceBank, 'totalHits'))
450
+ lines.push(`## ${labels.experienceBank} (${entries.length} ${labels.entries}, ${totalHits} ${labels.hits})`)
451
+ lines.push('')
452
+ for (const e of entries) {
453
+ lines.push(
454
+ `- **[${mdCell(get(e, 'id'))}]** ${mdCell(get(e, 'pattern'))} — ${mdCell(get(e, 'description'))} (${labels.hits}: ${mdCell(get(e, 'hitCount'))})`,
455
+ )
456
+ }
457
+ lines.push('')
458
+ }
459
+
460
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
461
+ }
462
+
463
+ /** A SARIF 2.1.0 log (structurally typed; not a full SARIF model). */
464
+ export interface SarifLog {
465
+ $schema: string
466
+ version: string
467
+ runs: Array<Record<string, unknown>>
468
+ }
469
+
470
+ /** Render a SARIF 2.1.0 document from the same normalized data. */
471
+ export function renderRunReportSarif(
472
+ manifest: unknown,
473
+ extra?: { qualityGate?: unknown; experienceBank?: unknown; defenseEvents?: unknown } | null,
474
+ opts?: ReportRenderOptions,
475
+ ): SarifLog {
476
+ const data = buildRunReportData(manifest, extra)
477
+ const o = opts ?? {}
478
+ const rules: Array<Record<string, unknown>> = []
479
+ const seenRules = new Set<string>()
480
+ for (const f of data.findings) {
481
+ const id = f.dimension || 'review'
482
+ if (seenRules.has(id)) continue
483
+ seenRules.add(id)
484
+ rules.push({
485
+ id,
486
+ name: id,
487
+ shortDescription: { text: `${id} review dimension` },
488
+ defaultConfiguration: { level: 'warning' },
489
+ })
490
+ }
491
+ const results = data.findings.map((f) => {
492
+ const result: Record<string, unknown> = {
493
+ ruleId: f.dimension || 'review',
494
+ level: sarifLevel(f.severity),
495
+ message: { text: f.summary || `${f.severity} finding` },
496
+ properties: {
497
+ severity: f.severity,
498
+ atomic: f.isAtomic,
499
+ acknowledged: f.acknowledged,
500
+ },
501
+ }
502
+ const uri = sarifUri(f.file)
503
+ if (uri) {
504
+ result.locations = [
505
+ {
506
+ physicalLocation: {
507
+ artifactLocation: { uri },
508
+ ...(f.line > 0 ? { region: { startLine: f.line } } : {}),
509
+ },
510
+ },
511
+ ]
512
+ }
513
+ if (f.suggestedFix) result.fixes = [{ description: { text: f.suggestedFix } }]
514
+ return result
515
+ })
516
+ const failedValidations = data.validations.filter((v) => v.exitCode !== 0).length
517
+ return {
518
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
519
+ version: '2.1.0',
520
+ runs: [
521
+ {
522
+ tool: {
523
+ driver: {
524
+ name: 'iterate-plugin',
525
+ informationUri:
526
+ o.informationUri && o.informationUri
527
+ ? o.informationUri
528
+ : 'https://github.com/jingzhao-l/iterate-plugin',
529
+ ...(o.toolVersion ? { version: o.toolVersion } : {}),
530
+ rules,
531
+ },
532
+ },
533
+ invocations: [
534
+ {
535
+ executionSuccessful: failedValidations === 0 && data.qualityGate?.overallStatus !== 'fail',
536
+ ...(data.updatedAt ? { startTimeUtc: data.updatedAt } : {}),
537
+ properties: {
538
+ mode: data.mode,
539
+ taskMode: data.taskMode,
540
+ round: data.round,
541
+ maxRounds: data.maxRounds,
542
+ stoppedReason: data.stoppedReason,
543
+ qualityStatus: data.qualityGate ? (data.qualityGate.overallStatus ?? null) : null,
544
+ convergence: data.convergence,
545
+ findingsBySeverity: data.severityCounts,
546
+ },
547
+ },
548
+ ],
549
+ results,
550
+ },
551
+ ],
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Render a report in the requested format. Unknown formats fall back to
557
+ * Markdown (the tool's default).
558
+ */
559
+ export function renderRunReport(
560
+ format: string,
561
+ manifest: unknown,
562
+ extra?: { qualityGate?: unknown; experienceBank?: unknown; defenseEvents?: unknown } | null,
563
+ opts?: ReportRenderOptions,
564
+ ): string | SarifLog {
565
+ if (format === 'sarif') return renderRunReportSarif(manifest, extra, opts)
566
+ return renderRunReportMarkdown(manifest, extra, opts)
567
+ }
568
+
569
+ /**
570
+ * Deterministic download filename for a report, e.g.
571
+ * `iterate-report-2026-10-18T01-02-03-000Z.md`.
572
+ */
573
+ export function runReportFilename(format: string, opts?: { now?: string }): string {
574
+ const ext = format === 'sarif' ? 'sarif' : 'md'
575
+ const stamp =
576
+ opts?.now && opts.now ? opts.now.replace(/[:.]/g, '-') : new Date().toISOString().replace(/[:.]/g, '-')
577
+ return `iterate-report-${stamp}.${ext}`
578
+ }