niceeval 0.8.1 → 0.9.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 (140) hide show
  1. package/INDEX.md +77 -45
  2. package/dist/agents/types.d.ts +28 -6
  3. package/dist/i18n/en.d.ts +2 -0
  4. package/dist/i18n/zh-CN.d.ts +3 -1
  5. package/dist/report/built-in/index.d.ts +3 -2
  6. package/dist/report/built-in/index.js +7 -8
  7. package/dist/report/built-in/standard.d.ts +1 -0
  8. package/dist/report/built-in/standard.js +30 -0
  9. package/dist/report/components.d.ts +69 -2
  10. package/dist/report/components.js +152 -3
  11. package/dist/report/compute.d.ts +28 -1
  12. package/dist/report/compute.js +123 -0
  13. package/dist/report/index.d.ts +4 -4
  14. package/dist/report/index.js +3 -2
  15. package/dist/report/locale.d.ts +39 -1
  16. package/dist/report/locale.js +69 -0
  17. package/dist/report/react/AttemptList.d.ts +3 -1
  18. package/dist/report/react/AttemptList.js +3 -3
  19. package/dist/report/react/CopyFixPrompt.d.ts +12 -0
  20. package/dist/report/react/CopyFixPrompt.js +12 -0
  21. package/dist/report/react/HeroCard.d.ts +13 -0
  22. package/dist/report/react/HeroCard.js +35 -0
  23. package/dist/report/react/PoweredBy.d.ts +5 -0
  24. package/dist/report/react/PoweredBy.js +7 -0
  25. package/dist/report/react/ScopeWarnings.d.ts +12 -0
  26. package/dist/report/react/ScopeWarnings.js +18 -0
  27. package/dist/report/react/TraceWaterfall.d.ts +14 -0
  28. package/dist/report/react/TraceWaterfall.js +22 -0
  29. package/dist/report/react/index.d.ts +6 -1
  30. package/dist/report/react/index.js +6 -0
  31. package/dist/report/report.d.ts +22 -6
  32. package/dist/report/report.js +68 -55
  33. package/dist/report/scope-warnings.d.ts +28 -0
  34. package/dist/report/scope-warnings.js +101 -0
  35. package/dist/report/text/faces.d.ts +19 -1
  36. package/dist/report/text/faces.js +61 -0
  37. package/dist/report/tree.d.ts +1 -1
  38. package/dist/report/tree.js +7 -2
  39. package/dist/report/types.d.ts +45 -0
  40. package/dist/report/web.d.ts +5 -4
  41. package/dist/report/web.js +7 -22
  42. package/dist/results/select.d.ts +17 -4
  43. package/dist/results/select.js +76 -11
  44. package/dist/results/types.d.ts +26 -0
  45. package/dist/runner/fingerprint.d.ts +3 -3
  46. package/dist/runner/sandbox-selection.d.ts +12 -0
  47. package/dist/runner/types.d.ts +18 -6
  48. package/dist/sandbox/types.d.ts +12 -0
  49. package/dist/shared/aggregate.d.ts +1 -1
  50. package/dist/shared/aggregate.js +1 -1
  51. package/docs-site/zh/explanation/evals.mdx +2 -1
  52. package/docs-site/zh/explanation/experiment.mdx +2 -0
  53. package/docs-site/zh/how-to/custom-reports.mdx +23 -7
  54. package/docs-site/zh/how-to/experiments.mdx +2 -2
  55. package/docs-site/zh/how-to/publish-report.mdx +11 -4
  56. package/docs-site/zh/how-to/viewing-results.mdx +17 -8
  57. package/docs-site/zh/how-to/write-experiment.mdx +40 -1
  58. package/docs-site/zh/reference/builtin-agents.mdx +40 -3
  59. package/docs-site/zh/reference/cli.mdx +3 -3
  60. package/docs-site/zh/reference/define-eval.mdx +8 -0
  61. package/docs-site/zh/reference/official-adapters.mdx +10 -5
  62. package/docs-site/zh/troubleshooting/debugging.mdx +3 -3
  63. package/package.json +2 -1
  64. package/src/agents/bub.ts +13 -1
  65. package/src/agents/claude-code.test.ts +43 -1
  66. package/src/agents/claude-code.ts +32 -14
  67. package/src/agents/codex.test.ts +168 -1
  68. package/src/agents/codex.ts +51 -15
  69. package/src/agents/mcp.ts +31 -0
  70. package/src/agents/post-setup.ts +33 -0
  71. package/src/agents/types.ts +28 -7
  72. package/src/cli.ts +15 -14
  73. package/src/define.ts +3 -0
  74. package/src/i18n/en.ts +8 -5
  75. package/src/i18n/zh-CN.ts +8 -4
  76. package/src/index.ts +1 -0
  77. package/src/report/built-in/index.tsx +8 -7
  78. package/src/report/built-in/standard.tsx +59 -0
  79. package/src/report/components.tsx +218 -2
  80. package/src/report/compute.ts +138 -1
  81. package/src/report/dual-render.test.tsx +141 -14
  82. package/src/report/index.ts +20 -0
  83. package/src/report/locale.ts +83 -1
  84. package/src/report/react/AttemptList.tsx +13 -1
  85. package/src/report/react/CopyFixPrompt.tsx +37 -0
  86. package/src/report/react/HeroCard.tsx +59 -0
  87. package/src/report/react/PoweredBy.tsx +20 -0
  88. package/src/report/react/ScopeWarnings.tsx +74 -0
  89. package/src/report/react/TraceWaterfall.tsx +78 -0
  90. package/src/report/react/enhance.js +14 -0
  91. package/src/report/react/index.tsx +11 -0
  92. package/src/report/react/styles.css +193 -7
  93. package/src/report/report.ts +99 -64
  94. package/src/report/scope-warnings.ts +155 -0
  95. package/src/report/site-components.test.tsx +526 -0
  96. package/src/report/text/faces.ts +66 -0
  97. package/src/report/tree.ts +10 -3
  98. package/src/report/types.ts +51 -0
  99. package/src/report/web.ts +7 -40
  100. package/src/results/host-equivalence.test.ts +6 -2
  101. package/src/results/open.ts +5 -4
  102. package/src/results/results.test.ts +78 -1
  103. package/src/results/select.ts +80 -12
  104. package/src/results/types.ts +27 -0
  105. package/src/runner/attempt.ts +10 -9
  106. package/src/runner/discover.test.ts +9 -1
  107. package/src/runner/discover.ts +3 -3
  108. package/src/runner/fingerprint.ts +9 -4
  109. package/src/runner/ledger.test.ts +30 -1
  110. package/src/runner/ledger.ts +26 -4
  111. package/src/runner/run.ts +5 -1
  112. package/src/runner/sandbox-selection.test.ts +131 -0
  113. package/src/runner/sandbox-selection.ts +110 -0
  114. package/src/runner/types.ts +19 -2
  115. package/src/sandbox/types.ts +6 -0
  116. package/src/shared/aggregate.ts +1 -1
  117. package/src/show/index.ts +19 -12
  118. package/src/show/render.ts +12 -12
  119. package/src/show/report-host.test.ts +34 -17
  120. package/src/show/report-host.ts +6 -5
  121. package/src/show/show.test.ts +141 -4
  122. package/src/view/app/App.test.tsx +79 -17
  123. package/src/view/app/App.tsx +25 -74
  124. package/src/view/app/components/CopyControls.tsx +4 -42
  125. package/src/view/app/i18n.ts +5 -227
  126. package/src/view/app/lib/rows.ts +3 -21
  127. package/src/view/app/shared.ts +1 -3
  128. package/src/view/app/types.ts +2 -2
  129. package/src/view/client-dist/app.css +1 -1
  130. package/src/view/client-dist/app.js +20 -20
  131. package/src/view/data.ts +21 -10
  132. package/src/view/index.ts +2 -12
  133. package/src/view/server.ts +4 -4
  134. package/src/view/shared/types.ts +11 -6
  135. package/src/view/styles.css +9 -252
  136. package/src/view/view-report.test.ts +99 -33
  137. package/src/view/app/components/LazyArtifact.tsx +0 -51
  138. package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
  139. package/src/view/app/pages/AttemptsPage.tsx +0 -80
  140. package/src/view/app/pages/TracesPage.tsx +0 -35
