dsh-showcase 0.1.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.
@@ -0,0 +1,961 @@
1
+ import { mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { createStyledPosterHtml } from "./poster-html.js";
4
+ import { formatReportIssues, reportSchema } from "./report-schema.js";
5
+
6
+ const DEFAULT_REPORT_PATH = ".showcase/report.json";
7
+ const DEFAULT_OUTPUT_PATH = ".showcase/layout-summary.md";
8
+ const DEFAULT_POSTER_PATH = ".showcase/layout-poster.html";
9
+ const DIJIANG_SURFACE_FILENAME = "dsh-dijiang-survey.webp";
10
+ const STAGE_IDS = ["PROMPT", "BUILD", "TEST", "CAPTURE", "SHIP"];
11
+ const STAGE_LABELS = { "zh-CN": ["提示", "构建", "测试", "捕获", "交付"], en: STAGE_IDS };
12
+ const LOCALES = new Set(["zh-CN", "en"]);
13
+ const THEMES = new Set(["frontier-signal", "blue-big-fish"]);
14
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]);
15
+ const HTML_EXTENSIONS = new Set([".html", ".htm"]);
16
+ const MAX_TEXT_LENGTH = 4096;
17
+ const MAX_PROJECT_COPY_LENGTH = 120;
18
+ const MAX_TASK_COPY_LENGTH = 320;
19
+ // Keep report parsing and the generated integrity panel bounded even when a
20
+ // caller hands the plugin a corrupted or accidentally duplicated report.
21
+ const MAX_REPORT_BYTES = 2 * 1024 * 1024;
22
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
23
+ const MAX_IMAGE_DIMENSION = 16384;
24
+ const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
25
+ const MAX_SOURCE_FILES = 4000;
26
+ const MAX_SOURCE_DEPTH = 8;
27
+ const FRESHNESS_CLOCK_SKEW_MS = 2000;
28
+ const MAX_FRESHNESS_WARNINGS = 5;
29
+ const SOURCE_IGNORES = new Set([
30
+ ".git",
31
+ ".showcase",
32
+ ".next",
33
+ ".nuxt",
34
+ ".svelte-kit",
35
+ "coverage",
36
+ "dist",
37
+ "build",
38
+ "out",
39
+ "node_modules",
40
+ "target",
41
+ "vendor",
42
+ ".venv",
43
+ "venv",
44
+ "__pycache__",
45
+ ]);
46
+ const APP_SOURCE_PRIORITY = [
47
+ "src/App.tsx",
48
+ "src/App.jsx",
49
+ "src/App.js",
50
+ "src/App.ts",
51
+ "src/main.tsx",
52
+ "src/main.jsx",
53
+ "src/main.js",
54
+ "src/main.ts",
55
+ "src/index.tsx",
56
+ "src/index.jsx",
57
+ "src/index.js",
58
+ "src/index.ts",
59
+ "src/main.py",
60
+ "main.py",
61
+ "app.py",
62
+ "index.html",
63
+ "index.js",
64
+ "index.ts",
65
+ "server.js",
66
+ "app.js",
67
+ "main.js",
68
+ ];
69
+ const CSS_SOURCE_PRIORITY = [
70
+ "src/styles.css",
71
+ "src/style.css",
72
+ "src/index.css",
73
+ "src/App.css",
74
+ "styles.css",
75
+ "style.css",
76
+ "index.css",
77
+ ];
78
+ const APP_SOURCE_EXTENSIONS = new Set([
79
+ ".cjs", ".cs", ".go", ".htm", ".html", ".java", ".js", ".jsx", ".mjs", ".py", ".rb", ".rs", ".svelte", ".ts", ".tsx", ".vue",
80
+ ]);
81
+ const VISUAL_SOURCE_EXTENSIONS = new Set([".htm", ".html", ".jsx", ".svelte", ".tsx", ".vue"]);
82
+
83
+ function isInside(projectPath, candidatePath) {
84
+ const relativePath = relative(projectPath, candidatePath);
85
+ return relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
86
+ }
87
+
88
+ function insideProject(projectPath, value, label) {
89
+ const resolved = resolve(projectPath, value);
90
+ if (!isInside(projectPath, resolved)) {
91
+ throw new Error(`${label} must resolve inside projectPath.`);
92
+ }
93
+ return resolved;
94
+ }
95
+
96
+ async function realpathWithMissing(candidatePath) {
97
+ const suffix = [];
98
+ let probe = candidatePath;
99
+ while (true) {
100
+ try {
101
+ const resolvedProbe = await realpath(probe);
102
+ return suffix.length ? resolve(resolvedProbe, ...suffix.reverse()) : resolvedProbe;
103
+ } catch (error) {
104
+ if (!error || !["ENOENT", "ENOTDIR"].includes(error.code)) throw error;
105
+ const parent = dirname(probe);
106
+ if (parent === probe) throw error;
107
+ suffix.push(basename(probe));
108
+ probe = parent;
109
+ }
110
+ }
111
+ }
112
+
113
+ async function insideProjectRealpath(projectPath, value, label) {
114
+ const lexicalProjectPath = resolve(projectPath);
115
+ const lexicalPath = insideProject(lexicalProjectPath, value, label);
116
+ const [projectRealPath, candidateRealPath] = await Promise.all([
117
+ realpath(lexicalProjectPath),
118
+ realpathWithMissing(lexicalPath),
119
+ ]);
120
+ if (!isInside(projectRealPath, candidateRealPath)) {
121
+ throw new Error(`${label} must resolve inside projectPath.`);
122
+ }
123
+ return lexicalPath;
124
+ }
125
+
126
+ function canonicalPathKey(filePath) {
127
+ return process.platform === "win32" ? filePath.toLowerCase() : filePath;
128
+ }
129
+
130
+ async function assertDistinctArtifactPaths(entries) {
131
+ const seen = new Map();
132
+ for (const [label, filePath] of entries) {
133
+ const canonicalPath = canonicalPathKey(await realpathWithMissing(filePath));
134
+ const previousLabel = seen.get(canonicalPath);
135
+ if (previousLabel) {
136
+ throw new Error(`${previousLabel} and ${label} must resolve to different files.`);
137
+ }
138
+ seen.set(canonicalPath, label);
139
+ }
140
+ }
141
+
142
+ function assertArtifactOutputPath(projectPath, filePath, label, allowedExtensions) {
143
+ const showcasePath = resolve(projectPath, ".showcase");
144
+ if (!isInside(showcasePath, filePath)) {
145
+ throw new Error(`${label} must resolve inside the project's .showcase directory.`);
146
+ }
147
+ const extension = extname(filePath).toLowerCase();
148
+ if (!allowedExtensions.has(extension)) {
149
+ throw new Error(`${label} must use one of these extensions: ${[...allowedExtensions].join(", ")}.`);
150
+ }
151
+ }
152
+
153
+ let stagedWriteSequence = 0;
154
+
155
+ function stagedFilePath(filePath) {
156
+ stagedWriteSequence += 1;
157
+ return resolve(dirname(filePath), `.${basename(filePath)}.tmp-${process.pid}-${Date.now()}-${stagedWriteSequence}`);
158
+ }
159
+
160
+ async function removeStagedFile(filePath) {
161
+ try {
162
+ await unlink(filePath);
163
+ } catch (error) {
164
+ if (!error || error.code !== "ENOENT") return;
165
+ }
166
+ }
167
+
168
+ async function assertArtifactTargetsReplaceable(artifacts) {
169
+ for (const artifact of artifacts) {
170
+ try {
171
+ const details = await stat(artifact.path);
172
+ if (details.isDirectory()) {
173
+ throw new Error(`Cannot replace ${artifact.path}: the target is a directory.`);
174
+ }
175
+ } catch (error) {
176
+ if (error instanceof Error && error.message.startsWith("Cannot replace ")) throw error;
177
+ if (!error || error.code !== "ENOENT") throw error;
178
+ }
179
+ }
180
+ }
181
+
182
+ async function writeArtifactFiles(artifacts) {
183
+ const staged = [];
184
+ const backups = [];
185
+ const committed = [];
186
+ const preservedBackups = new Set();
187
+ try {
188
+ // Check every target before moving any file. This prevents a directory or
189
+ // other invalid target from producing a mixed-version delivery.
190
+ await assertArtifactTargetsReplaceable(artifacts);
191
+ for (const artifact of artifacts) {
192
+ await mkdir(dirname(artifact.path), { recursive: true });
193
+ const temporaryPath = stagedFilePath(artifact.path);
194
+ if (typeof artifact.content === "string") await writeFile(temporaryPath, artifact.content, "utf8");
195
+ else await writeFile(temporaryPath, artifact.content);
196
+ staged.push({ ...artifact, temporaryPath });
197
+ }
198
+ for (const artifact of staged) {
199
+ try {
200
+ const details = await stat(artifact.path);
201
+ if (details.isDirectory()) {
202
+ throw new Error(`Cannot replace ${artifact.path}: the target is a directory.`);
203
+ }
204
+ const backupPath = stagedFilePath(`${artifact.path}.backup`);
205
+ await rename(artifact.path, backupPath);
206
+ backups.push({ ...artifact, backupPath });
207
+ } catch (error) {
208
+ if (error instanceof Error && error.message.startsWith("Cannot replace ")) throw error;
209
+ if (!error || error.code !== "ENOENT") throw error;
210
+ }
211
+ await rename(artifact.temporaryPath, artifact.path);
212
+ committed.push(artifact);
213
+ }
214
+ for (const backup of backups) {
215
+ await removeStagedFile(backup.backupPath);
216
+ }
217
+ } catch (error) {
218
+ // Roll back in reverse order so a late failure cannot leave one artifact
219
+ // newer than the others. Best-effort cleanup preserves the original error.
220
+ for (const artifact of [...committed].reverse()) await removeStagedFile(artifact.path);
221
+ for (const backup of [...backups].reverse()) {
222
+ try {
223
+ await rename(backup.backupPath, backup.path);
224
+ } catch (restoreError) {
225
+ // If restoration itself fails, keep the backup on disk instead of
226
+ // deleting the last known-good copy during final cleanup.
227
+ if (!restoreError || restoreError.code !== "ENOENT") preservedBackups.add(backup.backupPath);
228
+ }
229
+ }
230
+ throw error;
231
+ } finally {
232
+ await Promise.all(staged.map((artifact) => removeStagedFile(artifact.temporaryPath)));
233
+ await Promise.all(backups
234
+ .filter((backup) => !preservedBackups.has(backup.backupPath))
235
+ .map((backup) => removeStagedFile(backup.backupPath)));
236
+ }
237
+ }
238
+
239
+ function isMissingSourceError(error) {
240
+ return Boolean(error && ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "EISDIR"].includes(error.code));
241
+ }
242
+
243
+ function sourcePathKey(projectPath, filePath) {
244
+ return projectRelativePath(projectPath, filePath).replace(/\\/g, "/").toLowerCase();
245
+ }
246
+
247
+ function sourceExtension(filePath) {
248
+ return extname(filePath).toLowerCase();
249
+ }
250
+
251
+ function sourceDepth(filePath) {
252
+ return filePath.split(/[\\/]/).length;
253
+ }
254
+
255
+ function sourceScore(projectPath, filePath, kind) {
256
+ const key = sourcePathKey(projectPath, filePath);
257
+ const priority = kind === "app" ? APP_SOURCE_PRIORITY : CSS_SOURCE_PRIORITY;
258
+ const exact = priority.findIndex((candidate) => candidate.toLowerCase() === key);
259
+ if (exact >= 0) return exact;
260
+
261
+ const base = basename(key, sourceExtension(key));
262
+ const depth = sourceDepth(key);
263
+ if (kind === "css") return 100 + depth;
264
+
265
+ const extension = sourceExtension(key);
266
+ const names = new Map([
267
+ ["index", 20],
268
+ ["main", 30],
269
+ ["app", 40],
270
+ ["server", 50],
271
+ ]);
272
+ const nameScore = names.get(base) ?? 80;
273
+ const extensionScore = extension === ".html" || extension === ".htm" ? 0 : extension === ".py" ? 10 : 20;
274
+ return 100 + nameScore + extensionScore + depth;
275
+ }
276
+
277
+ function isAppSource(filePath) {
278
+ return APP_SOURCE_EXTENSIONS.has(sourceExtension(filePath));
279
+ }
280
+
281
+ async function collectProjectFiles(projectPath, currentPath = projectPath, depth = 0, files = []) {
282
+ if (depth > MAX_SOURCE_DEPTH || files.length >= MAX_SOURCE_FILES) return files;
283
+ let entries;
284
+ try {
285
+ entries = await readdir(currentPath, { withFileTypes: true });
286
+ } catch (error) {
287
+ if (isMissingSourceError(error)) return files;
288
+ throw error;
289
+ }
290
+ entries.sort((left, right) => left.name.localeCompare(right.name));
291
+ for (const entry of entries) {
292
+ if (files.length >= MAX_SOURCE_FILES) break;
293
+ if (entry.name === "." || entry.name === "..") continue;
294
+ if (entry.isDirectory() && SOURCE_IGNORES.has(entry.name.toLowerCase())) continue;
295
+ const candidate = resolve(currentPath, entry.name);
296
+ if (entry.isDirectory()) {
297
+ await collectProjectFiles(projectPath, candidate, depth + 1, files);
298
+ } else if (entry.isFile() && (isAppSource(candidate) || sourceExtension(candidate) === ".css")) {
299
+ files.push(candidate);
300
+ }
301
+ }
302
+ return files;
303
+ }
304
+
305
+ async function optionalProjectFile(projectPath, value, label) {
306
+ const lexicalPath = await insideProjectRealpath(projectPath, value, label);
307
+ try {
308
+ const details = await stat(lexicalPath);
309
+ if (!details.isFile() || details.size > MAX_SOURCE_BYTES) return undefined;
310
+ return lexicalPath;
311
+ } catch (error) {
312
+ if (isMissingSourceError(error)) return undefined;
313
+ throw error;
314
+ }
315
+ }
316
+
317
+ async function pickSource(projectPath, candidates, kind) {
318
+ const label = kind === "app" ? "App source path" : "CSS source path";
319
+ const sorted = [...candidates]
320
+ .sort((left, right) => sourceScore(projectPath, left, kind) - sourceScore(projectPath, right, kind) || sourcePathKey(projectPath, left).localeCompare(sourcePathKey(projectPath, right)));
321
+ for (const candidate of sorted) {
322
+ const usable = await optionalProjectFile(projectPath, candidate, label);
323
+ if (usable) return usable;
324
+ }
325
+ return undefined;
326
+ }
327
+
328
+ async function discoverLayoutSources(projectPath, options = {}) {
329
+ const appOverride = typeof options.appPath === "string" ? options.appPath : undefined;
330
+ const cssOverride = typeof options.cssPath === "string" ? options.cssPath : undefined;
331
+ let appPath = appOverride ? await optionalProjectFile(projectPath, appOverride, "App source path") : undefined;
332
+ let cssPath = cssOverride ? await optionalProjectFile(projectPath, cssOverride, "CSS source path") : undefined;
333
+ const shouldScanApp = !appOverride;
334
+ const shouldScanCss = !cssOverride;
335
+ if (shouldScanApp || shouldScanCss) {
336
+ const candidates = await collectProjectFiles(projectPath);
337
+ if (shouldScanApp) {
338
+ appPath = await pickSource(projectPath, candidates.filter((candidate) => isAppSource(candidate)), "app");
339
+ }
340
+ if (shouldScanCss) {
341
+ cssPath = await pickSource(projectPath, candidates.filter((candidate) => sourceExtension(candidate) === ".css"), "css");
342
+ }
343
+ }
344
+ return { appPath, cssPath };
345
+ }
346
+
347
+ async function readOptionalSource(projectPath, sourcePath, label) {
348
+ if (!sourcePath) return "";
349
+ try {
350
+ const safePath = await insideProjectRealpath(projectPath, sourcePath, label);
351
+ const details = await stat(safePath);
352
+ if (!details.isFile() || details.size > MAX_SOURCE_BYTES) return "";
353
+ return await readFile(safePath, "utf8");
354
+ } catch (error) {
355
+ if (isMissingSourceError(error)) return "";
356
+ throw error;
357
+ }
358
+ }
359
+
360
+ function legacySafeText(value) {
361
+ return String(value ?? "")
362
+ .replace(/\b(?:authorization|api[_-]?key|access[_-]?token|token)\s*[:=]\s*[^\s,;]+/gi, "[REDACTED]")
363
+ .replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[REDACTED]")
364
+ .replace(/\b[A-Za-z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "[PATH OMITTED]")
365
+ .replace(/(?<![\w/])\/(?:Users|home)\/[^\s/:]+(?:\/[^\s:]+)*/g, "[PATH OMITTED]")
366
+ .replace(/[`\r\n]+/g, " ")
367
+ .trim();
368
+ }
369
+
370
+ function legacySafeRelativePath(value) {
371
+ const path = String(value ?? "").replace(/\\/g, "/");
372
+ if (!path || path.startsWith("/") || /^[a-z]:\//i.test(path) || path.startsWith("../")) return "[path omitted]";
373
+ return path.replace(/`/g, "");
374
+ }
375
+
376
+ function truncateText(value, maxLength = MAX_TEXT_LENGTH) {
377
+ const text = String(value ?? "");
378
+ if (text.length <= maxLength) return text;
379
+ return `${text.slice(0, Math.max(0, maxLength - 3))}...`;
380
+ }
381
+
382
+ function safeText(value, maxLength = MAX_TEXT_LENGTH) {
383
+ const redacted = String(value ?? "")
384
+ .replace(/\b(?:authorization|api\s*[_-]?key|access\s*[_-]?token|token)\s*[:=]\s*(?:bearer\s+)?[^\s,;]+/gi, "[REDACTED]")
385
+ .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
+ .replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[REDACTED]")
387
+ .replace(/(?<![\w])(?:[A-Za-z]:[\\/])[^<>\"|?*\r\n,;]+/g, "[PATH OMITTED]")
388
+ .replace(/(?<![\w/])\/(?:Users|home|mnt|var|tmp)\/[^\s/:,;]+(?:\/[^\s:,;]+)*/g, "[PATH OMITTED]")
389
+ .replace(/[\x60\r\n]+/g, " ")
390
+ .trim();
391
+ return truncateText(redacted, maxLength);
392
+ }
393
+
394
+ function safeRelativePath(value, maxLength = 240) {
395
+ const path = String(value ?? "").replace(/\\/g, "/").replace(/[\x60\r\n]+/g, "").trim();
396
+ if (!path || path.startsWith("/") || /^[a-z]:/i.test(path) || path === ".." || path.startsWith("../") || path.includes("/../")) return "[path omitted]";
397
+ return truncateText(path, maxLength);
398
+ }
399
+
400
+ function collectBreakpoints(css) {
401
+ const pattern = /@media[^{}]*\(\s*max-width\s*:\s*([0-9]+(?:\.[0-9]+)?(?:px|rem|em|ch|vw))\s*\)/gi;
402
+ return [...new Set([...css.matchAll(pattern)].map((match) => `max-width: ${match[1]}`))];
403
+ }
404
+
405
+ function collectStages(app, locale) {
406
+ const ids = [...app.matchAll(/\{\s*id:\s*["']([A-Z]+)["']/g)].map((match) => match[1]).filter((id) => STAGE_IDS.includes(id));
407
+ const stages = ids.length === STAGE_IDS.length ? ids : STAGE_IDS;
408
+ return locale === "zh-CN" ? stages.map((id) => STAGE_LABELS["zh-CN"][STAGE_IDS.indexOf(id)]) : stages;
409
+ }
410
+
411
+ function themeLabel(theme, locale) {
412
+ const labels = locale === "zh-CN"
413
+ ? { "frontier-signal": "终末地帝江号", "blue-big-fish": "蓝色大肥鱼" }
414
+ : { "frontier-signal": "Dijiang", "blue-big-fish": "Blue Big Fish" };
415
+ return labels[theme];
416
+ }
417
+
418
+ function escapeHtml(value) {
419
+ return safeText(value).replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
420
+ }
421
+
422
+ function projectRelativePath(projectPath, fullPath) {
423
+ return relative(projectPath, fullPath).split(sep).join("/");
424
+ }
425
+
426
+ function normalizeScreenshotTheme(value) {
427
+ if (value === "frontier-signal" || value === "blue-big-fish") return value;
428
+ if (value === "field") return "frontier-signal";
429
+ if (value === "fish") return "blue-big-fish";
430
+ return undefined;
431
+ }
432
+
433
+ function screenshotTheme(shot) {
434
+ const explicitTheme = normalizeScreenshotTheme(shot?.theme);
435
+ if (explicitTheme) return explicitTheme;
436
+ try {
437
+ return normalizeScreenshotTheme(new URL(String(shot?.url ?? "")).searchParams.get("theme"));
438
+ } catch {
439
+ return undefined;
440
+ }
441
+ }
442
+
443
+ function screenshotLabel(shot, locale) {
444
+ const name = viewportLabel(safeText(shot?.viewport?.name, 64), locale);
445
+ const width = Number(shot?.viewport?.width) || 0;
446
+ const height = Number(shot?.viewport?.height) || 0;
447
+ return `${name}: ${width} x ${height}`;
448
+ }
449
+
450
+ function pngDimensions(image) {
451
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
452
+ if (image.length < 24 || !signature.every((byte, index) => image[index] === byte)) return undefined;
453
+ let dimensions;
454
+ let hasImageData = false;
455
+ let hasEnd = false;
456
+ let offset = 8;
457
+ while (offset + 12 <= image.length) {
458
+ const chunkLength = image.readUInt32BE(offset);
459
+ const dataOffset = offset + 8;
460
+ const endOffset = dataOffset + chunkLength + 4;
461
+ if (endOffset > image.length) return undefined;
462
+ const chunk = image.toString("ascii", offset + 4, offset + 8);
463
+ if (chunk === "IHDR" && chunkLength === 13 && !dimensions) {
464
+ dimensions = { width: image.readUInt32BE(dataOffset), height: image.readUInt32BE(dataOffset + 4) };
465
+ } else if (chunk === "IDAT") {
466
+ hasImageData = true;
467
+ } else if (chunk === "IEND") {
468
+ hasEnd = true;
469
+ break;
470
+ }
471
+ offset = endOffset;
472
+ }
473
+ return dimensions && hasImageData && hasEnd ? dimensions : undefined;
474
+ }
475
+
476
+ function jpegDimensions(image) {
477
+ if (image.length < 4 || image[0] !== 0xff || image[1] !== 0xd8 || image[2] !== 0xff) return undefined;
478
+ if (image.lastIndexOf(Buffer.from([0xff, 0xd9])) < 0) return undefined;
479
+ const startOfFrame = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
480
+ let offset = 2;
481
+ while (offset + 4 <= image.length) {
482
+ while (offset < image.length && image[offset] === 0xff) offset += 1;
483
+ if (offset >= image.length) break;
484
+ const marker = image[offset];
485
+ offset += 1;
486
+ if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
487
+ if (offset + 2 > image.length) return undefined;
488
+ const segmentLength = image.readUInt16BE(offset);
489
+ if (segmentLength < 2 || offset + segmentLength > image.length) return undefined;
490
+ if (startOfFrame.has(marker)) {
491
+ if (segmentLength < 7) return undefined;
492
+ return { width: image.readUInt16BE(offset + 5), height: image.readUInt16BE(offset + 3) };
493
+ }
494
+ if (marker === 0xda) break;
495
+ offset += segmentLength;
496
+ }
497
+ return undefined;
498
+ }
499
+
500
+ function webpDimensions(image) {
501
+ if (image.length < 20 || image.toString("ascii", 0, 4) !== "RIFF" || image.toString("ascii", 8, 12) !== "WEBP") return undefined;
502
+ if (image.readUInt32LE(4) + 8 > image.length) return undefined;
503
+ let offset = 12;
504
+ while (offset + 8 <= image.length) {
505
+ const chunk = image.toString("ascii", offset, offset + 4);
506
+ const chunkLength = image.readUInt32LE(offset + 4);
507
+ const dataOffset = offset + 8;
508
+ if (dataOffset + chunkLength > image.length) return undefined;
509
+ if (chunk === "VP8X" && chunkLength >= 10) {
510
+ return {
511
+ width: 1 + image.readUIntLE(dataOffset + 4, 3),
512
+ height: 1 + image.readUIntLE(dataOffset + 7, 3),
513
+ };
514
+ }
515
+ if (chunk === "VP8 " && chunkLength >= 10 && image[dataOffset + 3] === 0x9d && image[dataOffset + 4] === 0x01 && image[dataOffset + 5] === 0x2a) {
516
+ return {
517
+ width: image.readUInt16LE(dataOffset + 6) & 0x3fff,
518
+ height: image.readUInt16LE(dataOffset + 8) & 0x3fff,
519
+ };
520
+ }
521
+ if (chunk === "VP8L" && chunkLength >= 5 && image[dataOffset] === 0x2f) {
522
+ const bits = image.readUInt32LE(dataOffset + 1);
523
+ return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >>> 14) & 0x3fff) };
524
+ }
525
+ offset = dataOffset + chunkLength + (chunkLength % 2);
526
+ }
527
+ return undefined;
528
+ }
529
+
530
+ function imageInfo(image) {
531
+ if (!image.length || image.length > MAX_IMAGE_BYTES) return undefined;
532
+ const candidates = [
533
+ ["image/png", pngDimensions(image)],
534
+ ["image/jpeg", jpegDimensions(image)],
535
+ ["image/webp", webpDimensions(image)],
536
+ ];
537
+ const match = candidates.find(([, dimensions]) => dimensions);
538
+ if (!match) return undefined;
539
+ const [mimeType, dimensions] = match;
540
+ if (!dimensions
541
+ || dimensions.width <= 0
542
+ || dimensions.height <= 0
543
+ || dimensions.width > MAX_IMAGE_DIMENSION
544
+ || dimensions.height > MAX_IMAGE_DIMENSION) return undefined;
545
+ return { mimeType, width: dimensions.width, height: dimensions.height };
546
+ }
547
+
548
+ // Keep one bounded, verified set for both Markdown and poster output. The
549
+ // Markdown-only path validates bytes without retaining a base64 copy.
550
+ async function collectVerifiedScreenshots(projectPath, screenshots, locale, { embed = false, strictPaths = embed } = {}) {
551
+ const mimeTypes = new Map([[".png", "image/png"], [".webp", "image/webp"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"]]);
552
+ const posterScreenshots = [];
553
+ const verifiedEvidenceViewports = [];
554
+ const invalidEvidenceViewports = [];
555
+ for (const shot of screenshots) {
556
+ if (verifiedEvidenceViewports.length >= 3) break;
557
+ const label = screenshotLabel(shot, locale);
558
+ if (!shot?.imagePath || typeof shot.imagePath !== "string") {
559
+ invalidEvidenceViewports.push(label);
560
+ continue;
561
+ }
562
+ try {
563
+ const fullPath = await insideProjectRealpath(projectPath, shot.imagePath, "screenshot imagePath");
564
+ const mimeType = mimeTypes.get(extname(fullPath).toLowerCase());
565
+ if (!mimeType) {
566
+ invalidEvidenceViewports.push(label);
567
+ continue;
568
+ }
569
+ const image = await readFile(fullPath);
570
+ const info = imageInfo(image);
571
+ if (!info) {
572
+ invalidEvidenceViewports.push(label);
573
+ continue;
574
+ }
575
+ const name = viewportLabel(safeText(shot.viewport?.name), locale);
576
+ const width = Number(shot.viewport?.width) || 0;
577
+ const height = Number(shot.viewport?.height) || 0;
578
+ const verifiedLabel = `${name}: ${width} x ${height}`;
579
+ verifiedEvidenceViewports.push(verifiedLabel);
580
+ if (embed) {
581
+ posterScreenshots.push({
582
+ image: image.toString("base64"),
583
+ mimeType: info.mimeType,
584
+ label: verifiedLabel,
585
+ });
586
+ }
587
+ } catch (error) {
588
+ // Embedded poster bytes must never cross the project boundary. A
589
+ // 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;
592
+ invalidEvidenceViewports.push(label);
593
+ }
594
+ }
595
+ return { posterScreenshots, verifiedEvidenceViewports, invalidEvidenceViewports };
596
+ }
597
+
598
+ function formatList(items, emptyLabel) {
599
+ return items.length ? items.map((item) => `- ${item}`).join("\n") : `- ${emptyLabel}`;
600
+ }
601
+
602
+ async function collectFreshnessWarnings(projectPath, report, sourcePaths, screenshots, locale) {
603
+ const generatedAt = Date.parse(String(report.generatedAt ?? ""));
604
+ if (!Number.isFinite(generatedAt)) return [];
605
+ const warnings = [];
606
+ const reportedFiles = Array.isArray(report.git?.files)
607
+ ? report.git.files.map((file) => file?.path).filter((filePath) => typeof filePath === "string")
608
+ : [];
609
+ const sourceCandidates = [...sourcePaths.filter(Boolean)];
610
+ for (const reportedPath of reportedFiles) {
611
+ try {
612
+ sourceCandidates.push(await insideProjectRealpath(projectPath, reportedPath, "Git file path"));
613
+ } catch (error) {
614
+ if (!(error instanceof Error && error.message.includes("must resolve inside projectPath."))) throw error;
615
+ }
616
+ }
617
+ for (const sourcePath of [...new Set(sourceCandidates)]) {
618
+ try {
619
+ const details = await stat(sourcePath);
620
+ if (details.mtimeMs > generatedAt + FRESHNESS_CLOCK_SKEW_MS) {
621
+ const relativePath = safeRelativePath(projectRelativePath(projectPath, sourcePath));
622
+ warnings.push(locale === "zh-CN"
623
+ ? `布局源文件 ${relativePath} 在报告生成后发生过修改,请重新采集。`
624
+ : `Layout source ${relativePath} changed after the report was generated; recapture evidence.`);
625
+ }
626
+ if (warnings.length >= MAX_FRESHNESS_WARNINGS) break;
627
+ } catch (error) {
628
+ if (!isMissingSourceError(error)) throw error;
629
+ }
630
+ }
631
+ for (const screenshot of screenshots) {
632
+ const capturedAt = Date.parse(String(screenshot?.capturedAt ?? ""));
633
+ if (Number.isFinite(capturedAt) && capturedAt > generatedAt + FRESHNESS_CLOCK_SKEW_MS) {
634
+ warnings.push(locale === "zh-CN"
635
+ ? `截图 ${screenshotLabel(screenshot, locale)} 的采集时间晚于报告生成时间,请重新生成报告。`
636
+ : `Screenshot ${screenshotLabel(screenshot, locale)} was captured after the report timestamp; regenerate the report.`);
637
+ }
638
+ if (Number.isFinite(capturedAt) && typeof screenshot?.imagePath === "string") {
639
+ try {
640
+ const imagePath = await insideProjectRealpath(projectPath, screenshot.imagePath, "screenshot imagePath");
641
+ const details = await stat(imagePath);
642
+ if (details.mtimeMs > capturedAt + FRESHNESS_CLOCK_SKEW_MS) {
643
+ warnings.push(locale === "zh-CN"
644
+ ? `截图 ${screenshotLabel(screenshot, locale)} 的文件在采集时间后发生过修改,请重新采集。`
645
+ : `Screenshot file ${screenshotLabel(screenshot, locale)} changed after its capture time; recapture evidence.`);
646
+ }
647
+ } catch (error) {
648
+ if (!(isMissingSourceError(error) || (error instanceof Error && error.message.includes("must resolve inside projectPath.")))) throw error;
649
+ }
650
+ }
651
+ if (warnings.length >= MAX_FRESHNESS_WARNINGS) break;
652
+ }
653
+ for (const receipt of Array.isArray(report.tests) ? report.tests : []) {
654
+ const startedAt = Date.parse(String(receipt?.startedAt ?? ""));
655
+ if (Number.isFinite(startedAt) && startedAt > generatedAt + FRESHNESS_CLOCK_SKEW_MS) {
656
+ warnings.push(locale === "zh-CN"
657
+ ? `测试 ${truncateText(safeText(receipt?.command), 120) || "未命名命令"} 晚于报告生成时间,请重新生成报告。`
658
+ : `Test ${truncateText(safeText(receipt?.command), 120) || "unnamed command"} started after the report timestamp; regenerate the report.`);
659
+ }
660
+ if (warnings.length >= MAX_FRESHNESS_WARNINGS) break;
661
+ }
662
+ return [...new Set(warnings)].slice(0, MAX_FRESHNESS_WARNINGS);
663
+ }
664
+
665
+ function localeText(locale) {
666
+ return locale === "zh-CN" ? {
667
+ 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: "仅在本地生成 / 不会上传",
668
+ interaction: ["带本地持久化偏好的分段主题切换", "支持键盘操作的改版前后范围控件", "可展开的测试回执输出", "带状态反馈的本地导出操作", "已变更文件 Diff 选择"],
669
+ } : {
670
+ title: "Layout Summary", projectTask: "Project and task", task: "Task", status: "Status", version: "Report version", poster: "Poster artifact", posterNotGenerated: "Not generated for this call", freshness: "Freshness review", freshnessOk: "No obvious timestamp conflicts found across the report, sources, and captures", theme: "Theme system", rail: "Operations rail", main: "Main page sections", breakpoints: "Responsive breakpoints", screenshots: "Screenshot viewports", git: "Git changes", range: "Range", receipts: "Test receipts", privacy: "Privacy review", redactions: "Redacted values", interactions: "Interaction controls", notRecorded: "Not recorded", untitledProject: "Untitled project", unknown: "unknown", notCaptured: "not captured", exit: "exit", conclusion: "Generated locally from report and layout source files. No content was uploaded.", empty: "None recorded", posterTitle: "Layout evidence poster", kicker: "DELIVERY EVIDENCE / LOCAL REPORT", verified: "VERIFIED", files: "FILES CHANGED", tests: "TESTS PASSED", redaction: "REDACTIONS", footer: "LOCAL ONLY / NO UPLOAD",
671
+ interaction: ["Segmented theme control with persisted local preference", "Keyboard-accessible Before/After range control", "Expandable test receipt output", "Local export actions with status feedback", "Changed-file Diff selection"],
672
+ };
673
+ }
674
+
675
+ function viewportLabel(value, locale) {
676
+ const labels = locale === "zh-CN" ? { desktop: "桌面端", tablet: "平板端", mobile: "移动端" } : {};
677
+ return labels[value] ?? value;
678
+ }
679
+
680
+ function interactionList(app, locale) {
681
+ const labels = localeText(locale).interaction;
682
+ const interactions = [];
683
+ if (app.includes("theme-switch")) interactions.push(labels[0]);
684
+ if (app.includes('type="range"')) interactions.push(labels[1]);
685
+ if (app.includes("<details")) interactions.push(labels[2]);
686
+ if (app.includes("export-actions")) interactions.push(labels[3]);
687
+ if (app.includes("setSelectedFile")) interactions.push(labels[4]);
688
+ return interactions;
689
+ }
690
+
691
+ function statusLabel(value, locale) {
692
+ const status = safeText(value);
693
+ const labels = locale === "zh-CN"
694
+ ? { completed: "已验证", passed: "已通过", partial: "需要复核", failed: "执行失败", skipped: "已跳过", running: "准备中" }
695
+ : { completed: "VERIFIED", passed: "PASSED", partial: "NEEDS REVIEW", failed: "FAILED", skipped: "SKIPPED", running: "RUNNING" };
696
+ return labels[status] ?? status;
697
+ }
698
+
699
+ function taskStatusModel(value, locale) {
700
+ const raw = safeText(value);
701
+ const key = raw === "completed" || raw === "partial" || raw === "failed" ? raw : "partial";
702
+ return { key, label: statusLabel(key, locale) };
703
+ }
704
+
705
+ function redactionLabel(value, locale) {
706
+ const label = safeText(value);
707
+ if (locale !== "zh-CN") return label;
708
+ return ({ token: "令牌", "windows-path": "本机路径", path: "路径", authorization: "授权信息" })[label] ?? label;
709
+ }
710
+
711
+ function durationLabel(value) {
712
+ const milliseconds = Math.max(0, Math.round(Number(value) || 0));
713
+ if (milliseconds >= 1000) return `${(milliseconds / 1000).toFixed(1)}s`;
714
+ return `${milliseconds}ms`;
715
+ }
716
+
717
+ export async function generateLayoutSummary(options = {}) {
718
+ if (!options.projectPath || typeof options.projectPath !== "string") {
719
+ throw new Error("projectPath is required.");
720
+ }
721
+
722
+ const locale = options.locale ?? "zh-CN";
723
+ if (!LOCALES.has(locale)) throw new Error("locale must be either zh-CN or en.");
724
+ const theme = options.theme ?? "frontier-signal";
725
+ if (!THEMES.has(theme)) throw new Error("theme must be either frontier-signal or blue-big-fish.");
726
+ const generatePoster = options.generatePoster === undefined ? true : options.generatePoster;
727
+ if (typeof generatePoster !== "boolean") throw new Error("generatePoster must be a boolean.");
728
+ const text = localeText(locale);
729
+ const projectPath = resolve(options.projectPath);
730
+ const paths = await Promise.all([
731
+ insideProjectRealpath(projectPath, options.reportPath ?? DEFAULT_REPORT_PATH, "reportPath"),
732
+ insideProjectRealpath(projectPath, options.outputPath ?? DEFAULT_OUTPUT_PATH, "outputPath"),
733
+ ...(generatePoster ? [insideProjectRealpath(projectPath, options.posterPath ?? DEFAULT_POSTER_PATH, "posterPath")] : []),
734
+ ]);
735
+ const [reportPath, outputPath] = paths;
736
+ const posterPath = generatePoster ? paths[2] : undefined;
737
+ const surfaceAssetPath = generatePoster && theme === "frontier-signal"
738
+ ? resolve(dirname(posterPath), DIJIANG_SURFACE_FILENAME)
739
+ : undefined;
740
+ await assertDistinctArtifactPaths([
741
+ ["reportPath", reportPath],
742
+ ["outputPath", outputPath],
743
+ ...(generatePoster ? [["posterPath", posterPath]] : []),
744
+ ...(surfaceAssetPath ? [["surfaceAssetPath", surfaceAssetPath]] : []),
745
+ ]);
746
+ assertArtifactOutputPath(projectPath, outputPath, "outputPath", MARKDOWN_EXTENSIONS);
747
+ if (generatePoster) assertArtifactOutputPath(projectPath, posterPath, "posterPath", HTML_EXTENSIONS);
748
+ if (surfaceAssetPath) {
749
+ if (!isInside(resolve(projectPath, ".showcase"), surfaceAssetPath) || extname(surfaceAssetPath).toLowerCase() !== ".webp") {
750
+ throw new Error("surfaceAssetPath must resolve to a WebP file inside the project's .showcase directory.");
751
+ }
752
+ }
753
+ const { appPath, cssPath } = await discoverLayoutSources(projectPath, options);
754
+ let report;
755
+
756
+ try {
757
+ const reportDetails = await stat(reportPath);
758
+ if (!reportDetails.isFile()) throw new Error("reportPath must point to a file.");
759
+ if (reportDetails.size > MAX_REPORT_BYTES) {
760
+ throw new Error(`reportPath exceeds the ${MAX_REPORT_BYTES} byte safety limit.`);
761
+ }
762
+ const rawReport = JSON.parse(await readFile(reportPath, "utf8"));
763
+ report = reportSchema.parse(rawReport);
764
+ } catch (error) {
765
+ if (Array.isArray(error?.issues)) {
766
+ throw new Error(`Invalid report JSON: ${formatReportIssues(error)}`);
767
+ }
768
+ throw new Error(`Unable to read report JSON: ${error instanceof Error ? error.message : String(error)}`);
769
+ }
770
+
771
+ let app = "";
772
+ let css = "";
773
+ let posterImage = Buffer.alloc(0);
774
+ try {
775
+ [app, css] = await Promise.all([
776
+ readOptionalSource(projectPath, appPath, "App source path"),
777
+ readOptionalSource(projectPath, cssPath, "CSS source path"),
778
+ ]);
779
+ // Static HTML projects often keep responsive rules in an inline <style>
780
+ // block. Let that source feed breakpoint detection when no .css file exists.
781
+ if (!css && appPath && [".html", ".htm"].includes(sourceExtension(appPath))) css = app;
782
+ if (generatePoster) {
783
+ posterImage = await readFile(new URL(
784
+ theme === "blue-big-fish" ? "./assets/whale-girl-keyvisual.webp" : "./assets/dijiang-survey-surface.webp",
785
+ import.meta.url,
786
+ ));
787
+ }
788
+ } catch (error) {
789
+ throw new Error(`Unable to read layout sources or poster asset: ${error instanceof Error ? error.message : String(error)}`);
790
+ }
791
+
792
+ const selectedTheme = themeLabel(theme, locale);
793
+ const stages = collectStages(app, locale);
794
+ const breakpoints = collectBreakpoints(css);
795
+ const screenshots = Array.isArray(report.screenshots) ? report.screenshots : [];
796
+ const matchingScreenshots = [];
797
+ const isolatedScreenshots = [];
798
+ const unclassifiedScreenshots = [];
799
+ for (const screenshot of screenshots) {
800
+ const captureTheme = screenshotTheme(screenshot);
801
+ if (captureTheme === theme) matchingScreenshots.push(screenshot);
802
+ else if (captureTheme) isolatedScreenshots.push(screenshot);
803
+ else unclassifiedScreenshots.push(screenshot);
804
+ }
805
+ const collectedEvidence = await collectVerifiedScreenshots(
806
+ projectPath,
807
+ generatePoster ? matchingScreenshots : screenshots,
808
+ locale,
809
+ { embed: generatePoster },
810
+ );
811
+ const posterScreenshots = collectedEvidence.posterScreenshots;
812
+ const invalidEvidenceViewports = collectedEvidence.invalidEvidenceViewports;
813
+ const evidenceViewports = collectedEvidence.verifiedEvidenceViewports;
814
+ 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);
818
+ const redaction = report.redaction ?? {};
819
+ 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
+ // A poster is scoped to one visual theme. Keep its textual viewport claims
821
+ // aligned with the same filtered evidence set used for embedded images.
822
+ const viewports = evidenceViewports;
823
+ const posterViewports = [...viewports];
824
+ const projectName = truncateText(safeText(report.project?.name), MAX_PROJECT_COPY_LENGTH) || text.untitledProject;
825
+ const taskText = truncateText(safeText(report.task?.goal), MAX_TASK_COPY_LENGTH) || text.notRecorded;
826
+ const passedTests = tests.filter((test) => test.status === "passed").length;
827
+ const gitUnavailable = report.git?.baseRef === "NO_GIT" && report.git?.headRef === "NO_GIT";
828
+ const gitState = gitUnavailable ? "unavailable" : files.length ? "changed" : "clean";
829
+ 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
+ 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) => ({
837
+ path: safeRelativePath(file.path),
838
+ additions: Number(file.additions) || 0,
839
+ deletions: Number(file.deletions) || 0,
840
+ }));
841
+ const testReceipts = tests.slice(0, 3).map((test) => ({
842
+ command: truncateText(safeText(test.command), 240),
843
+ duration: durationLabel(test.durationMs),
844
+ exitCode: Number(test.exitCode) || 0,
845
+ status: safeText(test.status),
846
+ statusLabel: statusLabel(test.status, locale),
847
+ }));
848
+ const changes = files.map((file) => `\`${safeRelativePath(file.path)}\` (${statusLabel(file.status, locale)}, +${Number(file.additions) || 0} / -${Number(file.deletions) || 0})`);
849
+ const receipts = tests.map((test) => `\`${truncateText(safeText(test.command), 240)}\` (${statusLabel(test.status, locale)}, ${text.exit} ${Number(test.exitCode) || 0})`);
850
+ const redactionItems = Object.entries(redaction.replacements ?? {}).map(([name, count]) => `${redactionLabel(name, locale)}: ${Number(count) || 0}`);
851
+ const interactions = interactionList(app, locale);
852
+ const posterReference = generatePoster ? projectRelativePath(projectPath, posterPath) : text.posterNotGenerated;
853
+ const markdown = [
854
+ `# ${text.title}`,
855
+ "",
856
+ `## ${text.projectTask}`,
857
+ `- ${text.task}: ${taskText}`,
858
+ `- ${text.status}: ${statusLabel(report.task?.status, locale) || text.notRecorded}`,
859
+ `- ${text.version}: ${safeText(report.version) || text.notRecorded}`,
860
+ `- ${text.poster}: ${posterReference}`,
861
+ "",
862
+ `## ${text.theme}`,
863
+ `- ${selectedTheme}`,
864
+ "",
865
+ `## ${text.rail}`,
866
+ `- ${stages.join(" -> ")}`,
867
+ "",
868
+ `## ${text.main}`,
869
+ formatList(sections, text.empty),
870
+ "",
871
+ `## ${text.breakpoints}`,
872
+ formatList(breakpoints, text.empty),
873
+ "",
874
+ `## ${text.screenshots}`,
875
+ formatList(viewports, text.notCaptured),
876
+ "",
877
+ `## ${text.git}`,
878
+ `- ${text.range}: ${safeText(report.git?.baseRef) || text.unknown}..${safeText(report.git?.headRef) || text.unknown}`,
879
+ ...changes.map((change) => `- ${change}`),
880
+ "",
881
+ `## ${text.receipts}`,
882
+ ...receipts.map((receipt) => `- ${receipt}`),
883
+ "",
884
+ `## ${text.freshness}`,
885
+ formatList(freshnessWarnings, text.freshnessOk),
886
+ "",
887
+ `## ${text.privacy}`,
888
+ `- ${text.redactions}: ${Number(redaction.totalReplacements) || 0}`,
889
+ ...redactionItems.map((item) => `- ${item}`),
890
+ "",
891
+ `## ${text.interactions}`,
892
+ formatList(interactions, text.empty),
893
+ "",
894
+ text.conclusion,
895
+ "",
896
+ ].join("\n");
897
+
898
+ const sanitizedMarkdown = markdown.replaceAll(projectPath, "[PATH OMITTED]");
899
+ let sanitizedPoster;
900
+ if (generatePoster) {
901
+ const posterHtml = createStyledPosterHtml({
902
+ image: posterImage.toString("base64"),
903
+ surfaceImage: surfaceAssetPath ? `./${DIJIANG_SURFACE_FILENAME}` : "",
904
+ projectName,
905
+ task: taskText,
906
+ stages,
907
+ fileCount: files.length,
908
+ passedTests,
909
+ testCount: tests.length,
910
+ redactionCount: Number(redaction.totalReplacements) || 0,
911
+ // Keep the filtered poster set available to the renderer as the source of
912
+ // truth; legacy fields remain populated for callers that already consume
913
+ // them.
914
+ viewports: posterViewports,
915
+ posterScreenshots,
916
+ evidenceImages: posterScreenshots,
917
+ evidenceViewports,
918
+ isolatedEvidenceViewports: isolatedScreenshots.map((shot) => screenshotLabel(shot, locale)),
919
+ unclassifiedEvidenceViewports: unclassifiedScreenshots.map((shot) => screenshotLabel(shot, locale)),
920
+ invalidEvidenceViewports,
921
+ evidenceThemeName: selectedTheme,
922
+ gitRange,
923
+ gitState,
924
+ testState,
925
+ visualProject,
926
+ taskStatus: taskStatus.key,
927
+ taskStatusLabel: taskStatus.label,
928
+ gitFileCount: Number(report.git?.summary?.changedFiles) || files.length,
929
+ gitFiles,
930
+ testReceipts,
931
+ freshnessWarnings,
932
+ reportPath: projectRelativePath(projectPath, reportPath),
933
+ summaryPath: projectRelativePath(projectPath, outputPath),
934
+ posterPath: projectRelativePath(projectPath, posterPath),
935
+ locale,
936
+ theme,
937
+ });
938
+ sanitizedPoster = posterHtml.replaceAll(projectPath, "[PATH OMITTED]");
939
+ }
940
+ await writeArtifactFiles([
941
+ ...(surfaceAssetPath ? [{ path: surfaceAssetPath, content: posterImage }] : []),
942
+ ...(generatePoster ? [{ path: posterPath, content: sanitizedPoster }] : []),
943
+ { path: outputPath, content: sanitizedMarkdown },
944
+ ]);
945
+ return {
946
+ locale,
947
+ outputPath: projectRelativePath(projectPath, outputPath),
948
+ ...(generatePoster ? { posterPath: projectRelativePath(projectPath, posterPath) } : {}),
949
+ posterGenerated: generatePoster,
950
+ sections,
951
+ theme: selectedTheme,
952
+ themeKey: theme,
953
+ freshnessWarnings,
954
+ breakpoints,
955
+ stages,
956
+ testCount: tests.length,
957
+ redactionCount: Number(redaction.totalReplacements) || 0,
958
+ };
959
+ }
960
+
961
+ export { insideProject, safeRelativePath };