feat(build): 最小门禁与状态机闭环

Task 6(Plan 4):validating 接真实门禁——五项产物检查(①内部死链 ②sitemap
一致 ③SEO 基线 ④大小写冲突 ⑤产物完整性),P0 阻断/P2 报告分级;validation_
runs/validation_issues 落库(severity/rule/url/evidence),任一 P0 → job failed
(VALIDATION_FAILED + 产物清场),全过 → succeeded。HTML 解析用 htmlparser2
(sanitize-html 既有传递依赖,lock 已解析 12.0.0——apps/api 显式声明复用,零新
包版本),禁正则提标签。报告 json/html 双落盘且 HTML 先写(Plan 3 窄窗口修,
html 转义模板)。顺手:build-snapshot 测试 spawn 缩减 7→2(六文件物化/bodyHtml
重写/ADR-010 改 materializeRenderSnapshot 直调;复用两场景并谱共享单次成功构建),
根套件 97.3s→75.8s;两测试文件清场补 validation 域反序(FK 链先行)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 12:29:48 +08:00
parent 3e436d907a
commit 8f2b9825a6
8 changed files with 1206 additions and 110 deletions
+95
View File
@@ -0,0 +1,95 @@
// 构建报告 HTML 渲染(Plan 4 Task 6——"报告 HTML 先写"窄窗口修的渲染面):
// 沿用 Plan 2/3 模板纪律(import 域 report-html.ts 同款):
// - 对象层(build.json)保留原文——JSON 归档/机器消费的语义层;
// - 渲染层对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告动态文本含站点键/
// 幂等键/路径等操作者可控串,未转义渲染会使报告本身成为注入载体;
// - 静态骨架无 script/事件属性;计数与清单按确定序渲染。
import type { BuildReport } from './build.service.js'
/** HTML 转义(渲染层唯一动态文本通道):<>&"' 全转义,& 最先避免二次转义陷阱 */
function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => {
switch (ch) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
default:
return '&#39;'
}
})
}
const esc = escapeHtml
/** 构建报告 → HTML 片段(结构:概览/门禁/环境/快照计数/产物/耗时/问题清单) */
export function renderBuildReportHtml(report: BuildReport): string {
const lines: string[] = []
lines.push('<h2>构建报告</h2>')
lines.push('<h3>概览</h3>')
lines.push(
`<ul><li>job: ${esc(report.jobId)}</li><li>状态: ${esc(report.status)}</li>` +
`<li>站点: ${esc(report.siteKey)}(${esc(report.siteId)})</li>` +
`<li>幂等键: ${esc(report.idempotencyKey)}</li><li>主题: ${esc(report.theme.key)}@${esc(report.theme.version)}</li>` +
`<li>生成时间: ${esc(report.generatedAt)}</li></ul>`,
)
lines.push('<h3>门禁</h3>')
lines.push(
`<ul><li>规则版本: ${esc(report.validation.ruleVersion)}</li>` +
`<li>状态: ${esc(report.validation.status)}</li>` +
`<li>run: ${esc(report.validation.runId)}</li>` +
`<li>P0(阻断): ${report.validation.p0Count}</li>` +
`<li>P2(报告): ${report.validation.p2Count}</li></ul>`,
)
lines.push('<h3>构建环境</h3>')
lines.push(
`<ul><li>node: ${esc(report.buildEnv.node)}</li><li>pnpm: ${esc(report.buildEnv.pnpm)}</li>` +
`<li>sourceCommit: ${esc(report.buildEnv.sourceCommit)}</li>` +
`<li>locale: ${esc(report.buildEnv.locale)}</li><li>timezone: ${esc(report.buildEnv.timezone)}</li></ul>`,
)
lines.push('<h3>快照</h3>')
lines.push(
`<ul><li>config 修订: ${esc(report.snapshot.siteConfigRevisionId)}</li>` +
`<li>manifest: ${esc(report.snapshot.manifestPath)}</li>` +
`<li>contents: ${report.snapshot.counts.contents}</li>` +
`<li>taxonomies: ${report.snapshot.counts.taxonomies}</li>` +
`<li>routes: ${report.snapshot.counts.routes}</li>` +
`<li>assets: ${report.snapshot.counts.assets}</li>` +
`<li>URL 重写: rewritten=${report.urlRewrite.rewritten} unresolved=${report.urlRewrite.unresolved}</li></ul>`,
)
lines.push('<h3>产物</h3>')
lines.push(
`<ul><li>dist: ${esc(report.artifacts.distPath)}</li>` +
`<li>dist sha256: ${esc(report.artifacts.distSha256)}</li>` +
`<li>dist 文件数: ${report.artifacts.distFileCount}</li>` +
`<li>route-manifest: ${esc(report.artifacts.routeManifestPath)}</li>` +
`<li>route-manifest sha256: ${esc(report.artifacts.routeManifestSha256)}</li>` +
`<li>sitemap 条目: ${report.artifacts.sitemapEntries}</li>` +
`<li>媒体复制: ${report.artifacts.mediaCopied}</li></ul>`,
)
lines.push('<h3>问题清单</h3>')
if (report.issues.length === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
for (const issue of report.issues) {
lines.push(`<li>${esc(issue)}</li>`)
}
lines.push('</ul>')
}
lines.push('<h3>耗时</h3>')
lines.push(`<ul><li>total: ${report.durationMs.toFixed(1)}ms</li></ul>`)
return lines.join('\n')
}