@@ -0,0 +1,78 @@
1
+ // TraceWaterfall:执行时间瀑布的 web 面——每个 attempt 一行,静态渲染顶层 span 分解条
2
+ // (定位按 startOffsetMs / durationMs 百分比;失败 span 带失败标记),行链接 attempt 详情。
3
+ // trace 缺失的行照常出现并如实显示缺失;排序、缩放是渐进增强,静态 HTML 无 JS 完整可读
4
+ // (docs/feature/reports/library/site-components.md「TraceWaterfall」)。
5
+
6
+ import type { ReactElement } from "react";
7
+ import type { TraceWaterfallRow } from "../types.ts";
8
+ import type { AttemptLocator } from "../../results/locator.ts";
9
+ import { DEFAULT_REPORT_LOCALE, countText, localeText, type ReportLocale } from "../locale.ts";
10
+ import { cx, formatDurationMs } from "./format.ts";
11
+
12
+ const DEFAULT_ATTEMPT_HREF = (locator: AttemptLocator): string => `#/attempt/${locator}`;
13
+
14
+ function pct(part: number, total: number): string {
15
+ if (total <= 0) return "0%";
16
+ return `${Math.min(100, Math.max(0, (part / total) * 100)).toFixed(2)}%`;
17
+ }
18
+
19
+ /**
20
+ * 执行时间瀑布(纯 web 渲染面):嵌入自有 React 页面时配合 `traceWaterfallData()` 使用。
21
+ * 只画被测 agent 的原始 span;runner 生命周期节点不在 data 里,组合视图归 attempt 详情。
22
+ */
23
+ export function TraceWaterfall({
24
+ data,
25
+ attemptHref = DEFAULT_ATTEMPT_HREF,
26
+ className,
27
+ locale = DEFAULT_REPORT_LOCALE,
28
+ }: {
29
+ data: readonly TraceWaterfallRow[];
30
+ attemptHref?: (locator: AttemptLocator) => string;
31
+ className?: string;
32
+ locale?: ReportLocale;
33
+ }): ReactElement {
34
+ return (
35
+ <section className={cx("nre", "nre-trace-waterfall", className)}>
36
+ {data.length === 0 && <p className="nre-waterfall-empty">{localeText(locale, "traceWaterfall.empty")}</p>}
37
+ <ul className="nre-waterfall">
38
+ {data.map((row) => {
39
+ const failedSpans = row.spans.filter((span) => span.failed).length;
40
+ return (
41
+ <li key={row.locator} className="nre-waterfall-row">
42
+ <div className="nre-waterfall-head">
43
+ <a className="nre-locator" href={attemptHref(row.locator)}>
44
+ {row.locator}
45
+ </a>
46
+ <span className="nre-waterfall-eval">{row.evalId}</span>
47
+ <span className="nre-waterfall-experiment">{row.experimentId}</span>
48
+ {/* trace 缺失如实显示缺失,不猜值、不藏行 */}
49
+ <span className="nre-waterfall-duration">
50
+ {row.durationMs === null ? localeText(locale, "traceWaterfall.noTrace") : formatDurationMs(row.durationMs)}
51
+ </span>
52
+ <span className="nre-waterfall-count">{countText(locale, "traceWaterfall.spans", row.spans.length)}</span>
53
+ {failedSpans > 0 && (
54
+ <span className="nre-waterfall-failed">✗ {countText(locale, "traceWaterfall.failedSpans", failedSpans)}</span>
55
+ )}
56
+ </div>
57
+ {row.durationMs !== null && row.spans.length > 0 && (
58
+ <div className="nre-waterfall-track">
59
+ {row.spans.map((span, i) => (
60
+ <span
61
+ key={i}
62
+ className={cx("nre-waterfall-span", `nre-span-${span.kind}`, span.failed && "nre-span-failed")}
63
+ style={{
64
+ left: pct(span.startOffsetMs, row.durationMs!),
65
+ width: `max(${pct(span.durationMs, row.durationMs!)}, 0.5%)`,
66
+ }}
67
+ title={`${span.name} · ${formatDurationMs(span.durationMs)}${span.failed ? " · ✗" : ""}`}
68
+ />
69
+ ))}
70
+ </div>
71
+ )}
72
+ </li>
73
+ );
74
+ })}
75
+ </ul>
76
+ </section>
77
+ );
78
+ }
@@ -196,6 +196,20 @@
196
196
  }
