dsh-showcase 0.1.2 → 0.1.4

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/plugin/index.js CHANGED
@@ -1,18 +1,22 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { settingsNamespace } from "@deepseek-ai/dsh-settings";
3
2
  import { defineTool } from "@deepseek-ai/dsh-tools";
4
3
  import { generateLayoutSummary } from "./layout-summary.js";
4
+ import { isAbsolute, resolve } from "node:path";
5
5
 
6
6
  export const name = "showcase-layout-summary";
7
- export const inject = ["tools", "settings", "connection"];
8
- export const SETTINGS_NAMESPACE = settingsNamespace("showcase-layout-summary");
9
- export const SETTINGS_RPC_CHANNEL = "/showcase-layout-summary";
7
+ export const inject = ["tools", "settings"];
8
+ // The settings service addresses forms by the profile entry id declared in
9
+ // cordis.patch.yml. Keep this stable across plugin releases so old profiles
10
+ // keep their selected theme and poster preference.
11
+ export const SETTINGS_NAMESPACE = "showcase-layout-summary";
10
12
  export const Config = z.object({
11
13
  theme: z.union(["frontier-signal", "blue-big-fish"])
12
14
  .default("frontier-signal")
15
+ .volatile()
13
16
  .description("生成 HTML 海报时使用的视觉风格:终末地帝江号或蓝色大肥鱼。主题只从插件设置读取。"),
14
17
  generatePoster: z.boolean()
15
18
  .default(false)
19
+ .volatile()
16
20
  .description("是否生成自包含 HTML 海报。关闭时只生成 Markdown 摘要。"),
17
21
  });
18
22
 
@@ -20,81 +24,63 @@ const outputSchema = {
20
24
  type: "object",
21
25
  additionalProperties: false,
22
26
  properties: {
23
- locale: { type: "string", required: true, enum: ["zh-CN", "en"] },
24
- outputPath: { type: "string", required: true, description: "Project-relative path to the generated Markdown file." },
27
+ locale: { type: "string", enum: ["zh-CN", "en"], required: true },
28
+ outputPath: { type: "string", description: "Project-relative path to the generated Markdown file.", required: true },
25
29
  posterPath: { type: "string", description: "Project-relative path to the self-contained evidence poster when generated." },
26
- posterGenerated: { type: "boolean", required: true, description: "Whether this call wrote a self-contained HTML poster." },
27
- sections: { type: "array", required: true, items: { type: "string" } },
30
+ posterGenerated: { type: "boolean", description: "Whether this call wrote a self-contained HTML poster.", required: true },
31
+ sections: { type: "array", items: { type: "string" }, required: true },
28
32
  theme: { type: "string", required: true },
29
- themeKey: { type: "string", required: true, enum: ["frontier-signal", "blue-big-fish"] },
30
- breakpoints: { type: "array", required: true, items: { type: "string" } },
31
- stages: { type: "array", required: true, items: { type: "string" } },
32
- freshnessWarnings: { type: "array", required: true, items: { type: "string" } },
33
+ themeKey: { type: "string", enum: ["frontier-signal", "blue-big-fish"], required: true },
34
+ breakpoints: { type: "array", items: { type: "string" }, required: true },
35
+ stages: { type: "array", items: { type: "string" }, required: true },
36
+ freshnessWarnings: { type: "array", items: { type: "string" }, required: true },
33
37
  testCount: { type: "integer", required: true },
34
38
  redactionCount: { type: "integer", required: true },
35
39
  },
36
40
  };
37
41
 
