niceeval 0.6.1 → 0.7.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 (378) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +69 -7
  4. package/dist/context/types.d.ts +32 -12
  5. package/dist/i18n/en.d.ts +54 -0
  6. package/dist/i18n/zh-CN.d.ts +57 -3
  7. package/dist/o11y/types.d.ts +16 -2
  8. package/dist/report/aggregate.d.ts +32 -24
  9. package/dist/report/aggregate.js +158 -50
  10. package/dist/report/built-in/index.d.ts +2 -0
  11. package/dist/report/built-in/index.js +8 -0
  12. package/dist/report/components.d.ts +93 -160
  13. package/dist/report/components.js +377 -114
  14. package/dist/report/compute.d.ts +87 -81
  15. package/dist/report/compute.js +597 -417
  16. package/dist/report/flag.d.ts +32 -6
  17. package/dist/report/flag.js +92 -4
  18. package/dist/report/format.d.ts +19 -11
  19. package/dist/report/format.js +30 -13
  20. package/dist/report/index.d.ts +16 -16
  21. package/dist/report/index.js +20 -21
  22. package/dist/report/load.js +3 -2
  23. package/dist/report/locale.d.ts +57 -33
  24. package/dist/report/locale.js +122 -56
  25. package/dist/report/metrics.d.ts +23 -4
  26. package/dist/report/metrics.js +110 -25
  27. package/dist/report/primitives.d.ts +48 -15
  28. package/dist/report/primitives.js +135 -26
  29. package/dist/report/react/AttemptList.d.ts +9 -7
  30. package/dist/report/react/AttemptList.js +17 -10
  31. package/dist/report/react/DeltaTable.js +19 -18
  32. package/dist/report/react/EvalList.d.ts +4 -4
  33. package/dist/report/react/EvalList.js +0 -0
  34. package/dist/report/react/ExperimentComparison.d.ts +10 -0
  35. package/dist/report/react/ExperimentComparison.js +12 -0
  36. package/dist/report/react/ExperimentList.d.ts +4 -3
  37. package/dist/report/react/ExperimentList.js +17 -18
  38. package/dist/report/react/MetricBars.js +5 -4
  39. package/dist/report/react/MetricLine.js +12 -5
  40. package/dist/report/react/MetricMatrix.js +1 -1
  41. package/dist/report/react/MetricScatter.js +59 -28
  42. package/dist/report/react/MetricTable.js +2 -12
  43. package/dist/report/react/ScopeSummary.d.ts +10 -0
  44. package/dist/report/react/ScopeSummary.js +17 -0
  45. package/dist/report/react/Scoreboard.js +6 -6
  46. package/dist/report/react/cell.js +2 -2
  47. package/dist/report/react/chart-math.d.ts +23 -6
  48. package/dist/report/react/chart-math.js +71 -19
  49. package/dist/report/react/fixtures.d.ts +5 -9
  50. package/dist/report/react/fixtures.js +110 -147
  51. package/dist/report/react/index.d.ts +15 -5
  52. package/dist/report/react/index.js +18 -7
  53. package/dist/report/report.d.ts +137 -16
  54. package/dist/report/report.js +259 -28
  55. package/dist/report/text/faces.d.ts +17 -19
  56. package/dist/report/text/faces.js +253 -184
  57. package/dist/report/text/plot.js +1 -1
  58. package/dist/report/text/table.js +38 -7
  59. package/dist/report/tree.d.ts +90 -40
  60. package/dist/report/tree.js +252 -94
  61. package/dist/report/types.d.ts +247 -284
  62. package/dist/report/types.js +4 -3
  63. package/dist/report/web.d.ts +21 -5
  64. package/dist/report/web.js +42 -16
  65. package/dist/results/select.d.ts +38 -16
  66. package/dist/results/select.js +73 -25
  67. package/dist/results/types.d.ts +49 -14
  68. package/dist/runner/feedback/sink.d.ts +110 -0
  69. package/dist/runner/types.d.ts +513 -22
  70. package/dist/sandbox/docker.d.ts +23 -2
  71. package/dist/sandbox/e2b.d.ts +15 -1
  72. package/dist/sandbox/errors.d.ts +30 -3
  73. package/dist/sandbox/io-retry.d.ts +17 -0
  74. package/dist/sandbox/registry.d.ts +2 -0
  75. package/dist/sandbox/resolve.d.ts +18 -5
  76. package/dist/sandbox/retry.d.ts +11 -1
  77. package/dist/sandbox/types.d.ts +39 -5
  78. package/dist/sandbox/vercel.d.ts +7 -1
  79. package/dist/scoring/coverage.d.ts +30 -0
  80. package/dist/scoring/display.d.ts +21 -0
  81. package/dist/scoring/display.js +120 -0
  82. package/dist/scoring/types.d.ts +103 -20
  83. package/dist/shared/aggregate.d.ts +4 -2
  84. package/dist/shared/aggregate.js +8 -7
  85. package/dist/shared/types.d.ts +28 -0
  86. package/dist/tty-line.d.ts +0 -4
  87. package/dist/util.d.ts +23 -0
  88. package/docs-site/zh/README.md +44 -0
  89. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  90. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  91. package/docs-site/zh/examples/index.mdx +50 -0
  92. package/docs-site/zh/{concepts → explanation}/adapter.mdx +31 -13
  93. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  94. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  95. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  96. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  97. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  98. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  99. package/docs-site/zh/{concepts → explanation}/overview.mdx +11 -11
  100. package/docs-site/zh/{guides → explanation}/runner.mdx +18 -8
  101. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  102. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +35 -33
  103. package/docs-site/zh/{guides → how-to}/authoring.mdx +35 -2
  104. package/docs-site/zh/{guides → how-to}/ci-integration.mdx +23 -12
  105. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  106. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +47 -21
  107. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +34 -39
  108. package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +25 -3
  109. package/docs-site/zh/{guides → how-to}/experiments.mdx +12 -5
  110. package/docs-site/zh/how-to/publish-report.mdx +105 -0
  111. package/docs-site/zh/{guides → how-to}/reporters.mdx +2 -2
  112. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +56 -7
  113. package/docs-site/zh/how-to/sandbox-providers.mdx +350 -0
  114. package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +4 -4
  115. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +82 -39
  116. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +6 -4
  117. package/docs-site/zh/{guides → how-to}/write-send.mdx +30 -14
  118. package/docs-site/zh/index.mdx +24 -26
  119. package/docs-site/zh/introduction.mdx +8 -8
  120. package/docs-site/zh/reference/builtin-agents.mdx +32 -5
  121. package/docs-site/zh/reference/capabilities.mdx +8 -8
  122. package/docs-site/zh/reference/cli.mdx +40 -12
  123. package/docs-site/zh/reference/define-agent.mdx +58 -5
  124. package/docs-site/zh/reference/define-config.mdx +1 -1
  125. package/docs-site/zh/reference/define-eval.mdx +42 -9
  126. package/docs-site/zh/reference/events.mdx +3 -3
  127. package/docs-site/zh/reference/expect.mdx +26 -1
  128. package/docs-site/zh/{guides → reference}/official-adapters.mdx +32 -8
  129. package/docs-site/zh/{guides → reference}/report-components.mdx +45 -33
  130. package/docs-site/zh/{guides → reference}/results-data.mdx +21 -13
  131. package/docs-site/zh/troubleshooting/debug-sandbox.mdx +57 -0
  132. package/docs-site/zh/troubleshooting/debugging.mdx +212 -0
  133. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  134. package/package.json +10 -2
  135. package/src/agents/ai-sdk-otel.test.ts +1 -0
  136. package/src/agents/ai-sdk.test.ts +3 -0
  137. package/src/agents/ai-sdk.ts +3 -0
  138. package/src/agents/bub-install-spec.test.ts +34 -0
  139. package/src/agents/bub-install-spec.ts +32 -0
  140. package/src/agents/bub.ts +31 -32
  141. package/src/agents/claude-code.test.ts +130 -9
  142. package/src/agents/claude-code.ts +76 -4
  143. package/src/agents/codex.test.ts +189 -40
  144. package/src/agents/codex.ts +155 -14
  145. package/src/agents/coding-cli-versions.test.ts +15 -0
  146. package/src/agents/coding-cli-versions.ts +3 -0
  147. package/src/agents/index.ts +13 -2
  148. package/src/agents/langgraph.test.ts +204 -0
  149. package/src/agents/langgraph.ts +495 -0
  150. package/src/agents/marketplace.ts +85 -0
  151. package/src/agents/native-config.test.ts +179 -0
  152. package/src/agents/native-config.ts +267 -0
  153. package/src/agents/openai-compat.test.ts +1 -0
  154. package/src/agents/openai-compat.ts +1 -1
  155. package/src/agents/openclaw.test.ts +31 -0
  156. package/src/agents/openclaw.ts +171 -0
  157. package/src/agents/plugin-config.test.ts +1 -0
  158. package/src/agents/sdk-streams.test.ts +79 -0
  159. package/src/agents/sdk-streams.ts +55 -10
  160. package/src/agents/skills.test.ts +1 -0
  161. package/src/agents/streaming.test.ts +3 -9
  162. package/src/agents/streaming.ts +2 -2
  163. package/src/agents/types.ts +71 -8
  164. package/src/agents/ui-message-stream.test.ts +3 -0
  165. package/src/cli.ts +446 -124
  166. package/src/context/context.test.ts +51 -12
  167. package/src/context/context.ts +162 -30
  168. package/src/context/session.test.ts +2 -1
  169. package/src/context/session.ts +115 -7
  170. package/src/context/types.ts +30 -12
  171. package/src/define.test.ts +13 -8
  172. package/src/define.ts +25 -4
  173. package/src/expect/index.ts +53 -23
  174. package/src/i18n/en.ts +81 -17
  175. package/src/i18n/zh-CN.ts +80 -17
  176. package/src/o11y/cost.test.ts +1 -0
  177. package/src/o11y/execution-tree.test.ts +1 -20
  178. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  179. package/src/o11y/otlp/parse.test.ts +1 -0
  180. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  181. package/src/o11y/parsers/bub.test.ts +1 -0
  182. package/src/o11y/parsers/claude-code.test.ts +1 -34
  183. package/src/o11y/parsers/openclaw.test.ts +154 -0
  184. package/src/o11y/parsers/openclaw.ts +310 -0
  185. package/src/o11y/prices.json +746 -311
  186. package/src/o11y/tool-names.test.ts +1 -0
  187. package/src/o11y/types.ts +16 -2
  188. package/src/report/aggregate.ts +178 -61
  189. package/src/report/built-in/index.tsx +9 -0
  190. package/src/report/components.tsx +625 -279
  191. package/src/report/compute.ts +723 -491
  192. package/src/report/dual-render.test.tsx +741 -1024
  193. package/src/report/flag.ts +104 -12
  194. package/src/report/format.ts +32 -12
  195. package/src/report/index.ts +119 -46
  196. package/src/report/load.ts +3 -2
  197. package/src/report/locale.ts +136 -65
  198. package/src/report/metrics.ts +108 -25
  199. package/src/report/primitives.tsx +196 -45
  200. package/src/report/react/AttemptList.tsx +30 -43
  201. package/src/report/react/DeltaTable.tsx +63 -45
  202. package/src/report/react/EvalList.tsx +0 -0
  203. package/src/report/react/ExperimentComparison.tsx +73 -0
  204. package/src/report/react/ExperimentList.tsx +50 -32
  205. package/src/report/react/MetricBars.tsx +5 -4
  206. package/src/report/react/MetricLine.tsx +13 -8
  207. package/src/report/react/MetricMatrix.tsx +2 -2
  208. package/src/report/react/MetricScatter.tsx +86 -34
  209. package/src/report/react/MetricTable.tsx +4 -76
  210. package/src/report/react/ScopeSummary.tsx +86 -0
  211. package/src/report/react/Scoreboard.tsx +28 -10
  212. package/src/report/react/cell.tsx +2 -2
  213. package/src/report/react/chart-math.test.ts +85 -0
  214. package/src/report/react/chart-math.ts +101 -22
  215. package/src/report/react/enhance.js +89 -5
  216. package/src/report/react/fixtures.ts +114 -154
  217. package/src/report/react/index.tsx +24 -39
  218. package/src/report/react/render.test.tsx +138 -158
  219. package/src/report/react/styles.css +243 -82
  220. package/src/report/report.test.ts +779 -841
  221. package/src/report/report.ts +423 -41
  222. package/src/report/text/faces.ts +290 -193
  223. package/src/report/text/plot.ts +1 -1
  224. package/src/report/text/table.ts +44 -7
  225. package/src/report/tree.ts +362 -104
  226. package/src/report/types.ts +261 -271
  227. package/src/report/web.ts +63 -20
  228. package/src/results/annotated-source.test.ts +62 -9
  229. package/src/results/annotated-source.ts +64 -6
  230. package/src/results/attempt-evidence.test.ts +13 -11
  231. package/src/results/attempt-evidence.ts +20 -13
  232. package/src/results/attempt-source.ts +6 -3
  233. package/src/results/copy.ts +150 -60
  234. package/src/results/host-equivalence.test.ts +34 -20
  235. package/src/results/index.ts +12 -4
  236. package/src/results/locator.test.ts +1 -22
  237. package/src/results/open.ts +15 -5
  238. package/src/results/publish.ts +149 -0
  239. package/src/results/results.test.ts +89 -54
  240. package/src/results/select.ts +104 -34
  241. package/src/results/truncate.ts +90 -0
  242. package/src/results/types.ts +43 -14
  243. package/src/results/writer.ts +31 -13
  244. package/src/runner/attempt.test.ts +138 -7
  245. package/src/runner/attempt.ts +603 -104
  246. package/src/runner/discover.test.ts +47 -0
  247. package/src/runner/discover.ts +36 -2
  248. package/src/runner/eval-source.test.ts +1 -27
  249. package/src/runner/feedback/agent.test.ts +504 -0
  250. package/src/runner/feedback/agent.ts +409 -0
  251. package/src/runner/feedback/ci.test.ts +562 -0
  252. package/src/runner/feedback/ci.ts +401 -0
  253. package/src/runner/feedback/coordinator.test.ts +317 -0
  254. package/src/runner/feedback/coordinator.ts +397 -0
  255. package/src/runner/feedback/failure.ts +40 -0
  256. package/src/runner/feedback/human.test.ts +616 -0
  257. package/src/runner/feedback/human.ts +535 -0
  258. package/src/runner/feedback/index.ts +66 -0
  259. package/src/runner/feedback/io.ts +78 -0
  260. package/src/runner/feedback/profile.test.ts +50 -0
  261. package/src/runner/feedback/profile.ts +58 -0
  262. package/src/runner/feedback/reducer.test.ts +395 -0
  263. package/src/runner/feedback/reducer.ts +260 -0
  264. package/src/runner/feedback/renderer.ts +82 -0
  265. package/src/runner/feedback/sink.ts +203 -0
  266. package/src/runner/feedback/testing.ts +106 -0
  267. package/src/runner/ledger.test.ts +230 -0
  268. package/src/runner/ledger.ts +329 -0
  269. package/src/runner/report.test.ts +128 -3
  270. package/src/runner/report.ts +33 -9
  271. package/src/runner/reporters/artifacts.ts +8 -2
  272. package/src/runner/reporters/braintrust.test.ts +8 -7
  273. package/src/runner/reporters/braintrust.ts +9 -2
  274. package/src/runner/reporters/index.ts +2 -2
  275. package/src/runner/reporters/json.test.ts +162 -0
  276. package/src/runner/reporters/json.ts +35 -8
  277. package/src/runner/reporters/shared.ts +1 -5
  278. package/src/runner/run.test.ts +760 -3
  279. package/src/runner/run.ts +243 -37
  280. package/src/runner/sandbox-prep.ts +3 -42
  281. package/src/runner/timing.ts +158 -0
  282. package/src/runner/types.ts +518 -22
  283. package/src/sandbox/checkpoint.test.ts +55 -0
  284. package/src/sandbox/checkpoint.ts +29 -8
  285. package/src/sandbox/cli-commands.ts +407 -0
  286. package/src/sandbox/docker.ts +115 -16
  287. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  288. package/src/sandbox/e2b-agent-template.ts +94 -0
  289. package/src/sandbox/e2b.ts +74 -9
  290. package/src/sandbox/errors.ts +111 -4
  291. package/src/sandbox/index.ts +2 -0
  292. package/src/sandbox/io-retry.test.ts +58 -0
  293. package/src/sandbox/io-retry.ts +45 -0
  294. package/src/sandbox/keep-registry.test.ts +86 -0
  295. package/src/sandbox/keep-registry.ts +142 -0
  296. package/src/sandbox/keep.ts +178 -0
  297. package/src/sandbox/paths.test.ts +1 -0
  298. package/src/sandbox/paths.ts +19 -8
  299. package/src/sandbox/registry.ts +20 -3
  300. package/src/sandbox/resolve.ts +76 -11
  301. package/src/sandbox/retry.test.ts +70 -0
  302. package/src/sandbox/retry.ts +46 -4
  303. package/src/sandbox/types.ts +44 -6
  304. package/src/sandbox/vercel.ts +43 -20
  305. package/src/scoring/collector.ts +60 -17
  306. package/src/scoring/coverage.ts +95 -0
  307. package/src/scoring/diff.ts +81 -0
  308. package/src/scoring/display.test.ts +121 -0
  309. package/src/scoring/display.ts +133 -0
  310. package/src/scoring/evidence.test.ts +189 -0
  311. package/src/scoring/judge.test.ts +142 -0
  312. package/src/scoring/judge.ts +15 -18
  313. package/src/scoring/scoped.ts +217 -50
  314. package/src/scoring/types.ts +117 -20
  315. package/src/scoring/verdict.ts +16 -4
  316. package/src/shared/aggregate.ts +8 -6
  317. package/src/shared/types.ts +31 -0
  318. package/src/show/compose.ts +50 -67
  319. package/src/show/index.ts +127 -56
  320. package/src/show/render.ts +662 -131
  321. package/src/show/report-host.test.ts +188 -0
  322. package/src/show/report-host.ts +375 -0
  323. package/src/show/show.test.ts +320 -54
  324. package/src/tty-line.ts +8 -26
  325. package/src/util.test.ts +1 -0
  326. package/src/util.ts +41 -0
  327. package/src/view/app/App.test.tsx +69 -0
  328. package/src/view/app/App.tsx +144 -48
  329. package/src/view/app/components/AttemptModal.tsx +423 -11
  330. package/src/view/app/components/CodeView.tsx +41 -14
  331. package/src/view/app/components/CopyControls.tsx +2 -2
  332. package/src/view/app/i18n.ts +37 -17
  333. package/src/view/app/lib/attempt-route.test.ts +1 -0
  334. package/src/view/app/lib/verdict.ts +7 -9
  335. package/src/view/app/main.tsx +13 -8
  336. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  337. package/src/view/app/types.ts +4 -1
  338. package/src/view/artifact-serving.test.ts +2 -1
  339. package/src/view/client-dist/app.css +1 -1
  340. package/src/view/client-dist/app.js +17 -17
  341. package/src/view/data.test.ts +10 -3
  342. package/src/view/data.ts +155 -49
  343. package/src/view/index.ts +56 -41
  344. package/src/view/server.ts +37 -15
  345. package/src/view/shared/types.ts +34 -5
  346. package/src/view/styles.css +227 -0
  347. package/src/view/view-report.test.ts +167 -62
  348. package/dist/report/built-ins/experiment-comparison.d.ts +0 -1
  349. package/dist/report/built-ins/experiment-comparison.js +0 -13
  350. package/dist/report/built-ins/index.d.ts +0 -1
  351. package/dist/report/built-ins/index.js +0 -2
  352. package/dist/report/react/GroupSummary.d.ts +0 -8
  353. package/dist/report/react/GroupSummary.js +0 -8
  354. package/dist/report/react/RunOverview.d.ts +0 -8
  355. package/dist/report/react/RunOverview.js +0 -12
  356. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  357. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  358. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  359. package/docs-site/zh/example/showcase.mdx +0 -39
  360. package/docs-site/zh/guides/publish-report.mdx +0 -91
  361. package/docs-site/zh/guides/sandbox-providers.mdx +0 -102
  362. package/src/report/built-in-user-parity.test.tsx +0 -640
  363. package/src/report/built-ins/experiment-comparison.tsx +0 -19
  364. package/src/report/built-ins/index.ts +0 -2
  365. package/src/report/react/GroupSummary.tsx +0 -66
  366. package/src/report/react/RunOverview.tsx +0 -109
  367. package/src/runner/reporters/console.ts +0 -70
  368. package/src/runner/reporters/live.test.ts +0 -56
  369. package/src/runner/reporters/live.ts +0 -247
  370. package/src/runner/reporters/quiet.test.ts +0 -66
  371. package/src/runner/reporters/quiet.ts +0 -49
  372. package/src/runner/reporters/table.ts +0 -277
  373. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  374. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  375. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  376. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  377. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  378. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