197
197
  });
198
198
 
199
+ // AttemptList 的过滤(<AttemptList filter />):按条目全文 + data-nre-verdict(verdict 词
200
+ // 不在可见文本里,判定符是 ✓/✗)收窄 .nre-attempt 行。只改浏览状态,不改数据与初始 HTML。
201
+ document.addEventListener("input", function (e) {
202
+ var input = closest(e.target, "input[data-nre-attempt-filter]");
203
+ if (!input) return;
204
+ var scope = input.parentElement;
205
+ var rows = scope ? scope.querySelectorAll(".nre-attempt") : [];
206
+ var query = input.value.trim().toLowerCase();
207
+ for (var i = 0; i < rows.length; i++) {
208
+ var haystack = rows[i].textContent + " " + (rows[i].getAttribute("data-nre-verdict") || "");
209
+ rows[i].classList.toggle("nre-row-hidden", query !== "" && haystack.toLowerCase().indexOf(query) === -1);
210
+ }
211
+ });
212
+
199
213
  // ───────────────────────── tooltip:.nre-scatter-point / .nre-line-point ─────────────────────────
200
214
  // 首次 hover 时把点内 <title> 的内容搬进 data-nre-title(避免与原生 tooltip 重影),
201
215
  // 渲染样式化 tooltip div(定位在点上方,挂在所属 figure 里)。无 JS 时 <title> 原样生效。
@@ -21,16 +21,25 @@ export { MetricLine } from "./MetricLine.tsx";
21
21
  export { DeltaTable } from "./DeltaTable.tsx";
22
22
  export { Scoreboard } from "./Scoreboard.tsx";
23
23
 
24
+ // 站点组件的纯 web 面(data 形态;Hero 是组合组件,只住 niceeval/report)
25
+ export { HeroCard } from "./HeroCard.tsx";
26
+ export { PoweredBy } from "./PoweredBy.tsx";
27
+ export { ScopeWarnings } from "./ScopeWarnings.tsx";
28
+ export { CopyFixPrompt } from "./CopyFixPrompt.tsx";
29
+ export { TraceWaterfall } from "./TraceWaterfall.tsx";
30
+
24
31
  // 数据契约类型(家在 ../types.ts,「算」与「画」两侧共用同一份)
25
32
  export type {
26
33
  AttemptListItem,
27
34
  AttemptLocator,
35
+ CopyFixPromptData,
28
36
  DeltaData,
29
37
  EvalListItem,
30
38
  ExperimentComparisonData,
31
39
  ExperimentComparisonGroupData,
32
40
  ExperimentListEvalRow,
33
41
  ExperimentListItem,
42
+ HeroData,
34
43
  LineData,
35
44
  MatrixData,
36
45
  MetricCell,
@@ -40,6 +49,8 @@ export type {
40
49
  ScopeWarning,
41
50
  ScoreboardData,
42
51
  TableData,
52
+ TraceSpanSummary,
53
+ TraceWaterfallRow,
43
54
  VerdictTally,
44
55
  } from "../types.ts";
45
56
 
@@ -436,18 +436,63 @@ table.nre,
436
436
  margin: 0.25rem 0 0;
437
437
  }
438
438
 
439
- /* ---- 宿主警告块:.nre-report-warnings 外壳 + ul.nre-warnings + li.nre-warning[data-kind] ---- */
439
+ /* ---- 宿主警告块:.nre-warnings 是默认折叠的 <details>,<summary> 汇总行恒可见。
440
+ 不占框:无边框、无底色、不缩进,警示色只落在汇总行文字与徽标上。 ---- */
440
441
  .nre .nre-warnings {
441
- list-style: none;
442
442
  margin: 0;
443
- padding: 10px 14px;
444
- border: 1px solid color-mix(in oklch, var(--warn), var(--line) 45%);
445
- border-left: 3px solid var(--warn);
446
- border-radius: 8px;
447
- background: color-mix(in oklch, var(--warn), var(--panel) 92%);
448
443
  color: var(--text);
449
444
  font-size: 13px;
450
445
  }
