iterate-plugin 3.7.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/README.md +43 -8
- package/README.zh-CN.md +26 -5
- package/dist/file-lock.js +75 -14
- package/dist/git-scope.js +22 -2
- package/dist/index.js +9 -4
- package/dist/report-render.js +452 -0
- package/dist/session-record.js +246 -0
- package/dist/skill-prompt.js +4 -3
- package/dist/tools/checkpoint.js +24 -17
- package/dist/tools/config.js +42 -33
- package/dist/tools/decision-log.js +13 -93
- package/dist/tools/report.js +239 -0
- package/dist/tools/transcript.js +260 -53
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/lib/client.js +486 -22
- package/lib/parse.js +29 -6
- package/package.json +38 -13
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +66 -4
- package/src/file-lock.ts +84 -14
- package/src/git-scope.ts +21 -2
- package/src/index.ts +9 -4
- package/src/report-render.ts +578 -0
- package/src/session-record.ts +333 -0
- package/src/skill-prompt.ts +4 -3
- package/src/tools/checkpoint.ts +23 -16
- package/src/tools/config.ts +40 -31
- package/src/tools/decision-log.ts +12 -80
- package/src/tools/report.ts +244 -0
- package/src/tools/transcript.ts +271 -52
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
|
@@ -0,0 +1,452 @@
|
|
|
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
|
+
function asArray(value) {
|
|
142
|
+
return Array.isArray(value) ? value : [];
|
|
143
|
+
}
|
|
144
|
+
function asObject(value) {
|
|
145
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
146
|
+
? value
|
|
147
|
+
: null;
|
|
148
|
+
}
|
|
149
|
+
/** Escape a value for a Markdown table cell / inline text. */
|
|
150
|
+
function mdCell(value) {
|
|
151
|
+
const s = value === null || value === undefined ? '' : String(value);
|
|
152
|
+
return s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
153
|
+
}
|
|
154
|
+
/** Severity of a finding, defaulting to the least-severe bucket. */
|
|
155
|
+
function findingSeverity(f) {
|
|
156
|
+
const s = get(f, 'severity');
|
|
157
|
+
return typeof s === 'string' && SEVERITY_ORDER.includes(s) ? s : 'low';
|
|
158
|
+
}
|
|
159
|
+
/** Repo-relative POSIX uri for SARIF artifact locations. */
|
|
160
|
+
function sarifUri(file) {
|
|
161
|
+
const s = typeof file === 'string' ? file.trim() : '';
|
|
162
|
+
if (!s)
|
|
163
|
+
return '';
|
|
164
|
+
return s.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
165
|
+
}
|
|
166
|
+
/** SARIF result level for an iterate severity. */
|
|
167
|
+
export function sarifLevel(severity) {
|
|
168
|
+
if (severity === 'critical' || severity === 'high')
|
|
169
|
+
return 'error';
|
|
170
|
+
if (severity === 'medium')
|
|
171
|
+
return 'warning';
|
|
172
|
+
return 'note';
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Normalize a manifest (+ the pulled disk artifacts) into ONE shape. Pure;
|
|
176
|
+
* tolerates junk at every field.
|
|
177
|
+
*/
|
|
178
|
+
export function buildRunReportData(manifest, extra) {
|
|
179
|
+
const src = asObject(manifest) ?? {};
|
|
180
|
+
const ex = asObject(extra);
|
|
181
|
+
const qg = asObject(get(ex, 'qualityGate'));
|
|
182
|
+
const bank = asObject(get(ex, 'experienceBank'));
|
|
183
|
+
const defense = asObject(get(ex, 'defenseEvents'));
|
|
184
|
+
const findings = asArray(get(src, 'findings')).map((f) => ({
|
|
185
|
+
dimension: asString(get(f, 'dimension')),
|
|
186
|
+
file: asString(get(f, 'file')),
|
|
187
|
+
line: asNumber(get(f, 'line')),
|
|
188
|
+
severity: findingSeverity(f),
|
|
189
|
+
summary: asString(get(f, 'summary')),
|
|
190
|
+
failureScenario: asString(get(f, 'failure_scenario')),
|
|
191
|
+
suggestedFix: asString(get(f, 'suggested_fix')),
|
|
192
|
+
isAtomic: get(f, 'is_atomic') === true,
|
|
193
|
+
acknowledged: get(f, 'acknowledged') === true,
|
|
194
|
+
}));
|
|
195
|
+
findings.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) -
|
|
196
|
+
SEVERITY_ORDER.indexOf(b.severity));
|
|
197
|
+
const fixes = asArray(get(src, 'fixes')).map((f) => ({
|
|
198
|
+
round: asNumber(get(f, 'round')),
|
|
199
|
+
file: asString(get(f, 'file')),
|
|
200
|
+
summary: asString(get(f, 'summary')),
|
|
201
|
+
linesAdded: asNumber(get(f, 'linesAdded')),
|
|
202
|
+
linesRemoved: asNumber(get(f, 'linesRemoved')),
|
|
203
|
+
success: get(f, 'success') !== false,
|
|
204
|
+
}));
|
|
205
|
+
const validations = asArray(get(src, 'validations')).map((v) => ({
|
|
206
|
+
round: asNumber(get(v, 'round')),
|
|
207
|
+
command: asString(get(v, 'command')),
|
|
208
|
+
exitCode: typeof get(v, 'exitCode') === 'number' ? asNumber(get(v, 'exitCode')) : null,
|
|
209
|
+
allowed: get(v, 'allowed') === true,
|
|
210
|
+
rejectReason: asString(get(v, 'rejectReason')),
|
|
211
|
+
}));
|
|
212
|
+
const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
213
|
+
for (const f of findings) {
|
|
214
|
+
const key = f.severity;
|
|
215
|
+
if (key in severityCounts)
|
|
216
|
+
severityCounts[key] += 1;
|
|
217
|
+
}
|
|
218
|
+
const mode = get(src, 'mode');
|
|
219
|
+
const taskMode = get(src, 'taskMode');
|
|
220
|
+
const stoppedReason = get(src, 'stoppedReason');
|
|
221
|
+
return {
|
|
222
|
+
project: asString(get(src, 'project')),
|
|
223
|
+
goal: asString(get(src, 'goal')),
|
|
224
|
+
mode: mode === 'normal' || mode === 'dry-run' ? mode : null,
|
|
225
|
+
taskMode: taskMode === 'code' || taskMode === 'iterate' ? taskMode : null,
|
|
226
|
+
round: asNumber(get(src, 'round')),
|
|
227
|
+
maxRounds: asNumber(get(src, 'maxRounds')),
|
|
228
|
+
active: get(src, 'active') !== false,
|
|
229
|
+
stoppedReason: typeof stoppedReason === 'string' && stoppedReason ? stoppedReason : null,
|
|
230
|
+
updatedAt: asString(get(src, 'updatedAt')),
|
|
231
|
+
convergence: asArray(get(src, 'convergence')).filter((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0),
|
|
232
|
+
findings,
|
|
233
|
+
severityCounts,
|
|
234
|
+
fixes,
|
|
235
|
+
validations,
|
|
236
|
+
qualityGate: qg,
|
|
237
|
+
experienceBank: bank,
|
|
238
|
+
defenseEvents: defense,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** One-line human status for the report header. */
|
|
242
|
+
function reportStatus(data, labels) {
|
|
243
|
+
if (data.active === true)
|
|
244
|
+
return labels.running;
|
|
245
|
+
if (data.stoppedReason)
|
|
246
|
+
return `${labels.stopped} · ${data.stoppedReason}`;
|
|
247
|
+
return labels.stopped;
|
|
248
|
+
}
|
|
249
|
+
/** Render a PR-pasteable Markdown run report. */
|
|
250
|
+
export function renderRunReportMarkdown(manifest, extra, opts) {
|
|
251
|
+
const data = buildRunReportData(manifest, extra);
|
|
252
|
+
const o = opts ?? {};
|
|
253
|
+
const labels = reportLabels(o.lang === 'en' ? 'en' : 'zh');
|
|
254
|
+
const when = o.now && o.now ? o.now : new Date().toISOString();
|
|
255
|
+
const lines = [];
|
|
256
|
+
lines.push(`# ${labels.title}`);
|
|
257
|
+
lines.push('');
|
|
258
|
+
lines.push(`- **${labels.project}**: \`${mdCell(data.project || labels.notRecorded)}\``);
|
|
259
|
+
if (data.goal)
|
|
260
|
+
lines.push(`- **${labels.goal}**: ${mdCell(data.goal)}`);
|
|
261
|
+
lines.push(`- **${labels.mode}**: ${mdCell(data.mode || labels.notRecorded)}${data.taskMode ? ` · task_mode: ${data.taskMode}` : ''}`);
|
|
262
|
+
const roundText = data.maxRounds > 0 ? `${data.round}/${data.maxRounds}` : String(data.round);
|
|
263
|
+
lines.push(`- **${labels.round}**: ${mdCell(roundText)} · **${labels.status}**: ${mdCell(reportStatus(data, labels))}`);
|
|
264
|
+
lines.push(`- **${labels.updated}**: ${mdCell(data.updatedAt || when)}`);
|
|
265
|
+
lines.push(`- **${labels.generatedAt}**: ${mdCell(when)}`);
|
|
266
|
+
lines.push('');
|
|
267
|
+
// ── Quality gate ──
|
|
268
|
+
const qg = data.qualityGate;
|
|
269
|
+
if (qg) {
|
|
270
|
+
lines.push(`## ${labels.qualityGate}`);
|
|
271
|
+
lines.push('');
|
|
272
|
+
const status = typeof qg.overallStatus === 'string' ? qg.overallStatus.toUpperCase() : 'PENDING';
|
|
273
|
+
lines.push(`- **${labels.overall}**: ${mdCell(status)} (${labels.score} ${asNumber(qg.overallScore)})`);
|
|
274
|
+
lines.push(`- **${labels.verification}**: ${asNumber(qg.passedChecks)}/${asNumber(qg.totalChecks)} (${asNumber(qg.verificationPassRate)}%)`);
|
|
275
|
+
if (typeof qg.failReason === 'string' && qg.failReason) {
|
|
276
|
+
lines.push(`- ${labels.status}: ${mdCell(qg.failReason)}`);
|
|
277
|
+
}
|
|
278
|
+
lines.push('');
|
|
279
|
+
const dims = asArray(qg.dimensions);
|
|
280
|
+
if (dims.length > 0) {
|
|
281
|
+
lines.push(`| ${labels.dimension} | ${labels.qstatus} | ${labels.score} | ${labels.findingsCount} | ${labels.fixed} | ${labels.convergence} |`);
|
|
282
|
+
lines.push('| --- | --- | --- | --- | --- | --- |');
|
|
283
|
+
for (const d of dims) {
|
|
284
|
+
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'))}% |`);
|
|
285
|
+
}
|
|
286
|
+
lines.push('');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ── Findings ──
|
|
290
|
+
lines.push(`## ${labels.findings} (${data.findings.length})`);
|
|
291
|
+
lines.push('');
|
|
292
|
+
if (data.findings.length === 0) {
|
|
293
|
+
lines.push(labels.none);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`);
|
|
297
|
+
lines.push('| --- | --- | --- | --- |');
|
|
298
|
+
for (const f of data.findings) {
|
|
299
|
+
const loc = f.file ? `${f.file}${f.line > 1 ? `:${f.line}` : ''}` : '';
|
|
300
|
+
lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
lines.push('');
|
|
304
|
+
// ── Fixes ──
|
|
305
|
+
lines.push(`## ${labels.fixes} (${data.fixes.length})`);
|
|
306
|
+
lines.push('');
|
|
307
|
+
if (data.fixes.length === 0) {
|
|
308
|
+
lines.push(labels.none);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
lines.push(`| # | ${labels.file} | ${labels.delta} | ${labels.result} | ${labels.summary} |`);
|
|
312
|
+
lines.push('| --- | --- | --- | --- | --- |');
|
|
313
|
+
for (const f of data.fixes) {
|
|
314
|
+
lines.push(`| ${mdCell(f.round)} | ${mdCell(f.file)} | ${mdCell(`+${f.linesAdded}/-${f.linesRemoved}`)} | ${f.success ? labels.applied : labels.failed} | ${mdCell(f.summary)} |`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
lines.push('');
|
|
318
|
+
// ── Validations ──
|
|
319
|
+
if (data.validations.length > 0) {
|
|
320
|
+
lines.push(`## ${labels.validations}`);
|
|
321
|
+
lines.push('');
|
|
322
|
+
lines.push(`| # | ${labels.command} | ${labels.exit} | ${labels.allowed} |`);
|
|
323
|
+
lines.push('| --- | --- | --- | --- |');
|
|
324
|
+
for (const v of data.validations) {
|
|
325
|
+
lines.push(`| ${mdCell(v.round)} | \`${mdCell(v.command)}\` | ${v.exitCode === null ? '—' : mdCell(v.exitCode)} | ${v.allowed ? labels.yes : labels.no} |`);
|
|
326
|
+
}
|
|
327
|
+
lines.push('');
|
|
328
|
+
}
|
|
329
|
+
// ── Defense events ──
|
|
330
|
+
const deEvents = asArray(get(data.defenseEvents, 'events'));
|
|
331
|
+
if (deEvents.length > 0) {
|
|
332
|
+
lines.push(`## ${labels.defenseEvents} (${deEvents.length})`);
|
|
333
|
+
lines.push('');
|
|
334
|
+
lines.push(`| ${labels.severity} | ${labels.defensiveType} | ${labels.round} | ${labels.summary} |`);
|
|
335
|
+
lines.push('| --- | --- | --- | --- |');
|
|
336
|
+
for (const e of deEvents) {
|
|
337
|
+
lines.push(`| ${mdCell(get(e, 'severity'))} | ${mdCell(get(e, 'type'))} | ${mdCell(get(e, 'round'))} | ${mdCell(get(e, 'description') || get(e, 'outcome'))} |`);
|
|
338
|
+
}
|
|
339
|
+
lines.push('');
|
|
340
|
+
}
|
|
341
|
+
// ── Experience bank ──
|
|
342
|
+
const entries = asArray(get(data.experienceBank, 'entries'));
|
|
343
|
+
if (entries.length > 0) {
|
|
344
|
+
const totalHits = asNumber(get(data.experienceBank, 'totalHits'));
|
|
345
|
+
lines.push(`## ${labels.experienceBank} (${entries.length} ${labels.entries}, ${totalHits} ${labels.hits})`);
|
|
346
|
+
lines.push('');
|
|
347
|
+
for (const e of entries) {
|
|
348
|
+
lines.push(`- **[${mdCell(get(e, 'id'))}]** ${mdCell(get(e, 'pattern'))} — ${mdCell(get(e, 'description'))} (${labels.hits}: ${mdCell(get(e, 'hitCount'))})`);
|
|
349
|
+
}
|
|
350
|
+
lines.push('');
|
|
351
|
+
}
|
|
352
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
353
|
+
}
|
|
354
|
+
/** Render a SARIF 2.1.0 document from the same normalized data. */
|
|
355
|
+
export function renderRunReportSarif(manifest, extra, opts) {
|
|
356
|
+
const data = buildRunReportData(manifest, extra);
|
|
357
|
+
const o = opts ?? {};
|
|
358
|
+
const rules = [];
|
|
359
|
+
const seenRules = new Set();
|
|
360
|
+
for (const f of data.findings) {
|
|
361
|
+
const id = f.dimension || 'review';
|
|
362
|
+
if (seenRules.has(id))
|
|
363
|
+
continue;
|
|
364
|
+
seenRules.add(id);
|
|
365
|
+
rules.push({
|
|
366
|
+
id,
|
|
367
|
+
name: id,
|
|
368
|
+
shortDescription: { text: `${id} review dimension` },
|
|
369
|
+
defaultConfiguration: { level: 'warning' },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const results = data.findings.map((f) => {
|
|
373
|
+
const result = {
|
|
374
|
+
ruleId: f.dimension || 'review',
|
|
375
|
+
level: sarifLevel(f.severity),
|
|
376
|
+
message: { text: f.summary || `${f.severity} finding` },
|
|
377
|
+
properties: {
|
|
378
|
+
severity: f.severity,
|
|
379
|
+
atomic: f.isAtomic,
|
|
380
|
+
acknowledged: f.acknowledged,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
const uri = sarifUri(f.file);
|
|
384
|
+
if (uri) {
|
|
385
|
+
result.locations = [
|
|
386
|
+
{
|
|
387
|
+
physicalLocation: {
|
|
388
|
+
artifactLocation: { uri },
|
|
389
|
+
...(f.line > 0 ? { region: { startLine: f.line } } : {}),
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
];
|
|
393
|
+
}
|
|
394
|
+
if (f.suggestedFix)
|
|
395
|
+
result.fixes = [{ description: { text: f.suggestedFix } }];
|
|
396
|
+
return result;
|
|
397
|
+
});
|
|
398
|
+
const failedValidations = data.validations.filter((v) => v.exitCode !== 0).length;
|
|
399
|
+
return {
|
|
400
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
401
|
+
version: '2.1.0',
|
|
402
|
+
runs: [
|
|
403
|
+
{
|
|
404
|
+
tool: {
|
|
405
|
+
driver: {
|
|
406
|
+
name: 'iterate-plugin',
|
|
407
|
+
informationUri: o.informationUri && o.informationUri
|
|
408
|
+
? o.informationUri
|
|
409
|
+
: 'https://github.com/jingzhao-l/iterate-plugin',
|
|
410
|
+
...(o.toolVersion ? { version: o.toolVersion } : {}),
|
|
411
|
+
rules,
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
invocations: [
|
|
415
|
+
{
|
|
416
|
+
executionSuccessful: failedValidations === 0 && data.qualityGate?.overallStatus !== 'fail',
|
|
417
|
+
...(data.updatedAt ? { startTimeUtc: data.updatedAt } : {}),
|
|
418
|
+
properties: {
|
|
419
|
+
mode: data.mode,
|
|
420
|
+
taskMode: data.taskMode,
|
|
421
|
+
round: data.round,
|
|
422
|
+
maxRounds: data.maxRounds,
|
|
423
|
+
stoppedReason: data.stoppedReason,
|
|
424
|
+
qualityStatus: data.qualityGate ? (data.qualityGate.overallStatus ?? null) : null,
|
|
425
|
+
convergence: data.convergence,
|
|
426
|
+
findingsBySeverity: data.severityCounts,
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
results,
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Render a report in the requested format. Unknown formats fall back to
|
|
437
|
+
* Markdown (the tool's default).
|
|
438
|
+
*/
|
|
439
|
+
export function renderRunReport(format, manifest, extra, opts) {
|
|
440
|
+
if (format === 'sarif')
|
|
441
|
+
return renderRunReportSarif(manifest, extra, opts);
|
|
442
|
+
return renderRunReportMarkdown(manifest, extra, opts);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Deterministic download filename for a report, e.g.
|
|
446
|
+
* `iterate-report-2026-10-18T01-02-03-000Z.md`.
|
|
447
|
+
*/
|
|
448
|
+
export function runReportFilename(format, opts) {
|
|
449
|
+
const ext = format === 'sarif' ? 'sarif' : 'md';
|
|
450
|
+
const stamp = opts?.now && opts.now ? opts.now.replace(/[:.]/g, '-') : new Date().toISOString().replace(/[:.]/g, '-');
|
|
451
|
+
return `iterate-report-${stamp}.${ext}`;
|
|
452
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/session-record.ts — dsh session-linked run records (dsh ≥ 0.2.1-rc.1).
|
|
3
|
+
*
|
|
4
|
+
* Upstream capability (dsh `0.2.1-rc.1`, released 2026-10-17): the *Session
|
|
5
|
+
* state-record API*. An experimental plugin may append its own record onto a
|
|
6
|
+
* session's append-only log with `appendPluginRecord(session, type, data)` and
|
|
7
|
+
* read it back with `pluginRecordOf(event)` (`@deepseek-ai/dsh-session`). The
|
|
8
|
+
* record is marked `ignorable`, never enters the model-visible surface, and is
|
|
9
|
+
* carried along by fork / resume / format migration.
|
|
10
|
+
*
|
|
11
|
+
* Why the plugin adopts it:
|
|
12
|
+
* The iterate run state has, until now, lived ONLY under `<projectRoot>/.iterate/`.
|
|
13
|
+
* That is correct for normal operation but has two blind spots the session
|
|
14
|
+
* log closes:
|
|
15
|
+
* 1. **Loss recovery** — if `.iterate/` is deleted, moved, or the working
|
|
16
|
+
* directory changes, the observatory goes blank even though the session
|
|
17
|
+
* that produced the run is still open. A compact run record on the
|
|
18
|
+
* session log lets `iterate_transcript read` rehydrate the run summary
|
|
19
|
+
* instead of returning an empty view.
|
|
20
|
+
* 2. **Session anchoring** — run provenance is tied to the *session* that
|
|
21
|
+
* produced it, not only to whatever directory the process happens to be
|
|
22
|
+
* in when someone looks afterwards.
|
|
23
|
+
*
|
|
24
|
+
* Design (fail-safe, never a hard dependency):
|
|
25
|
+
* - The API is loaded lazily via a dynamic `import()` and used structurally
|
|
26
|
+
* (no compile-time coupling to `PluginRecordMap`), so a host whose copy of
|
|
27
|
+
* `@deepseek-ai/dsh-session` predates the API simply reports
|
|
28
|
+
* `supported: false` instead of failing to load the plugin.
|
|
29
|
+
* - Every entry point returns a structured result; nothing throws. Writing a
|
|
30
|
+
* record is best-effort — a run must never fail because the session log
|
|
31
|
+
* could not accept a bookkeeping record.
|
|
32
|
+
* - `normalizeRunRecord` re-validates a restored payload, because a record
|
|
33
|
+
* read from a resumed session carries whatever JSON an earlier build wrote.
|
|
34
|
+
*/
|
|
35
|
+
/** The `plugin:` record type this plugin owns (grammar-checked by dsh). */
|
|
36
|
+
export const ITERATE_RUN_RECORD_TYPE = 'plugin:iterate/run';
|
|
37
|
+
/** Bump when the record payload shape changes incompatibly. */
|
|
38
|
+
export const ITERATE_RUN_RECORD_VERSION = 1;
|
|
39
|
+
// ─── Pure payload construction / validation ─────────────────────────────────
|
|
40
|
+
/** Coerce an unknown to a finite non-negative integer, else `fallback`. */
|
|
41
|
+
function nonNegInt(value, fallback = 0) {
|
|
42
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
43
|
+
return fallback;
|
|
44
|
+
const n = Math.floor(value);
|
|
45
|
+
return n >= 0 ? n : fallback;
|
|
46
|
+
}
|
|
47
|
+
/** Extract a string, else `''`. */
|
|
48
|
+
function str(value) {
|
|
49
|
+
return typeof value === 'string' ? value : '';
|
|
50
|
+
}
|
|
51
|
+
/** Extract one of the allowed literal values, else `null`. */
|
|
52
|
+
function oneOf(value, allowed) {
|
|
53
|
+
return typeof value === 'string' && allowed.includes(value) ? value : null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build a run record from the fields a persisted transcript manifest exposes.
|
|
57
|
+
* Pure: the caller passes already-normalized values, so this is unit-testable
|
|
58
|
+
* without touching disk or a session.
|
|
59
|
+
*/
|
|
60
|
+
export function buildRunRecord(input) {
|
|
61
|
+
const convergence = Array.isArray(input.convergence)
|
|
62
|
+
? input.convergence
|
|
63
|
+
.filter((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)
|
|
64
|
+
.slice(-1000)
|
|
65
|
+
: [];
|
|
66
|
+
const c = input.counts ?? {};
|
|
67
|
+
const stopped = str(input.stoppedReason);
|
|
68
|
+
return {
|
|
69
|
+
version: ITERATE_RUN_RECORD_VERSION,
|
|
70
|
+
project: str(input.project),
|
|
71
|
+
goal: str(input.goal),
|
|
72
|
+
mode: oneOf(input.mode, ['dry-run', 'normal']),
|
|
73
|
+
taskMode: oneOf(input.taskMode, ['code', 'iterate']),
|
|
74
|
+
round: nonNegInt(input.round),
|
|
75
|
+
maxRounds: nonNegInt(input.maxRounds),
|
|
76
|
+
active: input.active !== false,
|
|
77
|
+
stoppedReason: stopped ? stopped : null,
|
|
78
|
+
convergence,
|
|
79
|
+
findings: {
|
|
80
|
+
total: nonNegInt(c.total),
|
|
81
|
+
critical: nonNegInt(c.critical),
|
|
82
|
+
high: nonNegInt(c.high),
|
|
83
|
+
medium: nonNegInt(c.medium),
|
|
84
|
+
low: nonNegInt(c.low),
|
|
85
|
+
},
|
|
86
|
+
fixedCount: nonNegInt(input.fixedCount),
|
|
87
|
+
qualityStatus: oneOf(input.qualityStatus, ['pass', 'fail', 'pending']),
|
|
88
|
+
updatedAt: str(input.now) || new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Owner-side validation of a payload read back from a session log.
|
|
93
|
+
* A restored record carries whatever JSON an earlier build of this plugin
|
|
94
|
+
* wrote, so every field is re-checked; anything unrecognizable returns `null`
|
|
95
|
+
* (the caller skips it) rather than surfacing junk to a renderer.
|
|
96
|
+
*/
|
|
97
|
+
export function normalizeRunRecord(data) {
|
|
98
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
99
|
+
return null;
|
|
100
|
+
const d = data;
|
|
101
|
+
// A record written by a future/incompatible build is skipped rather than
|
|
102
|
+
// coerced: the version gate is the contract that lets the payload evolve.
|
|
103
|
+
if (nonNegInt(d.version) !== ITERATE_RUN_RECORD_VERSION)
|
|
104
|
+
return null;
|
|
105
|
+
const findings = (d.findings && typeof d.findings === 'object' && !Array.isArray(d.findings)
|
|
106
|
+
? d.findings
|
|
107
|
+
: {});
|
|
108
|
+
return buildRunRecord({
|
|
109
|
+
project: d.project,
|
|
110
|
+
goal: d.goal,
|
|
111
|
+
mode: d.mode,
|
|
112
|
+
taskMode: d.taskMode,
|
|
113
|
+
round: d.round,
|
|
114
|
+
maxRounds: d.maxRounds,
|
|
115
|
+
active: d.active,
|
|
116
|
+
stoppedReason: d.stoppedReason,
|
|
117
|
+
convergence: d.convergence,
|
|
118
|
+
counts: {
|
|
119
|
+
total: findings.total,
|
|
120
|
+
critical: findings.critical,
|
|
121
|
+
high: findings.high,
|
|
122
|
+
medium: findings.medium,
|
|
123
|
+
low: findings.low,
|
|
124
|
+
},
|
|
125
|
+
fixedCount: d.fixedCount,
|
|
126
|
+
qualityStatus: d.qualityStatus,
|
|
127
|
+
now: typeof d.updatedAt === 'string' ? d.updatedAt : undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// ─── Session access (lazy, fail-safe) ────────────────────────────────────────
|
|
131
|
+
/** Lazily-resolved production API; `undefined` = not yet attempted. */
|
|
132
|
+
let cachedApi;
|
|
133
|
+
/** Test override; when set (including `null`) the dynamic import is bypassed. */
|
|
134
|
+
let testApiOverride;
|
|
135
|
+
/** Resolve the `@deepseek-ai/dsh-session` record API, or `null` when absent. */
|
|
136
|
+
export function loadSessionRecordApi() {
|
|
137
|
+
if (testApiOverride !== undefined)
|
|
138
|
+
return Promise.resolve(testApiOverride);
|
|
139
|
+
if (cachedApi === undefined) {
|
|
140
|
+
cachedApi = import('@deepseek-ai/dsh-session')
|
|
141
|
+
.then((mod) => {
|
|
142
|
+
const m = mod;
|
|
143
|
+
if (typeof m.appendPluginRecord !== 'function' || typeof m.pluginRecordOf !== 'function') {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
appendPluginRecord: m.appendPluginRecord.bind(mod),
|
|
148
|
+
pluginRecordOf: m.pluginRecordOf.bind(mod),
|
|
149
|
+
};
|
|
150
|
+
})
|
|
151
|
+
.catch(() => null);
|
|
152
|
+
}
|
|
153
|
+
return cachedApi;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Override the session-record API for tests. Pass `undefined` to restore the
|
|
157
|
+
* real dynamic import, or an object / `null` to force a deterministic result.
|
|
158
|
+
*/
|
|
159
|
+
export function setSessionRecordApiForTests(api) {
|
|
160
|
+
testApiOverride = api;
|
|
161
|
+
}
|
|
162
|
+
/** Extract the session from a tool execution (`exec.agent.session`), defensively. */
|
|
163
|
+
export function sessionOf(exec) {
|
|
164
|
+
try {
|
|
165
|
+
const agent = exec?.agent;
|
|
166
|
+
const session = agent?.session;
|
|
167
|
+
if (!session || typeof session !== 'object')
|
|
168
|
+
return undefined;
|
|
169
|
+
const s = session;
|
|
170
|
+
if (typeof s.ownEvents === 'function' || typeof s.snapshotEvents === 'function')
|
|
171
|
+
return s;
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Read every event a session exposes (own events preferred, snapshot fallback). */
|
|
179
|
+
function eventsOf(session) {
|
|
180
|
+
try {
|
|
181
|
+
if (typeof session.ownEvents === 'function')
|
|
182
|
+
return session.ownEvents() ?? [];
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// fall through to snapshot
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
if (typeof session.snapshotEvents === 'function')
|
|
189
|
+
return session.snapshotEvents() ?? [];
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// no readable log
|
|
193
|
+
}
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Best-effort append of one run record to the session log.
|
|
198
|
+
* Never throws: an absent API, a degraded session, or a rejected payload all
|
|
199
|
+
* surface as `{ ok: false }` so the surrounding tool call can continue.
|
|
200
|
+
*/
|
|
201
|
+
export async function appendRunRecord(session, record) {
|
|
202
|
+
const api = await loadSessionRecordApi();
|
|
203
|
+
if (!api)
|
|
204
|
+
return { ok: false, supported: false, error: 'session record API unavailable' };
|
|
205
|
+
if (!session)
|
|
206
|
+
return { ok: false, supported: true, error: 'no session in scope' };
|
|
207
|
+
try {
|
|
208
|
+
const seq = api.appendPluginRecord(session, ITERATE_RUN_RECORD_TYPE, record);
|
|
209
|
+
return { ok: true, supported: true, seq };
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
supported: true,
|
|
215
|
+
error: `session rejected the run record: ${err instanceof Error ? err.message : String(err)}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Read every iterate run record from the session log (oldest → newest). */
|
|
220
|
+
export async function readRunRecords(session) {
|
|
221
|
+
const api = await loadSessionRecordApi();
|
|
222
|
+
if (!api)
|
|
223
|
+
return { supported: false, records: [] };
|
|
224
|
+
if (!session)
|
|
225
|
+
return { supported: true, records: [] };
|
|
226
|
+
try {
|
|
227
|
+
const records = [];
|
|
228
|
+
for (const event of eventsOf(session)) {
|
|
229
|
+
const rec = api.pluginRecordOf(event);
|
|
230
|
+
if (!rec || rec.type !== ITERATE_RUN_RECORD_TYPE)
|
|
231
|
+
continue;
|
|
232
|
+
const normalized = normalizeRunRecord(rec.data);
|
|
233
|
+
if (!normalized)
|
|
234
|
+
continue;
|
|
235
|
+
records.push({ ...normalized, seq: nonNegInt(rec.seq), time: nonNegInt(rec.time) });
|
|
236
|
+
}
|
|
237
|
+
return { supported: true, records };
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
supported: true,
|
|
242
|
+
records: [],
|
|
243
|
+
error: `failed to read session records: ${err instanceof Error ? err.message : String(err)}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|