niceeval 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +2 -2
  4. package/dist/i18n/zh-CN.d.ts +3 -3
  5. package/dist/report/aggregate.d.ts +32 -26
  6. package/dist/report/aggregate.js +157 -76
  7. package/dist/report/built-in/index.d.ts +2 -0
  8. package/dist/report/built-in/index.js +8 -0
  9. package/dist/report/components.d.ts +91 -164
  10. package/dist/report/components.js +377 -114
  11. package/dist/report/compute.d.ts +86 -73
  12. package/dist/report/compute.js +592 -432
  13. package/dist/report/flag.d.ts +28 -17
  14. package/dist/report/flag.js +86 -16
  15. package/dist/report/format.d.ts +11 -11
  16. package/dist/report/format.js +17 -15
  17. package/dist/report/index.d.ts +16 -17
  18. package/dist/report/index.js +20 -22
  19. package/dist/report/load.js +3 -2
  20. package/dist/report/locale.d.ts +49 -34
  21. package/dist/report/locale.js +106 -58
  22. package/dist/report/metrics.d.ts +10 -3
  23. package/dist/report/metrics.js +46 -12
  24. package/dist/report/primitives.d.ts +42 -15
  25. package/dist/report/primitives.js +135 -26
  26. package/dist/report/react/AttemptList.d.ts +10 -8
  27. package/dist/report/react/AttemptList.js +18 -10
  28. package/dist/report/react/DeltaTable.js +19 -18
  29. package/dist/report/react/EvalList.d.ts +3 -3
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +4 -2
  32. package/dist/report/react/ExperimentComparison.js +5 -4
  33. package/dist/report/react/ExperimentList.d.ts +3 -3
  34. package/dist/report/react/ExperimentList.js +16 -15
  35. package/dist/report/react/MetricBars.js +5 -4
  36. package/dist/report/react/MetricLine.js +12 -5
  37. package/dist/report/react/MetricMatrix.js +1 -1
  38. package/dist/report/react/MetricScatter.js +54 -17
  39. package/dist/report/react/MetricTable.js +2 -12
  40. package/dist/report/react/ScopeSummary.d.ts +10 -0
  41. package/dist/report/react/ScopeSummary.js +17 -0
  42. package/dist/report/react/Scoreboard.js +6 -6
  43. package/dist/report/react/cell.js +2 -2
  44. package/dist/report/react/fixtures.d.ts +5 -9
  45. package/dist/report/react/fixtures.js +105 -149
  46. package/dist/report/react/index.d.ts +15 -5
  47. package/dist/report/react/index.js +18 -7
  48. package/dist/report/report.d.ts +137 -20
  49. package/dist/report/report.js +261 -34
  50. package/dist/report/text/faces.d.ts +17 -19
  51. package/dist/report/text/faces.js +225 -157
  52. package/dist/report/text/plot.js +1 -1
  53. package/dist/report/text/table.js +2 -2
  54. package/dist/report/tree.d.ts +90 -40
  55. package/dist/report/tree.js +252 -94
  56. package/dist/report/types.d.ts +245 -300
  57. package/dist/report/types.js +4 -3
  58. package/dist/report/web.d.ts +21 -5
  59. package/dist/report/web.js +42 -16
  60. package/dist/results/select.d.ts +38 -16
  61. package/dist/results/select.js +73 -25
  62. package/dist/results/types.d.ts +38 -14
  63. package/dist/shared/aggregate.d.ts +3 -2
  64. package/dist/shared/aggregate.js +5 -4
  65. package/docs-site/zh/README.md +44 -0
  66. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  67. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  68. package/docs-site/zh/examples/index.mdx +50 -0
  69. package/docs-site/zh/{concepts → explanation}/adapter.mdx +11 -11
  70. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  71. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  72. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  73. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  74. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  75. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  76. package/docs-site/zh/{concepts → explanation}/overview.mdx +5 -5
  77. package/docs-site/zh/{guides → explanation}/runner.mdx +1 -1
  78. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  79. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +7 -7
  80. package/docs-site/zh/{guides → how-to}/authoring.mdx +2 -2
  81. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  82. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +18 -18
  83. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +6 -6
  84. package/docs-site/zh/{guides → how-to}/experiments.mdx +3 -3
  85. package/docs-site/zh/{guides → how-to}/publish-report.mdx +2 -2
  86. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +2 -2
  87. package/docs-site/zh/{guides → how-to}/sandbox-providers.mdx +1 -1
  88. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +6 -6
  89. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +3 -3
  90. package/docs-site/zh/{guides → how-to}/write-send.mdx +13 -13
  91. package/docs-site/zh/index.mdx +23 -25
  92. package/docs-site/zh/introduction.mdx +8 -8
  93. package/docs-site/zh/reference/builtin-agents.mdx +5 -5
  94. package/docs-site/zh/reference/capabilities.mdx +6 -6
  95. package/docs-site/zh/reference/cli.mdx +9 -7
  96. package/docs-site/zh/reference/define-agent.mdx +1 -1
  97. package/docs-site/zh/reference/events.mdx +3 -3
  98. package/docs-site/zh/{guides → reference}/official-adapters.mdx +7 -7
  99. package/docs-site/zh/{guides → reference}/report-components.mdx +5 -5
  100. package/docs-site/zh/{guides → reference}/results-data.mdx +5 -5
  101. package/docs-site/zh/{guides → troubleshooting}/debug-sandbox.mdx +2 -2
  102. package/docs-site/zh/{guides → troubleshooting}/debugging.mdx +4 -2
  103. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  104. package/package.json +6 -2
  105. package/src/agents/index.ts +2 -2
  106. package/src/agents/openai-compat.ts +1 -1
  107. package/src/agents/streaming.ts +2 -2
  108. package/src/agents/types.ts +3 -3
  109. package/src/cli.ts +42 -23
  110. package/src/context/context.ts +1 -1
  111. package/src/context/session.test.ts +1 -1
  112. package/src/context/session.ts +1 -1
  113. package/src/i18n/en.ts +18 -16
  114. package/src/i18n/zh-CN.ts +16 -15
  115. package/src/report/aggregate.ts +175 -87
  116. package/src/report/built-in/index.tsx +9 -0
  117. package/src/report/components.tsx +625 -285
  118. package/src/report/compute.ts +717 -515
  119. package/src/report/dual-render.test.tsx +738 -1148
  120. package/src/report/flag.ts +97 -33
  121. package/src/report/format.ts +18 -22
  122. package/src/report/index.ts +113 -58
  123. package/src/report/load.ts +3 -2
  124. package/src/report/locale.ts +120 -69
  125. package/src/report/metrics.ts +42 -12
  126. package/src/report/primitives.tsx +190 -45
  127. package/src/report/react/AttemptList.tsx +32 -20
  128. package/src/report/react/DeltaTable.tsx +63 -45
  129. package/src/report/react/EvalList.tsx +0 -0
  130. package/src/report/react/ExperimentComparison.tsx +12 -7
  131. package/src/report/react/ExperimentList.tsx +38 -26
  132. package/src/report/react/MetricBars.tsx +5 -4
  133. package/src/report/react/MetricLine.tsx +13 -8
  134. package/src/report/react/MetricMatrix.tsx +2 -2
  135. package/src/report/react/MetricScatter.tsx +74 -20
  136. package/src/report/react/MetricTable.tsx +4 -76
  137. package/src/report/react/ScopeSummary.tsx +86 -0
  138. package/src/report/react/Scoreboard.tsx +28 -10
  139. package/src/report/react/cell.tsx +2 -2
  140. package/src/report/react/enhance.js +57 -5
  141. package/src/report/react/fixtures.ts +109 -156
  142. package/src/report/react/index.tsx +24 -39
  143. package/src/report/react/render.test.tsx +139 -104
  144. package/src/report/react/styles.css +181 -91
  145. package/src/report/report.test.ts +761 -1031
  146. package/src/report/report.ts +425 -47
  147. package/src/report/text/faces.ts +257 -164
  148. package/src/report/text/plot.ts +1 -1
  149. package/src/report/text/table.ts +2 -2
  150. package/src/report/tree.ts +362 -104
  151. package/src/report/types.ts +257 -287
  152. package/src/report/web.ts +63 -20
  153. package/src/results/attempt-evidence.test.ts +4 -4
  154. package/src/results/attempt-evidence.ts +5 -5
  155. package/src/results/copy.ts +6 -6
  156. package/src/results/host-equivalence.test.ts +26 -14
  157. package/src/results/index.ts +10 -4
  158. package/src/results/open.ts +8 -4
  159. package/src/results/results.test.ts +4 -3
  160. package/src/results/select.ts +104 -34
  161. package/src/results/types.ts +36 -14
  162. package/src/runner/feedback/human.test.ts +1 -1
  163. package/src/runner/run.ts +1 -1
  164. package/src/sandbox/cli-commands.ts +2 -2
  165. package/src/scoring/judge.test.ts +1 -1
  166. package/src/shared/aggregate.ts +5 -4
  167. package/src/show/compose.ts +50 -67
  168. package/src/show/index.ts +107 -56
  169. package/src/show/render.ts +43 -27
  170. package/src/show/report-host.test.ts +188 -0
  171. package/src/show/report-host.ts +375 -0
  172. package/src/show/show.test.ts +86 -36
  173. package/src/view/app/App.test.tsx +69 -0
  174. package/src/view/app/App.tsx +144 -48
  175. package/src/view/app/components/AttemptModal.tsx +324 -63
  176. package/src/view/app/components/CodeView.tsx +10 -4
  177. package/src/view/app/i18n.ts +31 -17
  178. package/src/view/app/lib/artifact-url.ts +14 -3
  179. package/src/view/app/main.tsx +13 -8
  180. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  181. package/src/view/app/types.ts +4 -1
  182. package/src/view/artifact-serving.test.ts +21 -1
  183. package/src/view/client-dist/app.css +1 -1
  184. package/src/view/client-dist/app.js +6 -6
  185. package/src/view/data.test.ts +9 -3
  186. package/src/view/data.ts +145 -49
  187. package/src/view/index.ts +48 -44
  188. package/src/view/server.ts +35 -15
  189. package/src/view/shared/types.ts +34 -5
  190. package/src/view/styles.css +224 -0
  191. package/src/view/view-report.test.ts +161 -57
  192. package/dist/report/built-ins/experiment-comparison.d.ts +0 -39
  193. package/dist/report/built-ins/experiment-comparison.js +0 -119
  194. package/dist/report/built-ins/index.d.ts +0 -2
  195. package/dist/report/built-ins/index.js +0 -2
  196. package/dist/report/react/GroupSummary.d.ts +0 -8
  197. package/dist/report/react/GroupSummary.js +0 -8
  198. package/dist/report/react/RunOverview.d.ts +0 -8
  199. package/dist/report/react/RunOverview.js +0 -12
  200. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  201. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  202. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  203. package/docs-site/zh/example/showcase.mdx +0 -39
  204. package/src/report/built-in-user-parity.test.tsx +0 -597
  205. package/src/report/built-ins/experiment-comparison.tsx +0 -179
  206. package/src/report/built-ins/index.ts +0 -7
  207. package/src/report/react/GroupSummary.tsx +0 -66
  208. package/src/report/react/RunOverview.tsx +0 -109
  209. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  210. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  211. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  212. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  213. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  214. /package/docs-site/zh/{guides → how-to}/ci-integration.mdx +0 -0
  215. /package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +0 -0
  216. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
  217. /package/docs-site/zh/{guides → how-to}/reporters.mdx +0 -0
  218. /package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +0 -0