446
+ /* 外层 <details> 的 <summary>:分类计数汇总行,原生无 JS 可展开。 */
447
+ .nre .nre-warnings > .nre-warnings-summary {
448
+ font-weight: 650;
449
+ color: var(--warn);
450
+ cursor: pointer;
451
+ }
452
+ .nre .nre-warning-groups {
453
+ list-style: none;
454
+ margin: 8px 0 0;
455
+ padding: 0;
456
+ }
457
+ .nre .nre-warning-group + .nre-warning-group {
458
+ margin-top: 10px;
459
+ padding-top: 10px;
460
+ border-top: 1px solid var(--line);
461
+ }
462
+ .nre .nre-warning-head {
463
+ display: flex;
464
+ flex-wrap: wrap;
465
+ align-items: baseline;
466
+ gap: 4px 10px;
467
+ }
468
+ .nre .nre-warning-title { font-weight: 650; }
469
+ .nre .nre-warning-badge {
470
+ padding: 1px 8px;
471
+ border: 1px solid color-mix(in oklch, var(--warn), var(--line) 35%);
472
+ border-radius: 999px;
473
+ background: color-mix(in oklch, var(--warn), var(--panel) 84%);
474
+ font-size: 0.9em;
475
+ white-space: nowrap;
476
+ }
477
+ /* freshness 组(数字对但可能过期)视觉降一档,integrity 保持警示底色。 */
478
+ .nre .nre-warning-group[data-category="freshness"] .nre-warning-badge {
479
+ border-color: var(--line);
480
+ background: var(--panel);
481
+ color: var(--soft);
482
+ }
483
+ .nre .nre-warning-details { margin: 4px 0 0; }
484
+ /* <details> 原生无 JS 可展开,cursor 不经 .nre-js 门控。 */
485
+ .nre .nre-warning-details > summary {
486
+ color: var(--soft);
487
+ font-size: 0.9em;
488
+ cursor: pointer;
489
+ }
490
+ .nre .nre-warning-details > ul {
491
+ list-style: none;
492
+ margin: 4px 0 0;
493
+ padding: 0 0 0 12px;
494
+ border-left: 2px solid var(--line);
495
+ }
451
496
  .nre .nre-warning + .nre-warning { margin-top: 8px; }
452
497
  /* 警告携带的下一步命令:等宽块,无 JS 时点击即全选;enhance.js 在场时点击复制 */
453
498
  .nre .nre-warning-command {
@@ -470,6 +515,147 @@ table.nre,
470
515
  color: var(--good);
471
516
  }
472
517
 
