niceeval 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INDEX.md +77 -45
- package/dist/agents/types.d.ts +28 -6
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/zh-CN.d.ts +2 -0
- package/dist/report/built-in/index.d.ts +3 -2
- package/dist/report/built-in/index.js +7 -8
- package/dist/report/built-in/standard.d.ts +1 -0
- package/dist/report/built-in/standard.js +30 -0
- package/dist/report/components.d.ts +69 -2
- package/dist/report/components.js +152 -3
- package/dist/report/compute.d.ts +28 -1
- package/dist/report/compute.js +123 -0
- package/dist/report/index.d.ts +4 -4
- package/dist/report/index.js +3 -2
- package/dist/report/locale.d.ts +39 -1
- package/dist/report/locale.js +69 -0
- package/dist/report/react/AttemptList.d.ts +3 -1
- package/dist/report/react/AttemptList.js +3 -3
- package/dist/report/react/CopyFixPrompt.d.ts +12 -0
- package/dist/report/react/CopyFixPrompt.js +12 -0
- package/dist/report/react/HeroCard.d.ts +13 -0
- package/dist/report/react/HeroCard.js +35 -0
- package/dist/report/react/PoweredBy.d.ts +5 -0
- package/dist/report/react/PoweredBy.js +7 -0
- package/dist/report/react/ScopeWarnings.d.ts +12 -0
- package/dist/report/react/ScopeWarnings.js +18 -0
- package/dist/report/react/TraceWaterfall.d.ts +14 -0
- package/dist/report/react/TraceWaterfall.js +22 -0
- package/dist/report/react/index.d.ts +6 -1
- package/dist/report/react/index.js +6 -0
- package/dist/report/report.d.ts +22 -6
- package/dist/report/report.js +66 -53
- package/dist/report/scope-warnings.d.ts +28 -0
- package/dist/report/scope-warnings.js +101 -0
- package/dist/report/text/faces.d.ts +19 -1
- package/dist/report/text/faces.js +61 -0
- package/dist/report/tree.js +6 -1
- package/dist/report/types.d.ts +45 -0
- package/dist/report/web.d.ts +5 -4
- package/dist/report/web.js +7 -22
- package/dist/results/select.d.ts +15 -2
- package/dist/results/select.js +75 -10
- package/dist/results/types.d.ts +26 -0
- package/dist/runner/fingerprint.d.ts +3 -3
- package/dist/runner/sandbox-selection.d.ts +12 -0
- package/dist/runner/types.d.ts +18 -6
- package/dist/sandbox/types.d.ts +12 -0
- package/docs-site/zh/explanation/evals.mdx +2 -1
- package/docs-site/zh/explanation/experiment.mdx +2 -0
- package/docs-site/zh/how-to/custom-reports.mdx +22 -6
- package/docs-site/zh/how-to/publish-report.mdx +5 -4
- package/docs-site/zh/how-to/viewing-results.mdx +3 -3
- package/docs-site/zh/how-to/write-experiment.mdx +40 -1
- package/docs-site/zh/reference/builtin-agents.mdx +40 -3
- package/docs-site/zh/reference/cli.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +8 -0
- package/docs-site/zh/reference/official-adapters.mdx +10 -5
- package/package.json +2 -1
- package/src/agents/bub.ts +13 -1
- package/src/agents/claude-code.test.ts +43 -1
- package/src/agents/claude-code.ts +32 -14
- package/src/agents/codex.test.ts +168 -1
- package/src/agents/codex.ts +51 -15
- package/src/agents/mcp.ts +31 -0
- package/src/agents/post-setup.ts +33 -0
- package/src/agents/types.ts +28 -7
- package/src/cli.ts +8 -6
- package/src/define.ts +3 -0
- package/src/i18n/en.ts +5 -2
- package/src/i18n/zh-CN.ts +5 -1
- package/src/index.ts +1 -0
- package/src/report/built-in/index.tsx +8 -7
- package/src/report/built-in/standard.tsx +59 -0
- package/src/report/components.tsx +218 -2
- package/src/report/compute.ts +138 -1
- package/src/report/dual-render.test.tsx +139 -12
- package/src/report/index.ts +20 -0
- package/src/report/locale.ts +83 -1
- package/src/report/react/AttemptList.tsx +13 -1
- package/src/report/react/CopyFixPrompt.tsx +37 -0
- package/src/report/react/HeroCard.tsx +59 -0
- package/src/report/react/PoweredBy.tsx +20 -0
- package/src/report/react/ScopeWarnings.tsx +74 -0
- package/src/report/react/TraceWaterfall.tsx +78 -0
- package/src/report/react/enhance.js +14 -0
- package/src/report/react/index.tsx +11 -0
- package/src/report/react/styles.css +187 -7
- package/src/report/report.ts +97 -62
- package/src/report/scope-warnings.ts +155 -0
- package/src/report/site-components.test.tsx +526 -0
- package/src/report/text/faces.ts +66 -0
- package/src/report/tree.ts +8 -1
- package/src/report/types.ts +51 -0
- package/src/report/web.ts +7 -40
- package/src/results/host-equivalence.test.ts +5 -1
- package/src/results/open.ts +5 -4
- package/src/results/results.test.ts +78 -1
- package/src/results/select.ts +78 -10
- package/src/results/types.ts +27 -0
- package/src/runner/attempt.ts +10 -9
- package/src/runner/discover.test.ts +9 -1
- package/src/runner/discover.ts +3 -3
- package/src/runner/fingerprint.ts +9 -4
- package/src/runner/ledger.test.ts +30 -1
- package/src/runner/ledger.ts +26 -4
- package/src/runner/run.ts +5 -1
- package/src/runner/sandbox-selection.test.ts +131 -0
- package/src/runner/sandbox-selection.ts +110 -0
- package/src/runner/types.ts +19 -2
- package/src/sandbox/types.ts +6 -0
- package/src/show/index.ts +17 -10
- package/src/show/render.ts +11 -11
- package/src/show/report-host.test.ts +32 -15
- package/src/show/report-host.ts +5 -4
- package/src/show/show.test.ts +140 -3
- package/src/view/app/App.test.tsx +78 -17
- package/src/view/app/App.tsx +17 -78
- package/src/view/app/components/CopyControls.tsx +4 -42
- package/src/view/app/i18n.ts +5 -227
- package/src/view/app/lib/rows.ts +3 -21
- package/src/view/app/shared.ts +1 -3
- package/src/view/app/types.ts +2 -2
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.ts +2 -2
- package/src/view/shared/types.ts +11 -6
- package/src/view/styles.css +6 -266
- package/src/view/view-report.test.ts +59 -22
- package/src/view/app/components/LazyArtifact.tsx +0 -51
- package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
- package/src/view/app/pages/AttemptsPage.tsx +0 -80
- package/src/view/app/pages/TracesPage.tsx +0 -35
package/src/report/report.ts
CHANGED
|
@@ -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`
|
|
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
|
-
/**
|
|
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
|
-
/** 每页页脚的一段文字;省略时不渲染页脚(
|
|
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
|
-
| {
|
|
87
|
-
|
|
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
|
|
128
|
-
'To render the built-in 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
360
|
+
if (declared.length === 0) {
|
|
333
361
|
throw new Error(
|
|
334
|
-
`defineReport got
|
|
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
|
-
|
|
339
|
-
|
|
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 }. ${
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
'
|
|
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
|
-
...(
|
|
443
|
+
...(title !== undefined ? { title } : {}),
|
|
395
444
|
links: [...links],
|
|
396
|
-
...(
|
|
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
|
|
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
|
-
|
|
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 解析的标题字符串。 */
|
|
@@ -563,8 +601,8 @@ export interface RenderTreeTextOptions extends TextRenderOptions {
|
|
|
563
601
|
|
|
564
602
|
/**
|
|
565
603
|
* 渲染一页报告树的 text 面(宿主逐页调用;页选择归宿主):
|
|
566
|
-
* resolve(组合展开 + spec 取数)→ validate → render
|
|
567
|
-
*
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// ScopeWarnings 的聚合层:把 Scope 警告按「下一步动作」组织成组,web / text 两面共用
|
|
2
|
+
// (docs/feature/reports/library/site-components.md「聚合轴是动作,不是发生顺序」)。
|
|
3
|
+
// message 是完整叙述的单源,这里只组织、不改写;徽标 / 组头文案按 kind 表登记的模板
|
|
4
|
+
// (docs/feature/results/library.md「警告 kind 全集」)经 locale 词典渲染,未登记的 kind
|
|
5
|
+
// 回退为单独成组、逐条 message 原样。
|
|
6
|
+
|
|
7
|
+
import type { ScopeWarning } from "../results/types.ts";
|
|
8
|
+
import { gapParts } from "../results/select.ts";
|
|
9
|
+
import { localeText, type ReportLocale, type ReportMessageKey } from "./locale.ts";
|
|
10
|
+
|
|
11
|
+
/** kind 表登记的类别:integrity(选中集合的分母可能不对)组排在 freshness(可能过期)之前。 */
|
|
12
|
+
export type WarningCategory = "integrity" | "freshness";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 聚合层的宽松形态:kind 表新增 kind 时(如 unreadable-snapshot 先于类型落地)
|
|
16
|
+
* 不假设字段全集,读结构化字段前逐个判形。
|
|
17
|
+
*/
|
|
18
|
+
interface AnyWarning {
|
|
19
|
+
kind: string;
|
|
20
|
+
message: string;
|
|
21
|
+
command?: string;
|
|
22
|
+
experimentId?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScopeWarningGroup {
|
|
27
|
+
category: WarningCategory;
|
|
28
|
+
/** 实验组为 experimentId;kind 组为登记的组头文案(含条数);未登记 kind 用 kind 原文。 */
|
|
29
|
+
title: string;
|
|
30
|
+
/** 每条警告一枚、与 warnings 同序;未登记徽标模板的成员不出徽标。 */
|
|
31
|
+
badges: readonly { kind: string; text: string }[];
|
|
32
|
+
/** 组内命令去重后恰一条时归组头(复制即推进整组);多条或零条为 null,命令随明细逐条走。 */
|
|
33
|
+
headCommand: string | null;
|
|
34
|
+
/** 原始条目(明细层,message 单源)。 */
|
|
35
|
+
warnings: readonly ScopeWarning[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GroupedScopeWarnings {
|
|
39
|
+
/** 分类计数汇总行,任何组数下都产出;web 面用作外层折叠块的 <summary>,text 面只在多组时打印。 */
|
|
40
|
+
summary: string;
|
|
41
|
+
groups: readonly ScopeWarningGroup[];
|
|
42
|
+
/** 警告总条数 ≤ 3 时组级明细默认展开(web 面第二层 <details> 的 open;阈值是行为契约,无开关)。 */
|
|
43
|
+
detailsOpen: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CATEGORY: Record<string, WarningCategory> = {
|
|
47
|
+
"partial-coverage": "integrity",
|
|
48
|
+
"unfinished-snapshot": "integrity",
|
|
49
|
+
"unreadable-snapshot": "integrity",
|
|
50
|
+
"stale-snapshot": "freshness",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** 实验作用域且登记了徽标模板的 kind 才进实验组;其余(含未登记 kind)按 kind 聚合。 */
|
|
54
|
+
const EXPERIMENT_KINDS = new Set(["partial-coverage", "stale-snapshot", "unfinished-snapshot"]);
|
|
55
|
+
|
|
56
|
+
function pluralText(
|
|
57
|
+
locale: ReportLocale,
|
|
58
|
+
base: "warnings.summary.experiments" | "warnings.group.unreadableSnapshot" | "warnings.details",
|
|
59
|
+
n: number,
|
|
60
|
+
): string {
|
|
61
|
+
return localeText(locale, `${base}.${n === 1 ? "one" : "other"}` as ReportMessageKey, { n });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 明细折叠块的标签(「N 条原始警告」)。 */
|
|
65
|
+
export function warningDetailsLabel(locale: ReportLocale, n: number): string {
|
|
66
|
+
return pluralText(locale, "warnings.details", n);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function gapText(locale: ReportLocale, fromIso: string, toIso: string): string {
|
|
70
|
+
const { n, unit } = gapParts(fromIso, toIso);
|
|
71
|
+
return localeText(locale, `warnings.gap.${unit}.${n === 1 ? "one" : "other"}` as ReportMessageKey, { n });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function badgeText(w: AnyWarning, locale: ReportLocale): string | null {
|
|
75
|
+
switch (w.kind) {
|
|
76
|
+
case "partial-coverage":
|
|
77
|
+
return localeText(locale, "warnings.badge.partialCoverage", {
|
|
78
|
+
covered: String(w.covered),
|
|
79
|
+
total: String(w.total),
|
|
80
|
+
});
|
|
81
|
+
case "stale-snapshot":
|
|
82
|
+
return localeText(locale, "warnings.badge.staleSnapshot", {
|
|
83
|
+
gap: gapText(locale, String(w.startedAt), String(w.latestStartedAt)),
|
|
84
|
+
});
|
|
85
|
+
case "unfinished-snapshot":
|
|
86
|
+
return localeText(locale, "warnings.badge.unfinishedSnapshot");
|
|
87
|
+
default:
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 组内命令去重:恰一条时它就是「复制即推进整组」的组头命令。 */
|
|
93
|
+
function dedupeCommand(members: readonly AnyWarning[]): string | null {
|
|
94
|
+
const commands = new Set(members.map((w) => w.command).filter((c): c is string => typeof c === "string" && c !== ""));
|
|
95
|
+
return commands.size === 1 ? [...commands][0] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function groupCategory(members: readonly AnyWarning[]): WarningCategory {
|
|
99
|
+
return members.some((w) => (CATEGORY[w.kind] ?? "integrity") === "integrity") ? "integrity" : "freshness";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function groupScopeWarnings(input: readonly ScopeWarning[], locale: ReportLocale): GroupedScopeWarnings {
|
|
103
|
+
const warnings = input as readonly AnyWarning[];
|
|
104
|
+
const byExperiment = new Map<string, AnyWarning[]>();
|
|
105
|
+
const byKind = new Map<string, AnyWarning[]>();
|
|
106
|
+
for (const w of warnings) {
|
|
107
|
+
if (EXPERIMENT_KINDS.has(w.kind) && typeof w.experimentId === "string") {
|
|
108
|
+
const members = byExperiment.get(w.experimentId) ?? [];
|
|
109
|
+
members.push(w);
|
|
110
|
+
byExperiment.set(w.experimentId, members);
|
|
111
|
+
} else {
|
|
112
|
+
const members = byKind.get(w.kind) ?? [];
|
|
113
|
+
members.push(w);
|
|
114
|
+
byKind.set(w.kind, members);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const groups: ScopeWarningGroup[] = [];
|
|
119
|
+
for (const [experimentId, members] of byExperiment) {
|
|
120
|
+
groups.push({
|
|
121
|
+
category: groupCategory(members),
|
|
122
|
+
title: experimentId,
|
|
123
|
+
badges: members
|
|
124
|
+
.map((w) => ({ kind: w.kind, text: badgeText(w, locale) }))
|
|
125
|
+
.filter((b): b is { kind: string; text: string } => b.text !== null),
|
|
126
|
+
headCommand: dedupeCommand(members),
|
|
127
|
+
warnings: members as unknown as ScopeWarning[],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
for (const [kind, members] of byKind) {
|
|
131
|
+
groups.push({
|
|
132
|
+
category: CATEGORY[kind] ?? "integrity",
|
|
133
|
+
title:
|
|
134
|
+
kind === "unreadable-snapshot" ? pluralText(locale, "warnings.group.unreadableSnapshot", members.length) : kind,
|
|
135
|
+
badges: [],
|
|
136
|
+
headCommand: dedupeCommand(members),
|
|
137
|
+
warnings: members as unknown as ScopeWarning[],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// 稳定排序:integrity 在前,同类别保持首次出现顺序。
|
|
141
|
+
const rank = (c: WarningCategory) => (c === "integrity" ? 0 : 1);
|
|
142
|
+
groups.sort((a, b) => rank(a.category) - rank(b.category));
|
|
143
|
+
|
|
144
|
+
const parts: string[] = [];
|
|
145
|
+
if (byExperiment.size > 0) parts.push(pluralText(locale, "warnings.summary.experiments", byExperiment.size));
|
|
146
|
+
for (const [kind, members] of byKind) {
|
|
147
|
+
parts.push(
|
|
148
|
+
kind === "unreadable-snapshot"
|
|
149
|
+
? pluralText(locale, "warnings.group.unreadableSnapshot", members.length)
|
|
150
|
+
: `${kind} ×${members.length}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { summary: parts.join(" · "), groups, detailsOpen: warnings.length <= 3 };
|
|
155
|
+
}
|