38
42
  export function apply(ctx, config = {}) {
39
- const entry = Config(config);
40
- let settings;
41
- let settingsRegistrationError;
42
- try {
43
- settings = ctx.settings.register(SETTINGS_NAMESPACE, Config, { base: entry });
44
- } catch (error) {
45
- // A malformed value left by an older plugin version should not prevent the
46
- // tool itself from registering. Keep defaults readable and make writes
47
- // fail explicitly until the host-side setting is repaired or cleared.
48
- settingsRegistrationError = error instanceof Error ? error.message : String(error);
49
- settings = {
50
- get: () => entry,
51
- async update() {
52
- throw new Error(`Stored plugin settings could not be loaded: ${settingsRegistrationError}`);
53
- },
54
- };
43
+ // Cordis supplies a parsed Config whose volatile fields are stable refs. The
44
+ // plain-object branch keeps direct consumers and older test harnesses useful.
45
+ const entry = hasVolatileFields(config) ? config : Config(config);
46
+ const initial = {
47
+ theme: readConfigValue(entry.theme, "frontier-signal"),
48
+ generatePoster: readConfigValue(entry.generatePoster, false),
49
+ };
50
+ const legacySettings = typeof ctx.settings?.register === "function";
51
+ let legacyRegistrationError;
52
+ let legacy;
53
+ if (legacySettings) {
54
+ try {
55
+ legacy = ctx.settings.register(SETTINGS_NAMESPACE, Config, { base: initial });
56
+ } catch (error) {
57
+ // Keep the tool available when an older host has an invalid persisted
58
+ // section; the new SettingsForms service rejects the same write itself.
59
+ legacyRegistrationError = error instanceof Error ? error.message : String(error);
60
+ legacy = { get: () => initial, async update() { throw new Error(`Stored plugin settings could not be loaded: ${legacyRegistrationError}`); } };
61
+ }
62
+ }
63
+ if (!legacySettings && typeof ctx.inject === "function") {
64
+ // This plugin owns a custom Plugins-page form. Disable the generic form so
65
+ // DSH does not render a second, competing editor for the same fields.
66
+ ctx.inject(["settings"], (child) => {
67
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
68
+ });
55
69
  }
56
70
  const readSettings = () => {
57
- const value = settings.get?.() ?? entry;
71
+ const descriptor = !legacySettings && typeof ctx.settings?.describe === "function"
72
+ ? ctx.settings.describe().find((candidate) => candidate.ns === SETTINGS_NAMESPACE)
73
+ : undefined;
74
+ const value = legacySettings ? legacy.get?.() ?? initial : descriptor?.value ?? {
75
+ theme: readConfigValue(entry.theme, initial.theme),
76
+ generatePoster: readConfigValue(entry.generatePoster, initial.generatePoster),
77
+ };
58
78
  return {
59
- theme: value.theme === "blue-big-fish" || value.theme === "frontier-signal" ? value.theme : entry.theme,
60
- generatePoster: typeof value.generatePoster === "boolean" ? value.generatePoster : entry.generatePoster,
79
+ theme: value.theme === "blue-big-fish" || value.theme === "frontier-signal" ? value.theme : initial.theme,
80
+ generatePoster: typeof value.generatePoster === "boolean" ? value.generatePoster : initial.generatePoster,
81
+ revision: descriptor?.revision,
61
82
  };
62
83
  };
63
- ctx.connection.rpc.handle(SETTINGS_RPC_CHANNEL, async (endpoint, payload) => {
64
- if (endpoint === "get") {
65
- return { ok: true, value: readSettings() };
66
- }
67
- if (endpoint !== "set" || typeof payload !== "object" || payload === null || Array.isArray(payload)) {
68
- return { ok: false, error: { code: "bad-request", message: "Expected get or set with theme or generatePoster.", details: { issues: [] } } };
69
- }
70
- const unknownKeys = Object.keys(payload).filter((key) => key !== "theme" && key !== "generatePoster");
71
- if (unknownKeys.length) {
72
- return { ok: false, error: { code: "bad-request", message: `Unsupported settings fields: ${unknownKeys.join(", ")}.`, details: { issues: [] } } };
73
- }
74
- const patch = {};
75
- if ("theme" in payload) {
76
- const theme = payload.theme;
77
- if (theme !== "frontier-signal" && theme !== "blue-big-fish") {
78
- return { ok: false, error: { code: "bad-request", message: "Unsupported showcase theme.", details: { issues: [] } } };
79
- }
80
- patch.theme = theme;
81
- }
82
- if ("generatePoster" in payload) {
83
- if (typeof payload.generatePoster !== "boolean") {
84
- return { ok: false, error: { code: "bad-request", message: "generatePoster must be a boolean.", details: { issues: [] } } };
85
- }
86
- patch.generatePoster = payload.generatePoster;
87
- }
88
- if (!Object.keys(patch).length) {
89
- return { ok: false, error: { code: "bad-request", message: "Provide theme or generatePoster to update.", details: { issues: [] } } };
90
- }
91
- try {
92
- await settings.update(patch);
93
- } catch (error) {
94
- return { ok: false, error: { code: "settings-rejected", message: error instanceof Error ? error.message : String(error), details: { ns: SETTINGS_NAMESPACE } } };
95
- }
96
- return { ok: true, value: readSettings() };
97
- }, { authority: "loopback" });
98
84
  ctx.tools.register(defineTool({
99
85
  name: "showcase_layout_summary",
100
86
  description: "Generate a local Markdown layout summary from the current .showcase/report.json checkpoint. For visual UI projects, call it once after responsive captures are ready; for non-visual projects, call it after the report and tests are ready. Partial or failed reports are valid review checkpoints, but do not call after every code edit, status check, or intermediate tweak. Markdown is the lightweight default; generate an HTML poster only when the plugin setting allows it or the user explicitly requests a poster for this call. Reads only project files, writes only inside the project, and never uploads content.",
@@ -118,13 +104,31 @@ export function apply(ctx, config = {}) {
118
104
  return [{ type: "text", text: `${labels.heading}${punctuation}\n${labels.markdown}: ${value.outputPath}\n${labels.poster}: ${poster}\n${labels.freshness}: ${value.freshnessWarnings.length}${punctuation}\n${labels.sections}: ${value.sections.join(", ")}\n${labels.theme}: ${value.theme}\n${labels.stages}: ${value.stages.join(", ")}\n${labels.breakpoints}: ${value.breakpoints.join(", ")}\n${labels.tests}: ${value.testCount}; ${labels.redactions}: ${value.redactionCount}${punctuation}` }];
119
105
  },
120
106
  },
121
- async execute({ projectPath, reportPath, outputPath, posterPath, appPath, cssPath, locale, generatePoster: requestedGeneratePoster }) {
107
+ async execute({ projectPath, reportPath, outputPath, posterPath, appPath, cssPath, locale, generatePoster: requestedGeneratePoster }, exec) {
122
108
  if (requestedGeneratePoster !== undefined && typeof requestedGeneratePoster !== "boolean") {
123
109
  throw new Error("generatePoster must be a boolean.");
124
110
  }
125
111
  const current = readSettings();
112
+ const sessionRoot = exec?.agent?.session?.meta?.cwd;
113
+ if (!isAbsolute(projectPath)) {
114
+ if (!sessionRoot) throw new Error("Relative projectPath requires a session workspace. Provide an absolute projectPath.");
115
+ projectPath = resolve(sessionRoot, projectPath);
116
+ }
126
117
  const shouldGeneratePoster = typeof requestedGeneratePoster === "boolean" ? requestedGeneratePoster : current.generatePoster;
127
- return generateLayoutSummary({ projectPath, reportPath, outputPath, posterPath, appPath, cssPath, locale, theme: current.theme, generatePoster: shouldGeneratePoster });
118
+ return generateLayoutSummary({ projectPath, reportPath, outputPath, posterPath, appPath, cssPath, locale, theme: current.theme, generatePoster: shouldGeneratePoster, signal: exec?.signal });
128
119
  },
129
120
  }));
130
121
  }
122
+
123
+ function hasVolatileFields(value) {
124
+ return value !== null && typeof value === "object" && hasVolatile(value.theme) && hasVolatile(value.generatePoster);
125
+ }
126
+
127
+ function hasVolatile(value) {
128
+ return value !== null && typeof value === "object" && typeof value.get === "function";
129
+ }
130
+
131
+ function readConfigValue(value, fallback) {
132
+ const current = hasVolatile(value) ? value.get() : value;
133
+ return current === undefined ? fallback : current;
134
+ }
@@ -15,6 +15,7 @@ export type LayoutSummaryResult = {
15
15
 
16
16
  export function generateLayoutSummary(options: {
17
17
  projectPath: string;
18
+ signal?: AbortSignal;
18
19
  reportPath?: string;
19
20
  /** Optional Markdown output under .showcase; must end in .md or .markdown. */
20
21
  outputPath?: string;
@@ -1,7 +1,10 @@
1
1
  import { mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import { createStyledPosterHtml } from "./poster-html.js";
4
+ import { findComparisonPair, sanitizeReportExport } from "./report-content.js";
4
5
  import { formatReportIssues, reportSchema } from "./report-schema.js";
6
+ import { withProjectLock, readBoundedFile, parseJson } from "./project-io.js";
7
+ import { redact } from "./redaction.js";
5
8
 
6
9
  const DEFAULT_REPORT_PATH = ".showcase/report.json";
7
10
  const DEFAULT_OUTPUT_PATH = ".showcase/layout-summary.md";
@@ -380,7 +383,7 @@ function truncateText(value, maxLength = MAX_TEXT_LENGTH) {
380
383
  }
381
384
 
382
385
  function safeText(value, maxLength = MAX_TEXT_LENGTH) {
383
- const redacted = String(value ?? "")
386
+ const redacted = redact(String(value ?? "")).text
384
387
  .replace(/\b(?:authorization|api\s*[_-]?key|access\s*[_-]?token|token)\s*[:=]\s*(?:bearer\s+)?[^\s,;]+/gi, "[REDACTED]")
385
388
  .replace(/\b(?:sk-(?:proj-|ant-|or-v1-)?[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{20,}|xai-[A-Za-z0-9_-]{16,}|pplx-[A-Za-z0-9_-]{16,}|hf_[A-Za-z0-9_-]{16,})\b/g, "[REDACTED]")
386
389
  .replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[REDACTED]")
@@ -391,6 +394,10 @@ function safeText(value, maxLength = MAX_TEXT_LENGTH) {
391
394
  return truncateText(redacted, maxLength);
392
395
  }
393
396
 
397
+ function safeBlock(value, maxLength = 16_000) {
398
+ return truncateText(redact(String(value ?? "")).text, maxLength);
399
+ }
400
+
394
401
  function safeRelativePath(value, maxLength = 240) {
395
402
  const path = String(value ?? "").replace(/\\/g, "/").replace(/[\x60\r\n]+/g, "").trim();
396
403
  if (!path || path.startsWith("/") || /^[a-z]:/i.test(path) || path === ".." || path.startsWith("../") || path.includes("/../")) return "[path omitted]";
@@ -547,12 +554,17 @@ function imageInfo(image) {
547
554
 
548
555
  // Keep one bounded, verified set for both Markdown and poster output. The
549
556
  // Markdown-only path validates bytes without retaining a base64 copy.
550
- async function collectVerifiedScreenshots(projectPath, screenshots, locale, { embed = false, strictPaths = embed } = {}) {
557
+ async function collectVerifiedScreenshots(projectPath, screenshots, locale, { embed = false, strictPaths = embed, generatedAt = Infinity, sourceTime = 0, fingerprint, signal } = {}) {
551
558
  const mimeTypes = new Map([[".png", "image/png"], [".webp", "image/webp"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"]]);
552
559
  const posterScreenshots = [];
553
560
  const verifiedEvidenceViewports = [];
554
561
  const invalidEvidenceViewports = [];
555
- for (const shot of screenshots) {
562
+ let currentCaptureCount = 0;
563
+ const pair = findComparisonPair(screenshots);
564
+ const ordered = [...screenshots].sort((a, b) => (a.kind === "before" ? 1 : 0) - (b.kind === "before" ? 1 : 0));
565
+ if (pair) ordered.splice(0, ordered.length, pair.after, pair.before, ...ordered.filter((shot) => shot !== pair.after && shot !== pair.before));
566
+ for (const shot of ordered) {
567
+ signal?.throwIfAborted();
556
568
  if (verifiedEvidenceViewports.length >= 3) break;
557
569
  const label = screenshotLabel(shot, locale);
558
570
  if (!shot?.imagePath || typeof shot.imagePath !== "string") {
@@ -566,7 +578,16 @@ async function collectVerifiedScreenshots(projectPath, screenshots, locale, { em
566
578
  invalidEvidenceViewports.push(label);
567
579
  continue;
568
580
  }
569
- const image = await readFile(fullPath);
581
+ const details = await stat(fullPath);
582
+ const capturedAt = Date.parse(shot.capturedAt);
583
+ if (!Number.isFinite(capturedAt) || capturedAt <= 0 || capturedAt > generatedAt + FRESHNESS_CLOCK_SKEW_MS
584
+ || details.mtimeMs > capturedAt + FRESHNESS_CLOCK_SKEW_MS
585
+ || (shot.kind !== "before" && (sourceTime > capturedAt + FRESHNESS_CLOCK_SKEW_MS
586
+ || (shot.sourceFingerprint && fingerprint && shot.sourceFingerprint !== fingerprint)))) {
587
+ invalidEvidenceViewports.push(`${label} (${locale === "zh-CN" ? "过期或时间未验证" : "stale or unverified timestamp"})`);
588
+ continue;
589
+ }
590
+ const image = await readBoundedFile(fullPath, MAX_IMAGE_BYTES, signal);
570
591
  const info = imageInfo(image);
571
592
  if (!info) {
572
593
  invalidEvidenceViewports.push(label);
@@ -575,24 +596,48 @@ async function collectVerifiedScreenshots(projectPath, screenshots, locale, { em
575
596
  const name = viewportLabel(safeText(shot.viewport?.name), locale);
576
597
  const width = Number(shot.viewport?.width) || 0;
577
598
  const height = Number(shot.viewport?.height) || 0;
578
- const verifiedLabel = `${name}: ${width} x ${height}`;
599
+ const scale = shot.deviceScaleFactor ?? info.width / width;
600
+ const region = shot.captureMode === "region";
601
+ if (!region && (!(scale >= (shot.deviceScaleFactor ? 0 : 1) && scale <= 8)
602
+ || Math.abs(info.width - width * scale) > 1
603
+ || info.height < height * scale - 1
604
+ || (shot.captureMode === "viewport" && Math.abs(info.height - height * scale) > 1))) {
605
+ invalidEvidenceViewports.push(`${label} (${locale === "zh-CN" ? "图片尺寸不匹配" : "pixel dimensions mismatch"})`);
606
+ continue;
607
+ }
608
+ const kind = shot.kind === "before" ? (locale === "zh-CN" ? "改版前" : "Before") : (locale === "zh-CN" ? "改版后" : "After");
609
+ const verifiedLabel = `${kind} / ${name}: ${width} x ${height}${region ? ` / region ${info.width} x ${info.height}` : ""}`;
579
610
  verifiedEvidenceViewports.push(verifiedLabel);
611
+ if (shot.kind !== "before" && !region) currentCaptureCount += 1;
580
612
  if (embed) {
581
613
  posterScreenshots.push({
582
614
  image: image.toString("base64"),
583
615
  mimeType: info.mimeType,
584
616
  label: verifiedLabel,
617
+ kind: shot.kind,
618
+ viewport: { name: shot.viewport.name, width, height },
619
+ captureMode: shot.captureMode,
620
+ imageWidth: info.width,
621
+ imageHeight: info.height,
622
+ url: safeText(shot.url),
623
+ comparisonId: shot.comparisonId ? safeText(shot.comparisonId) : undefined,
585
624
  });
586
625
  }
587
626
  } catch (error) {
627
+ signal?.throwIfAborted();
628
+ const outsideProject = error instanceof Error && error.message.includes("must resolve inside projectPath.");
588
629
  // Embedded poster bytes must never cross the project boundary. A
589
630
  // Markdown-only summary does not read or expose the outside file, so an
590
- // obsolete or cross-theme symlink is recorded as invalid and skipped.
591
- if (strictPaths && error instanceof Error && error.message.includes("must resolve inside projectPath.")) throw error;
631
+ // obsolete or cross-theme symlink is skipped without echoing its
632
+ // viewport label into the generated document.
633
+ if (outsideProject) {
634
+ if (strictPaths) throw error;
635
+ continue;
636
+ }
592
637
  invalidEvidenceViewports.push(label);
593
638
  }
594
639
  }
595
- return { posterScreenshots, verifiedEvidenceViewports, invalidEvidenceViewports };
640
+ return { posterScreenshots, verifiedEvidenceViewports, invalidEvidenceViewports, currentCaptureCount };
596
641
  }
597
642
 
598
643
  function formatList(items, emptyLabel) {
@@ -662,6 +707,19 @@ async function collectFreshnessWarnings(projectPath, report, sourcePaths, screen
662
707
  return [...new Set(warnings)].slice(0, MAX_FRESHNESS_WARNINGS);
663
708
  }
664
709
 
710
+ async function latestSourceTime(projectPath, sourcePaths, files) {
711
+ let latest = 0;
712
+ for (const path of new Set([...sourcePaths.filter(Boolean), ...files.filter((file) => file.status !== "deleted").map((file) => file.path)])) {
713
+ try {
714
+ const details = await stat(await insideProjectRealpath(projectPath, path, "Source path"));
715
+ if (details.isFile()) latest = Math.max(latest, details.mtimeMs);
716
+ } catch (error) {
717
+ if (!isMissingSourceError(error) && !error.message?.includes("must resolve inside projectPath")) throw error;
718
+ }
719
+ }
720
+ return latest;
721
+ }
722
+
665
723
  function localeText(locale) {
666
724
  return locale === "zh-CN" ? {
667
725
  title: "布局摘要", projectTask: "项目与任务", task: "任务", status: "状态", version: "报告版本", poster: "海报产物", posterNotGenerated: "本次未生成", freshness: "时效审查", freshnessOk: "报告、源文件和截图的时间顺序未发现明显冲突", theme: "主题系统", rail: "交付轨道", main: "主页面区段", breakpoints: "响应式断点", screenshots: "截图视口", git: "Git 改动", range: "范围", receipts: "测试回执", privacy: "隐私审查", redactions: "已脱敏值", interactions: "交互控件", notRecorded: "未记录", untitledProject: "未命名项目", unknown: "未知", notCaptured: "未捕获", exit: "退出码", conclusion: "根据报告和布局源文件在本地生成。未上传任何内容。", empty: "未记录", posterTitle: "布局证据海报", kicker: "交付证据 / 本地报告", verified: "已验证", files: "改动文件", tests: "通过测试", redaction: "已脱敏", footer: "仅在本地生成 / 不会上传",
@@ -715,6 +773,11 @@ function durationLabel(value) {
715
773
  }
716
774
 
717
775
  export async function generateLayoutSummary(options = {}) {
776
+ if (!options.projectPath || typeof options.projectPath !== "string") throw new Error("projectPath is required.");
777
+ return withProjectLock(options.projectPath, () => generateLockedSummary(options), options.signal);
778
+ }
779
+
780
+ async function generateLockedSummary(options) {
718
781
  if (!options.projectPath || typeof options.projectPath !== "string") {
719
782
  throw new Error("projectPath is required.");
720
783
  }
@@ -759,7 +822,7 @@ export async function generateLayoutSummary(options = {}) {
759
822
  if (reportDetails.size > MAX_REPORT_BYTES) {
760
823
  throw new Error(`reportPath exceeds the ${MAX_REPORT_BYTES} byte safety limit.`);
761
824
  }
762
- const rawReport = JSON.parse(await readFile(reportPath, "utf8"));
825
+ const rawReport = parseJson(await readBoundedFile(reportPath, MAX_REPORT_BYTES, options.signal));
763
826
  report = reportSchema.parse(rawReport);
764
827
  } catch (error) {
765
828
  if (Array.isArray(error?.issues)) {
@@ -793,12 +856,13 @@ export async function generateLayoutSummary(options = {}) {
793
856
  const stages = collectStages(app, locale);
794
857
  const breakpoints = collectBreakpoints(css);
795
858
  const screenshots = Array.isArray(report.screenshots) ? report.screenshots : [];
859
+ const sourceTime = await latestSourceTime(projectPath, [appPath, cssPath], report.git.files);
796
860
  const matchingScreenshots = [];
797
861
  const isolatedScreenshots = [];
798
862
  const unclassifiedScreenshots = [];
799
863
  for (const screenshot of screenshots) {
800
864
  const captureTheme = screenshotTheme(screenshot);
801
- if (captureTheme === theme) matchingScreenshots.push(screenshot);
865
+ if (captureTheme === theme || !captureTheme) matchingScreenshots.push(screenshot);
802
866
  else if (captureTheme) isolatedScreenshots.push(screenshot);
803
867
  else unclassifiedScreenshots.push(screenshot);
804
868
  }
@@ -806,15 +870,28 @@ export async function generateLayoutSummary(options = {}) {
806
870
  projectPath,
807
871
  generatePoster ? matchingScreenshots : screenshots,
808
872
  locale,
809
- { embed: generatePoster },
873
+ { embed: generatePoster, generatedAt: Date.parse(report.generatedAt), sourceTime, fingerprint: report.git.fingerprint, signal: options.signal },
810
874
  );
811
875
  const posterScreenshots = collectedEvidence.posterScreenshots;
812
876
  const invalidEvidenceViewports = collectedEvidence.invalidEvidenceViewports;
813
877
  const evidenceViewports = collectedEvidence.verifiedEvidenceViewports;
814
878
  const files = Array.isArray(report.git?.files) ? report.git.files : [];
815
- const tests = Array.isArray(report.tests) ? report.tests : [];
816
- const freshnessWarnings = await collectFreshnessWarnings(projectPath, report, [appPath, cssPath], screenshots, locale);
817
- const taskStatus = taskStatusModel(report.task?.status, locale);
879
+ const tests = report.tests.map((test) => ({ ...test, status: test.exitCode !== 0 ? "failed" : test.status }));
880
+ const visualProject = Boolean(
881
+ cssPath
882
+ || (appPath && VISUAL_SOURCE_EXTENSIONS.has(sourceExtension(appPath)))
883
+ || /<(?:html|body|main|section|article|div|button|form|canvas|svg)\b|React\.createElement\s*\(|document\.(?:querySelector|getElementById|createElement)\s*\(/i.test(app),
884
+ );
885
+ const freshnessWarnings = [
886
+ ...(report.warnings ?? []).map((warning) => safeText(warning)),
887
+ ...await collectFreshnessWarnings(projectPath, report, [appPath, cssPath], screenshots, locale),
888
+ ...invalidEvidenceViewports.map((label) => locale === "zh-CN" ? `截图证据不可用: ${label}` : `Capture evidence unavailable: ${label}`),
889
+ ...(visualProject && !collectedEvidence.currentCaptureCount ? [locale === "zh-CN" ? "尚无有效的改版后整屏截图,视觉验收未完成。" : "No valid after viewport capture; visual acceptance is incomplete."] : []),
890
+ ].slice(0, MAX_FRESHNESS_WARNINGS);
891
+ const failed = tests.some((test) => test.status === "failed");
892
+ const incomplete = !tests.length || tests.some((test) => test.status !== "passed") || freshnessWarnings.length > 0
893
+ || (report.git.baseRef === "NO_GIT" && report.git.headRef === "NO_GIT");
894
+ const taskStatus = taskStatusModel(failed || report.task.status === "failed" ? "failed" : incomplete ? "partial" : report.task.status, locale);
818
895
  const redaction = report.redaction ?? {};
819
896
  const sections = locale === "zh-CN" ? ["项目与任务", "主题系统", "交付轨道", "证据带", "响应式采集", "变更清单", "验证回执", "时效审查", "隐私审查", "本地导出控件"] : ["Project and task", "Theme system", "Operations rail", "Evidence bands", "Responsive capture", "Change set", "Verification receipts", "Freshness review", "Privacy review", "Local export controls"];
820
897
  // A poster is scoped to one visual theme. Keep its textual viewport claims
@@ -827,23 +904,25 @@ export async function generateLayoutSummary(options = {}) {
827
904
  const gitUnavailable = report.git?.baseRef === "NO_GIT" && report.git?.headRef === "NO_GIT";
828
905
  const gitState = gitUnavailable ? "unavailable" : files.length ? "changed" : "clean";
829
906
  const testState = tests.length ? (tests.some((test) => test.status === "failed") ? "failed" : "configured") : "unconfigured";
830
- const visualProject = Boolean(
831
- cssPath
832
- || (appPath && VISUAL_SOURCE_EXTENSIONS.has(sourceExtension(appPath)))
833
- || /<(?:html|body|main|section|article|div|button|form|canvas|svg)\b|React\.createElement\s*\(|document\.(?:querySelector|getElementById|createElement)\s*\(/i.test(app),
834
- );
835
907
  const gitRange = truncateText(`${safeText(report.git?.baseRef) || text.unknown}..${safeText(report.git?.headRef) || text.unknown}`, 180);
836
- const gitFiles = files.slice(0, 5).map((file) => ({
908
+ const gitFiles = files.slice(0, 20).map((file) => ({
837
909
  path: safeRelativePath(file.path),
838
910
  additions: Number(file.additions) || 0,
839
911
  deletions: Number(file.deletions) || 0,
912
+ status: file.status,
913
+ previousPath: file.previousPath ? safeRelativePath(file.previousPath) : undefined,
914
+ diff: file.diff === undefined ? undefined : safeBlock(file.diff),
915
+ diffTruncated: Boolean(file.diffTruncated || (file.diff?.length ?? 0) > 16_000),
916
+ diffUnavailable: file.diffUnavailable,
840
917
  }));
841
- const testReceipts = tests.slice(0, 3).map((test) => ({
918
+ const testReceipts = tests.slice(0, 20).map((test) => ({
842
919
  command: truncateText(safeText(test.command), 240),
843
920
  duration: durationLabel(test.durationMs),
844
921
  exitCode: Number(test.exitCode) || 0,
845
922
  status: safeText(test.status),
846
923
  statusLabel: statusLabel(test.status, locale),
924
+ output: safeBlock(test.output, 4000),
925
+ outputTruncated: Boolean(test.outputTruncated || test.output.length > 4000),
847
926
  }));
848
927
  const changes = files.map((file) => `\`${safeRelativePath(file.path)}\` (${statusLabel(file.status, locale)}, +${Number(file.additions) || 0} / -${Number(file.deletions) || 0})`);
849
928
  const receipts = tests.map((test) => `\`${truncateText(safeText(test.command), 240)}\` (${statusLabel(test.status, locale)}, ${text.exit} ${Number(test.exitCode) || 0})`);
@@ -855,7 +934,7 @@ export async function generateLayoutSummary(options = {}) {
855
934
  "",
856
935
  `## ${text.projectTask}`,
857
936
  `- ${text.task}: ${taskText}`,
858
- `- ${text.status}: ${statusLabel(report.task?.status, locale) || text.notRecorded}`,
937
+ `- ${text.status}: ${taskStatus.label}`,
859
938
  `- ${text.version}: ${safeText(report.version) || text.notRecorded}`,
860
939
  `- ${text.poster}: ${posterReference}`,
861
940
  "",
@@ -902,12 +981,20 @@ export async function generateLayoutSummary(options = {}) {
902
981
  image: posterImage.toString("base64"),
903
982
  surfaceImage: surfaceAssetPath ? `./${DIJIANG_SURFACE_FILENAME}` : "",
904
983
  projectName,
984
+ generatedAt: safeText(report.generatedAt),
905
985
  task: taskText,
906
986
  stages,
907
987
  fileCount: files.length,
908
988
  passedTests,
909
989
  testCount: tests.length,
910
990
  redactionCount: Number(redaction.totalReplacements) || 0,
991
+ redactionDetails: Object.entries(redaction.replacements ?? {}).map(([name, count]) => ({ name: redactionLabel(name, locale), count })),
992
+ durationMs: report.task.durationMs,
993
+ additions: report.git.summary.additions,
994
+ deletions: report.git.summary.deletions,
995
+ reportData: sanitizeReportExport({ ...report, task: { ...report.task, status: taskStatus.key }, tests, warnings: freshnessWarnings,
996
+ evidenceReview: { theme, matched: evidenceViewports, excluded: invalidEvidenceViewports } }),
997
+ summaryMarkdown: sanitizedMarkdown,
911
998
  // Keep the filtered poster set available to the renderer as the source of
912
999
  // truth; legacy fields remain populated for callers that already consume
913
1000
  // them.
@@ -925,7 +1012,7 @@ export async function generateLayoutSummary(options = {}) {
925
1012
  visualProject,
926
1013
  taskStatus: taskStatus.key,
927
1014
  taskStatusLabel: taskStatus.label,
928
- gitFileCount: Number(report.git?.summary?.changedFiles) || files.length,
1015
+ gitFileCount: files.length,
929
1016
  gitFiles,
930
1017
  testReceipts,
931
1018
  freshnessWarnings,
@@ -937,6 +1024,7 @@ export async function generateLayoutSummary(options = {}) {
937
1024
  });
938
1025
  sanitizedPoster = posterHtml.replaceAll(projectPath, "[PATH OMITTED]");
939
1026
  }
1027
+ options.signal?.throwIfAborted();
940
1028
  await writeArtifactFiles([
941
1029
  ...(surfaceAssetPath ? [{ path: surfaceAssetPath, content: posterImage }] : []),
942
1030
  ...(generatePoster ? [{ path: posterPath, content: sanitizedPoster }] : []),