518
+ /* ---- Hero / PoweredBy(站点标题区与品牌行) ---- */
519
+ /* 居中大标题:hero 是页首的门面块,整块占满内容列宽、文本水平居中,标题随视口 clamp 放大,
520
+ meta 与品牌行居中跟随。不设 max-width——窄框居中在宽内容列里会读成左偏,占满列宽才真居中。 */
521
+ .nre-hero {
522
+ text-align: center;
523
+ margin: 0 0 clamp(28px, 5vw, 44px);
524
+ }
525
+ .nre-hero .nre-hero-title {
526
+ margin: 0;
527
+ font-size: clamp(38px, 5vw, 60px);
528
+ line-height: 0.98;
529
+ font-weight: 760;
530
+ letter-spacing: -0.01em;
531
+ }
532
+ .nre-hero .nre-hero-meta {
533
+ margin: 14px 0 0;
534
+ color: var(--muted);
535
+ font-size: 14px;
536
+ }
537
+ .nre-powered-by {
538
+ margin: 10px 0 0;
539
+ font-size: 12px;
540
+ }
541
+ .nre-powered-by a {
542
+ color: var(--soft);
543
+ text-decoration: none;
544
+ }
545
+ .nre-powered-by a:hover { color: var(--text); text-decoration: underline; }
546
+
547
+ /* ---- CopyFixPrompt(批量修复 prompt:折叠块 + 增强层复制) ---- */
548
+ .nre-copy-fix-prompt {
549
+ margin: 0 0 16px;
550
+ border: 1px solid var(--line);
551
+ border-radius: 8px;
552
+ background: var(--panel);
553
+ padding: 8px 12px;
554
+ }
555
+ .nre-copy-fix-prompt > summary {
556
+ cursor: pointer;
557
+ font-weight: 620;
558
+ font-size: 13px;
559
+ }
560
+ .nre-copy-fix-prompt .nre-copy-fix-prompt-copy {
561
+ margin: 8px 0 0;
562
+ padding: 4px 10px;
563
+ border: 1px solid var(--line);
564
+ border-radius: 7px;
565
+ background: var(--panel);
566
+ color: var(--text);
567
+ font: inherit;
568
+ font-size: 12px;
569
+ }
570
+ :root.nre-js .nre-copy-fix-prompt .nre-copy-fix-prompt-copy { cursor: copy; }
571
+ .nre-copy-fix-prompt .nre-copy-fix-prompt-copy[data-nre-copied]::after {
572
+ content: "✓";
573
+ margin-left: 0.6em;
574
+ color: var(--good);
575
+ }
576
+ .nre-copy-fix-prompt .nre-copy-fix-prompt-text {
577
+ margin: 8px 0 0;
578
+ padding: 8px 10px;
579
+ border: 1px solid var(--line);
580
+ border-radius: 7px;
581
+ background: var(--bg, transparent);
582
+ font-size: 12px;
583
+ line-height: 1.5;
584
+ overflow-x: auto;
585
+ white-space: pre-wrap;
586
+ overflow-wrap: anywhere;
587
+ }
588
+
589
+ /* ---- TraceWaterfall(每 attempt 一行的静态时间瀑布) ---- */
590
+ .nre-trace-waterfall .nre-waterfall {
591
+ list-style: none;
592
+ margin: 0;
593
+ padding: 0;
594
+ }
595
+ .nre-trace-waterfall .nre-waterfall-row {
596
+ border: 1px solid var(--line);
597
+ border-radius: 8px;
598
+ padding: 10px 14px;
599
+ margin-bottom: 8px;
600
+ background: var(--panel);
601
+ }
602
+ .nre-trace-waterfall .nre-waterfall-head {
603
+ display: flex;
604
+ flex-wrap: wrap;
605
+ gap: 0.75em;
606
+ align-items: baseline;
607
+ }
608
+ .nre-trace-waterfall .nre-waterfall-eval { font-weight: 620; }
609
+ .nre-trace-waterfall .nre-waterfall-experiment,
610
+ .nre-trace-waterfall .nre-waterfall-duration,
611
+ .nre-trace-waterfall .nre-waterfall-count {
612
+ color: var(--muted);
613
+ font-size: 0.9em;
614
+ font-variant-numeric: tabular-nums;
615
+ }
616
+ .nre-trace-waterfall .nre-waterfall-failed { color: var(--bad); font-size: 0.9em; }
617
+ /* 分解条:span 按 startOffsetMs / durationMs 百分比静态定位,失败 span 换警示色 */
618
+ .nre-trace-waterfall .nre-waterfall-track {
619
+ position: relative;
620
+ height: 14px;
621
+ margin-top: 8px;
622
+ border-radius: 4px;
623
+ background: color-mix(in oklch, var(--line), var(--panel) 60%);
624
+ overflow: hidden;
625
+ }
626
+ .nre-trace-waterfall .nre-waterfall-span {
627
+ position: absolute;
628
+ top: 2px;
629
+ bottom: 2px;
630
+ border-radius: 2px;
631
+ background: var(--line-strong);
632
+ }
633
+ .nre-trace-waterfall .nre-span-agent { background: color-mix(in oklch, var(--good), var(--line-strong) 40%); }
634
+ .nre-trace-waterfall .nre-span-model { background: color-mix(in oklch, var(--warn), var(--line-strong) 40%); }
635
+ .nre-trace-waterfall .nre-span-tool { background: color-mix(in oklch, var(--line-strong), var(--text) 25%); }
636
+ .nre-trace-waterfall .nre-span-failed { background: var(--bad); }
637
+ .nre-trace-waterfall .nre-waterfall-empty {
638
+ border: 1px dashed var(--line-strong);
639
+ border-radius: 8px;
640
+ padding: 18px;
641
+ color: var(--muted);
642
+ text-align: center;
643
+ background: var(--panel);
644
+ }
645
+
646
+ /* ---- AttemptList 的过滤框(<AttemptList filter />,渐进增强) ---- */
647
+ .nre-attempt-list > .nre-filter {
648
+ height: 34px;
649
+ min-width: 260px;
650
+ margin-bottom: 12px;
651
+ border: 1px solid var(--line);
652
+ border-radius: 7px;
653
+ padding: 0 11px;
654
+ background: var(--panel);
655
+ color: var(--text);
656
+ font: inherit;
657
+ }
658
+
473
659
  /* ---- MetricMatrix ---- */
