iterate-plugin 3.8.0 → 3.9.1

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.
@@ -0,0 +1,462 @@
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
+ /** Report formats accepted by the tool and the export buttons. */
25
+ export const REPORT_FORMATS = ['markdown', 'sarif'];
26
+ /** Severity ordering (lowest index = most severe). */
27
+ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];
28
+ /** Human labels for the report, keyed by language. */
29
+ const REPORT_LABELS = {
30
+ en: {
31
+ title: 'iterate run report',
32
+ project: 'Project',
33
+ goal: 'Goal',
34
+ mode: 'Mode',
35
+ status: 'Status',
36
+ round: 'Round',
37
+ updated: 'Updated',
38
+ converged: 'converged',
39
+ running: 'running',
40
+ stopped: 'stopped',
41
+ qualityGate: 'Quality gate',
42
+ overall: 'Overall',
43
+ verification: 'Verification',
44
+ dimension: 'Dimension',
45
+ qstatus: 'Status',
46
+ score: 'Score',
47
+ findingsCount: 'Findings',
48
+ fixed: 'Fixed',
49
+ convergence: 'Convergence',
50
+ findings: 'Findings',
51
+ severity: 'Severity',
52
+ location: 'Location',
53
+ summary: 'Summary',
54
+ defensiveType: 'Type',
55
+ none: '_none_',
56
+ fixes: 'Fixes',
57
+ file: 'File',
58
+ delta: 'Δ',
59
+ result: 'Result',
60
+ applied: 'applied',
61
+ failed: 'failed',
62
+ validations: 'Validations',
63
+ command: 'Command',
64
+ exit: 'Exit',
65
+ allowed: 'Allowed',
66
+ yes: 'yes',
67
+ no: 'no',
68
+ defenseEvents: 'Defense events',
69
+ experienceBank: 'Experience bank',
70
+ hits: 'hits',
71
+ entries: 'entries',
72
+ generatedAt: 'Generated at',
73
+ notRecorded: 'not recorded',
74
+ },
75
+ zh: {
76
+ title: 'iterate 运行报告',
77
+ project: '项目',
78
+ goal: '目标',
79
+ mode: '模式',
80
+ status: '状态',
81
+ round: '轮次',
82
+ updated: '更新时间',
83
+ converged: '已收敛',
84
+ running: '运行中',
85
+ stopped: '已结束',
86
+ qualityGate: '质量门禁',
87
+ overall: '总体',
88
+ verification: '验证',
89
+ dimension: '维度',
90
+ qstatus: '状态',
91
+ score: '得分',
92
+ findingsCount: '发现数',
93
+ fixed: '已修复',
94
+ convergence: '收敛率',
95
+ findings: '发现',
96
+ severity: '严重度',
97
+ location: '位置',
98
+ summary: '摘要',
99
+ defensiveType: '类型',
100
+ none: '无',
101
+ fixes: '修复',
102
+ file: '文件',
103
+ delta: '变更',
104
+ result: '结果',
105
+ applied: '成功',
106
+ failed: '失败',
107
+ validations: '验证',
108
+ command: '命令',
109
+ exit: '退出码',
110
+ allowed: '已授权',
111
+ yes: '是',
112
+ no: '否',
113
+ defenseEvents: '防御事件',
114
+ experienceBank: '经验银行',
115
+ hits: '命中',
116
+ entries: '条目',
117
+ generatedAt: '生成时间',
118
+ notRecorded: '未记录',
119
+ },
120
+ };
121
+ function reportLabels(lang) {
122
+ return lang === 'en' ? REPORT_LABELS.en : REPORT_LABELS.zh;
123
+ }
124
+ /** Proxy-safe object read (never lets a hostile getter trap throw). */
125
+ function get(source, key) {
126
+ if (!source || typeof source !== 'object')
127
+ return undefined;
128
+ try {
129
+ return source[key];
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ function asString(value) {
136
+ return typeof value === 'string' ? value : '';
137
+ }
138
+ function asNumber(value) {
139
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
140
+ }
141
+ /** Non-negative integer (floors fractions); 0 for anything else. */
142
+ function asInt(value) {
143
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
144
+ }
145
+ function asArray(value) {
146
+ return Array.isArray(value) ? value : [];
147
+ }
148
+ function asObject(value) {
149
+ return value && typeof value === 'object' && !Array.isArray(value)
150
+ ? value
151
+ : null;
152
+ }
153
+ /** Escape a value for a Markdown table cell / inline text. */
154
+ function mdCell(value) {
155
+ const s = value === null || value === undefined ? '' : String(value);
156
+ return s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
157
+ }
158
+ /** Severity of a finding, defaulting to the least-severe bucket. */
159
+ function findingSeverity(f) {
160
+ const s = get(f, 'severity');
161
+ return typeof s === 'string' && SEVERITY_ORDER.includes(s) ? s : 'low';
162
+ }
163
+ /** Repo-relative POSIX uri for SARIF artifact locations. */
164
+ function sarifUri(file) {
165
+ const s = typeof file === 'string' ? file.trim() : '';
166
+ if (!s)
167
+ return '';
168
+ return s.replace(/\\/g, '/').replace(/^\.\//, '');
169
+ }
170
+ /** SARIF result level for an iterate severity. */
171
+ export function sarifLevel(severity) {
172
+ if (severity === 'critical' || severity === 'high')
173
+ return 'error';
174
+ if (severity === 'medium')
175
+ return 'warning';
176
+ return 'note';
177
+ }
178
+ /**
179
+ * Normalize a manifest (+ the pulled disk artifacts) into ONE shape. Pure;
180
+ * tolerates junk at every field.
181
+ */
182
+ export function buildRunReportData(manifest, extra) {
183
+ const src = asObject(manifest) ?? {};
184
+ const ex = asObject(extra);
185
+ const qg = asObject(get(ex, 'qualityGate'));
186
+ const bank = asObject(get(ex, 'experienceBank'));
187
+ const defense = asObject(get(ex, 'defenseEvents'));
188
+ const findings = asArray(get(src, 'findings')).map((f) => ({
189
+ dimension: asString(get(f, 'dimension')),
190
+ file: asString(get(f, 'file')),
191
+ line: asInt(get(f, 'line')),
192
+ severity: findingSeverity(f),
193
+ summary: asString(get(f, 'summary')),
194
+ failureScenario: asString(get(f, 'failure_scenario')),
195
+ suggestedFix: asString(get(f, 'suggested_fix')),
196
+ isAtomic: get(f, 'is_atomic') === true,
197
+ acknowledged: get(f, 'acknowledged') === true,
198
+ }));
199
+ findings.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) -
200
+ SEVERITY_ORDER.indexOf(b.severity));
201
+ const fixes = asArray(get(src, 'fixes')).map((f) => ({
202
+ round: asNumber(get(f, 'round')),
203
+ file: asString(get(f, 'file')),
204
+ summary: asString(get(f, 'summary')),
205
+ linesAdded: asNumber(get(f, 'linesAdded')),
206
+ linesRemoved: asNumber(get(f, 'linesRemoved')),
207
+ success: get(f, 'success') !== false,
208
+ }));
209
+ const validations = asArray(get(src, 'validations')).map((v) => ({
210
+ round: asNumber(get(v, 'round')),
211
+ command: asString(get(v, 'command')),
212
+ exitCode: typeof get(v, 'exitCode') === 'number' ? asInt(get(v, 'exitCode')) : null,
213
+ allowed: get(v, 'allowed') === true,
214
+ rejectReason: asString(get(v, 'rejectReason')),
215
+ }));
216
+ const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
217
+ for (const f of findings) {
218
+ const key = f.severity;
219
+ if (key in severityCounts)
220
+ severityCounts[key] += 1;
221
+ }
222
+ const mode = get(src, 'mode');
223
+ const taskMode = get(src, 'taskMode');
224
+ const stoppedReason = get(src, 'stoppedReason');
225
+ return {
226
+ project: asString(get(src, 'project')),
227
+ goal: asString(get(src, 'goal')),
228
+ mode: mode === 'normal' || mode === 'dry-run' ? mode : null,
229
+ taskMode: taskMode === 'code' || taskMode === 'iterate' ? taskMode : null,
230
+ round: asNumber(get(src, 'round')),
231
+ maxRounds: asNumber(get(src, 'maxRounds')),
232
+ // An ABSENT `active` means "no run / unknown", not "running": a missing or
233
+ // empty transcript used to render a live, pulsing run at round 0. The
234
+ // transcript builder always writes an explicit boolean, so a strict `true`
235
+ // only ever loses the phantom run, never a real one.
236
+ active: get(src, 'active') === true,
237
+ stoppedReason: typeof stoppedReason === 'string' && stoppedReason ? stoppedReason : null,
238
+ updatedAt: asString(get(src, 'updatedAt')),
239
+ convergence: asArray(get(src, 'convergence')).filter((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0),
240
+ findings,
241
+ severityCounts,
242
+ fixes,
243
+ validations,
244
+ qualityGate: qg,
245
+ experienceBank: bank,
246
+ defenseEvents: defense,
247
+ };
248
+ }
249
+ /** One-line human status for the report header. */
250
+ function reportStatus(data, labels) {
251
+ if (data.active === true)
252
+ return labels.running;
253
+ if (data.stoppedReason)
254
+ return `${labels.stopped} · ${data.stoppedReason}`;
255
+ return labels.stopped;
256
+ }
257
+ /** Render a PR-pasteable Markdown run report. */
258
+ export function renderRunReportMarkdown(manifest, extra, opts) {
259
+ const data = buildRunReportData(manifest, extra);
260
+ const o = opts ?? {};
261
+ const labels = reportLabels(o.lang === 'en' ? 'en' : 'zh');
262
+ const when = o.now && o.now ? o.now : new Date().toISOString();
263
+ const lines = [];
264
+ lines.push(`# ${labels.title}`);
265
+ lines.push('');
266
+ lines.push(`- **${labels.project}**: \`${mdCell(data.project || labels.notRecorded)}\``);
267
+ if (data.goal)
268
+ lines.push(`- **${labels.goal}**: ${mdCell(data.goal)}`);
269
+ lines.push(`- **${labels.mode}**: ${mdCell(data.mode || labels.notRecorded)}${data.taskMode ? ` · task_mode: ${data.taskMode}` : ''}`);
270
+ const roundText = data.maxRounds > 0 ? `${data.round}/${data.maxRounds}` : String(data.round);
271
+ lines.push(`- **${labels.round}**: ${mdCell(roundText)} · **${labels.status}**: ${mdCell(reportStatus(data, labels))}`);
272
+ lines.push(`- **${labels.updated}**: ${mdCell(data.updatedAt || when)}`);
273
+ lines.push(`- **${labels.generatedAt}**: ${mdCell(when)}`);
274
+ lines.push('');
275
+ // ── Quality gate ──
276
+ const qg = data.qualityGate;
277
+ if (qg) {
278
+ lines.push(`## ${labels.qualityGate}`);
279
+ lines.push('');
280
+ const status = typeof qg.overallStatus === 'string' ? qg.overallStatus.toUpperCase() : 'PENDING';
281
+ lines.push(`- **${labels.overall}**: ${mdCell(status)} (${labels.score} ${asNumber(qg.overallScore)})`);
282
+ lines.push(`- **${labels.verification}**: ${asNumber(qg.passedChecks)}/${asNumber(qg.totalChecks)} (${asNumber(qg.verificationPassRate)}%)`);
283
+ if (typeof qg.failReason === 'string' && qg.failReason) {
284
+ lines.push(`- ${labels.status}: ${mdCell(qg.failReason)}`);
285
+ }
286
+ lines.push('');
287
+ const dims = asArray(qg.dimensions);
288
+ if (dims.length > 0) {
289
+ lines.push(`| ${labels.dimension} | ${labels.qstatus} | ${labels.score} | ${labels.findingsCount} | ${labels.fixed} | ${labels.convergence} |`);
290
+ lines.push('| --- | --- | --- | --- | --- | --- |');
291
+ for (const d of dims) {
292
+ lines.push(`| ${mdCell(get(d, 'dimension'))} | ${mdCell(get(d, 'status'))} | ${mdCell(get(d, 'score'))} | ${mdCell(get(d, 'findingsCount'))} | ${mdCell(get(d, 'fixedCount'))} | ${mdCell(get(d, 'convergenceRate'))}% |`);
293
+ }
294
+ lines.push('');
295
+ }
296
+ }
297
+ // ── Findings ──
298
+ lines.push(`## ${labels.findings} (${data.findings.length})`);
299
+ lines.push('');
300
+ if (data.findings.length === 0) {
301
+ lines.push(labels.none);
302
+ }
303
+ else {
304
+ lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`);
305
+ lines.push('| --- | --- | --- | --- |');
306
+ for (const f of data.findings) {
307
+ const loc = f.file ? `${f.file}${f.line > 0 ? `:${f.line}` : ''}` : '';
308
+ lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`);
309
+ }
310
+ }
311
+ lines.push('');
312
+ // ── Fixes ──
313
+ lines.push(`## ${labels.fixes} (${data.fixes.length})`);
314
+ lines.push('');
315
+ if (data.fixes.length === 0) {
316
+ lines.push(labels.none);
317
+ }
318
+ else {
319
+ lines.push(`| # | ${labels.file} | ${labels.delta} | ${labels.result} | ${labels.summary} |`);
320
+ lines.push('| --- | --- | --- | --- | --- |');
321
+ for (const f of data.fixes) {
322
+ lines.push(`| ${mdCell(f.round)} | ${mdCell(f.file)} | ${mdCell(`+${f.linesAdded}/-${f.linesRemoved}`)} | ${f.success ? labels.applied : labels.failed} | ${mdCell(f.summary)} |`);
323
+ }
324
+ }
325
+ lines.push('');
326
+ // ── Validations ──
327
+ if (data.validations.length > 0) {
328
+ lines.push(`## ${labels.validations}`);
329
+ lines.push('');
330
+ lines.push(`| # | ${labels.command} | ${labels.exit} | ${labels.allowed} |`);
331
+ lines.push('| --- | --- | --- | --- |');
332
+ for (const v of data.validations) {
333
+ lines.push(`| ${mdCell(v.round)} | \`${mdCell(v.command)}\` | ${v.exitCode === null ? '—' : mdCell(v.exitCode)} | ${v.allowed ? labels.yes : labels.no} |`);
334
+ }
335
+ lines.push('');
336
+ }
337
+ // ── Defense events ──
338
+ const deEvents = asArray(get(data.defenseEvents, 'events'));
339
+ if (deEvents.length > 0) {
340
+ lines.push(`## ${labels.defenseEvents} (${deEvents.length})`);
341
+ lines.push('');
342
+ lines.push(`| ${labels.severity} | ${labels.defensiveType} | ${labels.round} | ${labels.summary} |`);
343
+ lines.push('| --- | --- | --- | --- |');
344
+ for (const e of deEvents) {
345
+ lines.push(`| ${mdCell(get(e, 'severity'))} | ${mdCell(get(e, 'type'))} | ${mdCell(get(e, 'round'))} | ${mdCell(get(e, 'description') || get(e, 'outcome'))} |`);
346
+ }
347
+ lines.push('');
348
+ }
349
+ // ── Experience bank ──
350
+ const entries = asArray(get(data.experienceBank, 'entries'));
351
+ if (entries.length > 0) {
352
+ const totalHits = asNumber(get(data.experienceBank, 'totalHits'));
353
+ lines.push(`## ${labels.experienceBank} (${entries.length} ${labels.entries}, ${totalHits} ${labels.hits})`);
354
+ lines.push('');
355
+ for (const e of entries) {
356
+ lines.push(`- **[${mdCell(get(e, 'id'))}]** ${mdCell(get(e, 'pattern'))} — ${mdCell(get(e, 'description'))} (${labels.hits}: ${mdCell(get(e, 'hitCount'))})`);
357
+ }
358
+ lines.push('');
359
+ }
360
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
361
+ }
362
+ /** Render a SARIF 2.1.0 document from the same normalized data. */
363
+ export function renderRunReportSarif(manifest, extra, opts) {
364
+ const data = buildRunReportData(manifest, extra);
365
+ const o = opts ?? {};
366
+ const rules = [];
367
+ const seenRules = new Set();
368
+ for (const f of data.findings) {
369
+ const id = f.dimension || 'review';
370
+ if (seenRules.has(id))
371
+ continue;
372
+ seenRules.add(id);
373
+ rules.push({
374
+ id,
375
+ name: id,
376
+ shortDescription: { text: `${id} review dimension` },
377
+ defaultConfiguration: { level: 'warning' },
378
+ });
379
+ }
380
+ const results = data.findings.map((f) => {
381
+ const result = {
382
+ ruleId: f.dimension || 'review',
383
+ level: sarifLevel(f.severity),
384
+ message: { text: f.summary || `${f.severity} finding` },
385
+ properties: {
386
+ severity: f.severity,
387
+ atomic: f.isAtomic,
388
+ acknowledged: f.acknowledged,
389
+ },
390
+ };
391
+ const uri = sarifUri(f.file);
392
+ if (uri) {
393
+ result.locations = [
394
+ {
395
+ physicalLocation: {
396
+ artifactLocation: { uri },
397
+ ...(f.line > 0 ? { region: { startLine: f.line } } : {}),
398
+ },
399
+ },
400
+ ];
401
+ }
402
+ if (f.suggestedFix)
403
+ result.fixes = [{ description: { text: f.suggestedFix } }];
404
+ return result;
405
+ });
406
+ // `exitCode: null` is the documented "unknown / no exit code" value, not a
407
+ // failure: only a concrete non-zero code makes the run unsuccessful.
408
+ const failedValidations = data.validations.filter((v) => v.exitCode !== null && v.exitCode !== 0).length;
409
+ return {
410
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
411
+ version: '2.1.0',
412
+ runs: [
413
+ {
414
+ tool: {
415
+ driver: {
416
+ name: 'iterate-plugin',
417
+ informationUri: o.informationUri && o.informationUri
418
+ ? o.informationUri
419
+ : 'https://github.com/jingzhao-l/iterate-plugin',
420
+ ...(o.toolVersion ? { version: o.toolVersion } : {}),
421
+ rules,
422
+ },
423
+ },
424
+ invocations: [
425
+ {
426
+ executionSuccessful: failedValidations === 0 && data.qualityGate?.overallStatus !== 'fail',
427
+ ...(data.updatedAt ? { startTimeUtc: data.updatedAt } : {}),
428
+ properties: {
429
+ mode: data.mode,
430
+ taskMode: data.taskMode,
431
+ round: data.round,
432
+ maxRounds: data.maxRounds,
433
+ stoppedReason: data.stoppedReason,
434
+ qualityStatus: data.qualityGate ? (data.qualityGate.overallStatus ?? null) : null,
435
+ convergence: data.convergence,
436
+ findingsBySeverity: data.severityCounts,
437
+ },
438
+ },
439
+ ],
440
+ results,
441
+ },
442
+ ],
443
+ };
444
+ }
445
+ /**
446
+ * Render a report in the requested format. Unknown formats fall back to
447
+ * Markdown (the tool's default).
448
+ */
449
+ export function renderRunReport(format, manifest, extra, opts) {
450
+ if (format === 'sarif')
451
+ return renderRunReportSarif(manifest, extra, opts);
452
+ return renderRunReportMarkdown(manifest, extra, opts);
453
+ }
454
+ /**
455
+ * Deterministic download filename for a report, e.g.
456
+ * `iterate-report-2026-10-18T01-02-03-000Z.md`.
457
+ */
458
+ export function runReportFilename(format, opts) {
459
+ const ext = format === 'sarif' ? 'sarif' : 'md';
460
+ const stamp = opts?.now && opts.now ? opts.now.replace(/[:.]/g, '-') : new Date().toISOString().replace(/[:.]/g, '-');
461
+ return `iterate-report-${stamp}.${ext}`;
462
+ }