niceeval 0.5.4 → 0.6.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.
Files changed (342) hide show
  1. package/INDEX.md +55 -0
  2. package/dist/agents/types.d.ts +317 -0
  3. package/dist/context/types.d.ts +335 -0
  4. package/dist/i18n/core.d.ts +11 -0
  5. package/dist/i18n/en.d.ts +181 -0
  6. package/dist/i18n/index.d.ts +5 -0
  7. package/dist/i18n/zh-CN.d.ts +183 -0
  8. package/dist/o11y/execution-tree.d.ts +103 -0
  9. package/dist/o11y/otlp/parse.d.ts +3 -0
  10. package/dist/o11y/otlp/receiver.d.ts +21 -0
  11. package/dist/o11y/otlp/select.d.ts +22 -0
  12. package/dist/o11y/otlp/turn-otel.d.ts +43 -0
  13. package/dist/o11y/types.d.ts +190 -0
  14. package/dist/report/aggregate.d.ts +57 -0
  15. package/dist/report/aggregate.js +212 -0
  16. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +1 -0
  17. package/dist/report/built-ins/cost-pass-rate-comparison.js +17 -0
  18. package/dist/report/built-ins/index.d.ts +1 -0
  19. package/dist/report/built-ins/index.js +2 -0
  20. package/dist/report/components.d.ts +178 -0
  21. package/dist/report/components.js +132 -0
  22. package/dist/report/compute.d.ts +106 -0
  23. package/dist/report/compute.js +618 -0
  24. package/dist/report/flag.d.ts +6 -0
  25. package/dist/report/flag.js +8 -0
  26. package/dist/report/format.d.ts +32 -0
  27. package/dist/report/format.js +126 -0
  28. package/dist/report/index.d.ts +19 -0
  29. package/dist/report/index.js +30 -0
  30. package/dist/report/load.d.ts +12 -0
  31. package/dist/report/load.js +56 -0
  32. package/dist/report/locale.d.ts +83 -0
  33. package/dist/report/locale.js +160 -0
  34. package/dist/report/metrics.d.ts +21 -0
  35. package/dist/report/metrics.js +115 -0
  36. package/dist/report/primitives.d.ts +64 -0
  37. package/dist/report/primitives.js +113 -0
  38. package/dist/report/react/AttemptList.d.ts +23 -0
  39. package/dist/report/react/AttemptList.js +20 -0
  40. package/dist/report/react/DeltaTable.d.ts +8 -0
  41. package/dist/report/react/DeltaTable.js +24 -0
  42. package/dist/report/react/EvalList.d.ts +10 -0
  43. package/dist/report/react/EvalList.js +0 -0
  44. package/dist/report/react/ExperimentList.d.ts +10 -0
  45. package/dist/report/react/ExperimentList.js +15 -0
  46. package/dist/report/react/GroupSummary.d.ts +8 -0
  47. package/dist/report/react/GroupSummary.js +8 -0
  48. package/dist/report/react/MetricBars.d.ts +10 -0
  49. package/dist/report/react/MetricBars.js +48 -0
  50. package/dist/report/react/MetricLine.d.ts +9 -0
  51. package/dist/report/react/MetricLine.js +68 -0
  52. package/dist/report/react/MetricMatrix.d.ts +10 -0
  53. package/dist/report/react/MetricMatrix.js +23 -0
  54. package/dist/report/react/MetricScatter.d.ts +9 -0
  55. package/dist/report/react/MetricScatter.js +100 -0
  56. package/dist/report/react/MetricTable.d.ts +11 -0
  57. package/dist/report/react/MetricTable.js +26 -0
  58. package/dist/report/react/RunOverview.d.ts +8 -0
  59. package/dist/report/react/RunOverview.js +12 -0
  60. package/dist/report/react/Scoreboard.d.ts +8 -0
  61. package/dist/report/react/Scoreboard.js +26 -0
  62. package/dist/report/react/cell.d.ts +9 -0
  63. package/dist/report/react/cell.js +9 -0
  64. package/dist/report/react/chart-math.d.ts +14 -0
  65. package/dist/report/react/chart-math.js +68 -0
  66. package/dist/report/react/colors.d.ts +14 -0
  67. package/dist/report/react/colors.js +45 -0
  68. package/dist/report/react/fixtures.d.ts +19 -0
  69. package/dist/report/react/fixtures.js +393 -0
  70. package/dist/report/react/format.d.ts +3 -0
  71. package/dist/report/react/format.js +7 -0
  72. package/dist/report/react/index.d.ts +6 -0
  73. package/dist/report/react/index.js +14 -0
  74. package/dist/report/report.d.ts +24 -0
  75. package/dist/report/report.js +49 -0
  76. package/dist/report/text/faces.d.ts +29 -0
  77. package/dist/report/text/faces.js +503 -0
  78. package/dist/report/text/layout.d.ts +75 -0
  79. package/dist/report/text/layout.js +173 -0
  80. package/dist/report/text/plot.d.ts +37 -0
  81. package/dist/report/text/plot.js +81 -0
  82. package/dist/report/text/table.d.ts +8 -0
  83. package/dist/report/text/table.js +86 -0
  84. package/dist/report/tree.d.ts +99 -0
  85. package/dist/report/tree.js +228 -0
  86. package/dist/report/types.d.ts +420 -0
  87. package/dist/report/types.js +4 -0
  88. package/dist/report/web.d.ts +15 -0
  89. package/dist/report/web.js +37 -0
  90. package/dist/results/annotated-source.d.ts +61 -0
  91. package/dist/results/attempt-evidence.d.ts +69 -0
  92. package/dist/results/attempt-source.d.ts +15 -0
  93. package/dist/results/locator.d.ts +75 -0
  94. package/dist/results/locator.js +0 -0
  95. package/dist/results/select.d.ts +45 -0
  96. package/dist/results/select.js +230 -0
  97. package/dist/results/source-hash.d.ts +9 -0
  98. package/dist/results/types.d.ts +204 -0
  99. package/dist/runner/eval-source.d.ts +22 -0
  100. package/dist/runner/fingerprint.d.ts +23 -0
  101. package/dist/runner/types.d.ts +332 -0
  102. package/dist/sandbox/docker-stream.d.ts +24 -0
  103. package/dist/sandbox/docker.d.ts +84 -0
  104. package/dist/sandbox/e2b.d.ts +31 -0
  105. package/dist/sandbox/errors.d.ts +4 -0
  106. package/dist/sandbox/local-files.d.ts +6 -0
  107. package/dist/sandbox/paths.d.ts +4 -0
  108. package/dist/sandbox/registry.d.ts +13 -0
  109. package/dist/sandbox/resolve.d.ts +40 -0
  110. package/dist/sandbox/retry.d.ts +10 -0
  111. package/dist/sandbox/shell.d.ts +11 -0
  112. package/dist/sandbox/source-files.d.ts +13 -0
  113. package/dist/sandbox/types.d.ts +203 -0
  114. package/dist/sandbox/vercel.d.ts +37 -0
  115. package/dist/scoring/types.d.ts +70 -0
  116. package/dist/shared/aggregate.d.ts +29 -0
  117. package/dist/shared/aggregate.js +55 -0
  118. package/dist/shared/types.d.ts +27 -0
  119. package/dist/shared/verdict.d.ts +27 -0
  120. package/dist/shared/verdict.js +34 -0
  121. package/dist/tty-line.d.ts +6 -0
  122. package/dist/types.d.ts +7 -0
  123. package/dist/util.d.ts +22 -0
  124. package/docs-site/zh/concepts/adapter.mdx +5 -5
  125. package/docs-site/zh/concepts/assert.mdx +8 -8
  126. package/docs-site/zh/concepts/evals.mdx +5 -5
  127. package/docs-site/zh/concepts/experiment.mdx +2 -2
  128. package/docs-site/zh/concepts/judge.mdx +9 -9
  129. package/docs-site/zh/concepts/overview.mdx +2 -2
  130. package/docs-site/zh/concepts/tier.mdx +6 -6
  131. package/docs-site/zh/example/claude-code-codex-plugin.mdx +9 -5
  132. package/docs-site/zh/example/claude-code-codex-skill.mdx +12 -7
  133. package/docs-site/zh/guides/agent-feedback-loop.mdx +235 -78
  134. package/docs-site/zh/guides/connect-otel.mdx +3 -3
  135. package/docs-site/zh/guides/connect-your-agent.mdx +2 -2
  136. package/docs-site/zh/guides/custom-reports.mdx +196 -36
  137. package/docs-site/zh/guides/experiments.mdx +7 -1
  138. package/docs-site/zh/guides/fixtures.mdx +1 -1
  139. package/docs-site/zh/guides/official-adapters.mdx +31 -9
  140. package/docs-site/zh/guides/publish-report.mdx +3 -3
  141. package/docs-site/zh/guides/report-components.mdx +194 -55
  142. package/docs-site/zh/guides/reporters.mdx +2 -2
  143. package/docs-site/zh/guides/results-data.mdx +35 -34
  144. package/docs-site/zh/guides/runner.mdx +2 -2
  145. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  146. package/docs-site/zh/guides/{sandbox-backends.mdx → sandbox-providers.mdx} +11 -11
  147. package/docs-site/zh/guides/viewing-results.mdx +199 -79
  148. package/docs-site/zh/guides/write-experiment.mdx +9 -1
  149. package/docs-site/zh/guides/write-send.mdx +2 -2
  150. package/docs-site/zh/index.mdx +4 -3
  151. package/docs-site/zh/introduction.mdx +2 -1
  152. package/docs-site/zh/quickstart.mdx +5 -3
  153. package/docs-site/zh/reference/builtin-agents.mdx +38 -13
  154. package/docs-site/zh/reference/capabilities.mdx +1 -1
  155. package/docs-site/zh/reference/cli.mdx +36 -13
  156. package/docs-site/zh/reference/define-agent.mdx +12 -12
  157. package/docs-site/zh/reference/define-config.mdx +1 -1
  158. package/docs-site/zh/reference/define-eval.mdx +3 -3
  159. package/docs-site/zh/reference/events.mdx +7 -1
  160. package/docs-site/zh/reference/expect.mdx +3 -3
  161. package/package.json +10 -6
  162. package/src/agents/bub.ts +129 -42
  163. package/src/agents/claude-code.test.ts +152 -0
  164. package/src/agents/claude-code.ts +127 -13
  165. package/src/agents/codex.test.ts +192 -0
  166. package/src/agents/codex.ts +151 -12
  167. package/src/agents/index.ts +9 -3
  168. package/src/agents/manifest.ts +41 -0
  169. package/src/agents/plugin-config.test.ts +94 -0
  170. package/src/agents/shared.ts +33 -2
  171. package/src/agents/skills.test.ts +214 -0
  172. package/src/agents/skills.ts +304 -0
  173. package/src/agents/streaming.ts +5 -5
  174. package/src/agents/types.ts +54 -2
  175. package/src/cli.ts +74 -47
  176. package/src/context/context.test.ts +20 -2
  177. package/src/context/control-flow.ts +1 -1
  178. package/src/context/session.test.ts +92 -1
  179. package/src/context/session.ts +23 -1
  180. package/src/context/types.ts +4 -4
  181. package/src/define.test.ts +8 -8
  182. package/src/define.ts +11 -11
  183. package/src/expect/index.ts +5 -5
  184. package/src/i18n/en.ts +34 -15
  185. package/src/i18n/zh-CN.ts +37 -21
  186. package/src/index.ts +1 -1
  187. package/src/o11y/execution-tree.test.ts +452 -0
  188. package/src/o11y/execution-tree.ts +367 -0
  189. package/src/o11y/otlp/receiver.ts +1 -1
  190. package/src/o11y/otlp/select.ts +7 -3
  191. package/src/o11y/parsers/claude-code.test.ts +175 -0
  192. package/src/o11y/parsers/claude-code.ts +32 -0
  193. package/src/o11y/types.ts +6 -1
  194. package/src/report/aggregate.ts +26 -20
  195. package/src/report/built-in-user-parity.test.tsx +643 -0
  196. package/src/report/built-ins/cost-pass-rate-comparison.tsx +23 -0
  197. package/src/report/built-ins/index.ts +2 -0
  198. package/src/report/components.tsx +223 -50
  199. package/src/report/compute.ts +358 -53
  200. package/src/report/dual-render.test.tsx +1125 -0
  201. package/src/report/flag.ts +1 -1
  202. package/src/report/format.ts +50 -2
  203. package/src/report/index.ts +48 -14
  204. package/src/report/load.ts +1 -1
  205. package/src/report/locale.ts +206 -0
  206. package/src/report/metrics.ts +39 -15
  207. package/src/report/primitives.tsx +100 -2
  208. package/src/report/react/AttemptList.tsx +115 -0
  209. package/src/report/react/DeltaTable.tsx +9 -6
  210. package/src/report/react/EvalList.tsx +0 -0
  211. package/src/report/react/ExperimentList.tsx +108 -0
  212. package/src/report/react/GroupSummary.tsx +66 -0
  213. package/src/report/react/MetricBars.tsx +24 -13
  214. package/src/report/react/MetricLine.tsx +21 -21
  215. package/src/report/react/MetricMatrix.tsx +8 -4
  216. package/src/report/react/MetricScatter.tsx +136 -96
  217. package/src/report/react/MetricTable.tsx +115 -15
  218. package/src/report/react/RunOverview.tsx +48 -26
  219. package/src/report/react/Scoreboard.tsx +15 -7
  220. package/src/report/react/cell.tsx +18 -9
  221. package/src/{view/app/lib/chart.ts → report/react/chart-math.ts} +2 -6
  222. package/src/report/react/colors.ts +24 -15
  223. package/src/report/react/enhance.js +129 -0
  224. package/src/report/react/fixtures.ts +163 -34
  225. package/src/report/react/format.ts +1 -1
  226. package/src/report/react/index.tsx +22 -6
  227. package/src/report/react/render.test.tsx +164 -31
  228. package/src/report/react/styles.css +405 -213
  229. package/src/report/report.test.ts +403 -94
  230. package/src/report/report.ts +29 -11
  231. package/src/report/text/faces.ts +303 -95
  232. package/src/report/text/layout.ts +76 -11
  233. package/src/report/text/table.ts +98 -0
  234. package/src/report/tree.ts +103 -20
  235. package/src/report/types.ts +196 -25
  236. package/src/report/web.ts +46 -19
  237. package/src/results/annotated-source.test.ts +146 -0
  238. package/src/results/annotated-source.ts +128 -0
  239. package/src/results/attempt-evidence.test.ts +158 -0
  240. package/src/results/attempt-evidence.ts +158 -0
  241. package/src/results/attempt-source.ts +48 -0
  242. package/src/results/copy.ts +154 -139
  243. package/src/results/format.ts +54 -47
  244. package/src/results/host-equivalence.test.ts +527 -0
  245. package/src/results/index.ts +56 -14
  246. package/src/results/locator.test.ts +198 -0
  247. package/src/results/locator.ts +0 -0
  248. package/src/results/open.ts +392 -175
  249. package/src/results/results.test.ts +943 -494
  250. package/src/results/select.ts +129 -23
  251. package/src/results/skipped-notice.ts +0 -0
  252. package/src/results/source-hash.ts +28 -0
  253. package/src/results/types.ts +98 -62
  254. package/src/results/writer.ts +304 -176
  255. package/src/runner/attempt.test.ts +170 -0
  256. package/src/runner/attempt.ts +48 -13
  257. package/src/runner/discover.test.ts +65 -0
  258. package/src/runner/discover.ts +6 -2
  259. package/src/runner/eval-source.test.ts +121 -0
  260. package/src/runner/eval-source.ts +45 -0
  261. package/src/runner/fingerprint.ts +54 -1
  262. package/src/runner/remote-sandbox.ts +1 -1
  263. package/src/runner/report.test.ts +6 -8
  264. package/src/runner/report.ts +5 -4
  265. package/src/runner/reporters/artifacts.ts +27 -41
  266. package/src/runner/reporters/braintrust.test.ts +3 -3
  267. package/src/runner/reporters/braintrust.ts +1 -1
  268. package/src/runner/reporters/console.ts +6 -6
  269. package/src/runner/reporters/json.ts +4 -4
  270. package/src/runner/reporters/live.test.ts +56 -0
  271. package/src/runner/reporters/live.ts +36 -10
  272. package/src/runner/reporters/quiet.test.ts +66 -0
  273. package/src/runner/reporters/quiet.ts +49 -0
  274. package/src/runner/reporters/shared.ts +5 -5
  275. package/src/runner/reporters/table.ts +17 -17
  276. package/src/runner/run.ts +61 -102
  277. package/src/runner/types.ts +43 -32
  278. package/src/sandbox/checkpoint.ts +1 -1
  279. package/src/sandbox/docker.ts +15 -2
  280. package/src/sandbox/e2b.ts +11 -5
  281. package/src/sandbox/errors.ts +13 -0
  282. package/src/sandbox/index.ts +3 -3
  283. package/src/sandbox/local-files.ts +1 -1
  284. package/src/sandbox/registry.ts +6 -5
  285. package/src/sandbox/resolve.ts +38 -23
  286. package/src/sandbox/retry.test.ts +68 -0
  287. package/src/sandbox/retry.ts +44 -0
  288. package/src/sandbox/types.ts +24 -24
  289. package/src/sandbox/vercel.ts +17 -3
  290. package/src/scoring/collector.ts +3 -3
  291. package/src/scoring/judge.ts +2 -2
  292. package/src/scoring/scoped.ts +13 -1
  293. package/src/scoring/types.ts +7 -7
  294. package/src/scoring/verdict.ts +4 -4
  295. package/src/shared/aggregate.ts +23 -3
  296. package/src/shared/types.ts +2 -2
  297. package/src/shared/{outcome.ts → verdict.ts} +15 -15
  298. package/src/show/compose.ts +12 -132
  299. package/src/show/index.ts +125 -94
  300. package/src/show/render.ts +660 -160
  301. package/src/show/show.test.ts +472 -253
  302. package/src/source-loc.ts +1 -1
  303. package/src/tty-line.ts +32 -0
  304. package/src/types.ts +2 -2
  305. package/src/util.test.ts +21 -1
  306. package/src/util.ts +5 -1
  307. package/src/view/app/App.tsx +33 -198
  308. package/src/view/app/components/AttemptModal.tsx +6 -6
  309. package/src/view/app/components/CodeView.tsx +2 -2
  310. package/src/view/app/components/CopyControls.tsx +32 -36
  311. package/src/view/app/components/SkippedRunsBanner.tsx +140 -0
  312. package/src/view/app/components/ui/badge.tsx +1 -1
  313. package/src/view/app/components/ui/dialog.tsx +2 -2
  314. package/src/view/app/i18n.ts +40 -31
  315. package/src/view/app/lib/artifact-url.ts +1 -1
  316. package/src/view/app/lib/attempt-route.test.ts +37 -47
  317. package/src/view/app/lib/attempt-route.ts +26 -26
  318. package/src/view/app/lib/rows.ts +9 -112
  319. package/src/view/app/lib/verdict.ts +25 -0
  320. package/src/view/app/main.tsx +9 -12
  321. package/src/view/app/pages/RunsPage.tsx +4 -4
  322. package/src/view/app/pages/TracesPage.tsx +3 -3
  323. package/src/view/app/shared.ts +0 -1
  324. package/src/view/app/types.ts +6 -51
  325. package/src/view/artifact-serving.test.ts +136 -0
  326. package/src/view/client-dist/app.css +1 -1
  327. package/src/view/client-dist/app.js +21 -21
  328. package/src/view/data.test.ts +214 -143
  329. package/src/view/data.ts +166 -120
  330. package/src/view/index.ts +23 -11
  331. package/src/view/server.ts +52 -17
  332. package/src/view/shared/types.ts +22 -31
  333. package/src/view/styles.css +117 -327
  334. package/src/view/view-report.test.ts +146 -98
  335. package/src/report/default-report.tsx +0 -222
  336. package/src/report/dual-face.test.tsx +0 -527
  337. package/src/report/react/CaseList.tsx +0 -70
  338. package/src/view/app/components/CostScoreChart.tsx +0 -147
  339. package/src/view/app/components/ExperimentTable.tsx +0 -284
  340. package/src/view/app/components/GroupSelector.tsx +0 -61
  341. package/src/view/app/components/primitives.tsx +0 -43
  342. package/src/view/app/lib/outcome.ts +0 -82