474
660
  .nre-metric-matrix .nre-matrix-caption {
475
661
  caption-side: top;
@@ -1,7 +1,9 @@
1
1
  // defineReport:唯一可被宿主装载的产物 —— 一层外壳(标题、外链、页脚、head 标签、脚本、样式)加
2
2
  // 非空页列表;单页与多页不是两种机制,页数只是列表长度(docs/feature/reports/library/shell.md)。
3
3
  // 入参有两级缩写,各有精确展开:树入参 ≡ { content: 树 } ≡ pages: [{ id: "report",
4
- // title: 内置页名, content: 树 }]。`content` `pages` 恰好声明一个,没有隐式默认。
4
+ // title: 内置页名, content: 树 }]。`content` / `pages` / `extends` 恰好声明一个,没有隐式默认;
5
+ // `extends` 在另一份报告上叠外壳——页归 base、外壳逐字段覆盖,合并在调用时折叠完成,
6
+ // 宿主装载看到的永远是已折叠的普通产物。
5
7
  //
6
8
  // renderReportToText 是 text 宿主(show)的装载入口;web 宿主(view)的
7
9
  // renderReportToStaticHtml 在 ./web.ts(那一侧才 import react-dom)。管线以页为单位执行:
@@ -51,11 +53,11 @@ export type HeadTag =
51
53
  | { tag: "script" | "style"; attrs?: Record<string, string | true>; children?: string };
52
54
 
53
55
  export interface ReportShell {
54
- /** 标题:首页 hero 与浏览器标题。页头左端是恒定的 NiceEval 品牌字标,不由 title 覆盖;回退链 def.title → 唯一快照 name → 内置文案「Eval 运行结果 / Eval Results」。 */
56
+ /** 站点标题:浏览器标题、show 页索引标题行与 `ctx.report.title` 的取值源;`Hero` 组件缺省消费它。回退链 def.title → 唯一快照 name → 内置文案「Eval 运行结果 / Eval Results」。 */
55
57
  title?: LocalizedText;
56
58
  /** 页头右侧的外部链接,如 GitHub、文档、CI。 */
57
59
  links?: ReportLink[];
58
- /** 每页页脚的一段文字;省略时不渲染页脚(品牌行恒在 hero 下方,不占页脚)。 */
60
+ /** 每页页脚的一段文字;省略时不渲染页脚(品牌行归 PoweredBy 组件,不占页脚)。 */
59
61
  footer?: LocalizedText;
60
62
  /**
61
63
  * 注入每页 `<head>` 的结构化标签,在官方与外壳样式之后按声明顺序渲染。
@@ -80,11 +82,32 @@ export interface ReportPage {
80
82
  content: ReportNode;
81
83
  }
82
84
 
83
- /** content / pages 互斥由类型表达,不把非法状态留到运行期。 */
85
+ /** content / pages / extends 三选一由类型表达,不把非法状态留到运行期。 */
84
86
  export type ReportDef = ReportShell &
85
87
  (
86
- | { content: ReportNode; pages?: never }
87
- | { pages: NonEmptyArray<ReportPage>; content?: never }
88
+ | {
89
+ /** 单页缩写,等价于只含 id `report` 的页列表。 */
90
+ content: ReportNode;
91
+ pages?: never;
92
+ extends?: never;
93
+ }
94
+ | {
95
+ /** 非空页列表;导航按数组顺序显示。 */
96
+ pages: NonEmptyArray<ReportPage>;
97
+ content?: never;
98
+ extends?: never;
99
+ }
100
+ | {
101
+ /**
102
+ * 在另一份报告上叠外壳:页列表取 base 的页列表;本对象声明的外壳字段整字段覆盖
103
+ * base 的同名字段,未声明的沿用 base——没有数组拼接、没有深合并。base 是任何
104
+ * `defineReport` 产物(内建视图或自己别的报告文件的具名导出);合并在
105
+ * `defineReport` 调用时折叠完成,产物仍是普通 ReportDefinition,可以再被 extends。
106
+ */
107
+ extends: ReportDefinition;
108
+ content?: never;
109
+ pages?: never;
110
+ }
88
111
  );
89
112
 
90
113
  const REPORT_DEFINITION: unique symbol = Symbol.for("niceeval.report.definition");
@@ -124,8 +147,8 @@ const DEFAULT_PAGE_TITLE: LocalizedText = { en: "Report", "zh-CN": "报告" };
124
147
 
125
148
  // ───────────────────────── 装载规范化与静态校验 ─────────────────────────
126
149
 
127
- const CONTENT_NEXT_STEP =
128
- 'To render the built-in report content, write content: <ExperimentComparison /> (imported from "niceeval/report").';
150
+ const EXTENDS_NEXT_STEP =
151
+ 'To render the built-in report, write extends: standard (import { standard } from "niceeval/report/built-in").';
129
152
 
130
153
  function isReportNodeInput(value: unknown): boolean {
131
154
  if (value === null || value === undefined || typeof value === "boolean") return true;
@@ -148,7 +171,8 @@ function assertNotDefinition(value: unknown, where: string): void {
148
171
  ) {
149
172
  throw new Error(
150
173
  `${where} received a defineReport(...) product, but a report definition is not a report node — the shell cannot nest. ` +
151
- "Pass the page's tree or component here, and export the defineReport product only as the file's default export.",
174
+ "Pass the page's tree or component here. To layer a shell over another report, write defineReport({ extends: base, … }); " +
175
+ "otherwise export the defineReport product as the file's default export.",
152
176
  );
153
177
  }
154
178
  }
@@ -317,33 +341,50 @@ export function defineReport(input: ReportNode | ReportDef): ReportDefinition {
317
341
  : (input as ReportDef);
318
342
  if (typeof def !== "object" || def === null) {
319
343
  throw new Error(
320
- "defineReport expects a report tree or a config object ({ title?, links?, footer?, head?, scripts?, styles?, content | pages }). " +
321
- CONTENT_NEXT_STEP,
344
+ "defineReport expects a report tree or a config object ({ title?, links?, footer?, head?, scripts?, styles?, content | pages | extends }). " +
345
+ EXTENDS_NEXT_STEP,
322
346
  );
323
347
  }
324
348
 
325
349
  const hasContent = "content" in def && def.content !== undefined;
326
350
  const hasPages = "pages" in def && def.pages !== undefined;
327
- if (hasContent && hasPages) {
351
+ const hasExtends = "extends" in def && (def as { extends?: unknown }).extends !== undefined;
352
+ const declared = [hasContent && '"content"', hasPages && '"pages"', hasExtends && '"extends"'].filter(
353
+ (name): name is string => typeof name === "string",
354
+ );
355
+ if (declared.length > 1) {
328
356
  throw new Error(
329
- `defineReport got both "content" and "pages" — declare exactly one. Keep "pages" for a multi-page report, or keep a single tree in "content". ${CONTENT_NEXT_STEP}`,
357
+ `defineReport got ${declared.join(" and ")} — declare exactly one of "content" (a single tree), "pages" (a multi-page report), or "extends" (another report plus this shell). ${EXTENDS_NEXT_STEP}`,
330
358
  );
331
359
  }
332
- if (!hasContent && !hasPages) {
360
+ if (declared.length === 0) {
333
361
  throw new Error(
334
- `defineReport got neither "content" nor "pages" — declare exactly one; omission is not a meaningful value, the file must show what renders. ${CONTENT_NEXT_STEP}`,
362
+ `defineReport got none of "content", "pages" or "extends" — declare exactly one; omission is not a meaningful value, the file must show what renders. ${EXTENDS_NEXT_STEP}`,
335
363
  );
336
364
  }
337
365
 
338
- let pages: ReportPage[];
339
- if (hasContent) {
366
+ // extends:报告级复用的唯一位置。页归 base,本对象只贡献外壳;base 已经过 defineReport
367
+ // 校验,页不重验。
368
+ let base: ReportDefinition | undefined;
369
+ let pages: readonly ReportPage[];
370
+ if (hasExtends) {
371
+ const candidate = (def as { extends: unknown }).extends;
372
+ if (!isReportDefinition(candidate)) {
373
+ throw new Error(
374
+ 'defineReport "extends" must be a defineReport(...) product — the base report whose pages this report inherits. ' +
375
+ EXTENDS_NEXT_STEP,
376
+ );
377
+ }
378
+ base = candidate;
379
+ pages = base.pages;
380
+ } else if (hasContent) {
340
381
  assertNotDefinition(def.content, 'defineReport "content"');
341
382
  pages = [{ id: DEFAULT_PAGE_ID, title: DEFAULT_PAGE_TITLE, content: def.content as ReportNode }];
342
383
  } else {
343
384
  const raw = def.pages as unknown;
344
385
  if (!Array.isArray(raw) || raw.length === 0) {
345
386
  throw new Error(
346
- `defineReport "pages" must be a non-empty array of { id, title, content }. ${CONTENT_NEXT_STEP}`,
387
+ `defineReport "pages" must be a non-empty array of { id, title, content }. ${EXTENDS_NEXT_STEP}`,
347
388
  );
348
389
  }
349
390
  const seen = new Set<string>();
@@ -367,36 +408,44 @@ export function defineReport(input: ReportNode | ReportDef): ReportDefinition {
367
408
 
368
409
  if (def.title !== undefined) assertLocalizedText(def.title, "defineReport title");
369
410
  if (def.footer !== undefined) assertLocalizedText(def.footer, "defineReport footer");
370
- const links = def.links ?? [];
371
- if (!Array.isArray(links)) throw new Error("defineReport links must be an array of { label, href }.");
372
- for (const link of links) {
373
- assertLocalizedText((link as ReportLink)?.label, "defineReport link label");
374
- if (typeof (link as ReportLink)?.href !== "string" || (link as ReportLink).href.length === 0) {
375
- throw new Error("defineReport link href must be a non-empty string URL.");
376
- }
377
- // icon 唯一合法形状是 { svg: string }(无类型 JS 传组件 / ReactNode / 裸字符串都在装载期拒绝):
378
- // 外壳声明经序列化边界进前端,ReactNode 过不去,可序列化是外壳契约的一部分。
379
- const icon = (link as { icon?: unknown }).icon;
380
- if (icon !== undefined) {
381
- const svg = (icon as { svg?: unknown })?.svg;
382
- if (typeof icon !== "object" || icon === null || typeof svg !== "string" || svg.length === 0) {
383
- throw new Error(
384
- 'defineReport link "icon" must be { svg: string } an inline SVG string rendered before the label. ' +
385
- "Components and React nodes are not accepted: the shell declaration crosses a serialization boundary. " +
386
- 'Write e.g. icon: { svg: "<svg …>…</svg>" }.',
387
- );
411
+ // 外壳合并:声明即整字段覆盖,未声明沿用 base(base 的字段已规范化,不重验)。
412
+ let links: readonly ReportLink[];
413
+ if (def.links !== undefined) {
414
+ if (!Array.isArray(def.links)) throw new Error("defineReport links must be an array of { label, href }.");
415
+ for (const link of def.links) {
416
+ assertLocalizedText((link as ReportLink)?.label, "defineReport link label");
417
+ if (typeof (link as ReportLink)?.href !== "string" || (link as ReportLink).href.length === 0) {
418
+ throw new Error("defineReport link href must be a non-empty string URL.");
419
+ }
420
+ // icon 唯一合法形状是 { svg: string }(无类型 JS 传组件 / ReactNode / 裸字符串都在装载期拒绝):
421
+ // 外壳声明经序列化边界进前端,ReactNode 过不去,可序列化是外壳契约的一部分。
422
+ const icon = (link as { icon?: unknown }).icon;
423
+ if (icon !== undefined) {
424
+ const svg = (icon as { svg?: unknown })?.svg;
425
+ if (typeof icon !== "object" || icon === null || typeof svg !== "string" || svg.length === 0) {
426
+ throw new Error(
427
+ 'defineReport link "icon" must be { svg: string } — an inline SVG string rendered before the label. ' +
428
+ "Components and React nodes are not accepted: the shell declaration crosses a serialization boundary. " +
429
+ 'Write e.g. icon: { svg: "<svg …>…</svg>" }.',
430
+ );
431
+ }
388
432
  }
389
433
  }
434
+ links = def.links;
435
+ } else {
436
+ links = base?.links ?? [];
390
437
  }
438
+ const title = def.title !== undefined ? def.title : base?.title;
439
+ const footer = def.footer !== undefined ? def.footer : base?.footer;
391
440
 
392
441
  const definition = {
393
442
  kind: "report" as const,
394
- ...(def.title !== undefined ? { title: def.title } : {}),
443
+ ...(title !== undefined ? { title } : {}),
395
444
  links: [...links],
396
- ...(def.footer !== undefined ? { footer: def.footer } : {}),
397
- head: assertHeadTags(def.head),
398
- scripts: assertAssets(def.scripts, "scripts"),
399
- styles: assertAssets(def.styles, "styles"),
445
+ ...(footer !== undefined ? { footer } : {}),
446
+ head: def.head !== undefined ? assertHeadTags(def.head) : [...(base?.head ?? [])],
447
+ scripts: def.scripts !== undefined ? assertAssets(def.scripts, "scripts") : [...(base?.scripts ?? [])],
448
+ styles: def.styles !== undefined ? assertAssets(def.styles, "styles") : [...(base?.styles ?? [])],
400
449
  pages: pages as unknown as NonEmptyArray<ReportPage>,
401
450
  };
402
451
  Object.defineProperty(definition, REPORT_DEFINITION, { value: true });
@@ -484,18 +533,11 @@ export interface RenderReportTextOptions extends TextRenderOptions {
484
533
  pageId?: string;
485
534
  }
486
535
 
487
- /**
488
- * 挑选警告的 text 形态:每条渲染好的 message 前缀 "! ",一行一条。宿主级前置块——
489
- * 宿主是 warning 的唯一呈现者,组件数据不复制 warning;裸跑 / --report 都在报告顶上
490
- * 如实报残缺,不静默(docs/feature/reports/architecture.md「Scope 是计算入口」)。
491
- */
492
- function renderScopeWarningsText(scope: Scope, _locale: ReportLocale): string {
493
- return scope.warnings.map((w) => `! ${w.message}`).join("\n");
494
- }
495
-
496
536
  /**
497
537
  * text 宿主的装载语义:选页 → resolve(组合展开 + spec 取数,唯一的 await 边界)→ 树校验 →
498
- * 遍历渲染 text 面;Scope 有挑选警告时在报告顶部前置一块 "! <message>"。不需要 react-dom
538
+ * 遍历渲染 text 面。不需要 react-dom。宿主不在报告树外另设警告通道——挑选警告的呈现件是
539
+ * `ScopeWarnings` 组件,内建报告每页都放它,自定义报告放不放是作者义务
540
+ * (docs/feature/reports/architecture.md「Scope 是计算入口」)。
499
541
  */
500
542
  export async function renderReportToText(
501
543
  definition: ReportDefinition,
@@ -511,11 +553,7 @@ export async function renderReportToText(
511
553
  memo: new ResolveMemo(),
512
554
  });
513
555
  validateReportTree(resolved);
514
- const textCtx = createTextContext(options);
515
- const body = renderNodeToText(resolved, textCtx);
516
- return ctx.scope.warnings.length > 0
517
- ? [renderScopeWarningsText(ctx.scope, textCtx.locale), body].join("\n\n")
518
- : body;
556
+ return renderNodeToText(resolved, createTextContext(options));
519
557
  }
520
558
 
521
559
  /** 页索引标题行(show 多页索引 / view 导航共用的解析结果):按 locale 解析的标题字符串。 */
@@ -538,10 +576,10 @@ function quoteArg(value: string): string {
538
576
  return /^[A-Za-z0-9._/@-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
539
577
  }
540
578
 
541
- /** 按上下文拼组索引的可复制命令:`niceeval show <patterns> --experiment <id> [--results/--report/--page]`。 */
579
+ /** 按上下文拼组索引的可复制命令:`niceeval show <patterns> --exp <id> [--results/--report/--page]`。 */
542
580
  function experimentCommandFor(ctx: HostCommandContext): (experimentIdPrefix: string) => string {
543
581
  return (prefix) => {
544
- const parts = ["niceeval show", ...ctx.patterns.map(quoteArg), `--experiment ${quoteArg(prefix)}`];
582
+ const parts = ["niceeval show", ...ctx.patterns.map(quoteArg), `--exp ${quoteArg(prefix)}`];
545
583
  if (ctx.results !== undefined) parts.push(`--results ${quoteArg(ctx.results)}`);
546
584
  if (ctx.report !== undefined) parts.push(`--report ${quoteArg(ctx.report)}`);
547
585
  if (ctx.page !== undefined) parts.push(`--page ${quoteArg(ctx.page)}`);
@@ -563,8 +601,8 @@ export interface RenderTreeTextOptions extends TextRenderOptions {
563
601
 
564
602
  /**
565
603
  * 渲染一页报告树的 text 面(宿主逐页调用;页选择归宿主):
566
- * resolve(组合展开 + spec 取数)→ validate → render。Scope 有挑选警告时在页顶前置
567
- * "! <message>" 块——宿主是 warning 的唯一呈现者,组件数据不复制 warning
604
+ * resolve(组合展开 + spec 取数)→ validate → render。宿主不在报告树外另设警告通道,
605
+ * 挑选警告由页内的 `ScopeWarnings` 组件呈现(内建报告每页都放它)
568
606
  */
569
607
  export async function renderReportTreeToText(
570
608
  tree: ReportNode,
@@ -584,8 +622,5 @@ export async function renderReportTreeToText(
584
622
  ? { experimentCommand: experimentCommandFor(options.commandContext) }
585
623
  : {}),
586
624
  });
587
- const body = renderNodeToText(resolved, textCtx);
588
- return ctx.scope.warnings.length > 0
589
- ? [renderScopeWarningsText(ctx.scope, textCtx.locale), body].join("\n\n")
590
- : body;
625
+ return renderNodeToText(resolved, textCtx);
591
626
  }