@@ -1,10 +1,13 @@
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
  import { DEFAULT_REPORT_LOCALE } from "./locale.js";
9
12
  // react/jsx-runtime 的 Fragment 是注册符号,跨 react 版本稳定;不 import react 也认得它
10
13
  const REACT_FRAGMENT = Symbol.for("react.fragment");
@@ -16,17 +19,20 @@ function isReportElement(node) {
16
19
  "props" in node &&
17
20
  typeof node.props === "object");
18
21
  }
19
- // ───────────────────────── 双面组件 ─────────────────────────
20
- /** 挂 faces 的私有键:text 宿主与树校验靠它识别双面组件。 */
22
+ // ───────────────────────── 组件模型 ─────────────────────────
23
+ /** 挂 faces 的私有键:管线与树校验靠它识别双面组件。 */
21
24
  export const COMPONENT_FACES = Symbol.for("niceeval.report.faces");
25
+ /** 挂组合函数的私有键:resolve 阶段靠它识别组合组件。 */
26
+ export const COMPONENT_COMPOSE = Symbol.for("niceeval.report.compose");
27
+ /** Tabs / Tab 的结构角色标记:树校验的配对规则靠它,不 import primitives(避免环)。 */
28
+ export const COMPONENT_ROLE = Symbol.for("niceeval.report.role");
29
+ /**
30
+ * children 是不透明值(自由文本 / CSS 字符串)而非报告树的组件标记(Text / Style):
31
+ * resolve 与 validate 不下钻它们的 children——那是组件自己的 props,不是树。
32
+ */
33
+ export const COMPONENT_RAW_CHILDREN = Symbol.for("niceeval.report.rawChildren");
22
34
  // web 面的环境上下文:web 宿主渲染前设好;宿主之外(组件直接嵌进用户 React 应用)