@@ -0,0 +1,527 @@
1
+ // show / view 宿主等价契约(设计契约:plan/show-view-equivalence.md 完成定义;
2
+ // docs/feature/reports/architecture.md「Selection 是计算入口」;docs/feature/results/library.md「选择快照」)。
3
+ //
4
+ // 守护的不变量:同一结果根、同一组范围参数下,两扇门(niceeval show 的 text 面、
5
+ // niceeval view 的 web 面)的报告槽收到同一份现刻水位 Selection,并按同一公式算出同一批事实。
6
+ //
7
+ // 分三层验:
8
+ // 1. 结构化身份契约 —— 直接对两个宿主共同调用的选择入口 selectCurrentResults(results, scope)
9
+ // 断言归一化后的 Selection(experiment 集 / 每 experiment 的 eval 集 / 每 eval 的 attempt 原始身份
10
+ // via AttemptRef.snapshot+attempt / warnings 的 kind + 结构字段)。这是最直接的契约对象:
11
+ // 两个宿主都调这一个函数、传同形状的 scope({ experiment, patterns }),它对了两扇门就对。
12
+ // 2. 宿主接线冒烟 —— 用真实 runShow(...) 与 loadViewScan(...) 跑同一 fixture + scope,断言两条
13
+ // 真实渲染路径反映同一批事实(同一 experiment / eval / 通过率 / 警告在/不在),证明两扇门确实
14
+ // 接在共享选择器上,而不是碰巧一致。轻断言渲染文案,不做整段 snapshot(排版差异是预期)。
15
+ // 3. 默认报告计算事实对照 —— 即使 Selection 相同,也要 text 面与 web 面算出同一通过率、同一警告
16
+ // 横幅在/不在,防止某一宿主在下游偷换公式或吞掉 warning。
17
+ //
18
+ // fixture 直接写新布局(<expDir>/<snapDir>/snapshot.json + <evalId>/a<n>/result.json),
19
+ // 依据 docs/feature/results/architecture.md 的稳定磁盘契约(与 show.test.ts / view/data.test.ts 同一写法)。
20
+
21
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
22
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { openResults } from "./index.ts";
26
+ import type { Selection, SelectionWarning } from "./index.ts";
27
+ import { selectCurrentResults, type ResultScope } from "./select.ts";
28
+ import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION, type EvalResult, type Verdict } from "../types.ts";
29
+ import { runShow, type ShowFlags } from "../show/index.ts";
30
+ import { loadViewScan, type ViewScanOptions } from "../view/data.ts";
31
+
32
+ // ───────────────────────── fixture 工具 ─────────────────────────
33
+
34
+ const roots: string[] = [];
35
+ async function makeRoot(): Promise<string> {
36
+ const root = await mkdtemp(join(tmpdir(), "niceeval-equiv-"));
37
+ roots.push(root);
38
+ return root;
39
+ }
40
+ afterEach(async () => {
41
+ await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
42
+ });
43
+
44
+ // 报告 chrome 跟随 CLI 界面语言(detectLocale);固定 en 让文案断言不随宿主机 LANG 漂移。
45
+ let langBackup: string | undefined;
46
+ beforeAll(() => {
47
+ langBackup = process.env.NICEEVAL_LANG;
48
+ process.env.NICEEVAL_LANG = "en";
49
+ });
50
+ afterAll(() => {
51
+ if (langBackup === undefined) delete process.env.NICEEVAL_LANG;
52
+ else process.env.NICEEVAL_LANG = langBackup;
53
+ });
54
+
55
+ type AttemptFixture = Pick<EvalResult, "id" | "verdict"> &
56
+ Partial<Pick<EvalResult, "attempt" | "durationMs" | "assertions" | "estimatedCostUSD" | "usage" | "startedAt" | "artifactBase" | "hasEvents">>;
57
+
58
+ function res(id: string, verdict: Verdict, extra: Partial<AttemptFixture> = {}): AttemptFixture {
59
+ return { id, verdict, attempt: 0, durationMs: 1000, assertions: [], ...extra };
60
+ }
61
+
62
+ /** 实验目录名清洗:与 docs/feature/results/architecture.md 一致(/ 与非 [\w.@-] 换成 _)。 */
63
+ function cleanDirName(id: string): string {
64
+ return id.replace(/[^\w.@-]/g, "_");
65
+ }
66
+
67
+ interface SnapshotOpts {
68
+ experimentId: string;
69
+ agent?: string;
70
+ model?: string;
71
+ startedAt: string;
72
+ /** 缺省 = 已收尾(completedAt = startedAt);置 true 则不写 completedAt,模拟中断快照。 */
73
+ unfinished?: boolean;
74
+ knownEvalIds?: string[];
75
+ }
76
+
77
+ /** 写一份新布局快照:snapshot.json + 各 attempt 的 result.json。返回快照目录绝对路径。 */
78
+ async function writeSnapshot(
79
+ root: string,
80
+ snapDirName: string,
81
+ opts: SnapshotOpts,
82
+ results: AttemptFixture[],
83
+ ): Promise<string> {
84
+ const dir = join(root, cleanDirName(opts.experimentId), snapDirName);
85
+ await mkdir(dir, { recursive: true });
86
+ const meta = {
87
+ format: RESULTS_FORMAT,
88
+ schemaVersion: RESULTS_SCHEMA_VERSION,
89
+ producer: { name: "niceeval", version: "0.4.6" },
90
+ experimentId: opts.experimentId,
91
+ agent: opts.agent ?? "bub",
92
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
93
+ startedAt: opts.startedAt,
94
+ ...(opts.unfinished ? {} : { completedAt: opts.startedAt }),
95
+ ...(opts.knownEvalIds ? { knownEvalIds: opts.knownEvalIds } : {}),
96
+ };
97
+ await writeFile(join(dir, "snapshot.json"), JSON.stringify(meta, null, 2), "utf-8");
98
+ for (const r of results) {
99
+ const attemptDir = join(dir, r.id, `a${r.attempt ?? 0}`);
100
+ await mkdir(attemptDir, { recursive: true });
101
+ await writeFile(join(attemptDir, "result.json"), JSON.stringify(r, null, 2), "utf-8");
102
+ }
103
+ return dir;
104
+ }
105
+
106
+ // ───────────────────────── Selection 身份归一化 helper(测试专用) ─────────────────────────
107
+ //
108
+ // 生产逻辑保证的稳定顺序原样保留(evals 已按 id 排序、attempts 按 a<n> 读入顺序);helper 不再排序,
109
+ // 以免掩盖生产代码可能的不确定顺序。时间 / 成本 / verdict 保留真值;宿主机绝对路径(unfinished
110
+ // 警告的 dir、快照 dir)不进归一化结果 —— attempt 身份一律走 AttemptRef.snapshot + attempt(根相对)。
111
+
112
+ interface NormAttempt {
113
+ snapshot: string;
114
+ attempt: string;
115
+ verdict: Verdict;
116
+ }
117
+ interface NormEval {
118
+ evalId: string;
119
+ attempts: NormAttempt[];
120
+ }
121
+ interface NormExperiment {
122
+ experimentId: string;
123
+ evals: NormEval[];
124
+ }
125
+ type NormWarning =
126
+ | { kind: "partial-coverage"; experimentId: string; covered: number; total: number }
127
+ | { kind: "stale-snapshot"; experimentId: string; startedAt: string; latestStartedAt: string }
128
+ | { kind: "unfinished-snapshot"; experimentId: string; startedAt: string };
129
+ interface NormSelection {
130
+ warnings: NormWarning[];
131
+ experiments: NormExperiment[];
132
+ }
133
+
134
+ function normalizeWarning(w: SelectionWarning): NormWarning {
135
+ switch (w.kind) {
136
+ case "partial-coverage":
137
+ return { kind: w.kind, experimentId: w.experimentId, covered: w.covered, total: w.total };
138
+ case "stale-snapshot":
139
+ return { kind: w.kind, experimentId: w.experimentId, startedAt: w.startedAt, latestStartedAt: w.latestStartedAt };
140
+ case "unfinished-snapshot":
141
+ // dir 是宿主机绝对路径,归一化掉;身份靠 experimentId + startedAt。
142
+ return { kind: w.kind, experimentId: w.experimentId, startedAt: w.startedAt };
143
+ }
144
+ }
145
+
146
+ function normalizeSelection(selection: Selection): NormSelection {
147
+ return {
148
+ warnings: selection.warnings.map(normalizeWarning),
149
+ experiments: selection.snapshots.map((snapshot) => ({
150
+ experimentId: snapshot.experimentId,
151
+ evals: snapshot.evals.map((ev) => ({
152
+ evalId: ev.id,
153
+ attempts: ev.attempts.map((a) => ({
154
+ snapshot: a.ref.snapshot,
155
+ attempt: a.ref.attempt,
156
+ verdict: a.result.verdict,
157
+ })),
158
+ })),
159
+ })),
160
+ };
161
+ }
162
+
163
+ // ───────────────────────── 宿主运行封装 ─────────────────────────
164
+
165
+ /** show 的 text 面:与 CLI 同一调用面(runShow),报告槽输出捕获成字符串。 */
166
+ async function showText(root: string, patterns: string[], flags: ShowFlags = {}): Promise<string> {
167
+ let out = "";
168
+ let err = "";
169
+ const code = await runShow(root, patterns, { run: root, ...flags }, {
170
+ out: (s) => (out += s),
171
+ err: (s) => (err += s),
172
+ width: 120,
173
+ now: Date.parse("2026-07-09T10:01:00.000Z"),
174
+ });
175
+ if (code !== 0) throw new Error(`runShow exited ${code}: ${err}`);
176
+ return out;
177
+ }
178
+
179
+ /** view 的 web 面:loadViewScan → 报告槽 HTML(en)。 */
180
+ async function viewHtml(root: string, opts: ViewScanOptions = {}): Promise<string> {
181
+ const { reportHtml } = await loadViewScan(root, opts);
182
+ return reportHtml.en;
183
+ }
184
+
185
+ /** 两个宿主构造给选择器的 scope 完全同形:验证读源无误,避免"我以为它们一样"。 */
186
+ function hostScope(patterns: string[], experiment?: string): ResultScope {
187
+ return { experiment, patterns };
188
+ }
189
+
190
+ // ══════════════════════════════════════════════════════════════════════════
191
+ // 第 1 层:selectCurrentResults 结构化身份契约(11 必测场景中的选择器可判定部分)
192
+ // ══════════════════════════════════════════════════════════════════════════
193
+
194
+ describe("selectCurrentResults · 现刻水位结构化身份", () => {
195
+ it("场景1 单 experiment / 单快照 / 单 attempt", async () => {
196
+ const root = await makeRoot();
197
+ await writeSnapshot(root, "2026-07-01T00-00-00-000Z", { experimentId: "solo/bub", startedAt: "2026-07-01T00:00:00.000Z" }, [
198
+ res("q1", "passed"),
199
+ ]);
200
+ const results = await openResults(root);
201
+ expect(normalizeSelection(selectCurrentResults(results))).toEqual({
202
+ warnings: [],
203
+ experiments: [
204
+ {
205
+ experimentId: "solo/bub",
206
+ evals: [{ evalId: "q1", attempts: [{ snapshot: "solo_bub/2026-07-01T00-00-00-000Z", attempt: "q1/a0", verdict: "passed" }] }],
207
+ },
208
+ ],
209
+ } satisfies NormSelection);
210
+ });
211
+
212
+ it("场景2 全量快照后局部补跑一个 eval:q1 取周二、q2 从周一补齐,无伪残缺", async () => {
213
+ const root = await seedPartialRerun();
214
+ const results = await openResults(root);
215
+ expect(normalizeSelection(selectCurrentResults(results))).toEqual({
216
+ warnings: [],
217
+ experiments: [
218
+ {
219
+ experimentId: "compare/bub",
220
+ evals: [
221
+ // q1 来自周二快照(局部补跑),q2 来自周一全量快照(补齐)—— 深链各指各的物理 run。
222
+ { evalId: "q1", attempts: [{ snapshot: "compare_bub/2026-07-02T08-00-00-000Z", attempt: "q1/a0", verdict: "passed" }] },
223
+ { evalId: "q2", attempts: [{ snapshot: "compare_bub/2026-07-01T08-00-00-000Z", attempt: "q2/a0", verdict: "failed" }] },
224
+ ],
225
+ },
226
+ ],
227
+ } satisfies NormSelection);
228
+ // 对照:results.latest() 只挑周二快照,是残缺的(这正是宿主要合成现刻水位的原因)。
229
+ expect(results.latest().warnings.some((w) => w.kind === "partial-coverage")).toBe(true);
230
+ });
231
+
232
+ it("场景3 同一 eval 多 attempts:最新快照整批替换旧 attempts,不跨快照混装", async () => {
233
+ const root = await makeRoot();
234
+ await writeSnapshot(root, "2026-07-01T08-00-00-000Z", { experimentId: "retry/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [
235
+ res("q1", "passed", { attempt: 0 }),
236
+ ]);
237
+ await writeSnapshot(root, "2026-07-02T08-00-00-000Z", { experimentId: "retry/bub", startedAt: "2026-07-02T08:00:00.000Z" }, [
238
+ res("q1", "failed", { attempt: 0 }),
239
+ res("q1", "passed", { attempt: 1 }),
240
+ ]);
241
+ const results = await openResults(root);
242
+ const norm = normalizeSelection(selectCurrentResults(results));
243
+ // q1 整批取自周二(两个 attempt 都在周二快照),周一的那次 attempt 不掺进来。
244
+ expect(norm.experiments[0].evals).toEqual([
245
+ {
246
+ evalId: "q1",
247
+ attempts: [
248
+ { snapshot: "retry_bub/2026-07-02T08-00-00-000Z", attempt: "q1/a0", verdict: "failed" },
249
+ { snapshot: "retry_bub/2026-07-02T08-00-00-000Z", attempt: "q1/a1", verdict: "passed" },
250
+ ],
251
+ },
252
+ ] satisfies NormEval[]);
253
+ expect(norm.warnings).toEqual([]);
254
+ });
255
+
256
+ it("场景4 多 experiment 更新时间不同:较早的实验触发 stale-snapshot", async () => {
257
+ const root = await makeRoot();
258
+ await writeSnapshot(root, "2026-07-01T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [
259
+ res("q1", "passed"),
260
+ ]);
261
+ await writeSnapshot(root, "2026-07-03T08-00-00-000Z", { experimentId: "compare/codex", agent: "codex", startedAt: "2026-07-03T08:00:00.000Z" }, [
262
+ res("q1", "passed"),
263
+ ]);
264
+ const results = await openResults(root);
265
+ const norm = normalizeSelection(selectCurrentResults(results));
266
+ expect(norm.warnings).toEqual([
267
+ { kind: "stale-snapshot", experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z", latestStartedAt: "2026-07-03T08:00:00.000Z" },
268
+ ] satisfies NormWarning[]);
269
+ expect(norm.experiments.map((e) => e.experimentId)).toEqual(["compare/bub", "compare/codex"]);
270
+ });
271
+
272
+ it("场景5 未完成快照(无 completedAt):触发 unfinished-snapshot", async () => {
273
+ const root = await makeRoot();
274
+ await writeSnapshot(root, "2026-07-01T00-00-00-000Z", { experimentId: "solo/bub", startedAt: "2026-07-01T00:00:00.000Z", unfinished: true }, [
275
+ res("q1", "passed"),
276
+ ]);
277
+ const results = await openResults(root);
278
+ expect(normalizeSelection(selectCurrentResults(results)).warnings).toEqual([
279
+ { kind: "unfinished-snapshot", experimentId: "solo/bub", startedAt: "2026-07-01T00:00:00.000Z" },
280
+ ] satisfies NormWarning[]);
281
+ });
282
+
283
+ it("场景6 历史已知 eval 从未有可读结果:触发真实 partial-coverage", async () => {
284
+ const root = await makeRoot();
285
+ // knownEvalIds 声明 q1 与 q2,但 q2 从未落盘 —— 跨快照补齐后仍缺,这是真残缺。
286
+ await writeSnapshot(
287
+ root,
288
+ "2026-07-01T08-00-00-000Z",
289
+ { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z", knownEvalIds: ["q1", "q2"] },
290
+ [res("q1", "passed")],
291
+ );
292
+ const results = await openResults(root);
293
+ const norm = normalizeSelection(selectCurrentResults(results));
294
+ expect(norm.experiments[0].evals.map((e) => e.evalId)).toEqual(["q1"]);
295
+ expect(norm.warnings).toEqual([
296
+ { kind: "partial-coverage", experimentId: "compare/bub", covered: 1, total: 2 },
297
+ ] satisfies NormWarning[]);
298
+ });
299
+
300
+ it("场景7 eval id 前缀过滤:覆盖分母同步收窄到范围内", async () => {
301
+ const root = await makeRoot();
302
+ await writeSnapshot(
303
+ root,
304
+ "2026-07-01T08-00-00-000Z",
305
+ {
306
+ experimentId: "compare/bub",
307
+ startedAt: "2026-07-01T08:00:00.000Z",
308
+ // 已知并集:weather 两题 + 一道范围外的 algebra。
309
+ knownEvalIds: ["weather/brooklyn", "weather/queens", "algebra/quadratic"],
310
+ },
311
+ [res("weather/brooklyn", "passed"), res("algebra/quadratic", "passed")],
312
+ );
313
+ const results = await openResults(root);
314
+
315
+ const weather = normalizeSelection(selectCurrentResults(results, hostScope(["weather"])));
316
+ expect(weather.experiments[0].evals.map((e) => e.evalId)).toEqual(["weather/brooklyn"]);
317
+ // 分母 = {weather/brooklyn, weather/queens} ∩ 范围 = 2,缺 queens → 1/2;algebra 的缺口不进来。
318
+ expect(weather.warnings).toEqual([
319
+ { kind: "partial-coverage", experimentId: "compare/bub", covered: 1, total: 2 },
320
+ ] satisfies NormWarning[]);
321
+
322
+ // algebra 范围:该题有结果,范围内无缺口 → 不刷 weather 的残缺屏。
323
+ const algebra = normalizeSelection(selectCurrentResults(results, hostScope(["algebra"])));
324
+ expect(algebra.warnings).toEqual([]);
325
+ });
326
+
327
+ it("场景8 --experiment 分段前缀过滤:只留匹配段,不误配同前缀实验", async () => {
328
+ const root = await makeRoot();
329
+ await writeSnapshot(root, "2026-07-01T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [res("q1", "passed")]);
330
+ await writeSnapshot(root, "2026-07-01T09-00-00-000Z", { experimentId: "compare/codex", agent: "codex", startedAt: "2026-07-01T09:00:00.000Z" }, [res("q1", "passed")]);
331
+ await writeSnapshot(root, "2026-07-01T10-00-00-000Z", { experimentId: "solo/bub", startedAt: "2026-07-01T10:00:00.000Z" }, [res("q1", "passed")]);
332
+ const results = await openResults(root);
333
+ const norm = normalizeSelection(selectCurrentResults(results, hostScope([], "compare")));
334
+ // "compare" 分段前缀匹配 compare/bub、compare/codex,不含 solo/bub。
335
+ expect(norm.experiments.map((e) => e.experimentId)).toEqual(["compare/bub", "compare/codex"]);
336
+ });
337
+
338
+ it("场景9 --run 指向单个结果根:选择器只看该根的实验,不串到另一个根", async () => {
339
+ const rootA = await makeRoot();
340
+ const rootB = await makeRoot();
341
+ // 失败 eval 让 id 现于两面(通过 eval 只进折叠子行,不便断言);qa / qb 各根独有。
342
+ const boom = { assertions: [{ name: "succeeded()", severity: "gate" as const, score: 0, passed: false, detail: "boom" }] };
343
+ await writeSnapshot(rootA, "2026-07-01T08-00-00-000Z", { experimentId: "onlyA/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [res("qa", "failed", boom)]);
344
+ await writeSnapshot(rootB, "2026-07-02T08-00-00-000Z", { experimentId: "onlyB/bub", startedAt: "2026-07-02T08:00:00.000Z" }, [res("qb", "failed", boom)]);
345
+ const normB = normalizeSelection(selectCurrentResults(await openResults(rootB)));
346
+ expect(normB.experiments.map((e) => e.experimentId)).toEqual(["onlyB/bub"]);
347
+ // 宿主接线:show --run rootB / view rootB 只反映 rootB(qb 在、qa 不在)。
348
+ const text = await showText(rootB, []);
349
+ expect(text).toContain("qb");
350
+ expect(text).not.toContain("qa");
351
+ const html = await viewHtml(rootB);
352
+ expect(html).toContain("qb");
353
+ expect(html).not.toContain("qa");
354
+ });
355
+
356
+ it("场景11 resume 携带的复印件不重复计票,证据 ref 仍指向可读 artifact", async () => {
357
+ const root = await makeRoot();
358
+ // 周一原始:q1 通过,带 events artifact。
359
+ const oldDir = await writeSnapshot(root, "2026-07-01T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [
360
+ res("q1", "passed", { hasEvents: true }),
361
+ ]);
362
+ await writeFile(join(oldDir, "q1", "a0", "events.json"), "[]", "utf-8");
363
+ // 周二 resume:q1 是复印件(startedAt 锚原快照,artifactBase 指原快照 artifact),q2 是新题。
364
+ await writeSnapshot(root, "2026-07-02T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-02T08:00:00.000Z" }, [
365
+ res("q1", "passed", { hasEvents: true, startedAt: "2026-07-01T08:00:00.000Z", artifactBase: "compare_bub/2026-07-01T08-00-00-000Z/q1/a0" }),
366
+ res("q2", "passed"),
367
+ ]);
368
+ const results = await openResults(root);
369
+ const selection = selectCurrentResults(results);
370
+ const norm = normalizeSelection(selection);
371
+ // q1 整批取自周二(含它的最新快照 = 复印件那份),只出现一次;不因为它也活在周一而计两票。
372
+ expect(norm.experiments[0].evals).toEqual([
373
+ { evalId: "q1", attempts: [{ snapshot: "compare_bub/2026-07-02T08-00-00-000Z", attempt: "q1/a0", verdict: "passed" }] },
374
+ { evalId: "q2", attempts: [{ snapshot: "compare_bub/2026-07-02T08-00-00-000Z", attempt: "q2/a0", verdict: "passed" }] },
375
+ ] satisfies NormEval[]);
376
+ expect(norm.warnings).toEqual([]);
377
+ // 证据 ref 可达:复印件的 artifactBase 回退到原快照,events.json 仍读得到(非 null)。
378
+ const q1 = selection.snapshots[0].evals.find((e) => e.id === "q1")!;
379
+ expect(await q1.attempts[0].events()).not.toBeNull();
380
+ });
381
+ });
382
+
383
+ // ══════════════════════════════════════════════════════════════════════════
384
+ // 第 2 层:两个宿主接线同一选择器(真实 runShow / loadViewScan 反映同一批事实)
385
+ // ══════════════════════════════════════════════════════════════════════════
386
+
387
+ /** 周一全量(q1 通过、q2 失败)+ 周二只补跑 q1(仍通过):现刻水位 = q1 周二 + q2 周一,50%。 */
388
+ async function seedPartialRerun(): Promise<string> {
389
+ const root = await makeRoot();
390
+ await writeSnapshot(root, "2026-07-01T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [
391
+ res("q1", "passed"),
392
+ res("q2", "failed", { assertions: [{ name: 'fileChanged("q2.tsx")', severity: "gate", score: 0, passed: false, detail: "file was not modified" }] }),
393
+ ]);
394
+ await writeSnapshot(root, "2026-07-02T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-02T08:00:00.000Z" }, [
395
+ res("q1", "passed"),
396
+ ]);
397
+ return root;
398
+ }
399
+
400
+ /** partial-coverage:knownEvalIds 声明 q2 但从未落盘;q1 失败让失败 eval id 现于两面。 */
401
+ async function seedPartialCoverage(): Promise<string> {
402
+ const root = await makeRoot();
403
+ await writeSnapshot(
404
+ root,
405
+ "2026-07-01T08-00-00-000Z",
406
+ { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z", knownEvalIds: ["q1", "q2"] },
407
+ [res("q1", "failed", { assertions: [{ name: "succeeded()", severity: "gate", score: 0, passed: false, detail: "boom" }] })],
408
+ );
409
+ return root;
410
+ }
411
+
412
+ describe("宿主接线 · show text 面与 view web 面反映同一批事实", () => {
413
+ it("局部补跑:两扇门都从周一补齐 q2,通过率 50%,不出伪残缺警告", async () => {
414
+ const root = await seedPartialRerun();
415
+ const text = await showText(root, []);
416
+ const html = await viewHtml(root);
417
+ for (const face of [text, html]) {
418
+ expect(face).toContain("compare/bub");
419
+ expect(face).toContain("q2"); // 周一补齐的失败 eval,若宿主回退 results.latest() 就会消失
420
+ }
421
+ expect(text).toContain("1 passed · 1 failed");
422
+ expect(html).toContain("50%");
423
+ // 现刻水位覆盖齐全 → 两面都不出 partial-coverage(text: "verdicts cover"; html: data-kind)。
424
+ expect(hasPartialCoverageText(text)).toBe(false);
425
+ expect(hasPartialCoverageHtml(html)).toBe(false);
426
+ // 直接对照选择器口径:两个宿主传的 scope 同形,选择器给出的正是这份现刻水位。
427
+ const norm = normalizeSelection(selectCurrentResults(await openResults(root), hostScope([])));
428
+ expect(norm.experiments[0].evals.map((e) => e.evalId)).toEqual(["q1", "q2"]);
429
+ });
430
+
431
+ it("位置前缀收窄:两扇门都只见范围内的 eval,范围外一致排除", async () => {
432
+ const root = await makeRoot();
433
+ await writeSnapshot(root, "2026-07-01T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [
434
+ res("weather/brooklyn", "failed", { assertions: [{ name: "succeeded()", severity: "gate", score: 0, passed: false, detail: "boom" }] }),
435
+ res("weather/queens", "passed"),
436
+ res("algebra/quadratic", "passed"),
437
+ ]);
438
+ // "weather" 前缀匹配 2 题 → show 仍走报告槽(非单 eval 详情)。
439
+ const text = await showText(root, ["weather"]);
440
+ const html = await viewHtml(root, { patterns: ["weather"] });
441
+ for (const face of [text, html]) {
442
+ expect(face).toContain("weather/brooklyn"); // 范围内失败 eval 现身两面
443
+ expect(face).not.toContain("algebra"); // 范围外一致排除
444
+ }
445
+ });
446
+
447
+ it("--report 收到与裸默认报告相同的 Selection(eval 集一致)", async () => {
448
+ const root = await seedPartialRerun();
449
+ // 回显报告:把注入 Selection 的 eval id 集打印出来 —— 与裸默认报告用同一份 Selection 才对得上。
450
+ const reportPath = join(root, "echo-report.mjs");
451
+ await writeFile(
452
+ reportPath,
453
+ [
454
+ 'const FACES = Symbol.for("niceeval.report.faces");',
455
+ "const Echo = (props) => Echo[FACES].web(props);",
456
+ "Echo[FACES] = {",
457
+ ' web: (props) => "EVALS[" + props.ids + "]",',
458
+ ' text: (props) => "EVALS[" + props.ids + "]",',
459
+ "};",
460
+ "export default {",
461
+ ' [Symbol.for("niceeval.report.definition")]: true,',
462
+ " build: (ctx) => ({",
463
+ ' $$typeof: Symbol.for("react.transitional.element"),',
464
+ " type: Echo,",
465
+ ' props: { ids: ctx.selection.snapshots.flatMap((s) => s.evals.map((e) => e.id)).sort().join(",") },',
466
+ " key: null,",
467
+ " }),",
468
+ "};",
469
+ "",
470
+ ].join("\n"),
471
+ "utf-8",
472
+ );
473
+ const bareNorm = normalizeSelection(selectCurrentResults(await openResults(root)));
474
+ const expectedIds = bareNorm.experiments.flatMap((e) => e.evals.map((ev) => ev.evalId)).sort().join(",");
475
+
476
+ const text = await showText(root, [], { report: reportPath });
477
+ const html = await viewHtml(root, { report: { path: reportPath, cwd: root } });
478
+ expect(text).toContain(`EVALS[${expectedIds}]`);
479
+ expect(html).toContain(`EVALS[${expectedIds}]`);
480
+ });
481
+ });
482
+
483
+ // ══════════════════════════════════════════════════════════════════════════
484
+ // 第 3 层:默认报告计算事实对照(通过率 / 警告横幅 在 text 面与 web 面口径一致)
485
+ // ══════════════════════════════════════════════════════════════════════════
486
+
487
+ /** partial-coverage 的 text 形态(report.ts 的 "! <message>" 前置块,message 含 "verdicts cover N of M")。 */
488
+ function hasPartialCoverageText(text: string): boolean {
489
+ return /verdicts cover \d+ of \d+ evals/.test(text);
490
+ }
491
+ /** partial-coverage 的 web 形态(web.ts 的 li.nre-warning[data-kind="partial-coverage"])。 */
492
+ function hasPartialCoverageHtml(html: string): boolean {
493
+ return html.includes('data-kind="partial-coverage"');
494
+ }
495
+
496
+ describe("默认宿主事实对照 · show 索引与 view 报告同口径", () => {
497
+ it("通过计数一致:同一 fixture 两面反映 3 过 1 败", async () => {
498
+ const root = await makeRoot();
499
+ await writeSnapshot(root, "2026-07-01T08-00-00-000Z", { experimentId: "compare/bub", startedAt: "2026-07-01T08:00:00.000Z" }, [
500
+ res("q1", "failed", { durationMs: 40_000, estimatedCostUSD: 0.04, assertions: [{ name: "succeeded()", severity: "gate", score: 0, passed: false, detail: "boom" }] }),
501
+ res("q2", "passed", { durationMs: 2_000, estimatedCostUSD: 0.02 }),
502
+ res("q3", "passed", { durationMs: 2_000, estimatedCostUSD: 0.02 }),
503
+ res("q4", "passed", { durationMs: 2_000, estimatedCostUSD: 0.02 }),
504
+ ]);
505
+ const text = await showText(root, []);
506
+ const html = await viewHtml(root);
507
+ expect(text).toContain("3 passed · 1 failed");
508
+ expect(html).toContain("75%");
509
+ for (const face of [text, html]) expect(face).toContain("compare/bub");
510
+ // 无残缺:两面都不出 partial-coverage,布尔一致(某面偷偷补/漏警告即失配)。
511
+ expect(hasPartialCoverageText(text)).toBe(hasPartialCoverageHtml(html));
512
+ expect(hasPartialCoverageText(text)).toBe(false);
513
+ });
514
+
515
+ it("警告横幅一致:真实 partial-coverage 在两面同时在场(布尔对照)", async () => {
516
+ const root = await seedPartialCoverage();
517
+ const text = await showText(root, []);
518
+ const html = await viewHtml(root);
519
+ // 两面都必须报出真残缺;某一宿主吞掉 warning → 下面这条布尔对照失配。
520
+ expect(hasPartialCoverageText(text)).toBe(true);
521
+ expect(hasPartialCoverageHtml(html)).toBe(true);
522
+ expect(hasPartialCoverageText(text)).toBe(hasPartialCoverageHtml(html));
523
+ // 且 message 里的分子/分母(1 of 2)两面一致 —— 同一 SelectionWarning.message。
524
+ expect(text).toContain("verdicts cover 1 of 2 evals");
525
+ expect(html).toContain("verdicts cover 1 of 2 evals");
526
+ });
527
+ });
@@ -1,25 +1,67 @@
1
- // niceeval/results —— 实验结果数据的读写库(定稿见 docs/results-lib.md)。
1
+ // niceeval/results —— 实验结果数据的读写库(定稿见 docs/feature/results/library.md、docs/feature/results/architecture.md)。
2
2
  //
