iterate-plugin 3.15.0 → 3.16.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 +3 -1
- package/README.zh-CN.md +3 -1
- package/lib/client.js +276 -7
- package/package.json +1 -1
- package/src/client/i18n.ts +21 -2
- package/src/client/index.ts +196 -21
- package/src/client/run-compare.ts +213 -0
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ Both modes share:
|
|
|
107
107
|
### UI layer (build-free client slots, v3.12: 12 tabs, bilingual)
|
|
108
108
|
|
|
109
109
|
- **ConvergenceDashboard** (`conversation.input.dock`) — live round progress bar, severity stats, dimension badges, trend mini-chart above the input; normal mode also shows fix-count badges; plus a live workflow-phase chip (current phase + running/stopped) and a **task_mode indicator (code / iterate)**, now backed by the persisted transcript (`iterate_status` / `iterate_transcript` emit `taskMode`). While a run is active the dashboard switches to a running state (round + phase) instead of the static empty state, and the start buttons are hidden.
|
|
110
|
-
- **ObservatoryPanel** (`conversation.input.dock`) — a **twelve-tab** runtime observatory below the input: **live activity stream** (default tab, type filter) + **F1** review threads (expand / collapse all), **F2** convergence trend (incl. the per-round comparison table: findings raised / fixed / severity mix / validation outcomes), **F3** finding locations (severity / dimension / search filter), **F4** fixes + rollback, **F5** checkpoint resume, **F6** run console, **F7** decision timeline (type / round filter + search), **F8** Quality Gate, **F9** Experience Bank, **F10** Defense Events, **F11** Preflight. Number-key shortcuts: `1`–`9` jump to F1–F9, `0` to F10, `-` back to the live stream (F11 is click-only). **F8/F9/F10 are live** — they scan the session stream for the latest `iterate_quality_gate` / `iterate_experience` / `iterate_defense_events` results and render real data (PASS/FAIL chip + per-dimension score bars, experience entries with adopt buttons + client-side search, defense type-count chips + filtered event stream); when the session stream has none they distinguish "no data on disk" from "not pulled yet" and offer a **拉取磁盘快照 (pull disk snapshot)** instruction that has the model call the read-side tools and echo the `.iterate/` artifacts back into the stream. One-click JSON export of all observatory data — manifest + live + `qualityGate` / `experienceBank` / `defenseEvents` / `report` (download, copy fallback). **v3.9: report-export buttons** (导出报告 / 导出 SARIF, **v3.11 + 导出 JSON 报告**) render the *same shared data model* as the `iterate_report` tool — a PR-pasteable Markdown run report, a SARIF 2.1.0 log for CI / GitHub Code Scanning, and (v3.11) the complete normalized run model as machine-readable JSON. **v3.5.3: reset actions surfaced** — F5 copies an `iterate_checkpoint clear` instruction, F8 an `iterate_quality_gate clear` instruction, F10 an `iterate_defense_events clear` instruction. **v3.12: pre-run readiness (Preflight)** — F11 renders the latest `iterate_preflight` checklist scanned from the session stream (validation commands configured / git repo / dirty tree / stale FAIL gate / interrupted checkpoint), each warn/fail row offering the exact fix instruction (configure `validation.commands`, clear the stale gate, resume the checkpoint) so the environment is repaired BEFORE a round is spent on it; the empty state offers a **运行预检** instruction that has the model call `iterate_preflight`. **v3.13: the blocking validation row auto-detects commands from the project manifest and offers a one-click 「应用建议」 (copies an `iterate_config write` payload).**
|
|
110
|
+
- **ObservatoryPanel** (`conversation.input.dock`) — a **twelve-tab** runtime observatory below the input: **live activity stream** (default tab, type filter) + **F1** review threads (expand / collapse all), **F2** convergence trend (incl. the per-round comparison table: findings raised / fixed / severity mix / validation outcomes, plus **v3.16 a cross-session comparison**: save the current run as a baseline (`存为对比基准`), then compare any later run against it side by side (`与基准对比`) with per-metric deltas tinted by direction), **F3** finding locations (severity / dimension / search filter), **F4** fixes + rollback, **F5** checkpoint resume, **F6** run console, **F7** decision timeline (type / round filter + search), **F8** Quality Gate, **F9** Experience Bank, **F10** Defense Events, **F11** Preflight. Number-key shortcuts: `1`–`9` jump to F1–F9, `0` to F10, `-` back to the live stream (F11 is click-only). **F8/F9/F10 are live** — they scan the session stream for the latest `iterate_quality_gate` / `iterate_experience` / `iterate_defense_events` results and render real data (PASS/FAIL chip + per-dimension score bars, experience entries with adopt buttons + client-side search, defense type-count chips + filtered event stream); when the session stream has none they distinguish "no data on disk" from "not pulled yet" and offer a **拉取磁盘快照 (pull disk snapshot)** instruction that has the model call the read-side tools and echo the `.iterate/` artifacts back into the stream. One-click JSON export of all observatory data — manifest + live + `qualityGate` / `experienceBank` / `defenseEvents` / `report` (download, copy fallback). **v3.9: report-export buttons** (导出报告 / 导出 SARIF, **v3.11 + 导出 JSON 报告**) render the *same shared data model* as the `iterate_report` tool — a PR-pasteable Markdown run report, a SARIF 2.1.0 log for CI / GitHub Code Scanning, and (v3.11) the complete normalized run model as machine-readable JSON. **v3.5.3: reset actions surfaced** — F5 copies an `iterate_checkpoint clear` instruction, F8 an `iterate_quality_gate clear` instruction, F10 an `iterate_defense_events clear` instruction. **v3.12: pre-run readiness (Preflight)** — F11 renders the latest `iterate_preflight` checklist scanned from the session stream (validation commands configured / git repo / dirty tree / stale FAIL gate / interrupted checkpoint), each warn/fail row offering the exact fix instruction (configure `validation.commands`, clear the stale gate, resume the checkpoint) so the environment is repaired BEFORE a round is spent on it; the empty state offers a **运行预检** instruction that has the model call `iterate_preflight`. **v3.13: the blocking validation row auto-detects commands from the project manifest and offers a one-click 「应用建议」 (copies an `iterate_config write` payload).**
|
|
111
111
|
- **TriagePanel** (`conversation.chat.turnTail`) — per-finding triage with `y` (keep → fix), `n` (skip), `a` (known-intentional) keyboard shortcuts and `↑`/`↓` navigation (focus-guarded: only active while the panel has focus), filtering, batch ops (incl. select-all), localStorage persistence. **Write-back closed loop**: the primary button copies an `iterate_triage apply` instruction for the marked entries (the tool validates, dedupes, backs up, rolls back on failure); a secondary button copies the raw `known_intentional` YAML; **回读已写入条目** copies an `iterate_triage list` instruction to confirm what actually landed. Copy payloads are aligned to the target tool schemas. **v3.2: native command buttons** (approve architectural fix, trigger new round, rollback to checkpoint). **v3.3: §8 指派修复 (assign)** — one click copies an `iterate_fix` instruction carrying every in-scope finding (respecting the batch toggle).
|
|
112
112
|
- **StatsCard** (`conversation.chat.turnTail`) — convergence stats, round history, trend chart and completion summary when no findings remain.
|
|
113
113
|
- **iterate theme skin** (`theme.overrideTokens`) — a warm-amber 13-token `--dsw-*` override, light/dark modes, togglable in settings.
|
|
@@ -202,6 +202,8 @@ The observatory below the input carries **12 tabs**: the live activity stream (d
|
|
|
202
202
|
|
|
203
203
|
`F1` review threads · `F2` convergence trend (incl. per-round comparison) · `F3` finding locations · `F4` fixes + rollback · `F5` checkpoint resume · `F6` run console · `F7` decision timeline · `F8` quality gate · `F9` experience bank · `F10` defense events · `F11` preflight
|
|
204
204
|
|
|
205
|
+
**Cross-session comparison (v3.16)** — the F2 tab also carries a **跨会话对比 (cross-session comparison)** block: **存为对比基准** saves the current run's summary (rounds / findings / critical+high / fixes applied / failed fixes / validation pass rate / quality-gate score, persisted to `localStorage`) and **与基准对比** renders the next run against it side by side with a directional delta (better / worse tinted). This replaces the old "export two JSONs and diff by hand" chore; a corrupt / stale saved baseline reads as "no baseline", never a phantom run.
|
|
206
|
+
|
|
205
207
|
Press `1`–`9` to jump to F1–F9, `0` for F10, and `-` to return to the live stream.
|
|
206
208
|
|
|
207
209
|
### Disk snapshot pull & export
|
package/README.zh-CN.md
CHANGED
|
@@ -106,7 +106,7 @@ dsh plugin --profile web add iterate-plugin
|
|
|
106
106
|
### UI 层(客户端免构建槽位,v3.12:12 个页签,双语)
|
|
107
107
|
|
|
108
108
|
- **收敛看板 `ConvergenceDashboard`**(`conversation.input.dock`)— 输入框上方实时显示轮次进度条、严重度统计、维度徽章、趋势迷你图;normal 模式另显示修复计数徽章;还有运行阶段芯片(当前工作流阶段 + 运行中/已结束)与 **task_mode 指示器(code / iterate)**。运行期间看板切换为"运行中"状态(显示轮次与阶段),并隐藏启动按钮。
|
|
109
|
-
- **运行时观测台 `ObservatoryPanel`**(`conversation.input.dock`)— 输入框下方**十二个页签**运行时观测台:**实时活动流**(默认页,支持按活动类型筛选)+ **F1** 审查线程(全部展开/全部收起)、**F2** 收敛趋势(含跨轮对比表:每轮 findings / fixed / 严重度分布 /
|
|
109
|
+
- **运行时观测台 `ObservatoryPanel`**(`conversation.input.dock`)— 输入框下方**十二个页签**运行时观测台:**实时活动流**(默认页,支持按活动类型筛选)+ **F1** 审查线程(全部展开/全部收起)、**F2** 收敛趋势(含跨轮对比表:每轮 findings / fixed / 严重度分布 / 验证结果;**v3.16 新增跨会话对比**:把当前运行**存为对比基准**,下次运行一键**与基准对比**,逐项并排 + 带方向的差值着色)、**F3** 发现定位(按严重度/维度/关键词筛选)、**F4** 修复与回滚、**F5** 断点恢复、**F6** 运行控制台、**F7** 决策时间线(按类型/轮次筛选与关键词搜索)、**F8** 质量门禁、**F9** 经验银行、**F10** 防御事件、**F11** 开跑预检。数字键快捷键:`1`–`9` 跳到 F1–F9,`0` 到 F10,`-` 回实时活动流(F11 仅点击进入)。**F8/F9/F10 为实时数据**——扫描会话流中最近一次 `iterate_quality_gate` / `iterate_experience` / `iterate_defense_events` 结果并渲染真实数据(PASS/FAIL 徽章 + 分维度评分条、经验条目(含采纳按钮 + 客户端搜索)、防御类型计数芯片 + 筛选后的事件流);会话流中没有数据时区分"磁盘无数据"与"尚未拉取",并提供**拉取磁盘快照**指令(让模型调用只读工具、把 `.iterate/` 产物回显进会话流)。一键导出全部观测数据为 JSON——manifest + live + `qualityGate` / `experienceBank` / `defenseEvents` / `report`(优先下载,失败回退复制)。**v3.9 新增报告导出按钮**(导出报告 / 导出 SARIF,**v3.11 新增导出 JSON 报告**),与 `iterate_report` 工具共用同一份数据模型——分别产出可直接粘贴进 PR 的 Markdown 运行报告、供 CI / GitHub Code Scanning 消费的 SARIF 2.1.0 日志,以及(v3.11)完整的归一化运行模型 JSON(无损格式)。**v3.5.3 新增重置操作**:F5 复制 `iterate_checkpoint clear`、F8 复制 `iterate_quality_gate clear`、F10 复制 `iterate_defense_events clear` 指令。**v3.12 新增开跑预检(Preflight)**——F11 渲染会话流中最近一次 `iterate_preflight` 清单(验证命令是否配置 / 是否 Git 仓库 / 脏工作区 / 陈旧 FAIL 门禁 / 中断断点),每个 warn/fail 行都给出精确的修复指令(配置 `validation.commands`、清除陈旧门禁、恢复断点),让环境在**花掉一轮之前**就被修好;空态提供**运行预检**指令(让模型调用 `iterate_preflight`)。**v3.13:阻断的验证命令行自动检测项目 manifest,提供一键「应用建议」**(复制 `iterate_config` 的 `operation:"write"` 指令,携带完整 `validation.commands.default` 载荷)。
|
|
110
110
|
- **Findings 分诊面板 `TriagePanel`**(`conversation.chat.turnTail`)— 逐条分诊,键盘快捷键 `y`(保留→修复)、`n`(跳过)、`a`(已知有意)与 `↑`/`↓` 移动(焦点守卫:仅面板内焦点时生效),支持筛选、批量(含一键全选所有 findings)、localStorage 持久化。**写回闭环**:主按钮复制已标记条目的 `iterate_triage apply` 指令(工具负责校验、去重、备份、失败回滚);次按钮复制裸 `known_intentional` YAML;**回读已写入条目**复制 `iterate_triage list` 指令核对实际落盘内容。复制载荷与目标工具 schema 对齐。**v3.2:原生命令按钮**(批准架构修复、触发新一轮、回滚到断点)。
|
|
111
111
|
- **收敛统计卡片 `StatsCard`**(`conversation.chat.turnTail`)— 无 findings 时显示收敛统计、历史轮次、趋势图、完成摘要。
|
|
112
112
|
- **iterate 主题皮肤**(`theme.overrideTokens`)— 暖琥珀配色的 13 个 `--dsw-*` token 覆盖,明暗双模式,可在设置页开关。
|
|
@@ -201,6 +201,8 @@ pnpm add /path/to/iterate-skill/harness/iterate-plugin
|
|
|
201
201
|
|
|
202
202
|
`F1` 审查线程 · `F2` 收敛趋势(含跨轮对比)· `F3` 发现定位 · `F4` 修复与回滚 · `F5` 断点恢复 · `F6` 运行控制台 · `F7` 决策时间线 · `F8` 质量门禁 · `F9` 经验银行 · `F10` 防御事件 · `F11` 开跑预检
|
|
203
203
|
|
|
204
|
+
**跨会话对比(v3.16)**——F2 页签还带一个**跨会话对比**块:**存为对比基准**把当前运行摘要(轮次 / 发现 / critical+high / 修复成功 / 修复失败 / 验证通过率 / 门禁得分,持久化到 `localStorage`)保存下来,**与基准对比**把下一次运行与它并排对比,差值按「更好 / 更差」方向着色。这取代了以前「导出两份 JSON 手工并排对比」的繁琐做法;损坏 / 过期的已存基准读作「无基准」,绝不变成幽灵运行。
|
|
205
|
+
|
|
204
206
|
按 `1`–`9` 跳到 F1–F9,`0` 到 F10,`-` 回实时活动流。
|
|
205
207
|
|
|
206
208
|
### 磁盘快照拉取与导出
|
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* iterate-plugin client source-digest:
|
|
1
|
+
/* iterate-plugin client source-digest: 55b24ad8a8a5d8abf400d42314a6bf75394e8e529fc587ac991bb4e50d868393 */
|
|
2
2
|
window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {
|
|
3
3
|
var module = { exports: {} };
|
|
4
4
|
var exports = module.exports;
|
|
@@ -2645,7 +2645,24 @@ var EN = {
|
|
|
2645
2645
|
"\u5404\u8F6E\u53D1\u73B0\u6570\u91CF\uFF1A{a0}{a1}": "Findings per round: {a0}{a1}",
|
|
2646
2646
|
" \xB7 \u5F53\u524D Round {a0}": " \xB7 current round {a0}",
|
|
2647
2647
|
"\u8DE8\u8F6E\u5BF9\u6BD4\uFF08\u672C\u4F1A\u8BDD\uFF09": "Cross-round comparison (this session)",
|
|
2648
|
-
"\u8DE8\u4F1A\u8BDD\u5BF9\u6BD4\uFF1A\u672C\u8868\u53EA\u8986\u76D6\u5F53\u524D\u4F1A\u8BDD\uFF1B\
|
|
2648
|
+
"\u8DE8\u4F1A\u8BDD\u5BF9\u6BD4\uFF1A\u672C\u8868\u53EA\u8986\u76D6\u5F53\u524D\u4F1A\u8BDD\uFF1B\u8981\u8DE8\u4E24\u6B21\u8FED\u4EE3\u5BF9\u6BD4\uFF0C\u7528\u4E0B\u65B9\u300C\u5B58\u4E3A\u5BF9\u6BD4\u57FA\u51C6\u300D\u4FDD\u5B58\u672C\u6B21\u8FD0\u884C\uFF0C\u518D\u4E0E\u4E0B\u4E00\u6B21\u8FED\u4EE3\u5BF9\u6BD4\u3002": 'Cross-session comparison: this table only covers the current session \u2014 to compare two iterations, save this run with "Save as comparison baseline" below, then compare the next iteration against it.',
|
|
2649
|
+
\u8DE8\u4F1A\u8BDD\u5BF9\u6BD4: "Cross-session comparison",
|
|
2650
|
+
"\u57FA\u51C6\u5DF2\u4FDD\u5B58\uFF1A{a0}": "Baseline saved: {a0}",
|
|
2651
|
+
"\u5148\u4FDD\u5B58\u5F53\u524D\u8FD0\u884C\u4E3A\u57FA\u51C6\uFF0C\u518D\u8DD1\u4E0B\u4E00\u6B21\u8FED\u4EE3\u540E\u4E00\u952E\u5BF9\u6BD4": "Save the current run as the baseline, then one-click compare the next iteration against it",
|
|
2652
|
+
"\u628A\u5F53\u524D\u8FD0\u884C\u6458\u8981\u4FDD\u5B58\u4E3A\u5BF9\u6BD4\u57FA\u51C6\uFF08\u542B\u8F6E\u6B21/\u53D1\u73B0/\u4FEE\u590D/\u9A8C\u8BC1/\u95E8\u7981\uFF09": "Save the current run summary as the comparison baseline (rounds / findings / fixes / validation / gate)",
|
|
2653
|
+
"\u5F53\u524D\u6CA1\u6709\u53EF\u4FDD\u5B58\u7684\u8FD0\u884C\u6570\u636E": "No run data to save yet",
|
|
2654
|
+
\u5DF2\u5B58\u4E3A\u57FA\u51C6: "Saved as baseline",
|
|
2655
|
+
\u5B58\u4E3A\u5BF9\u6BD4\u57FA\u51C6: "Save as comparison baseline",
|
|
2656
|
+
"\u663E\u793A\u6216\u9690\u85CF\u4E0E\u5DF2\u4FDD\u5B58\u57FA\u51C6\u7684\u5BF9\u6BD4\u8868": "Show or hide the comparison against the saved baseline",
|
|
2657
|
+
\u6536\u8D77\u5BF9\u6BD4: "Hide comparison",
|
|
2658
|
+
\u4E0E\u57FA\u51C6\u5BF9\u6BD4: "Compare with baseline",
|
|
2659
|
+
"\u6E05\u9664\u5DF2\u4FDD\u5B58\u7684\u5BF9\u6BD4\u57FA\u51C6": "Clear the saved comparison baseline",
|
|
2660
|
+
\u6E05\u9664\u57FA\u51C6: "Clear baseline",
|
|
2661
|
+
"\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u8FD0\u884C\u6570\u636E\u53EF\u5BF9\u6BD4": "This session has no run data to compare yet",
|
|
2662
|
+
\u6307\u6807: "Metric",
|
|
2663
|
+
\u57FA\u51C6: "Baseline",
|
|
2664
|
+
\u5F53\u524D: "Current",
|
|
2665
|
+
\u53D8\u5316: "Delta",
|
|
2649
2666
|
// ── F3 finding locations ─────────────────────────────────────────────────
|
|
2650
2667
|
\u6682\u65E0\u53D1\u73B0: "No findings",
|
|
2651
2668
|
"{a0}\uFF08{a1} \u9879\uFF09": "{a0} ({a1} findings)",
|
|
@@ -3042,6 +3059,101 @@ function registerIterateContextMenus(service, deps, instructionByEntry) {
|
|
|
3042
3059
|
};
|
|
3043
3060
|
}
|
|
3044
3061
|
|
|
3062
|
+
// src/client/run-compare.ts
|
|
3063
|
+
var RUN_BASELINE_VERSION = 1;
|
|
3064
|
+
var RUN_BASELINE_STORAGE_KEY = "iterate.run-baseline";
|
|
3065
|
+
function buildRunBaseline(input, savedAt) {
|
|
3066
|
+
const convergence = Array.isArray(input.convergence) ? input.convergence : [];
|
|
3067
|
+
const findings = (Array.isArray(input.findings) ? input.findings : []).filter(
|
|
3068
|
+
(f) => typeof f === "object" && f !== null
|
|
3069
|
+
);
|
|
3070
|
+
const fixes = (Array.isArray(input.fixes) ? input.fixes : []).filter(
|
|
3071
|
+
(f) => typeof f === "object" && f !== null
|
|
3072
|
+
);
|
|
3073
|
+
const validations = (Array.isArray(input.validations) ? input.validations : []).filter(
|
|
3074
|
+
(v) => typeof v === "object" && v !== null
|
|
3075
|
+
);
|
|
3076
|
+
let criticalHigh = 0;
|
|
3077
|
+
for (const f of findings) {
|
|
3078
|
+
const sev = String(f && f.severity || "").toLowerCase();
|
|
3079
|
+
if (sev === "critical" || sev === "high") criticalHigh += 1;
|
|
3080
|
+
}
|
|
3081
|
+
let fixed = 0;
|
|
3082
|
+
let failedFixes = 0;
|
|
3083
|
+
for (const x of fixes) {
|
|
3084
|
+
if (x && x.success === true) fixed += 1;
|
|
3085
|
+
else if (x && x.success === false) failedFixes += 1;
|
|
3086
|
+
}
|
|
3087
|
+
let passed = 0;
|
|
3088
|
+
for (const v of validations) {
|
|
3089
|
+
if (v && v.allowed === true) passed += 1;
|
|
3090
|
+
}
|
|
3091
|
+
const validationTotal = validations.length;
|
|
3092
|
+
const validationPassRate = validationTotal > 0 ? Math.round(passed / validationTotal * 100) : null;
|
|
3093
|
+
return {
|
|
3094
|
+
version: RUN_BASELINE_VERSION,
|
|
3095
|
+
savedAt,
|
|
3096
|
+
...input.project ? { project: input.project } : {},
|
|
3097
|
+
...input.mode ? { mode: input.mode } : {},
|
|
3098
|
+
rounds: convergence.length,
|
|
3099
|
+
findings: findings.length,
|
|
3100
|
+
criticalHigh,
|
|
3101
|
+
fixed,
|
|
3102
|
+
failedFixes,
|
|
3103
|
+
validationPassRate,
|
|
3104
|
+
validationTotal,
|
|
3105
|
+
qualityGateStatus: input.qualityGateStatus || null,
|
|
3106
|
+
qualityGateScore: typeof input.qualityGateScore === "number" ? input.qualityGateScore : null,
|
|
3107
|
+
stoppedReason: input.stoppedReason || null,
|
|
3108
|
+
convergence
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
function parseRunBaseline(raw) {
|
|
3112
|
+
if (!raw) return null;
|
|
3113
|
+
let parsed;
|
|
3114
|
+
try {
|
|
3115
|
+
parsed = JSON.parse(raw);
|
|
3116
|
+
} catch {
|
|
3117
|
+
return null;
|
|
3118
|
+
}
|
|
3119
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
3120
|
+
const b = parsed;
|
|
3121
|
+
if (b.version !== RUN_BASELINE_VERSION) return null;
|
|
3122
|
+
if (typeof b.savedAt !== "string" || b.savedAt.length === 0) return null;
|
|
3123
|
+
if (typeof b.findings !== "number" || !Number.isFinite(b.findings)) return null;
|
|
3124
|
+
const convergence = Array.isArray(b.convergence) ? b.convergence.filter((n) => typeof n === "number" && Number.isFinite(n)) : [];
|
|
3125
|
+
return {
|
|
3126
|
+
version: RUN_BASELINE_VERSION,
|
|
3127
|
+
savedAt: b.savedAt,
|
|
3128
|
+
...typeof b.project === "string" && b.project ? { project: b.project } : {},
|
|
3129
|
+
...typeof b.mode === "string" && b.mode ? { mode: b.mode } : {},
|
|
3130
|
+
rounds: typeof b.rounds === "number" && Number.isFinite(b.rounds) ? b.rounds : convergence.length,
|
|
3131
|
+
findings: b.findings,
|
|
3132
|
+
criticalHigh: typeof b.criticalHigh === "number" && Number.isFinite(b.criticalHigh) ? b.criticalHigh : 0,
|
|
3133
|
+
fixed: typeof b.fixed === "number" && Number.isFinite(b.fixed) ? b.fixed : 0,
|
|
3134
|
+
failedFixes: typeof b.failedFixes === "number" && Number.isFinite(b.failedFixes) ? b.failedFixes : 0,
|
|
3135
|
+
validationPassRate: typeof b.validationPassRate === "number" && Number.isFinite(b.validationPassRate) ? b.validationPassRate : null,
|
|
3136
|
+
validationTotal: typeof b.validationTotal === "number" && Number.isFinite(b.validationTotal) ? b.validationTotal : 0,
|
|
3137
|
+
qualityGateStatus: typeof b.qualityGateStatus === "string" && b.qualityGateStatus ? b.qualityGateStatus : null,
|
|
3138
|
+
qualityGateScore: typeof b.qualityGateScore === "number" && Number.isFinite(b.qualityGateScore) ? b.qualityGateScore : null,
|
|
3139
|
+
stoppedReason: typeof b.stoppedReason === "string" && b.stoppedReason ? b.stoppedReason : null,
|
|
3140
|
+
convergence
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
function compareRuns(baseline, current3) {
|
|
3144
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
3145
|
+
const rows = [
|
|
3146
|
+
{ key: "rounds", baseline: num(baseline.rounds), current: num(current3.rounds), higherIsBetter: null },
|
|
3147
|
+
{ key: "findings", baseline: num(baseline.findings), current: num(current3.findings), higherIsBetter: false },
|
|
3148
|
+
{ key: "criticalHigh", baseline: num(baseline.criticalHigh), current: num(current3.criticalHigh), higherIsBetter: false },
|
|
3149
|
+
{ key: "fixed", baseline: num(baseline.fixed), current: num(current3.fixed), higherIsBetter: true },
|
|
3150
|
+
{ key: "failedFixes", baseline: num(baseline.failedFixes), current: num(current3.failedFixes), higherIsBetter: false },
|
|
3151
|
+
{ key: "validationPassRate", baseline: num(baseline.validationPassRate), current: num(current3.validationPassRate), higherIsBetter: true },
|
|
3152
|
+
{ key: "qualityGateScore", baseline: num(baseline.qualityGateScore), current: num(current3.qualityGateScore), higherIsBetter: true }
|
|
3153
|
+
];
|
|
3154
|
+
return rows;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3045
3157
|
// src/client/index.ts
|
|
3046
3158
|
var React = require("react");
|
|
3047
3159
|
var name = "iterate-plugin";
|
|
@@ -4737,6 +4849,15 @@ var OBS_LIVE_META = {
|
|
|
4737
4849
|
info: { label: { zh: "\u4FE1\u606F", en: "Info" }, color: "var(--dsw-alias-label-secondary)" }
|
|
4738
4850
|
};
|
|
4739
4851
|
var OBS_LIVE_FALLBACK_COLOR = "var(--dsw-alias-label-secondary)";
|
|
4852
|
+
var RUN_COMPARE_METRIC_LABELS = {
|
|
4853
|
+
rounds: { zh: "\u8F6E\u6B21", en: "Rounds" },
|
|
4854
|
+
findings: { zh: "\u53D1\u73B0", en: "Findings" },
|
|
4855
|
+
criticalHigh: { zh: "\u4E25\u91CD\uFF08critical+high\uFF09", en: "Critical + high" },
|
|
4856
|
+
fixed: { zh: "\u4FEE\u590D\u6210\u529F", en: "Fixes applied" },
|
|
4857
|
+
failedFixes: { zh: "\u4FEE\u590D\u5931\u8D25", en: "Failed fixes" },
|
|
4858
|
+
validationPassRate: { zh: "\u9A8C\u8BC1\u901A\u8FC7\u7387", en: "Validation pass rate" },
|
|
4859
|
+
qualityGateScore: { zh: "\u95E8\u7981\u5F97\u5206", en: "Quality gate score" }
|
|
4860
|
+
};
|
|
4740
4861
|
function formatObsLiveTime(ts) {
|
|
4741
4862
|
if (!ts) return "";
|
|
4742
4863
|
let date = null;
|
|
@@ -4804,6 +4925,15 @@ function ObservatoryPanel(props) {
|
|
|
4804
4925
|
);
|
|
4805
4926
|
const [expSearch, setExpSearch] = React.useState("");
|
|
4806
4927
|
const [defenseFilter, setDefenseFilter] = React.useState("");
|
|
4928
|
+
const [baseline, setBaseline] = React.useState(() => {
|
|
4929
|
+
try {
|
|
4930
|
+
return parseRunBaseline(storage2 ? storage2.get(RUN_BASELINE_STORAGE_KEY) : null);
|
|
4931
|
+
} catch {
|
|
4932
|
+
return null;
|
|
4933
|
+
}
|
|
4934
|
+
});
|
|
4935
|
+
const [showCompare, setShowCompare] = React.useState(false);
|
|
4936
|
+
const [baselineFlash, setBaselineFlash] = React.useState(false);
|
|
4807
4937
|
const copyTimer = React.useRef(null);
|
|
4808
4938
|
React.useEffect(() => () => {
|
|
4809
4939
|
if (copyTimer.current) clearTimeout(copyTimer.current);
|
|
@@ -5047,11 +5177,51 @@ function ObservatoryPanel(props) {
|
|
|
5047
5177
|
})
|
|
5048
5178
|
);
|
|
5049
5179
|
};
|
|
5180
|
+
const currentRunSummary = () => {
|
|
5181
|
+
const hasRun = (manifest.convergence || []).length > 0 || (manifest.findings || []).length > 0 || (manifest.fixes || []).length > 0 || typeof manifest.round === "number" && manifest.round > 0;
|
|
5182
|
+
if (!hasRun) return null;
|
|
5183
|
+
return buildRunBaseline(
|
|
5184
|
+
{
|
|
5185
|
+
project: manifest.project || void 0,
|
|
5186
|
+
mode: manifest.mode || void 0,
|
|
5187
|
+
convergence: manifest.convergence || [],
|
|
5188
|
+
findings: manifest.findings || [],
|
|
5189
|
+
fixes: manifest.fixes || [],
|
|
5190
|
+
validations: manifest.validations || [],
|
|
5191
|
+
qualityGateStatus: qualityGate && qualityGate.overallStatus || null,
|
|
5192
|
+
qualityGateScore: qualityGate && typeof qualityGate.overallScore === "number" ? qualityGate.overallScore : null,
|
|
5193
|
+
stoppedReason: manifest.stoppedReason || null
|
|
5194
|
+
},
|
|
5195
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
5196
|
+
);
|
|
5197
|
+
};
|
|
5198
|
+
const saveBaseline = () => {
|
|
5199
|
+
const summary = currentRunSummary();
|
|
5200
|
+
if (!summary || !storage2) return;
|
|
5201
|
+
try {
|
|
5202
|
+
storage2.set(RUN_BASELINE_STORAGE_KEY, JSON.stringify(summary));
|
|
5203
|
+
setBaseline(summary);
|
|
5204
|
+
setShowCompare(true);
|
|
5205
|
+
setBaselineFlash(true);
|
|
5206
|
+
if (copyTimer.current) clearTimeout(copyTimer.current);
|
|
5207
|
+
copyTimer.current = setTimeout(() => setBaselineFlash(false), 1600);
|
|
5208
|
+
} catch (err) {
|
|
5209
|
+
log("failed to persist run baseline", err);
|
|
5210
|
+
}
|
|
5211
|
+
};
|
|
5212
|
+
const clearBaseline = () => {
|
|
5213
|
+
if (storage2) {
|
|
5214
|
+
try {
|
|
5215
|
+
storage2.remove(RUN_BASELINE_STORAGE_KEY);
|
|
5216
|
+
} catch (err) {
|
|
5217
|
+
log("failed to clear run baseline", err);
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
setBaseline(null);
|
|
5221
|
+
setShowCompare(false);
|
|
5222
|
+
};
|
|
5050
5223
|
const renderTrend = () => {
|
|
5051
5224
|
const conv = manifest.convergence || [];
|
|
5052
|
-
if (conv.length === 0) {
|
|
5053
|
-
return React.createElement("div", { className: "iterate-obs-empty" }, t("\u6682\u65E0\u6536\u655B\u6570\u636E"));
|
|
5054
|
-
}
|
|
5055
5225
|
const points = conv.map((n, i) => ({ round: i + 1, count: n }));
|
|
5056
5226
|
const report = latestReport(session);
|
|
5057
5227
|
const comparison = buildRoundComparison(report, manifest.validations || [], manifest.fixes || []);
|
|
@@ -5092,7 +5262,7 @@ function ObservatoryPanel(props) {
|
|
|
5092
5262
|
})
|
|
5093
5263
|
)
|
|
5094
5264
|
);
|
|
5095
|
-
|
|
5265
|
+
const trendBody = conv.length === 0 ? React.createElement("div", { className: "iterate-obs-empty" }, t("\u6682\u65E0\u6536\u655B\u6570\u636E")) : React.createElement(
|
|
5096
5266
|
"div",
|
|
5097
5267
|
{},
|
|
5098
5268
|
React.createElement(TrendChart, { points }),
|
|
@@ -5111,12 +5281,111 @@ function ObservatoryPanel(props) {
|
|
|
5111
5281
|
React.createElement(
|
|
5112
5282
|
"span",
|
|
5113
5283
|
{ className: "iterate-obs-head-meta" },
|
|
5114
|
-
t("\u8DE8\u4F1A\u8BDD\u5BF9\u6BD4\uFF1A\u672C\u8868\u53EA\u8986\u76D6\u5F53\u524D\u4F1A\u8BDD\uFF1B\
|
|
5284
|
+
t("\u8DE8\u4F1A\u8BDD\u5BF9\u6BD4\uFF1A\u672C\u8868\u53EA\u8986\u76D6\u5F53\u524D\u4F1A\u8BDD\uFF1B\u8981\u8DE8\u4E24\u6B21\u8FED\u4EE3\u5BF9\u6BD4\uFF0C\u7528\u4E0B\u65B9\u300C\u5B58\u4E3A\u5BF9\u6BD4\u57FA\u51C6\u300D\u4FDD\u5B58\u672C\u6B21\u8FD0\u884C\uFF0C\u518D\u4E0E\u4E0B\u4E00\u6B21\u8FED\u4EE3\u5BF9\u6BD4\u3002")
|
|
5115
5285
|
)
|
|
5116
5286
|
),
|
|
5117
5287
|
React.createElement("div", { className: "iterate-obs-block-body" }, comparisonTable)
|
|
5118
5288
|
) : null
|
|
5119
5289
|
);
|
|
5290
|
+
return React.createElement("div", {}, trendBody, renderCrossSessionCompare());
|
|
5291
|
+
};
|
|
5292
|
+
const renderCrossSessionCompare = () => {
|
|
5293
|
+
const current3 = currentRunSummary();
|
|
5294
|
+
const hasRun = current3 !== null;
|
|
5295
|
+
const metricLabel = (key) => {
|
|
5296
|
+
const m = RUN_COMPARE_METRIC_LABELS[key];
|
|
5297
|
+
return m ? m[getLang()] : key;
|
|
5298
|
+
};
|
|
5299
|
+
const fmtCell = (v, key) => {
|
|
5300
|
+
if (v === null) return "\u2014";
|
|
5301
|
+
return key === "validationPassRate" ? `${v}%` : String(v);
|
|
5302
|
+
};
|
|
5303
|
+
const deltaText = (row) => {
|
|
5304
|
+
if (row.baseline === null || row.current === null) return "\u2014";
|
|
5305
|
+
const d = row.current - row.baseline;
|
|
5306
|
+
return d === 0 ? "0" : `${d > 0 ? "+" : ""}${d}`;
|
|
5307
|
+
};
|
|
5308
|
+
const deltaTint = (row) => {
|
|
5309
|
+
if (row.baseline === null || row.current === null || row.higherIsBetter === null) return void 0;
|
|
5310
|
+
const d = row.current - row.baseline;
|
|
5311
|
+
if (d === 0) return void 0;
|
|
5312
|
+
const improved = row.higherIsBetter ? d > 0 : d < 0;
|
|
5313
|
+
return improved ? { color: "var(--dsw-alias-state-success-primary)", fontWeight: 600 } : { color: "var(--dsw-alias-state-error-primary)", fontWeight: 600 };
|
|
5314
|
+
};
|
|
5315
|
+
let compareBody = null;
|
|
5316
|
+
if (baseline && current3 && showCompare) {
|
|
5317
|
+
const rows = compareRuns(baseline, current3);
|
|
5318
|
+
compareBody = React.createElement(
|
|
5319
|
+
"table",
|
|
5320
|
+
{ className: "iterate-obs-table" },
|
|
5321
|
+
React.createElement(
|
|
5322
|
+
"thead",
|
|
5323
|
+
{},
|
|
5324
|
+
React.createElement(
|
|
5325
|
+
"tr",
|
|
5326
|
+
{},
|
|
5327
|
+
React.createElement("th", {}, t("\u6307\u6807")),
|
|
5328
|
+
React.createElement("th", {}, t("\u57FA\u51C6")),
|
|
5329
|
+
React.createElement("th", {}, t("\u5F53\u524D")),
|
|
5330
|
+
React.createElement("th", {}, t("\u53D8\u5316"))
|
|
5331
|
+
)
|
|
5332
|
+
),
|
|
5333
|
+
React.createElement(
|
|
5334
|
+
"tbody",
|
|
5335
|
+
{},
|
|
5336
|
+
...rows.map(
|
|
5337
|
+
(row) => React.createElement(
|
|
5338
|
+
"tr",
|
|
5339
|
+
{ key: `rc-${row.key}` },
|
|
5340
|
+
React.createElement("td", {}, metricLabel(row.key)),
|
|
5341
|
+
React.createElement("td", {}, fmtCell(row.baseline, row.key)),
|
|
5342
|
+
React.createElement("td", {}, fmtCell(row.current, row.key)),
|
|
5343
|
+
React.createElement("td", { style: deltaTint(row) }, deltaText(row))
|
|
5344
|
+
)
|
|
5345
|
+
)
|
|
5346
|
+
)
|
|
5347
|
+
);
|
|
5348
|
+
}
|
|
5349
|
+
return React.createElement(
|
|
5350
|
+
"div",
|
|
5351
|
+
{ className: "iterate-obs-block", style: { marginTop: 8 } },
|
|
5352
|
+
React.createElement(
|
|
5353
|
+
"div",
|
|
5354
|
+
{ className: "iterate-obs-block-head" },
|
|
5355
|
+
React.createElement("span", {}, t("\u8DE8\u4F1A\u8BDD\u5BF9\u6BD4")),
|
|
5356
|
+
baseline ? React.createElement("span", { className: "iterate-obs-head-meta" }, tf(`\u57FA\u51C6\u5DF2\u4FDD\u5B58\uFF1A{a0}`, { a0: baseline.savedAt })) : React.createElement("span", { className: "iterate-obs-head-meta" }, t("\u5148\u4FDD\u5B58\u5F53\u524D\u8FD0\u884C\u4E3A\u57FA\u51C6\uFF0C\u518D\u8DD1\u4E0B\u4E00\u6B21\u8FED\u4EE3\u540E\u4E00\u952E\u5BF9\u6BD4"))
|
|
5357
|
+
),
|
|
5358
|
+
React.createElement(
|
|
5359
|
+
"div",
|
|
5360
|
+
{ className: "iterate-obs-block-body" },
|
|
5361
|
+
React.createElement(
|
|
5362
|
+
"div",
|
|
5363
|
+
{ className: "iterate-obs-bar", style: { flexWrap: "wrap" } },
|
|
5364
|
+
React.createElement("button", {
|
|
5365
|
+
className: "iterate-btn",
|
|
5366
|
+
"data-primary": "",
|
|
5367
|
+
disabled: !hasRun,
|
|
5368
|
+
title: hasRun ? t("\u628A\u5F53\u524D\u8FD0\u884C\u6458\u8981\u4FDD\u5B58\u4E3A\u5BF9\u6BD4\u57FA\u51C6\uFF08\u542B\u8F6E\u6B21/\u53D1\u73B0/\u4FEE\u590D/\u9A8C\u8BC1/\u95E8\u7981\uFF09") : t("\u5F53\u524D\u6CA1\u6709\u53EF\u4FDD\u5B58\u7684\u8FD0\u884C\u6570\u636E"),
|
|
5369
|
+
onClick: saveBaseline
|
|
5370
|
+
}, baselineFlash ? t("\u5DF2\u5B58\u4E3A\u57FA\u51C6") : t("\u5B58\u4E3A\u5BF9\u6BD4\u57FA\u51C6")),
|
|
5371
|
+
baseline ? React.createElement("button", {
|
|
5372
|
+
className: "iterate-btn",
|
|
5373
|
+
"data-copied": showCompare ? "" : void 0,
|
|
5374
|
+
disabled: !hasRun,
|
|
5375
|
+
title: t("\u663E\u793A\u6216\u9690\u85CF\u4E0E\u5DF2\u4FDD\u5B58\u57FA\u51C6\u7684\u5BF9\u6BD4\u8868"),
|
|
5376
|
+
onClick: () => setShowCompare((v) => !v)
|
|
5377
|
+
}, showCompare ? t("\u6536\u8D77\u5BF9\u6BD4") : t("\u4E0E\u57FA\u51C6\u5BF9\u6BD4")) : null,
|
|
5378
|
+
baseline ? React.createElement("button", {
|
|
5379
|
+
className: "iterate-btn",
|
|
5380
|
+
"data-ghost": "",
|
|
5381
|
+
title: t("\u6E05\u9664\u5DF2\u4FDD\u5B58\u7684\u5BF9\u6BD4\u57FA\u51C6"),
|
|
5382
|
+
onClick: clearBaseline
|
|
5383
|
+
}, t("\u6E05\u9664\u57FA\u51C6")) : null
|
|
5384
|
+
),
|
|
5385
|
+
showCompare && baseline && !hasRun ? React.createElement("div", { className: "iterate-obs-empty", style: { marginTop: 8 } }, t("\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u8FD0\u884C\u6570\u636E\u53EF\u5BF9\u6BD4")) : null,
|
|
5386
|
+
compareBody
|
|
5387
|
+
)
|
|
5388
|
+
);
|
|
5120
5389
|
};
|
|
5121
5390
|
const renderFindings = () => {
|
|
5122
5391
|
const findings = manifest.findings || [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/client/i18n.ts
CHANGED
|
@@ -296,8 +296,27 @@ export const EN: Record<string, string> = {
|
|
|
296
296
|
'各轮发现数量:{a0}{a1}': 'Findings per round: {a0}{a1}',
|
|
297
297
|
' · 当前 Round {a0}': ' · current round {a0}',
|
|
298
298
|
'跨轮对比(本会话)': 'Cross-round comparison (this session)',
|
|
299
|
-
'
|
|
300
|
-
'Cross-session comparison: this table only covers the current session —
|
|
299
|
+
'跨会话对比:本表只覆盖当前会话;要跨两次迭代对比,用下方「存为对比基准」保存本次运行,再与下一次迭代对比。':
|
|
300
|
+
'Cross-session comparison: this table only covers the current session — to compare two iterations, save this run with \"Save as comparison baseline\" below, then compare the next iteration against it.',
|
|
301
|
+
跨会话对比: 'Cross-session comparison',
|
|
302
|
+
'基准已保存:{a0}': 'Baseline saved: {a0}',
|
|
303
|
+
'先保存当前运行为基准,再跑下一次迭代后一键对比':
|
|
304
|
+
'Save the current run as the baseline, then one-click compare the next iteration against it',
|
|
305
|
+
'把当前运行摘要保存为对比基准(含轮次/发现/修复/验证/门禁)':
|
|
306
|
+
'Save the current run summary as the comparison baseline (rounds / findings / fixes / validation / gate)',
|
|
307
|
+
'当前没有可保存的运行数据': 'No run data to save yet',
|
|
308
|
+
已存为基准: 'Saved as baseline',
|
|
309
|
+
存为对比基准: 'Save as comparison baseline',
|
|
310
|
+
'显示或隐藏与已保存基准的对比表': 'Show or hide the comparison against the saved baseline',
|
|
311
|
+
收起对比: 'Hide comparison',
|
|
312
|
+
与基准对比: 'Compare with baseline',
|
|
313
|
+
'清除已保存的对比基准': 'Clear the saved comparison baseline',
|
|
314
|
+
清除基准: 'Clear baseline',
|
|
315
|
+
'当前会话没有运行数据可对比': 'This session has no run data to compare yet',
|
|
316
|
+
指标: 'Metric',
|
|
317
|
+
基准: 'Baseline',
|
|
318
|
+
当前: 'Current',
|
|
319
|
+
变化: 'Delta',
|
|
301
320
|
|
|
302
321
|
// ── F3 finding locations ─────────────────────────────────────────────────
|
|
303
322
|
暂无发现: 'No findings',
|
package/src/client/index.ts
CHANGED
|
@@ -146,6 +146,17 @@ import {
|
|
|
146
146
|
registerIterateShortcuts,
|
|
147
147
|
type ShortcutsService,
|
|
148
148
|
} from './shortcuts.ts'
|
|
149
|
+
// v3.16: cross-session run comparison — the F2 tab compares two iterations of
|
|
150
|
+
// the same project (save the current run as a baseline, then compare any later
|
|
151
|
+
// run against it), instead of the old export-two-JSONs-and-diff-by-hand chore.
|
|
152
|
+
import {
|
|
153
|
+
buildRunBaseline,
|
|
154
|
+
parseRunBaseline,
|
|
155
|
+
compareRuns,
|
|
156
|
+
RUN_BASELINE_STORAGE_KEY,
|
|
157
|
+
type RunBaseline,
|
|
158
|
+
type RunCompareMetric,
|
|
159
|
+
} from './run-compare.ts'
|
|
149
160
|
|
|
150
161
|
// ─── Module contract ─────────────────────────────────────────────────────────
|
|
151
162
|
|
|
@@ -2318,6 +2329,17 @@ const OBS_LIVE_META: Record<string, { label: { zh: string; en: string }; color:
|
|
|
2318
2329
|
}
|
|
2319
2330
|
const OBS_LIVE_FALLBACK_COLOR = 'var(--dsw-alias-label-secondary)'
|
|
2320
2331
|
|
|
2332
|
+
/** v3.16: cross-session comparison metric labels (inline {zh,en} pair, like OBS_TABS). */
|
|
2333
|
+
const RUN_COMPARE_METRIC_LABELS: Record<string, { zh: string; en: string }> = {
|
|
2334
|
+
rounds: { zh: '轮次', en: 'Rounds' },
|
|
2335
|
+
findings: { zh: '发现', en: 'Findings' },
|
|
2336
|
+
criticalHigh: { zh: '严重(critical+high)', en: 'Critical + high' },
|
|
2337
|
+
fixed: { zh: '修复成功', en: 'Fixes applied' },
|
|
2338
|
+
failedFixes: { zh: '修复失败', en: 'Failed fixes' },
|
|
2339
|
+
validationPassRate: { zh: '验证通过率', en: 'Validation pass rate' },
|
|
2340
|
+
qualityGateScore: { zh: '门禁得分', en: 'Quality gate score' },
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2321
2343
|
/**
|
|
2322
2344
|
* Format a live-event timestamp as DD/MM HH:mm:ss (0-padded). Accepts an epoch
|
|
2323
2345
|
* ms number or an ISO-ish string; any unparseable value degrades to ''.
|
|
@@ -2428,6 +2450,20 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2428
2450
|
// v3.0: Defense events filter
|
|
2429
2451
|
const [defenseFilter, setDefenseFilter] = React.useState('')
|
|
2430
2452
|
|
|
2453
|
+
// v3.16: cross-session comparison — the baseline is read from localStorage
|
|
2454
|
+
// once on mount (a corrupt/stale payload reads as "no baseline", never as a
|
|
2455
|
+
// phantom run); `showCompare` toggles the side-by-side table, `baselineFlash`
|
|
2456
|
+
// confirms the save action.
|
|
2457
|
+
const [baseline, setBaseline] = React.useState<RunBaseline | null>(() => {
|
|
2458
|
+
try {
|
|
2459
|
+
return parseRunBaseline(storage ? storage.get(RUN_BASELINE_STORAGE_KEY) : null)
|
|
2460
|
+
} catch {
|
|
2461
|
+
return null
|
|
2462
|
+
}
|
|
2463
|
+
})
|
|
2464
|
+
const [showCompare, setShowCompare] = React.useState(false)
|
|
2465
|
+
const [baselineFlash, setBaselineFlash] = React.useState(false)
|
|
2466
|
+
|
|
2431
2467
|
// Single shared "copied" flash timer (reused across all copy buttons).
|
|
2432
2468
|
const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
2433
2469
|
React.useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
|
|
@@ -2691,18 +2727,62 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2691
2727
|
}))
|
|
2692
2728
|
}
|
|
2693
2729
|
|
|
2730
|
+
// ── v3.16: cross-session run comparison handlers ────────────────────────
|
|
2731
|
+
// The summary is built from the SAME manifest the F2 tab renders, so a saved
|
|
2732
|
+
// baseline can never disagree with what the user is looking at.
|
|
2733
|
+
const currentRunSummary = (): RunBaseline | null => {
|
|
2734
|
+
const hasRun =
|
|
2735
|
+
(manifest.convergence || []).length > 0 ||
|
|
2736
|
+
(manifest.findings || []).length > 0 ||
|
|
2737
|
+
(manifest.fixes || []).length > 0 ||
|
|
2738
|
+
(typeof manifest.round === 'number' && manifest.round > 0)
|
|
2739
|
+
if (!hasRun) return null
|
|
2740
|
+
return buildRunBaseline(
|
|
2741
|
+
{
|
|
2742
|
+
project: manifest.project || undefined,
|
|
2743
|
+
mode: manifest.mode || undefined,
|
|
2744
|
+
convergence: manifest.convergence || [],
|
|
2745
|
+
findings: (manifest.findings || []) as Array<{ severity?: string }>,
|
|
2746
|
+
fixes: (manifest.fixes || []) as Array<{ success?: boolean }>,
|
|
2747
|
+
validations: (manifest.validations || []) as Array<{ allowed?: boolean }>,
|
|
2748
|
+
qualityGateStatus: (qualityGate && qualityGate.overallStatus) || null,
|
|
2749
|
+
qualityGateScore: qualityGate && typeof qualityGate.overallScore === 'number' ? qualityGate.overallScore : null,
|
|
2750
|
+
stoppedReason: manifest.stoppedReason || null,
|
|
2751
|
+
},
|
|
2752
|
+
new Date().toISOString(),
|
|
2753
|
+
)
|
|
2754
|
+
}
|
|
2755
|
+
const saveBaseline = () => {
|
|
2756
|
+
const summary = currentRunSummary()
|
|
2757
|
+
if (!summary || !storage) return
|
|
2758
|
+
try {
|
|
2759
|
+
storage.set(RUN_BASELINE_STORAGE_KEY, JSON.stringify(summary))
|
|
2760
|
+
setBaseline(summary)
|
|
2761
|
+
setShowCompare(true)
|
|
2762
|
+
setBaselineFlash(true)
|
|
2763
|
+
if (copyTimer.current) clearTimeout(copyTimer.current)
|
|
2764
|
+
copyTimer.current = setTimeout(() => setBaselineFlash(false), 1600)
|
|
2765
|
+
} catch (err) {
|
|
2766
|
+
log('failed to persist run baseline', err)
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
const clearBaseline = () => {
|
|
2770
|
+
if (storage) {
|
|
2771
|
+
try { storage.remove(RUN_BASELINE_STORAGE_KEY) } catch (err) { log('failed to clear run baseline', err) }
|
|
2772
|
+
}
|
|
2773
|
+
setBaseline(null)
|
|
2774
|
+
setShowCompare(false)
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2694
2777
|
// ── F2: convergence trend (inline bars, reusing TrendChart) ─────────────
|
|
2695
2778
|
const renderTrend = () => {
|
|
2696
2779
|
const conv = manifest.convergence || []
|
|
2697
|
-
if (conv.length === 0) {
|
|
2698
|
-
return React.createElement('div', { className: 'iterate-obs-empty' }, t('暂无收敛数据'))
|
|
2699
|
-
}
|
|
2700
2780
|
const points = conv.map((n, i) => ({ round: i + 1, count: n }))
|
|
2701
2781
|
// Gap #13 (step ①): single-session cross-round comparison. One row per
|
|
2702
2782
|
// round — findings raised / fixed / severity mix / validation outcome.
|
|
2703
|
-
//
|
|
2704
|
-
//
|
|
2705
|
-
//
|
|
2783
|
+
// v3.16: the cross-SESSION half is now the `renderCrossSessionCompare`
|
|
2784
|
+
// block below (save the current run as a baseline, then compare any later
|
|
2785
|
+
// run against it) instead of the old export-two-JSONs-and-diff-by-hand chore.
|
|
2706
2786
|
const report = latestReport(session)
|
|
2707
2787
|
const comparison = buildRoundComparison(report, manifest.validations || [], manifest.fixes || [])
|
|
2708
2788
|
const comparisonTable = comparison.length === 0
|
|
@@ -2742,22 +2822,117 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2742
2822
|
}),
|
|
2743
2823
|
),
|
|
2744
2824
|
)
|
|
2745
|
-
|
|
2746
|
-
React.createElement(
|
|
2747
|
-
React.createElement('div', {
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2825
|
+
const trendBody = conv.length === 0
|
|
2826
|
+
? React.createElement('div', { className: 'iterate-obs-empty' }, t('暂无收敛数据'))
|
|
2827
|
+
: React.createElement('div', {},
|
|
2828
|
+
React.createElement(TrendChart, { points }),
|
|
2829
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
|
|
2830
|
+
tf(`各轮发现数量:{a0}{a1}`, { a0: conv.join(' → '), a1: typeof manifest.round === 'number' ? tf(` · 当前 Round {a0}`, { a0: manifest.round }) : '' }),
|
|
2831
|
+
),
|
|
2832
|
+
comparisonTable
|
|
2833
|
+
? React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
|
|
2834
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
2835
|
+
React.createElement('span', {}, t('跨轮对比(本会话)')),
|
|
2836
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' },
|
|
2837
|
+
t('跨会话对比:本表只覆盖当前会话;要跨两次迭代对比,用下方「存为对比基准」保存本次运行,再与下一次迭代对比。'),
|
|
2838
|
+
),
|
|
2839
|
+
),
|
|
2840
|
+
React.createElement('div', { className: 'iterate-obs-block-body' }, comparisonTable),
|
|
2841
|
+
)
|
|
2842
|
+
: null,
|
|
2843
|
+
)
|
|
2844
|
+
return React.createElement('div', {}, trendBody, renderCrossSessionCompare())
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
// ── v3.16: cross-session run comparison (F2) ─────────────────────────────
|
|
2848
|
+
// Side-by-side comparison of the CURRENT run against a previously saved
|
|
2849
|
+
// baseline, persisted in localStorage. This replaces the manual
|
|
2850
|
+
// export-JSON-and-diff chore the old cross-session hint pointed at: save the
|
|
2851
|
+
// current run once, then compare any later run against it in one click.
|
|
2852
|
+
const renderCrossSessionCompare = () => {
|
|
2853
|
+
const current = currentRunSummary()
|
|
2854
|
+
const hasRun = current !== null
|
|
2855
|
+
const metricLabel = (key: string): string => {
|
|
2856
|
+
const m = RUN_COMPARE_METRIC_LABELS[key]
|
|
2857
|
+
return m ? m[getLang()] : key
|
|
2858
|
+
}
|
|
2859
|
+
const fmtCell = (v: number | null, key: string): string => {
|
|
2860
|
+
if (v === null) return '—'
|
|
2861
|
+
return key === 'validationPassRate' ? `${v}%` : String(v)
|
|
2862
|
+
}
|
|
2863
|
+
const deltaText = (row: RunCompareMetric): string => {
|
|
2864
|
+
if (row.baseline === null || row.current === null) return '—'
|
|
2865
|
+
const d = row.current - row.baseline
|
|
2866
|
+
return d === 0 ? '0' : `${d > 0 ? '+' : ''}${d}`
|
|
2867
|
+
}
|
|
2868
|
+
const deltaTint = (row: RunCompareMetric): { color: string; fontWeight: number } | undefined => {
|
|
2869
|
+
if (row.baseline === null || row.current === null || row.higherIsBetter === null) return undefined
|
|
2870
|
+
const d = row.current - row.baseline
|
|
2871
|
+
if (d === 0) return undefined
|
|
2872
|
+
const improved = row.higherIsBetter ? d > 0 : d < 0
|
|
2873
|
+
return improved
|
|
2874
|
+
? { color: 'var(--dsw-alias-state-success-primary)', fontWeight: 600 }
|
|
2875
|
+
: { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 600 }
|
|
2876
|
+
}
|
|
2877
|
+
let compareBody: React.ReactNode = null
|
|
2878
|
+
if (baseline && current && showCompare) {
|
|
2879
|
+
const rows = compareRuns(baseline, current)
|
|
2880
|
+
compareBody = React.createElement('table', { className: 'iterate-obs-table' },
|
|
2881
|
+
React.createElement('thead', {},
|
|
2882
|
+
React.createElement('tr', {},
|
|
2883
|
+
React.createElement('th', {}, t('指标')),
|
|
2884
|
+
React.createElement('th', {}, t('基准')),
|
|
2885
|
+
React.createElement('th', {}, t('当前')),
|
|
2886
|
+
React.createElement('th', {}, t('变化')),
|
|
2887
|
+
),
|
|
2888
|
+
),
|
|
2889
|
+
React.createElement('tbody', {},
|
|
2890
|
+
...rows.map((row) =>
|
|
2891
|
+
React.createElement('tr', { key: `rc-${row.key}` },
|
|
2892
|
+
React.createElement('td', {}, metricLabel(row.key)),
|
|
2893
|
+
React.createElement('td', {}, fmtCell(row.baseline, row.key)),
|
|
2894
|
+
React.createElement('td', {}, fmtCell(row.current, row.key)),
|
|
2895
|
+
React.createElement('td', { style: deltaTint(row) }, deltaText(row)),
|
|
2757
2896
|
),
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2897
|
+
),
|
|
2898
|
+
),
|
|
2899
|
+
)
|
|
2900
|
+
}
|
|
2901
|
+
return React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
|
|
2902
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
2903
|
+
React.createElement('span', {}, t('跨会话对比')),
|
|
2904
|
+
baseline
|
|
2905
|
+
? React.createElement('span', { className: 'iterate-obs-head-meta' }, tf(`基准已保存:{a0}`, { a0: baseline.savedAt }))
|
|
2906
|
+
: React.createElement('span', { className: 'iterate-obs-head-meta' }, t('先保存当前运行为基准,再跑下一次迭代后一键对比')),
|
|
2907
|
+
),
|
|
2908
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
2909
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { flexWrap: 'wrap' } },
|
|
2910
|
+
React.createElement('button', {
|
|
2911
|
+
className: 'iterate-btn', 'data-primary': '', disabled: !hasRun,
|
|
2912
|
+
title: hasRun ? t('把当前运行摘要保存为对比基准(含轮次/发现/修复/验证/门禁)') : t('当前没有可保存的运行数据'),
|
|
2913
|
+
onClick: saveBaseline,
|
|
2914
|
+
}, baselineFlash ? t('已存为基准') : t('存为对比基准')),
|
|
2915
|
+
baseline
|
|
2916
|
+
? React.createElement('button', {
|
|
2917
|
+
className: 'iterate-btn', 'data-copied': showCompare ? '' : undefined,
|
|
2918
|
+
disabled: !hasRun,
|
|
2919
|
+
title: t('显示或隐藏与已保存基准的对比表'),
|
|
2920
|
+
onClick: () => setShowCompare((v) => !v),
|
|
2921
|
+
}, showCompare ? t('收起对比') : t('与基准对比'))
|
|
2922
|
+
: null,
|
|
2923
|
+
baseline
|
|
2924
|
+
? React.createElement('button', {
|
|
2925
|
+
className: 'iterate-btn', 'data-ghost': '',
|
|
2926
|
+
title: t('清除已保存的对比基准'),
|
|
2927
|
+
onClick: clearBaseline,
|
|
2928
|
+
}, t('清除基准'))
|
|
2929
|
+
: null,
|
|
2930
|
+
),
|
|
2931
|
+
showCompare && baseline && !hasRun
|
|
2932
|
+
? React.createElement('div', { className: 'iterate-obs-empty', style: { marginTop: 8 } }, t('当前会话没有运行数据可对比'))
|
|
2933
|
+
: null,
|
|
2934
|
+
compareBody,
|
|
2935
|
+
),
|
|
2761
2936
|
)
|
|
2762
2937
|
}
|
|
2763
2938
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/client/run-compare.ts — cross-session run comparison (the observatory F2 tab).
|
|
3
|
+
*
|
|
4
|
+
* The F2 convergence tab compares ROUNDS *within* one session, but comparing
|
|
5
|
+
* two ITERATIONS of the same project (before / after a refactor) used to be a
|
|
6
|
+
* manual chore: export JSON for each run, open both files, line up the columns.
|
|
7
|
+
* This module turns that into a first-class interaction. The current run is
|
|
8
|
+
* reduced to a compact, serializable summary and saved to localStorage as the
|
|
9
|
+
* comparison baseline; any later run can then be compared against it side by
|
|
10
|
+
* side, with a per-metric delta and a directional "better / worse" tint.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints (mirrors `i18n.ts` / `session-cache.ts`):
|
|
13
|
+
* - **Pure / dependency-free**: no React, no DOM, no Chinese literals (the
|
|
14
|
+
* bilingual labels live next to `t()` in index.ts, which is the layer the
|
|
15
|
+
* i18n dictionary-contract test scans). The summary builder and the
|
|
16
|
+
* compare reducer are unit-testable on their own.
|
|
17
|
+
* - **Versioned**: the stored payload carries a `version` so a future schema
|
|
18
|
+
* change can migrate or reject stale baselines instead of mis-reading them.
|
|
19
|
+
* - **Never fabricated**: every field is derived from real manifest fields
|
|
20
|
+
* with defensive optional chaining; a run with no findings simply reads 0.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Current schema version of the persisted baseline. */
|
|
24
|
+
export const RUN_BASELINE_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** localStorage key holding the single comparison baseline (per browser). */
|
|
27
|
+
export const RUN_BASELINE_STORAGE_KEY = 'iterate.run-baseline'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A compact, serializable summary of one run — everything the side-by-side
|
|
31
|
+
* comparison needs, and nothing the client cannot already see in the manifest.
|
|
32
|
+
*/
|
|
33
|
+
export interface RunBaseline {
|
|
34
|
+
version: 1
|
|
35
|
+
/** ISO timestamp of when this baseline was saved (NOT the run end time). */
|
|
36
|
+
savedAt: string
|
|
37
|
+
/** Project root the run was anchored to (informational). */
|
|
38
|
+
project?: string
|
|
39
|
+
/** 'normal' | 'dry-run' — the mode that produced the run. */
|
|
40
|
+
mode?: string
|
|
41
|
+
/** Number of review rounds captured. */
|
|
42
|
+
rounds: number
|
|
43
|
+
/** Total findings raised across the run. */
|
|
44
|
+
findings: number
|
|
45
|
+
/** critical + high findings (the "must fix" surface). */
|
|
46
|
+
criticalHigh: number
|
|
47
|
+
/** Fixes that succeeded. */
|
|
48
|
+
fixed: number
|
|
49
|
+
/** Fixes that failed (rolled back / validation-rejected). */
|
|
50
|
+
failedFixes: number
|
|
51
|
+
/** Fraction of validation command runs that passed, 0-100, or null if none ran. */
|
|
52
|
+
validationPassRate: number | null
|
|
53
|
+
/** Number of validation command runs. */
|
|
54
|
+
validationTotal: number
|
|
55
|
+
/** 'pass' | 'fail' | null — the quality gate verdict, when it was computed. */
|
|
56
|
+
qualityGateStatus: string | null
|
|
57
|
+
/** Quality gate overall score (0-100) when computed. */
|
|
58
|
+
qualityGateScore: number | null
|
|
59
|
+
/** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
|
|
60
|
+
stoppedReason: string | null
|
|
61
|
+
/** Findings per round (the convergence series), for a side-by-side trend. */
|
|
62
|
+
convergence: number[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The manifest-shaped inputs the summary builder reads (defensively). */
|
|
66
|
+
export interface RunBaselineInput {
|
|
67
|
+
project?: string
|
|
68
|
+
mode?: string
|
|
69
|
+
convergence?: number[]
|
|
70
|
+
findings?: Array<{ severity?: string }>
|
|
71
|
+
fixes?: Array<{ success?: boolean }>
|
|
72
|
+
validations?: Array<{ allowed?: boolean }>
|
|
73
|
+
qualityGateStatus?: string | null
|
|
74
|
+
qualityGateScore?: number | null
|
|
75
|
+
stoppedReason?: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reduce a run manifest (plus the session-scanned quality gate) to a compact,
|
|
80
|
+
* comparison-ready summary. Pure: `savedAt` is injected by the caller so this
|
|
81
|
+
* stays deterministic and testable.
|
|
82
|
+
*/
|
|
83
|
+
export function buildRunBaseline(input: RunBaselineInput, savedAt: string): RunBaseline {
|
|
84
|
+
const convergence = Array.isArray(input.convergence) ? input.convergence : []
|
|
85
|
+
// Only object entries count — a null / primitive in a malformed manifest
|
|
86
|
+
// must never be read as a finding / fix / validation (and skew the rate).
|
|
87
|
+
const findings = (Array.isArray(input.findings) ? input.findings : []).filter(
|
|
88
|
+
(f): f is { severity?: string } => typeof f === 'object' && f !== null,
|
|
89
|
+
)
|
|
90
|
+
const fixes = (Array.isArray(input.fixes) ? input.fixes : []).filter(
|
|
91
|
+
(f): f is { success?: boolean } => typeof f === 'object' && f !== null,
|
|
92
|
+
)
|
|
93
|
+
const validations = (Array.isArray(input.validations) ? input.validations : []).filter(
|
|
94
|
+
(v): v is { allowed?: boolean } => typeof v === 'object' && v !== null,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
let criticalHigh = 0
|
|
98
|
+
for (const f of findings) {
|
|
99
|
+
const sev = String((f && f.severity) || '').toLowerCase()
|
|
100
|
+
if (sev === 'critical' || sev === 'high') criticalHigh += 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let fixed = 0
|
|
104
|
+
let failedFixes = 0
|
|
105
|
+
for (const x of fixes) {
|
|
106
|
+
if (x && x.success === true) fixed += 1
|
|
107
|
+
else if (x && x.success === false) failedFixes += 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let passed = 0
|
|
111
|
+
for (const v of validations) {
|
|
112
|
+
if (v && v.allowed === true) passed += 1
|
|
113
|
+
}
|
|
114
|
+
const validationTotal = validations.length
|
|
115
|
+
const validationPassRate = validationTotal > 0 ? Math.round((passed / validationTotal) * 100) : null
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
version: RUN_BASELINE_VERSION,
|
|
119
|
+
savedAt,
|
|
120
|
+
...(input.project ? { project: input.project } : {}),
|
|
121
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
122
|
+
rounds: convergence.length,
|
|
123
|
+
findings: findings.length,
|
|
124
|
+
criticalHigh,
|
|
125
|
+
fixed,
|
|
126
|
+
failedFixes,
|
|
127
|
+
validationPassRate,
|
|
128
|
+
validationTotal,
|
|
129
|
+
qualityGateStatus: input.qualityGateStatus || null,
|
|
130
|
+
qualityGateScore: typeof input.qualityGateScore === 'number' ? input.qualityGateScore : null,
|
|
131
|
+
stoppedReason: input.stoppedReason || null,
|
|
132
|
+
convergence,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse a persisted baseline JSON string. Returns `null` for anything that is
|
|
138
|
+
* not a well-formed, version-matched baseline — a stale/corrupt payload must
|
|
139
|
+
* never be mis-read as a real run, and never throws.
|
|
140
|
+
*/
|
|
141
|
+
export function parseRunBaseline(raw: string | null): RunBaseline | null {
|
|
142
|
+
if (!raw) return null
|
|
143
|
+
let parsed: unknown
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(raw)
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
150
|
+
const b = parsed as Record<string, unknown>
|
|
151
|
+
if (b.version !== RUN_BASELINE_VERSION) return null
|
|
152
|
+
if (typeof b.savedAt !== 'string' || b.savedAt.length === 0) return null
|
|
153
|
+
if (typeof b.findings !== 'number' || !Number.isFinite(b.findings)) return null
|
|
154
|
+
// convergence must be a number array (defensive; a non-array becomes []).
|
|
155
|
+
const convergence = Array.isArray(b.convergence)
|
|
156
|
+
? b.convergence.filter((n): n is number => typeof n === 'number' && Number.isFinite(n))
|
|
157
|
+
: []
|
|
158
|
+
return {
|
|
159
|
+
version: RUN_BASELINE_VERSION,
|
|
160
|
+
savedAt: b.savedAt,
|
|
161
|
+
...(typeof b.project === 'string' && b.project ? { project: b.project } : {}),
|
|
162
|
+
...(typeof b.mode === 'string' && b.mode ? { mode: b.mode } : {}),
|
|
163
|
+
rounds: typeof b.rounds === 'number' && Number.isFinite(b.rounds) ? b.rounds : convergence.length,
|
|
164
|
+
findings: b.findings,
|
|
165
|
+
criticalHigh: typeof b.criticalHigh === 'number' && Number.isFinite(b.criticalHigh) ? b.criticalHigh : 0,
|
|
166
|
+
fixed: typeof b.fixed === 'number' && Number.isFinite(b.fixed) ? b.fixed : 0,
|
|
167
|
+
failedFixes: typeof b.failedFixes === 'number' && Number.isFinite(b.failedFixes) ? b.failedFixes : 0,
|
|
168
|
+
validationPassRate:
|
|
169
|
+
typeof b.validationPassRate === 'number' && Number.isFinite(b.validationPassRate) ? b.validationPassRate : null,
|
|
170
|
+
validationTotal:
|
|
171
|
+
typeof b.validationTotal === 'number' && Number.isFinite(b.validationTotal) ? b.validationTotal : 0,
|
|
172
|
+
qualityGateStatus: typeof b.qualityGateStatus === 'string' && b.qualityGateStatus ? b.qualityGateStatus : null,
|
|
173
|
+
qualityGateScore:
|
|
174
|
+
typeof b.qualityGateScore === 'number' && Number.isFinite(b.qualityGateScore) ? b.qualityGateScore : null,
|
|
175
|
+
stoppedReason: typeof b.stoppedReason === 'string' && b.stoppedReason ? b.stoppedReason : null,
|
|
176
|
+
convergence,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One row of the side-by-side comparison.
|
|
182
|
+
*
|
|
183
|
+
* `higherIsBetter` tells the renderer how to tint the delta:
|
|
184
|
+
* - `true` → a larger current value is an improvement (fixed, pass rate, score)
|
|
185
|
+
* - `false` → a smaller current value is an improvement (findings, failed fixes)
|
|
186
|
+
* - `null` → neutral (rounds — fewer is usually faster but not always better)
|
|
187
|
+
*/
|
|
188
|
+
export interface RunCompareMetric {
|
|
189
|
+
key: 'rounds' | 'findings' | 'criticalHigh' | 'fixed' | 'failedFixes' | 'validationPassRate' | 'qualityGateScore'
|
|
190
|
+
baseline: number | null
|
|
191
|
+
current: number | null
|
|
192
|
+
higherIsBetter: boolean | null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Compare a saved baseline against the current run. Both sides come from
|
|
197
|
+
* `buildRunBaseline`, so `null` means "that side had no such measurement"
|
|
198
|
+
* (e.g. a dry-run has no fixes; a run that never validated has no pass rate).
|
|
199
|
+
*/
|
|
200
|
+
export function compareRuns(baseline: RunBaseline, current: RunBaseline): RunCompareMetric[] {
|
|
201
|
+
const num = (v: number | null | undefined): number | null =>
|
|
202
|
+
typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
203
|
+
const rows: RunCompareMetric[] = [
|
|
204
|
+
{ key: 'rounds', baseline: num(baseline.rounds), current: num(current.rounds), higherIsBetter: null },
|
|
205
|
+
{ key: 'findings', baseline: num(baseline.findings), current: num(current.findings), higherIsBetter: false },
|
|
206
|
+
{ key: 'criticalHigh', baseline: num(baseline.criticalHigh), current: num(current.criticalHigh), higherIsBetter: false },
|
|
207
|
+
{ key: 'fixed', baseline: num(baseline.fixed), current: num(current.fixed), higherIsBetter: true },
|
|
208
|
+
{ key: 'failedFixes', baseline: num(baseline.failedFixes), current: num(current.failedFixes), higherIsBetter: false },
|
|
209
|
+
{ key: 'validationPassRate', baseline: num(baseline.validationPassRate), current: num(current.validationPassRate), higherIsBetter: true },
|
|
210
|
+
{ key: 'qualityGateScore', baseline: num(baseline.qualityGateScore), current: num(current.qualityGateScore), higherIsBetter: true },
|
|
211
|
+
]
|
|
212
|
+
return rows
|
|
213
|
+
}
|