23
- // 用默认值 —— attemptHref 默认 view 的 attempt 路由格式(自定义组件显式调 ctx.attemptHref
24
- // 时总有去处);官方组件的「宿主里自动接证据室」只在宿主上下文激活时发生,
25
- // 宿主外不传 attemptHref 就是纯展示,不发明断链。
26
- //
27
- // URL 格式取 `#/attempt/${locator}`:AttemptLocator 本身已经是 `@` 前缀的不透明短串
28
- // (如 "@1x7f3q9"),原样嵌进路径段就得到 `#/attempt/@1x7f3q9`——与 docs/feature/reports/view.md「用
29
- // Reports 积木重建 view」定稿的单段路由逐字一致,不额外拆分或去掉 `@`。
35
+ // 用默认值 —— attemptHref 默认 view 的 attempt 路由格式。
30
36
  const DEFAULT_WEB_CONTEXT = {
31
37
  attemptHref: (locator) => `#/attempt/${locator}`,
32
38
  locale: DEFAULT_REPORT_LOCALE,
@@ -47,83 +53,182 @@ export function runWithWebContext(ctx, fn) {
47
53
  export function isHostWebContextActive() {
48
54
  return activeWebContext !== null;
49
55
  }
50
- /**
51
- * 定义一个双面组件:faces 两键必填 —— 少实现一个面编译不过,配对是结构义务。
52
- * 基础实现不 import react;产物以可调用组件的形状兼容 React 渲染。
53
- */
54
- export function defineComponent(faces) {
55
- if (typeof faces?.web !== "function" || typeof faces?.text !== "function") {
56
- throw new Error("defineComponent requires both faces: { web(props, ctx), text(props, ctx) }. " +
57
- "Every report component must render in both hosts (niceeval view and niceeval show).");
56
+ export function defineComponent(input) {
57
+ if (typeof input === "function") {
58
+ const component = ((_props) => {
59
+ throw new Error(`Compose component ${componentDisplayName(input) ?? "(anonymous)"} can only render inside the report pipeline ` +
60
+ "(niceeval show / view, or renderReportToText / renderReportToStaticHtml): it assembles other components " +
61
+ "and needs the host context (scope, results, report). To embed in your own React page, compute data with " +
62
+ "the *Data functions and render the pure components from niceeval/report/react instead.");
63
+ });
64
+ component[COMPONENT_COMPOSE] = input;
65
+ if (componentDisplayName(input))
66
+ component.displayName = componentDisplayName(input);
67
+ return component;
58
68
  }
69
+ if (typeof input?.web !== "function" || typeof input?.text !== "function") {
70
+ throw new Error("defineComponent requires both faces: { web(props, ctx), text(props, ctx) } (resolve is optional). " +
71
+ "Every report component must render in both hosts (niceeval view and niceeval show); " +
72
+ "define the missing face, or pass a compose function to assemble existing components instead.");
73
+ }
74
+ const faces = input;
59
75
  // 直接调用路径:把组件当普通 React 组件嵌进用户自己的页面时走这里,web 面只接收数据形态
60
- // props(R)。带 resolve 的组件若拿 selection 形态 props 走这条裸路径,web 面会缺 data ——
76
+ // props(R)。带 resolve 的组件若拿 spec 形态 props 走这条裸路径,web 面会缺 data ——
61
77
  // 这类组件只有经宿主的 resolveReportTree 解析后才安全渲染;纯数据 props 一直可以裸嵌。
62
78
  const component = ((props) => faces.web(props, activeWebContext ?? DEFAULT_WEB_CONTEXT));
63
79
  component[COMPONENT_FACES] = faces;
64
80
  return component;
65
81
  }
82
+ function componentDisplayName(fn) {
83
+ const named = fn;
84
+ return named.displayName || named.name || undefined;
85
+ }
66
86
  export function facesOf(type) {
67
87
  if (typeof type !== "function")
68
88
  return undefined;
69
89
  return type[COMPONENT_FACES];
70
90
  }
71
- // ───────────────────────── 数据解析(渲染前唯一的 await 边界)─────────────────────────
91
+ export function composeOf(type) {
92
+ if (typeof type !== "function")
93
+ return undefined;
94
+ return type[COMPONENT_COMPOSE];
95
+ }
96
+ function roleOf(type) {
97
+ if (typeof type !== "function")
98
+ return undefined;
99
+ return type[COMPONENT_ROLE];
100
+ }
101
+ function hasRawChildren(type) {
102
+ return typeof type === "function" && type[COMPONENT_RAW_CHILDREN] === true;
103
+ }
104
+ // ───────────────────────── 深相等(记忆化的 spec 比较)─────────────────────────
105
+ /**
106
+ * resolve 记忆化的深相等:只递归比较可序列化值(普通对象 / 数组 / 原始值);
107
+ * 函数与 Metric / Dimension / NumericAxis 一类携带函数的实例按引用比较——
108
+ * 共享计算的成立条件是引用同一实例,引用不同的等价定义只是各算一次,不构成错误。
109
+ */
110
+ export function deepEqualSpec(a, b) {
111
+ if (a === b)
112
+ return true;
113
+ if (typeof a !== typeof b)
114
+ return false;
115
+ if (typeof a !== "object" || a === null || b === null)
116
+ return false;
117
+ if (Array.isArray(a) || Array.isArray(b)) {
118
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
119
+ return false;
120
+ return a.every((item, i) => deepEqualSpec(item, b[i]));
121
+ }
122
+ const protoA = Object.getPrototypeOf(a);
123
+ const protoB = Object.getPrototypeOf(b);
124
+ if ((protoA !== Object.prototype && protoA !== null) || (protoB !== Object.prototype && protoB !== null)) {
125
+ return false; // 非纯对象(类实例等)按引用比较,=== 已在开头判过
126
+ }
127
+ const keysA = Object.keys(a).filter((k) => a[k] !== undefined);
128
+ const keysB = Object.keys(b).filter((k) => b[k] !== undefined);
129
+ if (keysA.length !== keysB.length)
130
+ return false;
131
+ return keysA.every((k) => deepEqualSpec(a[k], b[k]));
132
+ }
133
+ /** 一次页渲染内的记忆化缓存:键 = 计算函数引用 → (同引用 input + 深相等 spec) 命中。 */
134
+ export class ResolveMemo {
135
+ entries = new Map();
136
+ fetch(fn, input, spec, compute) {
137
+ let list = this.entries.get(fn);
138
+ if (!list)
139
+ this.entries.set(fn, (list = []));
140
+ for (const entry of list) {
141
+ if (entry.input === input && deepEqualSpec(entry.spec, spec))
142
+ return entry.result;
143
+ }
144
+ const result = compute();
145
+ list.push({ input, spec, result });
146
+ return result;
147
+ }
148
+ }
149
+ /** 组件 resolve 里安全取记忆化入口:经宿主管线时在场,手工直调 resolve 时退化为直接计算。 */
150
+ export function memoFetchOf(ctx) {
151
+ const internal = ctx;
152
+ return internal.memoFetch ?? (async (_fn, _input, _options, compute) => compute());
153
+ }
154
+ function illegalNodeError(type, path) {
155
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
156
+ if (typeof type === "string") {
157
+ return new Error(`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. ` +
158
+ "Use <Text> for free text, the layout primitives (<Row>/<Col>/<Section>/<Table>), or move the markup into the web face of a defineComponent component.");
159
+ }
160
+ const label = componentLabel(type);
161
+ return new Error(`${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. ` +
162
+ "Wrap it with defineComponent — a compose function defineComponent((props, ctx) => tree) to assemble existing components, " +
163
+ "or an object form defineComponent({ resolve?, text, web }) to render itself.");
164
+ }
165
+ function bareTextError(value, path) {
166
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
167
+ const preview = typeof value === "string" ? JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value) : String(value);
168
+ return new Error(`Report trees cannot contain a bare ${typeof value} (${preview})${where}: free text needs an explicit carrier for terminal line-wrapping and HTML escaping. ` +
169
+ "Wrap it in <Text>…</Text>.");
170
+ }
72
171
  /**
73
- * 报告 build 之后、树校验与 text/web 渲染之前的解析遍历:把声明式数据组件(实现了
74
- * `faces.resolve` 的双面组件,如 selection 形态的 MetricScatter)的 props
75
- * 就地换成算好的数据形态 props(三个实体列表没有 `resolve` 面,不经这一步——它们的 `items`
76
- * 由报告作者在 `build()` 里直接 `await .data(selection)` 备好)。计算发生在这里(唯一允许
77
- * await 的组件级步骤,连同报告函数体自己的 `build()`),两个渲染面
78
- * 之后都只看已解析的树、保持同步零 IO。遍历形状与 {@link validateReportTree} /
79
- * {@link renderNodeToText} 一致:
80
- *
81
- * - 同层数组兄弟并行解析(`Promise.all`),保持原始顺序 / keys;
82
- * - 双面组件带 resolve 的:调 `faces.resolve(props)` 换 props(有 children 再递归解析);
83
- * - 双面组件无 resolve 的(Row / Col / Section / RunOverview…):只递归 children,自身 props
84
- * 原样保留(title / className 等不能动);
85
- * - 普通函数组件:同步调用展开,展开结果继续解析(与 validate / render 对函数组件的处理一致);
86
- * - 字符串 intrinsic(<div>):原样返回,交给随后的 validateReportTree 报同一条错误。
87
- *
88
- * resolver 跑完后,树里已没有函数组件 / 未解析 props;validate / render 里对这两者的分支在
89
- * 正常管线下是 no-op,但保留着——手搭树直接调 validate / render(不过 resolver)的低层用法仍需要。
172
+ * 管线的 resolve 阶段:递归展开组合组件(以 (props, ctx) 调用并 await 返回树)、执行双面
173
+ * 组件的解析面;同层 sibling 并行取数且不改变节点顺序;带 resolve 的组件按「同引用 input +
174
+ * 深相等 spec」记忆化。非法节点(React 组件、未经 defineComponent 的普通函数、任意 HTML
175
+ * intrinsic)在展开遇到时立即以完整用户反馈拒绝,不为非法节点取数。
90
176
  */
91
- export async function resolveReportTree(node) {
177
+ export async function resolveReportTree(node, env) {
178
+ const memo = env.memo ?? new ResolveMemo();
179
+ const composeCtx = { scope: env.scope, results: env.results, report: env.report };
180
+ return resolveNode(node, { memo, composeCtx }, []);
181
+ }
182
+ async function resolveNode(node, state, path) {
92
183
  if (node === null || node === undefined || typeof node === "boolean")
93
184
  return node;
94
- if (typeof node === "string" || typeof node === "number")
185
+ if (typeof node === "string" || typeof node === "number") {
186
+ // 裸字符串在 validate 阶段以带指引的完整反馈拒绝;这里先如实透传,不为它取数。
95
187
  return node;
96
- if (Array.isArray(node))
97
- return Promise.all(node.map(resolveReportTree));
188
+ }
189
+ if (Array.isArray(node)) {
190
+ return Promise.all(node.map((child) => resolveNode(child, state, path)));
191
+ }
98
192
  if (!isReportElement(node))
99
193
  return node;
100
194
  const { type, props } = node;
101
- // 字符串 intrinsic:不在这里报错,让 resolve 后的 validateReportTree 抛统一的那条。
102
- if (typeof type === "string")
103
- return node;
104
195
  if (type === REACT_FRAGMENT) {
105
- const children = await resolveReportTree(props.children);
196
+ const children = await resolveNode(props.children, state, path);
106
197
  return { ...node, props: { ...props, children } };
107
198
  }
199
+ const compose = composeOf(type);
200
+ if (compose) {
201
+ // 组合组件不记忆化:它只装配、不承担取数;数据层的去重由其内部 *Data 调用经 memoFetch 命中。
202
+ const expanded = await compose(props, state.composeCtx);
203
+ return resolveNode(expanded, state, [...path, componentLabel(type)]);
204
+ }
108
205
  const faces = facesOf(type);
109
206
  if (faces) {
110
207
  if (typeof faces.resolve === "function") {
111
- const resolvedProps = { ...(await faces.resolve(props)) };
208
+ const resolveFn = faces.resolve;
209
+ const input = props.input ?? state.composeCtx.scope;
210
+ const ctx = {
211
+ input,
212
+ memoFetch: (dataFn, dataInput, options, compute) => state.memo.fetch(dataFn, dataInput, options, compute),
213
+ };
214
+ const resolved = await state.memo.fetch(resolveFn, input, props, async () => resolveFn(props, ctx));
215
+ const resolvedProps = { ...resolved };
112
216
  if (resolvedProps.children !== undefined) {
113
- resolvedProps.children = await resolveReportTree(resolvedProps.children);
217
+ resolvedProps.children = await resolveNode(resolvedProps.children, state, [
218
+ ...path,
219
+ componentLabel(type),
220
+ ]);
114
221
  }
115
222
  return { ...node, props: resolvedProps };
116
223
  }
117
- // 无 resolve 的容器 / 数据组件:只解析 children,自身 props 原样保留。
118
- const children = await resolveReportTree(props.children);
224
+ // 无 resolve 的容器 / 纯数据组件:只解析 children,自身 props 原样保留(title / className 不能动)。
225
+ if (hasRawChildren(type))
226
+ return node; // Text / Style 的 children 是不透明值,不是树
227
+ const children = await resolveNode(props.children, state, [...path, componentLabel(type)]);
119
228
  return { ...node, props: { ...props, children } };
120
229
  }
121
- if (typeof type === "function") {
122
- // 普通函数组件:调用展开,展开结果整体替换本节点后继续解析。
123
- const expanded = type(props);
124
- return resolveReportTree(expanded);
125
- }
126
- return node;
230
+ // 非法节点:HTML intrinsic、React 组件、未经 defineComponent 的普通函数 —— 立即拒绝,不取数。
231
+ throw illegalNodeError(type, path);
127
232
  }
128
233
  // ───────────────────────── 树校验 ─────────────────────────
129
234
  function componentLabel(type) {
@@ -137,72 +242,130 @@ function componentLabel(type) {
137
242
  return "<>";
138
243
  return `<${String(type)}>`;
139
244
  }
245
+ function directChildElements(children) {
246
+ const out = [];
247
+ const visit = (node) => {
248
+ if (node === null || node === undefined || typeof node === "boolean")
249
+ return;
250
+ if (Array.isArray(node)) {
251
+ for (const child of node)
252
+ visit(child);
253
+ return;
254
+ }
255
+ if (isReportElement(node)) {
256
+ if (node.type === REACT_FRAGMENT) {
257
+ visit(node.props.children);
258
+ return;
259
+ }
260
+ out.push(node);
261
+ return;
262
+ }
263
+ out.push(node);
264
+ };
265
+ visit(children);
266
+ return out;
267
+ }
140
268
  /**
141
- * 渲染前树校验:页面树里只放双面组件、排版原语与普通组合函数,字符串 intrinsic
142
- * (<div>)报错、指名组件路径。这是运行时校验而非编译期(标准 JSX 下 TS 把一切
143
- * JSX 表达式统一成 JSX.Element);两个宿主渲染前跑同一遍 —— 不做单侧宽容,否则
144
- * 对着 view 写的页面到 show 才炸。校验只下钻 children(children 就是报告树);
145
- * 普通函数组件调用展开(渲染面纯同步,重复调用无副作用)。
269
+ * 管线的 validate 阶段:确保展开后树中每个组件都有 text 和 web 两面。校验只看节点资格,
270
+ * 不限定树形:根节点可以是单个组件、Col Tabs,宿主不强制任何最外层容器。
271
+ * 裸字符串 / 数字、HTML intrinsic、无两面资格的函数都以完整用户反馈拒绝;
272
+ * Tabs / Tab 的结构配对(空 Tabs、游离 Tab、非 Tab 直接子节点)也在这里校验。
146
273
  */
147
- export function validateReportTree(node, path = []) {
274
+ export function validateReportTree(node, path = [], insideTabs = false) {
148
275
  if (node === null || node === undefined || typeof node === "boolean")
149
276
  return;
150
- if (typeof node === "string" || typeof node === "number")
151
- return;
277
+ if (typeof node === "string" || typeof node === "number") {
278
+ throw bareTextError(node, path);
279
+ }
152
280
  if (Array.isArray(node)) {
153
281
  for (const child of node)
154
- validateReportTree(child, path);
282
+ validateReportTree(child, path, insideTabs);
155
283
  return;
156
284
  }
157
- if (!isReportElement(node))
285
+ if (!isReportElement(node)) {
286
+ if (typeof node === "object" && node !== null && node.kind === "report") {
287
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
288
+ throw new Error(`A defineReport(...) product is not a report node${where}: the shell cannot nest. ` +
289
+ "Put the page content (a tree or component) here, and keep defineReport for the file's default export only.");
290
+ }
158
291
  return;
159
- const { type, props } = node;
160
- if (typeof type === "string") {
161
- const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
162
- throw new Error(`Raw HTML <${type}> has no terminal face; use <Text>, layout primitives, or a defineComponent component.${where}`);
163
292
  }
293
+ const { type, props } = node;
164
294
  if (type === REACT_FRAGMENT) {
165
- validateReportTree(props.children, path);
295
+ validateReportTree(props.children, path, insideTabs);
166
296
  return;
167
297
  }
298
+ if (typeof type === "string")
299
+ throw illegalNodeError(type, path);
168
300
  const label = componentLabel(type);
169
- if (facesOf(type)) {
170
- // 双面组件是校验的信任边界之内的叶子,但 children 仍是报告树的一部分
171
- validateReportTree(props.children, [...path, label]);
301
+ const role = roleOf(type);
302
+ if (role === "tab" && !insideTabs) {
303
+ const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
304
+ throw new Error(`<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.`);
305
+ }
306
+ if (role === "tabs") {
307
+ const children = directChildElements(props.children);
308
+ const nonTab = children.find((child) => roleOf(child.type) !== "tab");
309
+ if (nonTab !== undefined) {
310
+ throw new Error(`<Tabs> only accepts <Tab> as direct children (found ${isReportElement(nonTab) ? componentLabel(nonTab.type) : typeof nonTab} in ${[...path, label].join(" > ")}). Move the content inside a <Tab title="…">, or lift it out of <Tabs>.`);
311
+ }
312
+ if (children.length === 0) {
313
+ throw new Error(`<Tabs> needs at least one <Tab> child (in ${[...path, label].join(" > ")}). Add <Tab title="…">…</Tab>, or remove the empty <Tabs>.`);
314
+ }
315
+ for (const tab of children) {
316
+ validateReportTree(tab.props.children, [...path, label, componentLabel(tab.type)], false);
317
+ }
172
318
  return;
173
319
  }
174
- if (typeof type === "function") {
175
- // 普通函数组件 = 用户拿函数组合页面片段:调用展开继续校验
176
- const expanded = type(props);
177
- validateReportTree(expanded, [...path, label]);
320
+ const faces = facesOf(type);
321
+ if (faces) {
322
+ // 双面资格:无类型 JS 输入可能绕过 defineComponent 的定义期校验,这里再拦一遍
323
+ if (typeof faces.web !== "function" || typeof faces.text !== "function") {
324
+ const missing = typeof faces.web !== "function" ? "web" : "text";
325
+ throw new Error(`${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.`);
326
+ }
327
+ // 双面组件是校验的信任边界之内的叶子,但 children 仍是报告树的一部分
328
+ // (Text / Style 除外:它们的 children 是不透明的自由文本 / CSS 字符串)
329
+ if (!hasRawChildren(type))
330
+ validateReportTree(props.children, [...path, label], false);
178
331
  return;
179
332
  }
180
- const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
181
- throw new Error(`Unsupported node type ${label} in report tree.${where}`);
333
+ if (composeOf(type)) {
334
+ // 正常管线下 resolve 已把组合组件展开;手搭树直接 validate 时如实指出它还没展开
335
+ throw new Error(`${label} is a compose component and must be expanded by the resolve pipeline before validation. ` +
336
+ "Render through defineReport + niceeval show/view (or renderReportToText/renderReportToStaticHtml).");
337
+ }
338
+ throw illegalNodeError(type, path);
339
+ }
340
+ function shellQuote(value) {
341
+ return /^[A-Za-z0-9._/@-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
182
342
  }
183
343
  export function createTextContext(options) {
184
344
  const width = Math.max(20, options?.width ?? 80);
185
345
  const locale = options?.locale ?? DEFAULT_REPORT_LOCALE;
186
346
  // 默认下钻命令:AttemptLocator 是 `@` 前缀的不透明短串,`niceeval show @<locator>` 是
187
- // show/index.ts 已实现的真实 CLI 语法(见该文件 `@<locator>` 位置参数解析),不需要
188
- // 反查 eval id 再拼一条近似命令。
347
+ // show 已实现的真实 CLI 语法,不需要反查 eval id 再拼一条近似命令。
189
348
  const attemptCommand = options?.attemptCommand ?? ((locator) => `niceeval show ${locator}`);
349
+ const experimentCommand = options?.experimentCommand ?? ((prefix) => `niceeval show --experiment ${shellQuote(prefix)}`);
190
350
  const make = (w) => ({
191
351
  width: w,
192
352
  locale,
193
353
  attemptCommand,
354
+ experimentCommand,
194
355
  render(node, childWidth) {
195
356
  return renderNodeToText(node, childWidth === undefined ? this : make(Math.max(10, childWidth)));
196
357
  },
197
358
  });
198
359
  return make(width);
199
360
  }
200
- /** text 宿主的遍历渲染:双面组件走 text 面,普通函数调用展开,块之间以换行相接。 */
361
+ /** text 宿主的遍历渲染:双面组件走 text 面,块之间以换行相接。只吃已 resolve + validate 的树。 */
201
362
  export function renderNodeToText(node, ctx) {
202
363
  if (node === null || node === undefined || typeof node === "boolean")
203
364
  return "";
204
- if (typeof node === "string" || typeof node === "number")
205
- return String(node);
365
+ if (typeof node === "string" || typeof node === "number") {
366
+ // 校验先行会拦住;渲染路径自身也不宽容
367
+ throw bareTextError(node, []);
368
+ }
206
369
  if (Array.isArray(node)) {
207
370
  return node
208
371
  .map((child) => renderNodeToText(child, ctx))
@@ -212,17 +375,12 @@ export function renderNodeToText(node, ctx) {
212
375
  if (!isReportElement(node))
213
376
  return "";
214
377
  const { type, props } = node;
215
- if (typeof type === "string") {
216
- // 校验先行会拦住;这里兜底同一条错误,渲染路径自身也不宽容
217
- throw new Error(`Raw HTML <${type}> has no terminal face; use <Text>, layout primitives, or a defineComponent component.`);
218
- }
378
+ if (typeof type === "string")
379
+ throw illegalNodeError(type, []);
219
380
  if (type === REACT_FRAGMENT)
220
381
  return renderNodeToText(props.children, ctx);
221
382
  const faces = facesOf(type);
222
383
  if (faces)
223
384
  return faces.text(props, ctx);
224
- if (typeof type === "function") {
225
- return renderNodeToText(type(props), ctx);
226
- }
227
- return "";
385
+ throw illegalNodeError(type, []);
228
386
  }