3
- // 读:openResults(实验 → 快照 → eval → attempt 分层、skipped、runDirs、latest() 选集);
4
- // 写:createRunWriter(快照级声明 + attempt 增量落盘 + summary 收尾);
3
+ // 读:openResults(实验 → 快照 → eval → attempt 分层、skipped、latest() Selection);
4
+ // 写:createResultsWriter(快照声明 + attempt 增量落盘 + finish 补 completedAt);
5
5
  // 发布:copySnapshots(格式感知复制 + knownEvalIds 补记);
6
- // 身份:dedupeAttempts(跨快照聚合前按 (experimentId, evalId, attempt, startedAt) 去重)。
6
+ // 身份:dedupeAttempts(跨快照聚合前按 (experimentId, evalId, attempt, startedAt) 去重)、isNewerSnapshot
7
7
  // 布局知识(路径、清洗、拆分、版本)全宇宙只有这一份实现;
8
- // src/runner/reporters/artifacts.ts 是写入面的薄壳,view 的读取收编是下一波。
8
+ // src/runner/reporters/artifacts.ts 是写入面的薄壳,view 的读取经 openResults 消费。
9
9
 
10
- export { openResults } from "./open.ts";
11
- export { dedupeAttempts } from "./select.ts";
10
+ export {
11
+ openResults,
12
+ experimentOfSnapshot,
13
+ resolveLocator,
14
+ LocatorNotFoundError,
15
+ MalformedLocatorError,
16
+ } from "./open.ts";
17
+ export {
18
+ ATTEMPT_LOCATOR_PREFIX,
19
+ buildLocatorIndex,
20
+ decodeAttemptLocator,
21
+ encodeAttemptLocator,
22
+ resolveAttemptLocator,
23
+ LocatorCollisionError,
24
+ type AttemptIdentity,
25
+ type AttemptLocator,
26
+ type LocatorAttempt,
27
+ type LocatorDecodeResult,
28
+ type LocatorResolution,
29
+ } from "./locator.ts";
30
+ export {
31
+ buildAnnotatedEvalSource,
32
+ type AnnotatedEvalSource,
33
+ type AnnotatedEvalSourceSummary,
34
+ type AnnotatedSourceLine,
35
+ } from "./annotated-source.ts";
36
+ export { loadAnnotatedEvalSource } from "./attempt-source.ts";
37
+ export {
38
+ loadAttemptEvidence,
39
+ type AttemptEvidence,
40
+ type AttemptEvidenceCapabilities,
41
+ type EvidencePaths,
42
+ } from "./attempt-evidence.ts";
43
+ export { hashEvalSource, normalizeEvalSource } from "./source-hash.ts";
44
+ export { groupIncompatibleVersionSkips, type SkippedVersionGroup } from "./skipped-notice.ts";
45
+ export { dedupeAttempts, isNewerSnapshot } from "./select.ts";
12
46
  export { copySnapshots, type CopySnapshotsOptions, type CopySnapshotsResult } from "./copy.ts";