@@ -1,14 +1,20 @@
1
- // 报告的元素树与双面组件基座(docs/feature/reports/architecture.md「报告树与两个宿主」)。
1
+ // 报告的元素树、组件模型与 resolve 管线(docs/feature/reports/architecture.md「组件模型」
2
+ // 「报告树与两个宿主」、docs/feature/reports/library/layout.md)。
2
3
  //
3
4
  // 报告函数返回的树不是「React 树」,只是 { type, props } 节点 —— 标准 react
4
5
  // jsx-runtime 产的元素恰好就是这个形状。本文件是基础实现:零 react 运行时依赖
5
6
  // (只有类型层的 `import type`,编译后擦除);text 宿主遍历渲染不需要 react-dom,
6
- // web 宿主(web.ts)才真正 import react。渲染面是纯同步函数:零 IO、零 await ——
7
- // 计算全部发生在报告函数体里,可达百 MB artifact 永远不进渲染路径。
7
+ // web 宿主(web.ts)才真正 import react。管线固定为 装载 resolve(组合展开 + spec 取数,
8
+ // 同层并行保声明序,按「同引用 input + 深相等 spec」记忆化)→ validate(两面资格)→
9
+ // render(纯同步)。渲染面是纯同步函数:零 IO、零 await —— 可达百 MB 的 artifact
10
+ // 只在 resolve 阶段被懒加载,永远不进渲染路径。
8
11
 
