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/CHANGELOG.md +35 -0
- package/README.md +83 -21
- package/README.zh-CN.md +3 -1
- package/package.json +17 -14
- package/plugin/client.js +39 -34
- package/plugin/dijiang-layout.js +161 -0
- package/plugin/dijiang-motion.js +101 -0
- package/plugin/dijiang-style.js +37 -0
- package/plugin/index.d.ts +20 -7
- package/plugin/index.js +72 -68
- package/plugin/layout-summary.d.ts +1 -0
- package/plugin/layout-summary.js +111 -23
- package/plugin/poster-html.js +117 -24
- package/plugin/project-io.d.ts +5 -0
- package/plugin/project-io.js +82 -0
- package/plugin/redaction.js +26 -0
- package/plugin/report-content.js +67 -0
- package/plugin/report-schema.js +18 -8
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"
|
|
8
|
-
|
|
9
|
-
|
|
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",
|
|
24
|
-
outputPath: { type: "string",
|
|
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",
|
|
27
|
-
sections: { type: "array",
|
|
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",
|
|
30
|
-
breakpoints: { type: "array",
|
|
31
|
-
stages: { type: "array",
|
|
32
|
-
freshnessWarnings: { type: "array",
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 :
|
|
60
|
-
generatePoster: typeof value.generatePoster === "boolean" ? value.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;
|
package/plugin/layout-summary.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
591
|
-
|
|
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 =
|
|
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 =
|
|
816
|
-
const
|
|
817
|
-
|
|
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,
|
|
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,
|
|
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}: ${
|
|
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:
|
|
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 }] : []),
|