13
47
  export {
14
- createRunWriter,
48
+ createResultsWriter,
15
49
  type AttemptArtifacts,
16
50
  type AttemptEntry,
17
- type FinishOverrides,
18
- type RunWriter,
19
- type RunWriterOptions,
51
+ type ResultsWriter,
52
+ type ResultsWriterOptions,
20
53
  type SnapshotDeclaration,
21
54
  type SnapshotWriter,
22
55
  } from "./writer.ts";
56
+ export {
57
+ RESULT_FILE,
58
+ SNAPSHOT_FILE,
59
+ attemptDirOf,
60
+ artifactFileOf,
61
+ classifySnapshot,
62
+ experimentDirOf,
63
+ type SnapshotClassification,
64
+ } from "./format.ts";
23
65
  export {
24
66
  ARTIFACT_KINDS,
25
67
  type ArtifactKind,
@@ -29,11 +71,11 @@ export {
29
71
  type Eval,
30
72
  type Experiment,
31
73
  type Results,
32
- type RunDir,
33
74
  type Selection,
34
75
  type SelectionWarning,
35
- type SkippedRun,
76
+ type SkippedDir,
36
77
  type Snapshot,
78
+ type SnapshotMeta,
37
79
  } from "./types.ts";
38
80
 
39
81
  // 结果数据类型的家还没搬(facade 迁移是下一波);先从这里 re-export,
@@ -41,4 +83,4 @@ export {
41
83
  export { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../types.ts";
42
84
  export type { EvalResult, ExperimentRunInfo, RunSummary } from "../types.ts";
43
85
  export type { O11ySummary, StreamEvent, TraceSpan, Usage } from "../types.ts";
44
- export type { DiffData, SourceArtifact } from "../types.ts";
86
+ export type { AgentSetupManifest, AgentSetupSkill, DiffData, SourceArtifact } from "../types.ts";