9
12
  import type { ReactNode } from "react";
10
13
  import type { AttemptLocator } from "../results/locator.ts";
14
+ import type { Results, Scope } from "../results/types.ts";
11
15
  import { DEFAULT_REPORT_LOCALE, type ReportLocale } from "./locale.ts";
16
+ import type { ReportInput } from "./types.ts";
17
+ import type { ReportMeta } from "./report.ts";
12
18
 
13
19
  // ───────────────────────── 节点形状 ─────────────────────────
14
20
 
@@ -19,8 +25,12 @@ export interface ReportElement {
19
25
  key?: unknown;
20
26
  }
21
27
 
22
- /** 报告树节点:元素、文本、数组 / Fragment 的儿子们,或渲染为空的空值。 */
23
- export type ReportNode = ReportElement | string | number | boolean | null | undefined | ReportNode[];
28
+ /**
29
+ * 报告树节点,形状穷尽(docs/feature/reports/library/layout.md「树的节点」):
30
+ * 元素、数组 / Fragment(展平保序),或条件渲染的空分支(渲染为空)。
31
+ * 裸字符串与数字**不是**节点——自由文本必须经 <Text> 携带,树校验遇到时按完整用户反馈拒绝。
32
+ */
33
+ export type ReportNode = ReportElement | readonly ReportNode[] | null | undefined | boolean;
24
34
 
25
35
  // react/jsx-runtime 的 Fragment 是注册符号,跨 react 版本稳定;不 import react 也认得它
26
36
  const REACT_FRAGMENT = Symbol.for("react.fragment");
@@ -36,10 +46,19 @@ function isReportElement(node: unknown): node is ReportElement {
36
46
  );
37
47
  }
38
48
 
39
- // ───────────────────────── 双面组件 ─────────────────────────
49
+ // ───────────────────────── 组件模型 ─────────────────────────
40
50
 
41
- /** 挂 faces 的私有键:text 宿主与树校验靠它识别双面组件。 */
51
+ /** 挂 faces 的私有键:管线与树校验靠它识别双面组件。 */
42
52
  export const COMPONENT_FACES: unique symbol = Symbol.for("niceeval.report.faces");
53
+ /** 挂组合函数的私有键:resolve 阶段靠它识别组合组件。 */
54
+ export const COMPONENT_COMPOSE: unique symbol = Symbol.for("niceeval.report.compose");
55
+ /** Tabs / Tab 的结构角色标记:树校验的配对规则靠它,不 import primitives(避免环)。 */
56
+ export const COMPONENT_ROLE: unique symbol = Symbol.for("niceeval.report.role");
57
+ /**
58
+ * children 是不透明值(自由文本 / CSS 字符串)而非报告树的组件标记(Text / Style):
59
+ * resolve 与 validate 不下钻它们的 children——那是组件自己的 props,不是树。
60
+ */
61
+ export const COMPONENT_RAW_CHILDREN: unique symbol = Symbol.for("niceeval.report.rawChildren");
43
62
 
44
63
  export interface TextContext {
45
64
  /** 可用列宽;Row 分栏后变窄。 */
@@ -50,6 +69,11 @@ export interface TextContext {
50
69
  render(node: ReportNode, width?: number): string;
51
70
  /** 下钻命令,通证据室:`niceeval show @<locator>`。 */
52
71
  attemptCommand(locator: AttemptLocator): string;
72
+ /**
73
+ * 组索引一类「按实验收窄」命令的生成;宿主注入以携带完整上下文(--results / --report /
74
+ * --page 与位置参数),默认 `niceeval show --experiment <id>`。非契约字段,官方组件内部用。
75
+ */
76
+ experimentCommand(experimentIdPrefix: string): string;
53
77
  }
54
78
 
55
79
  export interface WebContext {
@@ -59,38 +83,49 @@ export interface WebContext {
59
83
  locale: ReportLocale;
60
84
  }
61
85
 
86
+ /** 双面组件解析面的上下文:宿主注入的数据来源;props 显式给出 input 时以 props 为准。 */
87
+ export interface ResolveContext {
88
+ input: ReportInput;
89
+ }
90
+
91
+ /** 组合组件的上下文:宿主 Scope、结果根完整读取面与规范化后的报告声明。 */
92
+ export interface ComposeContext {
93
+ /** 宿主注入的 Scope。 */
94
+ scope: Scope;
95
+ /** 结果根完整读取面;历史视图从这里自行挑 Snapshot[]。 */
96
+ results: Results;
97
+ /** 规范化后的报告声明,只读(docs/feature/reports/library/layout.md「自定义组件」)。 */
98
+ report: ReportMeta;
99
+ }
100
+
62
101
  export interface ComponentFaces<P, R = P> {
63
102
  /**
64
- * 可选的数据解析面:把声明式 props(selection + 计算选项)在渲染前解析成纯数据 props(R)
65
- * 宿主的 {@link resolveReportTree} build 之后、validate / render 之前调用它——这是整条
66
- * 管线里唯一允许 await / IO 的组件级步骤;渲染面(web / text)只看已解析的 R,保持同步、零 IO。
67
- * 不实现 resolve 时 R = P,组件被当作纯数据组件直接渲染。
103
+ * 组件唯一的异步 / IO 面:把作者写下的 props 规范化成渲染 props(R)。宿主管线在
104
+ * resolve 阶段调用它,按「同引用 input + 深相等 spec」在一次页渲染内记忆化;
105
+ * 渲染面(web / text)只看已解析的 R,保持同步、零 IO。不实现 resolve 时 R = P
68
106
  */
69
- resolve?(props: P): Promise<R>;
107
+ resolve?(props: P, context: ResolveContext): R | Promise<R>;
70
108
  /** 真 React JSX 在这个面里;返回静态可渲染的 ReactNode。只看已解析的 R。 */
71
109
  web(props: R, ctx: WebContext): ReactNode;
72
110
  text(props: R, ctx: TextContext): string;
73
111
  }
74
112
 
75
113
  /**
76
- * 双面组件的产物:可直接用于 JSX(React 把它当函数组件调用,走 web 面),
77
- * text 宿主经 COMPONENT_FACES 调 text 面。R(解析后 props 形态)在存储边界抹成 any——
114
+ * 报告组件的产物:可直接用于 JSX。双面组件当普通 React 组件调用时走 web 面(只接受
115
+ * 数据形态 props);组合组件只在报告管线内展开,宿主外直接渲染报错。R 在存储边界抹成 any——
78
116
  * 树遍历只按 ComponentFaces 的结构调用 resolve / web / text,不需要在类型层追踪 R。
79
117
  */
80
118
  export type ReportComponent<P> = ((props: P) => ReactNode) & {
81
119
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
- [COMPONENT_FACES]: ComponentFaces<P, any>;
120
+ [COMPONENT_FACES]?: ComponentFaces<P, any>;
121
+ [COMPONENT_COMPOSE]?: (props: P, ctx: ComposeContext) => ReportNode | Promise<ReportNode>;
122
+ [COMPONENT_ROLE]?: "tabs" | "tab";
123
+ [COMPONENT_RAW_CHILDREN]?: true;
83
124
  displayName?: string;
84
125
  };
85
126
 
86
127
  // web 面的环境上下文:web 宿主渲染前设好;宿主之外(组件直接嵌进用户 React 应用)
87
- // 用默认值 —— attemptHref 默认 view 的 attempt 路由格式(自定义组件显式调 ctx.attemptHref
88
- // 时总有去处);官方组件的「宿主里自动接证据室」只在宿主上下文激活时发生,
89
- // 宿主外不传 attemptHref 就是纯展示,不发明断链。
90
- //
91
- // URL 格式取 `#/attempt/${locator}`:AttemptLocator 本身已经是 `@` 前缀的不透明短串
92
- // (如 "@1x7f3q9"),原样嵌进路径段就得到 `#/attempt/@1x7f3q9`——与 docs/feature/reports/view.md「用
93
- // Reports 积木重建 view」定稿的单段路由逐字一致,不额外拆分或去掉 `@`。
128
+ // 用默认值 —— attemptHref 默认 view 的 attempt 路由格式。
94
129
  const DEFAULT_WEB_CONTEXT: WebContext = {
95
130
  attemptHref: (locator) => `#/attempt/${locator}`,
96
131
  locale: DEFAULT_REPORT_LOCALE,
@@ -113,83 +148,240 @@ export function isHostWebContextActive(): boolean {
113
148
  return activeWebContext !== null;
114
149
  }
115
150
 
116
- /**
117
- * 定义一个双面组件:faces 两键必填 —— 少实现一个面编译不过,配对是结构义务。
118
- * 基础实现不 import react;产物以可调用组件的形状兼容 React 渲染。
119
- */
120
- export function defineComponent<P, R = P>(faces: ComponentFaces<P, R>): ReportComponent<P> {
121
- if (typeof faces?.web !== "function" || typeof faces?.text !== "function") {
151
+ /** 函数形态:组合组件,只装配已有组件,可以异步;ctx 携带 scope / results / report。 */
152
+ export function defineComponent<P>(
153
+ compose: (props: P, context: ComposeContext) => ReportNode | Promise<ReportNode>,
154
+ ): ReportComponent<P>;
155
+ /** 对象形态:双面组件,自己渲染;text web 两面必填,可选 resolve 承担取数。 */
156
+ export function defineComponent<P, R = P>(faces: ComponentFaces<P, R>): ReportComponent<P>;
157
+ export function defineComponent<P, R = P>(
158
+ input: ComponentFaces<P, R> | ((props: P, context: ComposeContext) => ReportNode | Promise<ReportNode>),
159
+ ): ReportComponent<P> {
160
+ if (typeof input === "function") {
161
+ const component = ((_props: P): ReactNode => {
162
+ throw new Error(
163
+ `Compose component ${componentDisplayName(input) ?? "(anonymous)"} can only render inside the report pipeline ` +
164
+ "(niceeval show / view, or renderReportToText / renderReportToStaticHtml): it assembles other components " +
165
+ "and needs the host context (scope, results, report). To embed in your own React page, compute data with " +
166
+ "the *Data functions and render the pure components from niceeval/report/react instead.",
167
+ );
168
+ }) as ReportComponent<P>;
169
+ component[COMPONENT_COMPOSE] = input;
170
+ if (componentDisplayName(input)) component.displayName = componentDisplayName(input);
171
+ return component;
172
+ }
173
+ if (typeof input?.web !== "function" || typeof input?.text !== "function") {
122
174
  throw new Error(
123
- "defineComponent requires both faces: { web(props, ctx), text(props, ctx) }. " +
124
- "Every report component must render in both hosts (niceeval view and niceeval show).",
175
+ "defineComponent requires both faces: { web(props, ctx), text(props, ctx) } (resolve is optional). " +
176
+ "Every report component must render in both hosts (niceeval view and niceeval show); " +
177
+ "define the missing face, or pass a compose function to assemble existing components instead.",
125
178
  );
126
179
  }
180
+ const faces = input;
127
181
  // 直接调用路径:把组件当普通 React 组件嵌进用户自己的页面时走这里,web 面只接收数据形态
128
- // props(R)。带 resolve 的组件若拿 selection 形态 props 走这条裸路径,web 面会缺 data ——
182
+ // props(R)。带 resolve 的组件若拿 spec 形态 props 走这条裸路径,web 面会缺 data ——
129
183
  // 这类组件只有经宿主的 resolveReportTree 解析后才安全渲染;纯数据 props 一直可以裸嵌。
130
184
  const component = ((props: P) =>
131
185
  faces.web(props as unknown as R, activeWebContext ?? DEFAULT_WEB_CONTEXT)) as ReportComponent<P>;
132
- component[COMPONENT_FACES] = faces;
186
+ component[COMPONENT_FACES] = faces as ComponentFaces<P, unknown>;
133
187
  return component;
134
188
  }
135
189
 
190
+ function componentDisplayName(fn: unknown): string | undefined {
191
+ const named = fn as { displayName?: string; name?: string };
192
+ return named.displayName || named.name || undefined;
193
+ }
194
+
136
195
  export function facesOf(type: unknown): ComponentFaces<unknown> | undefined {
137
196
  if (typeof type !== "function") return undefined;
138
- return (type as Partial<ReportComponent<unknown>>)[COMPONENT_FACES] as ComponentFaces<unknown> | undefined;
197
+ return (type as ReportComponent<unknown>)[COMPONENT_FACES];
198
+ }
199
+
200
+ export function composeOf(
201
+ type: unknown,
202
+ ): ((props: unknown, ctx: ComposeContext) => ReportNode | Promise<ReportNode>) | undefined {
203
+ if (typeof type !== "function") return undefined;
204
+ return (type as ReportComponent<unknown>)[COMPONENT_COMPOSE] as
205
+ | ((props: unknown, ctx: ComposeContext) => ReportNode | Promise<ReportNode>)
206
+ | undefined;
207
+ }
208
+
209
+ function roleOf(type: unknown): "tabs" | "tab" | undefined {
210
+ if (typeof type !== "function") return undefined;
211
+ return (type as ReportComponent<unknown>)[COMPONENT_ROLE];
212
+ }
213
+
214
+ function hasRawChildren(type: unknown): boolean {
215
+ return typeof type === "function" && (type as ReportComponent<unknown>)[COMPONENT_RAW_CHILDREN] === true;
216
+ }
217
+
218
+ // ───────────────────────── 深相等(记忆化的 spec 比较)─────────────────────────
219
+
220
+ /**
221
+ * resolve 记忆化的深相等:只递归比较可序列化值(普通对象 / 数组 / 原始值);
222
+ * 函数与 Metric / Dimension / NumericAxis 一类携带函数的实例按引用比较——
223
+ * 共享计算的成立条件是引用同一实例,引用不同的等价定义只是各算一次,不构成错误。
224
+ */
225
+ export function deepEqualSpec(a: unknown, b: unknown): boolean {
226
+ if (a === b) return true;
227
+ if (typeof a !== typeof b) return false;
228
+ if (typeof a !== "object" || a === null || b === null) return false;
229
+ if (Array.isArray(a) || Array.isArray(b)) {
230
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
231
+ return a.every((item, i) => deepEqualSpec(item, (b as unknown[])[i]));
232
+ }
233
+ const protoA = Object.getPrototypeOf(a);
234
+ const protoB = Object.getPrototypeOf(b);
235
+ if ((protoA !== Object.prototype && protoA !== null) || (protoB !== Object.prototype && protoB !== null)) {
236
+ return false; // 非纯对象(类实例等)按引用比较,=== 已在开头判过
237
+ }
238
+ const keysA = Object.keys(a as Record<string, unknown>).filter(
239
+ (k) => (a as Record<string, unknown>)[k] !== undefined,
240
+ );
241
+ const keysB = Object.keys(b as Record<string, unknown>).filter(
242
+ (k) => (b as Record<string, unknown>)[k] !== undefined,
243
+ );
244
+ if (keysA.length !== keysB.length) return false;
245
+ return keysA.every((k) => deepEqualSpec((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
246
+ }
247
+
248
+ // ───────────────────────── resolve 管线 ─────────────────────────
249
+
250
+ interface MemoEntry {
251
+ input: unknown;
252
+ spec: unknown;
253
+ result: Promise<unknown>;
254
+ }
255
+
256
+ /** 一次页渲染内的记忆化缓存:键 = 计算函数引用 → (同引用 input + 深相等 spec) 命中。 */
257
+ export class ResolveMemo {
258
+ private readonly entries = new Map<unknown, MemoEntry[]>();
259
+
260
+ fetch(fn: unknown, input: unknown, spec: unknown, compute: () => Promise<unknown>): Promise<unknown> {
261
+ let list = this.entries.get(fn);
262
+ if (!list) this.entries.set(fn, (list = []));
263
+ for (const entry of list) {
264
+ if (entry.input === input && deepEqualSpec(entry.spec, spec)) return entry.result;
265
+ }
266
+ const result = compute();
267
+ list.push({ input, spec, result });
268
+ return result;
269
+ }
270
+ }
271
+
272
+ /** resolveReportTree 的环境:宿主注入的 Scope / 读取面 / 规范化声明。 */
273
+ export interface ResolveEnv {
274
+ scope: Scope;
275
+ results: Results;
276
+ report: ReportMeta;
277
+ /** 一次页渲染一份;跨页共享缓存时由宿主显式传同一实例。 */
278
+ memo?: ResolveMemo;
279
+ }
280
+
281
+ /** 官方 spec 组件在 resolve 内取数的记忆化入口;经内部扩展的 ResolveContext 传递。 */
282
+ export interface InternalResolveContext extends ResolveContext {
283
+ /** 按 (计算函数引用, 同引用 input, 深相等 options) 记忆化地调用计算函数。 */
284
+ memoFetch<T>(dataFn: unknown, input: unknown, options: unknown, compute: () => Promise<T>): Promise<T>;
285
+ }
286
+
287
+ /** 组件 resolve 里安全取记忆化入口:经宿主管线时在场,手工直调 resolve 时退化为直接计算。 */
288
+ export function memoFetchOf(ctx: ResolveContext): InternalResolveContext["memoFetch"] {
289
+ const internal = ctx as Partial<InternalResolveContext>;
290
+ return internal.memoFetch ?? (async (_fn, _input, _options, compute) => compute());
291
+ }
292
+
293
+ function illegalNodeError(type: unknown, path: string[]): Error {
294
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
295
+ if (typeof type === "string") {
296
+ return new Error(
297
+ `Report trees cannot contain raw HTML <${type}>${where}. Every node must render in both hosts (niceeval show and niceeval view), and raw HTML has no text face. ` +
298
+ "Use <Text> for free text, the layout primitives (<Row>/<Col>/<Section>/<Table>), or move the markup into the web face of a defineComponent component.",
299
+ );
300
+ }
301
+ const label = componentLabel(type);
302
+ return new Error(
303
+ `${label} is not a report component${where}: plain functions and React components cannot join a report tree because the hosts cannot render them in both faces. ` +
304
+ "Wrap it with defineComponent — a compose function defineComponent((props, ctx) => tree) to assemble existing components, " +
305
+ "or an object form defineComponent({ resolve?, text, web }) to render itself.",
306
+ );
139
307
  }
140
308
 
141
- // ───────────────────────── 数据解析(渲染前唯一的 await 边界)─────────────────────────
309
+ function bareTextError(value: string | number, path: string[]): Error {
310
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
311
+ const preview = typeof value === "string" ? JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value) : String(value);
312
+ return new Error(
313
+ `Report trees cannot contain a bare ${typeof value} (${preview})${where}: free text needs an explicit carrier for terminal line-wrapping and HTML escaping. ` +
314
+ "Wrap it in <Text>…</Text>.",
315
+ );
316
+ }
142
317
 
143
318
  /**
144
- * 报告 build 之后、树校验与 text/web 渲染之前的解析遍历:把声明式数据组件(实现了
145
- * `faces.resolve` 的双面组件,如 selection 形态的 MetricScatter)的 props
146
- * 就地换成算好的数据形态 props(三个实体列表没有 `resolve` 面,不经这一步——它们的 `items`
147
- * 由报告作者在 `build()` 里直接 `await .data(selection)` 备好)。计算发生在这里(唯一允许
148
- * await 的组件级步骤,连同报告函数体自己的 `build()`),两个渲染面
149
- * 之后都只看已解析的树、保持同步零 IO。遍历形状与 {@link validateReportTree} /
150
- * {@link renderNodeToText} 一致:
151
- *
152
- * - 同层数组兄弟并行解析(`Promise.all`),保持原始顺序 / keys;
153
- * - 双面组件带 resolve 的:调 `faces.resolve(props)` 换 props(有 children 再递归解析);
154
- * - 双面组件无 resolve 的(Row / Col / Section / RunOverview…):只递归 children,自身 props
155
- * 原样保留(title / className 等不能动);
156
- * - 普通函数组件:同步调用展开,展开结果继续解析(与 validate / render 对函数组件的处理一致);
157
- * - 字符串 intrinsic(<div>):原样返回,交给随后的 validateReportTree 报同一条错误。
158
- *
159
- * resolver 跑完后,树里已没有函数组件 / 未解析 props;validate / render 里对这两者的分支在
160
- * 正常管线下是 no-op,但保留着——手搭树直接调 validate / render(不过 resolver)的低层用法仍需要。
319
+ * 管线的 resolve 阶段:递归展开组合组件(以 (props, ctx) 调用并 await 返回树)、执行双面
320
+ * 组件的解析面;同层 sibling 并行取数且不改变节点顺序;带 resolve 的组件按「同引用 input +
321
+ * 深相等 spec」记忆化。非法节点(React 组件、未经 defineComponent 的普通函数、任意 HTML
322
+ * intrinsic)在展开遇到时立即以完整用户反馈拒绝,不为非法节点取数。
161
323
  */
162
- export async function resolveReportTree(node: ReportNode): Promise<ReportNode> {
324
+ export async function resolveReportTree(node: ReportNode, env: ResolveEnv): Promise<ReportNode> {
325
+ const memo = env.memo ?? new ResolveMemo();
326
+ const composeCtx: ComposeContext = { scope: env.scope, results: env.results, report: env.report };
327
+ return resolveNode(node, { memo, composeCtx }, []);
328
+ }
329
+
330
+ interface ResolveState {
331
+ memo: ResolveMemo;
332
+ composeCtx: ComposeContext;
333
+ }
334
+
335
+ async function resolveNode(node: ReportNode | string | number, state: ResolveState, path: string[]): Promise<ReportNode> {
163
336
  if (node === null || node === undefined || typeof node === "boolean") return node;
164
- if (typeof node === "string" || typeof node === "number") return node;
165
- if (Array.isArray(node)) return Promise.all(node.map(resolveReportTree));
337
+ if (typeof node === "string" || typeof node === "number") {
338
+ // 裸字符串在 validate 阶段以带指引的完整反馈拒绝;这里先如实透传,不为它取数。
339
+ return node as unknown as ReportNode;
340
+ }
341
+ if (Array.isArray(node)) {
342
+ return Promise.all(node.map((child) => resolveNode(child as ReportNode, state, path)));
343
+ }
166
344
  if (!isReportElement(node)) return node;
167
345
  const { type, props } = node;
168
- // 字符串 intrinsic:不在这里报错,让 resolve 后的 validateReportTree 抛统一的那条。
169
- if (typeof type === "string") return node;
170
346
  if (type === REACT_FRAGMENT) {
171
- const children = await resolveReportTree(props.children as ReportNode);
347
+ const children = await resolveNode(props.children as ReportNode, state, path);
172
348
  return { ...node, props: { ...props, children } };
173
349
  }
350
+ const compose = composeOf(type);
351
+ if (compose) {
352
+ // 组合组件不记忆化:它只装配、不承担取数;数据层的去重由其内部 *Data 调用经 memoFetch 命中。
353
+ const expanded = await compose(props, state.composeCtx);
354
+ return resolveNode(expanded, state, [...path, componentLabel(type)]);
355
+ }
174
356
  const faces = facesOf(type);
175
357
  if (faces) {
176
358
  if (typeof faces.resolve === "function") {
177
- const resolvedProps = { ...((await faces.resolve(props)) as Record<string, unknown>) };
359
+ const resolveFn = faces.resolve;
360
+ const input = ((props as { input?: unknown }).input as ReportInput | undefined) ?? state.composeCtx.scope;
361
+ const ctx: InternalResolveContext = {
362
+ input,
363
+ memoFetch: <T,>(dataFn: unknown, dataInput: unknown, options: unknown, compute: () => Promise<T>) =>
364
+ state.memo.fetch(dataFn, dataInput, options, compute) as Promise<T>,
365
+ };
366
+ const resolved = await state.memo.fetch(resolveFn, input, props, async () =>
367
+ resolveFn(props, ctx),
368
+ );
369
+ const resolvedProps = { ...(resolved as Record<string, unknown>) };
178
370
  if (resolvedProps.children !== undefined) {
179
- resolvedProps.children = await resolveReportTree(resolvedProps.children as ReportNode);
371
+ resolvedProps.children = await resolveNode(resolvedProps.children as ReportNode, state, [
372
+ ...path,
373
+ componentLabel(type),
374
+ ]);
180
375
  }
181
376
  return { ...node, props: resolvedProps };
182
377
  }
183
- // 无 resolve 的容器 / 数据组件:只解析 children,自身 props 原样保留。
184
- const children = await resolveReportTree(props.children as ReportNode);
378
+ // 无 resolve 的容器 / 纯数据组件:只解析 children,自身 props 原样保留(title / className 不能动)。
379
+ if (hasRawChildren(type)) return node; // Text / Style 的 children 是不透明值,不是树
380
+ const children = await resolveNode(props.children as ReportNode, state, [...path, componentLabel(type)]);
185
381
  return { ...node, props: { ...props, children } };
186
382
  }
187
- if (typeof type === "function") {
188
- // 普通函数组件:调用展开,展开结果整体替换本节点后继续解析。
189
- const expanded = (type as (p: unknown) => ReportNode)(props);
190
- return resolveReportTree(expanded);
191
- }
192
- return node;
383
+ // 非法节点:HTML intrinsic、React 组件、未经 defineComponent 的普通函数 —— 立即拒绝,不取数。
384
+ throw illegalNodeError(type, path);
193
385
  }
194
386
 
195
387
  // ───────────────────────── 树校验 ─────────────────────────
@@ -204,46 +396,109 @@ function componentLabel(type: unknown): string {
204
396
  return `<${String(type)}>`;
205
397
  }
206
398
 
399
+ function directChildElements(children: unknown): ReportElement[] {
400
+ const out: ReportElement[] = [];
401
+ const visit = (node: unknown): void => {
402
+ if (node === null || node === undefined || typeof node === "boolean") return;
403
+ if (Array.isArray(node)) {
404
+ for (const child of node) visit(child);
405
+ return;
406
+ }
407
+ if (isReportElement(node)) {
408
+ if (node.type === REACT_FRAGMENT) {
409
+ visit(node.props.children);
410
+ return;
411
+ }
412
+ out.push(node);
413
+ return;
414
+ }
415
+ out.push(node as never);
416
+ };
417
+ visit(children);
418
+ return out;
419
+ }
420
+
207
421
  /**
208
- * 渲染前树校验:页面树里只放双面组件、排版原语与普通组合函数,字符串 intrinsic
209
- * (<div>)报错、指名组件路径。这是运行时校验而非编译期(标准 JSX 下 TS 把一切
210
- * JSX 表达式统一成 JSX.Element);两个宿主渲染前跑同一遍 —— 不做单侧宽容,否则
211
- * 对着 view 写的页面到 show 才炸。校验只下钻 children(children 就是报告树);
212
- * 普通函数组件调用展开(渲染面纯同步,重复调用无副作用)。
422
+ * 管线的 validate 阶段:确保展开后树中每个组件都有 text 和 web 两面。校验只看节点资格,
423
+ * 不限定树形:根节点可以是单个组件、Col Tabs,宿主不强制任何最外层容器。
424
+ * 裸字符串 / 数字、HTML intrinsic、无两面资格的函数都以完整用户反馈拒绝;
425
+ * Tabs / Tab 的结构配对(空 Tabs、游离 Tab、非 Tab 直接子节点)也在这里校验。
213
426
  */
214
- export function validateReportTree(node: ReportNode, path: string[] = []): void {
427
+ export function validateReportTree(node: ReportNode, path: string[] = [], insideTabs = false): void {
215
428
  if (node === null || node === undefined || typeof node === "boolean") return;
216
- if (typeof node === "string" || typeof node === "number") return;
429
+ if (typeof node === "string" || typeof node === "number") {
430
+ throw bareTextError(node, path);
431
+ }
217
432
  if (Array.isArray(node)) {
218
- for (const child of node) validateReportTree(child, path);
433
+ for (const child of node) validateReportTree(child, path, insideTabs);
434
+ return;
435
+ }
436
+ if (!isReportElement(node)) {
437
+ if (typeof node === "object" && node !== null && (node as { kind?: unknown }).kind === "report") {
438
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
439
+ throw new Error(
440
+ `A defineReport(...) product is not a report node${where}: the shell cannot nest. ` +
441
+ "Put the page content (a tree or component) here, and keep defineReport for the file's default export only.",
442
+ );
443
+ }
219
444
  return;
220
445
  }
221
- if (!isReportElement(node)) return;
222
446
  const { type, props } = node;
223
- if (typeof type === "string") {
447
+ if (type === REACT_FRAGMENT) {
448
+ validateReportTree(props.children as ReportNode, path, insideTabs);
449
+ return;
450
+ }
451
+ if (typeof type === "string") throw illegalNodeError(type, path);
452
+ const label = componentLabel(type);
453
+ const role = roleOf(type);
454
+ if (role === "tab" && !insideTabs) {
224
455
  const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
225
456
  throw new Error(
226
- `Raw HTML <${type}> has no terminal face; use <Text>, layout primitives, or a defineComponent component.${where}`,
457
+ `<Tab> can only be a direct child of <Tabs>${where}. Wrap sibling views in <Tabs><Tab title="…">…</Tab></Tabs>, or drop the <Tab> wrapper if you only have one view.`,
227
458
  );
228
459
  }
229
- if (type === REACT_FRAGMENT) {
230
- validateReportTree(props.children as ReportNode, path);
460
+ if (role === "tabs") {
461
+ const children = directChildElements(props.children);
462
+ const nonTab = children.find((child) => roleOf(child.type) !== "tab");
463
+ if (nonTab !== undefined) {
464
+ throw new Error(
465
+ `<Tabs> only accepts <Tab> as direct children (found ${
466
+ isReportElement(nonTab) ? componentLabel(nonTab.type) : typeof nonTab
467
+ } in ${[...path, label].join(" > ")}). Move the content inside a <Tab title="…">, or lift it out of <Tabs>.`,
468
+ );
469
+ }
470
+ if (children.length === 0) {
471
+ throw new Error(
472
+ `<Tabs> needs at least one <Tab> child (in ${[...path, label].join(" > ")}). Add <Tab title="…">…</Tab>, or remove the empty <Tabs>.`,
473
+ );
474
+ }
475
+ for (const tab of children) {
476
+ validateReportTree((tab.props as { children?: ReportNode }).children, [...path, label, componentLabel(tab.type)], false);
477
+ }
231
478
  return;
232
479
  }
233
- const label = componentLabel(type);
234
- if (facesOf(type)) {
480
+ const faces = facesOf(type);
481
+ if (faces) {
482
+ // 双面资格:无类型 JS 输入可能绕过 defineComponent 的定义期校验,这里再拦一遍
483
+ if (typeof faces.web !== "function" || typeof faces.text !== "function") {
484
+ const missing = typeof faces.web !== "function" ? "web" : "text";
485
+ throw new Error(
486
+ `${label} is missing its ${missing} face${path.length > 0 ? ` (in ${path.join(" > ")})` : ""}: every component in a report tree must render in both hosts (niceeval show and niceeval view). Define both { text, web } in defineComponent.`,
487
+ );
488
+ }
235
489
  // 双面组件是校验的信任边界之内的叶子,但 children 仍是报告树的一部分
236
- validateReportTree(props.children as ReportNode, [...path, label]);
490
+ // (Text / Style 除外:它们的 children 是不透明的自由文本 / CSS 字符串)
491
+ if (!hasRawChildren(type)) validateReportTree(props.children as ReportNode, [...path, label], false);
237
492
  return;
238
493
  }
239
- if (typeof type === "function") {
240
- // 普通函数组件 = 用户拿函数组合页面片段:调用展开继续校验
241
- const expanded = (type as (p: unknown) => ReportNode)(props);
242
- validateReportTree(expanded, [...path, label]);
243
- return;
494
+ if (composeOf(type)) {
495
+ // 正常管线下 resolve 已把组合组件展开;手搭树直接 validate 时如实指出它还没展开
496
+ throw new Error(
497
+ `${label} is a compose component and must be expanded by the resolve pipeline before validation. ` +
498
+ "Render through defineReport + niceeval show/view (or renderReportToText/renderReportToStaticHtml).",
499
+ );
244
500
  }
245
- const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
246
- throw new Error(`Unsupported node type ${label} in report tree.${where}`);
501
+ throw illegalNodeError(type, path);
247
502
  }
248
503
 
249
504
  // ───────────────────────── text 渲染 ─────────────────────────
@@ -253,21 +508,29 @@ export interface TextRenderOptions {
253
508
  width?: number;
254
509
  /** 下钻命令的生成;宿主注入,默认 `niceeval show @<locator>`(真实可跑的 CLI 语法)。 */
255
510
  attemptCommand?: (locator: AttemptLocator) => string;
511
+ /** 组索引命令的生成;宿主注入以携带 --results / --report / --page 等上下文。 */
512
+ experimentCommand?: (experimentIdPrefix: string) => string;
256
513
  /** chrome 文案的 locale;默认 "en"(`niceeval show` 现有输出不变)。 */
257
514
  locale?: ReportLocale;
258
515
  }
259
516
 
517
+ function shellQuote(value: string): string {
518
+ return /^[A-Za-z0-9._/@-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
519
+ }
520
+
260
521
  export function createTextContext(options?: TextRenderOptions): TextContext {
261
522
  const width = Math.max(20, options?.width ?? 80);
262
523
  const locale = options?.locale ?? DEFAULT_REPORT_LOCALE;
263
524
  // 默认下钻命令:AttemptLocator 是 `@` 前缀的不透明短串,`niceeval show @<locator>` 是
264
- // show/index.ts 已实现的真实 CLI 语法(见该文件 `@<locator>` 位置参数解析),不需要
265
- // 反查 eval id 再拼一条近似命令。
525
+ // show 已实现的真实 CLI 语法,不需要反查 eval id 再拼一条近似命令。
266
526
  const attemptCommand = options?.attemptCommand ?? ((locator: AttemptLocator) => `niceeval show ${locator}`);
527
+ const experimentCommand =
528
+ options?.experimentCommand ?? ((prefix: string) => `niceeval show --experiment ${shellQuote(prefix)}`);
267
529
  const make = (w: number): TextContext => ({
268
530
  width: w,
269
531
  locale,
270
532
  attemptCommand,
533
+ experimentCommand,
271
534
  render(node, childWidth) {
272
535
  return renderNodeToText(node, childWidth === undefined ? this : make(Math.max(10, childWidth)));
273
536
  },
@@ -275,10 +538,13 @@ export function createTextContext(options?: TextRenderOptions): TextContext {
275
538
  return make(width);
276
539
  }
277
540
 
278
- /** text 宿主的遍历渲染:双面组件走 text 面,普通函数调用展开,块之间以换行相接。 */
541
+ /** text 宿主的遍历渲染:双面组件走 text 面,块之间以换行相接。只吃已 resolve + validate 的树。 */
279
542
  export function renderNodeToText(node: ReportNode, ctx: TextContext): string {
280
543
  if (node === null || node === undefined || typeof node === "boolean") return "";
281
- if (typeof node === "string" || typeof node === "number") return String(node);
544
+ if (typeof node === "string" || typeof node === "number") {
545
+ // 校验先行会拦住;渲染路径自身也不宽容
546
+ throw bareTextError(node, []);
547
+ }
282
548
  if (Array.isArray(node)) {
283
549
  return node
284
550
  .map((child) => renderNodeToText(child, ctx))
@@ -287,17 +553,9 @@ export function renderNodeToText(node: ReportNode, ctx: TextContext): string {
287
553
  }
288
554
  if (!isReportElement(node)) return "";
289
555
  const { type, props } = node;
290
- if (typeof type === "string") {
291
- // 校验先行会拦住;这里兜底同一条错误,渲染路径自身也不宽容
292
- throw new Error(
293
- `Raw HTML <${type}> has no terminal face; use <Text>, layout primitives, or a defineComponent component.`,
294
- );
295
- }
556
+ if (typeof type === "string") throw illegalNodeError(type, []);
296
557
  if (type === REACT_FRAGMENT) return renderNodeToText(props.children as ReportNode, ctx);
297
558
  const faces = facesOf(type);
298
559
  if (faces) return faces.text(props, ctx);
299
- if (typeof type === "function") {
300
- return renderNodeToText((type as (p: unknown) => ReportNode)(props), ctx);
301
- }
302
- return "";
560
+ throw illegalNodeError(type, []);
303
561
  }