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
+1
View File
@@ -14,6 +14,7 @@
"@nestjs/core": "^10.4.22",
"@sf/adapter-wp": "workspace:*",
"drizzle-orm": "^0.45.4",
"htmlparser2": "12.0.0",
"postgres": "^3.4.9",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
+111 -7
View File
@@ -3,9 +3,11 @@
// job(queued)→ snapshotting(render-snapshot.ts 纯物化六文件 + build_input_snapshots
// 行,repeatable read 单事务一致性读取)→ **building(Task 5:pipeline.ts 真实构建
// ——spawn astro → 媒体复制 → sitemap/robots → route-manifest → build_artifacts
// dist/manifest 登记)**→ validating(**空门禁**——Task 6 填真实门禁)→ succeeded;
// 报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json + build_artifacts report 行
// (temp+rename 原子写、**先于终态落行**——import 同款纪律)。
// dist/manifest 登记)**→ validating(**Task 6:gates.ts 最小门禁五项**——validation_
// runs/issues 落库,P0 → failed(VALIDATION_FAILED))→ succeeded;
// 报告落 <artifactsRoot>/jobs/<jobId>/reports/{build.html,build.json}(**HTML 先写**,
// json 在则 html 必在)+ build_artifacts report 行(temp+rename 原子写、**先于终态落行**
// ——import 同款纪律)。
//
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用**
// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/
@@ -28,11 +30,12 @@
// 经 tsx)与集成测试直接 import 本文件;Nest 侧由 build.module.ts 薄包装。
import { createHash } from 'node:crypto'
import { execSync } from 'node:child_process'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { desc, eq } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import { isRouteManifest, type RouteManifest } from '@sf/contracts'
import { canonicalJson } from '../../common/canonical.js'
import {
buildArtifacts,
@@ -41,11 +44,15 @@ import {
entryLocales,
siteConfigRevisions,
sites,
validationIssues,
validationRuns,
} from '../../db/schema.js'
import type * as schema from '../../db/schema.js'
import { isUniqueViolation } from '../source/register.service.js'
import { GATE_RULE_VERSION, runGates, type RunGatesResult } from './gates.js'
import { PipelineError, runBuildPipeline, themeDistDir, type BuildPipelineResult } from './pipeline.js'
import { materializeRenderSnapshot, type RewriteStats } from './render-snapshot.js'
import { renderBuildReportHtml } from './report-html.js'
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
@@ -60,6 +67,8 @@ export type BuildErrorCode =
| 'NO_SITE_CONFIG'
| 'SNAPSHOT_FAILED'
| 'PIPELINE_INTERNAL'
| 'VALIDATION_FAILED'
| 'GATE_INTERNAL'
| 'REPORT_WRITE_FAILED'
| 'ARTIFACT_REGISTER_FAILED'
@@ -111,6 +120,8 @@ export interface CreateBuildJobResult {
inputDir: string
/** 报告路径 <artifactsRoot>/jobs/<jobId>/reports/build.json */
reportPath: string
/** 报告 HTML 视图(与 build.json 同目录——HTML 先写:json 在则 html 必在) */
reportHtmlPath: string
/** 三源哈希(新建 = 本次物化;复用 = build_input_snapshots 行值——不可变权威) */
sourceHashes: { content: string; route: string; asset: string }
/** 以下仅新建路径填充(复用口径忠实化——权威记录在既有报告) */
@@ -158,6 +169,14 @@ export interface BuildReport {
sitemapEntries: number
mediaCopied: number
}
/** Task 6 门禁面(报告只记 PASS——FAIL 不落报告,权威在 validation_runs/issues) */
validation: {
ruleVersion: string
status: 'PASS'
runId: string
p0Count: number
p2Count: number
}
issues: string[]
durationMs: number
}
@@ -331,6 +350,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
siteId: site.id,
inputDir: join(artifactsRoot, 'jobs', found.jobId, 'input'),
reportPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.json'),
reportHtmlPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.html'),
sourceHashes: found.sourceHashes,
durationMs: performance.now() - startedAt,
}
@@ -362,6 +382,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
const inputDir = join(artifactsRoot, 'jobs', jobId, 'input')
const reportDir = join(artifactsRoot, 'jobs', jobId, 'reports')
const reportPath = join(reportDir, 'build.json')
const reportHtmlPath = join(reportDir, 'build.html')
input.onStatus?.('queued', jobId)
// 失败收口:job 行保留(failed 终态 + error 面 + 产物清场)后原样上抛——镜像 import 纪律。
@@ -429,12 +450,86 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
artifactsRoot,
})
// 阶段 7:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
// 阶段 7:validating——最小门禁(Task 6:gates.ts 五项产物检查)。onStatus 先行
// (测试/CLI 挂钩点:产物已拷出、门禁未跑——注入坏产物的观察窗)。门禁结果先落
// validation_runs + validation_issues(库内记录先于 job 终态),任一 P0 →
// VALIDATION_FAILED 上抛(统一 catch 收口:failed 终态 + 产物清场——failed 无产物
// 语义,调试面在 validation_issues 的 evidence);全过(P2 只报告)→ 继续报告/终态。
await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId))
input.onStatus?.('validating', jobId)
phase = 'validating'
// 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
let manifest: RouteManifest
try {
const manifestRaw: unknown = JSON.parse(await readFile(built.routeManifestPath, 'utf8'))
if (!isRouteManifest(manifestRaw)) {
throw new Error('isRouteManifest 不通过——manifest 数据面破裂')
}
manifest = manifestRaw
} catch (err) {
throw new BuildError(
'GATE_INTERNAL',
`门禁输入读回失败(${built.routeManifestPath}):${err instanceof Error ? err.message : String(err)}`,
)
}
let gates: RunGatesResult
const gateStartedAt = new Date()
try {
gates = await runGates({
distDir: built.distPath,
routeManifest: manifest,
sitemapPath: join(built.distPath, 'sitemap.xml'),
origin: manifest.origin,
})
} catch (err) {
// IO 破裂(dist 缺席/不可读)是管线产物问题不是门禁判定——GATE_INTERNAL 收口
throw new BuildError('GATE_INTERNAL', `门禁执行破裂:${err instanceof Error ? err.message : String(err)}`)
}
let validationRunId: string
try {
const runRows = await db
.insert(validationRuns)
.values({
siteId: site.id,
buildJobId: jobId,
ruleVersion: GATE_RULE_VERSION,
status: gates.pass ? 'PASS' : 'FAIL',
startedAt: gateStartedAt,
finishedAt: new Date(),
})
.returning({ id: validationRuns.id })
validationRunId = runRows[0]!.id
if (gates.issues.length > 0) {
await db.insert(validationIssues).values(
gates.issues.map((i) => ({
validationRunId,
severity: i.severity,
rule: i.rule,
url: i.url,
evidence: { ...i.evidence, message: i.message },
resolutionStatus: 'open',
})),
)
}
} catch (err) {
throw new BuildError(
'GATE_INTERNAL',
`validation 行落库失败(job=${jobId}):${err instanceof Error ? err.message : String(err)}`,
)
}
if (!gates.pass) {
const p0s = gates.issues.filter((i) => i.severity === 'P0')
throw new BuildError(
'VALIDATION_FAILED',
`门禁 P0 阻断(${p0s.length} 条)——首条:${p0s[0]!.message}${
p0s.length > 1 ? `(另 ${p0s.length - 1} 条见 validation_issues)` : ''
}`,
)
}
// 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在。
// HTML 先写(Plan 3 移交窄窗口修):build.json 在则 build.html 必在——JSON 落盘
// 即"双件齐备"的原子信号(html 是衍生视图,窗口内至多缺 json、不会缺 html)。
const report: BuildReport = {
jobId,
generatedAt: buildTimestamp.toISOString(),
@@ -477,6 +572,13 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
sitemapEntries: built.sitemapEntries,
mediaCopied: built.mediaCopied,
},
validation: {
ruleVersion: GATE_RULE_VERSION,
status: 'PASS',
runId: validationRunId,
p0Count: gates.issues.filter((i) => i.severity === 'P0').length,
p2Count: gates.issues.filter((i) => i.severity === 'P2').length,
},
issues: mat.issues,
durationMs: performance.now() - startedAt,
}
@@ -484,6 +586,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
phase = 'reporting'
try {
await mkdir(reportDir, { recursive: true })
await writeAtomically(reportHtmlPath, renderBuildReportHtml(report))
await writeAtomically(reportPath, reportText)
} catch (err) {
throw new BuildError(
@@ -520,6 +623,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
siteId: site.id,
inputDir,
reportPath,
reportHtmlPath,
sourceHashes: mat.sourceHashes,
counts: mat.counts,
urlRewrite: mat.urlRewrite,
@@ -536,7 +640,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
? err.code
: err instanceof PipelineError
? err.code
: phase === 'building'
: phase === 'building' || phase === 'validating' || phase === 'reporting'
? 'PIPELINE_INTERNAL'
: 'SNAPSHOT_FAILED'
const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
+391
View File
@@ -0,0 +1,391 @@
// 最小门禁引擎(Plan 4 Task 6,spec §13.4/§16.1):对**已构建产物**(dist + route-manifest
// + sitemap.xml)做五项只读检查,产 P0(阻断)/P2(报告)分级 issues。调用方(build.service
// validating 段)落 validation_runs/validation_issues 行并按 P0 决定 succeeded/failed。
//
// 纯函数纪律:只读产物文件与调用方传入的 manifest 对象——不查库、不联网、不写盘;
// 同输入同输出(文件遍历与 issue 排序均确定)。
//
// HTML/XML 解析纪律(spec §13.4"禁纯正则"):htmlparser2 Parser 事件流——它是
// sanitize-html 的既有传递依赖(lock 已解析 12.0.0),apps/api 显式声明复用同一版本,
// 零新包进入依赖图;门禁是**读取**非清洗,但解析面与清洗管线同源同版本。
//
// 五门禁(V1 最小集,Plan 5 扩 redirect 环/OG/hreflang):
// ① internal-links:dist 内 a[href]/img[src] 的**根相对**目标全命中(html 产物文件、
// asset 文件或 manifest 路由——redirect 落点无文件不算死链);外域/锚点/绝对 origin/
// 协议相对/mailto:/data: 等非根相对形态豁免(V1 只检根相对——主题渲染面恒根相对)。
// ② sitemap-consistency:sitemap <loc> 集 = manifest indexable html 路由集。
// ③ seo-baseline:每 html 有非空 title/meta description/canonical/恰一 H1。
// manifest html 页 canonical 精确 = origin+自身路径;非 manifest 页(404.html——站点
// 兜底非内容路由)canonical 只要求 origin 起头。description 缺席是 P2:主题契约
// "有则合规"(SeoHead 注记——metaDescription 兜底保证真实页恒在,缺席即主题面异常,
// 报告不阻断)。
// ④ case-collision:dist 全路径 lowercase 归一无重复(Windows/NTFS 大小写不敏感防线——
// 冲突对在敏感盘上部署即互相覆写)。
// ⑤ artifact-completeness:manifest html 行 target_file 全在场(asset 行由管线媒体
// 复制 sha256 全量校验兜底,不重复查)。
import { readFile, readdir } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { Parser } from 'htmlparser2'
import type { RouteManifest } from '@sf/contracts'
import { deriveTargetFile } from './pipeline.js'
/** 门禁规则版本(validation_runs.rule_version——规则集演进时递增,报告可对版本解读) */
export const GATE_RULE_VERSION = 'min-gates-v1'
/** 严重级别(P0 阻断 / P2 报告——对齐 spec §12.4 约定子集;DB 侧 text 列随规则演进) */
export type GateSeverity = 'P0' | 'P2'
export type GateRule = 'internal-links' | 'sitemap-consistency' | 'seo-baseline' | 'case-collision' | 'artifact-completeness'
export interface GateIssue {
severity: GateSeverity
rule: GateRule
/** issue 定位面:页面/路由的根相对路径(sitemap/case 类无单点 url → null) */
url: string | null
message: string
evidence: Record<string, unknown>
}
export interface RunGatesInput {
distDir: string
routeManifest: RouteManifest
sitemapPath: string
origin: string
}
export interface RunGatesResult {
/** true = 零 P0(P2 不阻断——"报告"语义) */
pass: boolean
issues: GateIssue[]
}
// ---- 解析(htmlparser2 事件流——单遍收集,无正则标签提取) ----
/** 一份 html 产物的门禁观测面(①③ 共用——单遍解析双消费) */
interface PageScan {
/** dist 相对 posix 路径(issue 定位与 evidence 用) */
rel: string
title: string
descriptions: string[]
canonicals: string[]
h1Count: number
/** a[href] / img[src] 原样值(解析与豁免判定在门禁层做——观测面保持零判断) */
links: Array<{ kind: 'a' | 'img'; href: string }>
}
function scanHtmlPage(rel: string, html: string): PageScan {
const scan: PageScan = { rel, title: '', descriptions: [], canonicals: [], h1Count: 0, links: [] }
let inTitle = false
const parser = new Parser({
onopentag(name, attribs) {
const tag = name.toLowerCase()
if (tag === 'title') {
inTitle = true
} else if (tag === 'meta' && (attribs.name ?? '').toLowerCase() === 'description') {
scan.descriptions.push(attribs.content ?? '')
} else if (tag === 'link' && (attribs.rel ?? '').toLowerCase().split(' ').includes('canonical')) {
scan.canonicals.push(attribs.href ?? '')
} else if (tag === 'h1') {
scan.h1Count++
} else if (tag === 'a' && typeof attribs.href === 'string' && attribs.href !== '') {
scan.links.push({ kind: 'a', href: attribs.href })
} else if (tag === 'img' && typeof attribs.src === 'string' && attribs.src !== '') {
scan.links.push({ kind: 'img', href: attribs.src })
}
},
ontext(text) {
if (inTitle) scan.title += text
},
onclosetag(name) {
if (name.toLowerCase() === 'title') inTitle = false
},
})
parser.write(html)
parser.end()
return scan
}
/** sitemap <loc> 文本集(xmlMode 解析——实体解码还原转义 URL,无正则提取) */
function parseSitemapLocs(xml: string): string[] {
const locs: string[] = []
let inLoc = false
let text = ''
const parser = new Parser(
{
onopentag(name) {
if (name === 'loc') {
inLoc = true
text = ''
}
},
ontext(t) {
if (inLoc) text += t
},
onclosetag(name) {
if (name === 'loc') {
locs.push(text)
inLoc = false
}
},
},
{ xmlMode: true },
)
parser.write(xml)
parser.end()
return locs
}
// ---- ① 死链 ----
/**
* 链接的内部目标归一:根相对路径(query/fragment 剥离)或 null(豁免)。
* 豁免面:锚点(#…)、任意 scheme(http/https 绝对 URL——含绝对 origin 形态、mailto:、
* tel:、data:、javascript:)、协议相对(//host)、纯相对形态(theme 恒根相对——不虚构
* 基准解析)。scheme 判定是 RFC 3986 形态(首段字母开头 + 单冒号),非正则标签提取。
*/
export function internalLinkTarget(href: string): string | null {
if (href === '' || href.startsWith('#')) return null
const colon = href.indexOf(':')
if (colon > 0 && /^[a-zA-Z][a-zA-Z0-9+.-]*$/.test(href.slice(0, colon))) return null
if (href.startsWith('//')) return null
if (!href.startsWith('/')) return null
const path = href.split('#')[0]!.split('?')[0]!
return path === '' ? '/' : path
}
// ---- ④ 大小写冲突(纯函数导出——NTFS 大小写不敏感无法物化冲突对,测试直测) ----
export interface CaseCollision {
/** 冲突组的 lowercase 归一键 */
lower: string
paths: string[]
}
/** lowercase 归一后重复的路径组(输入 = dist 相对 posix 路径清单;输出按 lower 字典序) */
export function findCaseCollisions(relPaths: string[]): CaseCollision[] {
const byLower = new Map<string, string[]>()
for (const p of relPaths) {
const lower = p.toLowerCase()
const group = byLower.get(lower)
if (group === undefined) byLower.set(lower, [p])
else group.push(p)
}
return [...byLower.entries()]
.filter(([, paths]) => paths.length > 1)
.map(([lower, paths]) => ({ lower, paths: paths.slice().sort() }))
.sort((a, b) => (a.lower < b.lower ? -1 : a.lower > b.lower ? 1 : 0))
}
// ---- 门禁主体 ----
/** evidence 数组封顶(缺集/冲突对清单防失控——计数始终完整,样本截断) */
const EVIDENCE_CAP = 20
async function walkFiles(dir: string): Promise<string[]> {
const rels: string[] = []
const walk = async (d: string): Promise<void> => {
for (const e of await readdir(d, { withFileTypes: true })) {
const full = join(d, e.name)
if (e.isDirectory()) await walk(full)
else rels.push(relative(dir, full).replaceAll('\\', '/'))
}
}
await walk(dir)
rels.sort()
return rels
}
/**
* 五门禁主体(编排顺序 ①→⑤,每门禁内按确定性序产 issues)。
* IO 破裂(dist 缺席/不可读)原样上抛——那是管线产物缺席,不是门禁判定失败
* (调用方按内部错误收口,不得误判 PASS/FAIL)。
*/
export async function runGates(input: RunGatesInput): Promise<RunGatesResult> {
const issues: GateIssue[] = []
const { distDir, routeManifest: manifest, sitemapPath, origin } = input
const rels = await walkFiles(distDir)
const fileSet = new Set(rels)
const htmlRels = rels.filter((r) => r.endsWith('.html'))
// 单遍解析全部 html(①③ 共用)
const pages: PageScan[] = []
for (const rel of htmlRels) {
pages.push(scanHtmlPage(rel, await readFile(join(distDir, rel), 'utf8')))
}
// manifest html 页的文件→路由映射(③ canonical 精确期望 + ⑤ 完整性)
const routeByTargetFile = new Map<string, string>()
for (const r of manifest.routes) {
if (r.kind === 'html' && typeof r.target_file === 'string') routeByTargetFile.set(r.target_file, r.exact_key)
}
/** 文件 → 页面 URL(manifest 路由优先,缺席时按 dist 路径派生——issue 定位面) */
const urlOf = (rel: string): string => routeByTargetFile.get(rel) ?? `/${rel}`
// ① internal-links:根相对目标全命中(html/asset 文件在场,或**非文件类路由**——
// redirect/gone 落点无产物文件由服务端响应,不算死链;html/asset 行不进兜底:其
// 产物缺席时死链与完整性双报告——链接级真相不因路由行在场而掩盖)
const nonFileRoutePaths = new Set(
manifest.routes
.filter((r) => r.kind !== 'html' && r.kind !== 'asset')
.map((r) => r.exact_key.split('?')[0]!),
)
for (const page of pages) {
for (const link of page.links) {
const target = internalLinkTarget(link.href)
if (target === null) continue
const exact = target === '/' ? 'index.html' : target.replace(/^\//, '')
const derived = deriveTargetFile(target)
if (fileSet.has(exact) || fileSet.has(derived) || nonFileRoutePaths.has(target)) continue
issues.push({
severity: 'P0',
rule: 'internal-links',
url: urlOf(page.rel),
message: `死链:页面 ${urlOf(page.rel)} 的 ${link.kind} 引用不存在目标 ${link.href}`,
evidence: { page: page.rel, kind: link.kind, href: link.href },
})
}
}
// ② sitemap-consistency:loc 集 = manifest indexable html 路由集
const expectedLocs = manifest.routes
.filter((r) => r.kind === 'html' && r.indexable)
.map((r) => `${origin}${r.exact_key}`)
.sort()
let sitemapText: string | null = null
try {
sitemapText = await readFile(sitemapPath, 'utf8')
} catch (err) {
issues.push({
severity: 'P0',
rule: 'sitemap-consistency',
url: null,
message: `sitemap 不可读/缺席(${sitemapPath}):${err instanceof Error ? err.message : String(err)}`,
evidence: { sitemapPath, expectedCount: expectedLocs.length },
})
}
if (sitemapText !== null) {
const actualLocs = parseSitemapLocs(sitemapText).sort()
const expectedSet = new Set(expectedLocs)
const actualSet = new Set(actualLocs)
const missing = expectedLocs.filter((l) => !actualSet.has(l))
const extra = actualLocs.filter((l) => !expectedSet.has(l))
if (missing.length > 0 || extra.length > 0) {
issues.push({
severity: 'P0',
rule: 'sitemap-consistency',
url: null,
message: `sitemap 与 indexable html 路由集不一致:缺 ${missing.length} 条、多 ${extra.length} 条`,
evidence: {
missing: missing.slice(0, EVIDENCE_CAP),
extra: extra.slice(0, EVIDENCE_CAP),
missingCount: missing.length,
extraCount: extra.length,
},
})
}
}
// ③ seo-baseline:title/description/canonical/恰一 H1(逐页;页面序 = 字典序)
for (const page of pages) {
const url = urlOf(page.rel)
if (page.title.trim() === '') {
issues.push({
severity: 'P0',
rule: 'seo-baseline',
url,
message: `SEO 基线:页面 ${url} 缺少非空 <title>`,
evidence: { page: page.rel, missing: 'title' },
})
}
if (!page.descriptions.some((d) => d.trim() !== '')) {
issues.push({
severity: 'P2',
rule: 'seo-baseline',
url,
message: `SEO 基线:页面 ${url} 缺少 meta description(主题契约"有则合规"——报告不阻断)`,
evidence: { page: page.rel, missing: 'description' },
})
}
const expectedCanonical = routeByTargetFile.get(page.rel)
const canonical = page.canonicals[0] ?? ''
if (canonical === '') {
issues.push({
severity: 'P0',
rule: 'seo-baseline',
url,
message: `SEO 基线:页面 ${url} 缺少 canonical`,
evidence: { page: page.rel, missing: 'canonical', expected: expectedCanonical === undefined ? null : `${origin}${expectedCanonical.split('?')[0]!}`, actual: '' },
})
} else if (expectedCanonical !== undefined) {
// manifest html 页:精确 = origin+自身路径(query 剥离——canonical 指内容路径)
const expected = `${origin}${expectedCanonical.split('?')[0]!}`
if (canonical !== expected) {
issues.push({
severity: 'P0',
rule: 'seo-baseline',
url,
message: `SEO 基线:页面 ${url} canonical 指向 ${canonical}(期望 ${expected})`,
evidence: { page: page.rel, missing: 'canonical', expected, actual: canonical },
})
}
} else if (!canonical.startsWith(origin)) {
// 非 manifest 页(404.html 等):只要求 origin 起头(无路由权威可精确对拍)
issues.push({
severity: 'P0',
rule: 'seo-baseline',
url,
message: `SEO 基线:页面 ${url} canonical 非 origin 起头(${canonical})`,
evidence: { page: page.rel, missing: 'canonical', expected: null, actual: canonical },
})
}
if (page.h1Count !== 1) {
issues.push({
severity: 'P0',
rule: 'seo-baseline',
url,
message: `SEO 基线:页面 ${url} H1 数量 ${page.h1Count}(期望恰一)`,
evidence: { page: page.rel, h1Count: page.h1Count },
})
}
}
// ④ case-collision:全路径 lowercase 归一无重复
for (const collision of findCaseCollisions(rels)) {
issues.push({
severity: 'P0',
rule: 'case-collision',
url: null,
message: `大小写冲突(大小写不敏感部署盘会互相覆写):${collision.paths.join(' ≡ ')}`,
evidence: { lower: collision.lower, paths: collision.paths.slice(0, EVIDENCE_CAP), pathCount: collision.paths.length },
})
}
// ⑤ artifact-completeness:manifest html 行 target_file 全在场(walk 的 fileSet 是
// dist 全量文件清单——递归遍历与此处同源,无须二次 stat)
for (const r of manifest.routes) {
if (r.kind !== 'html') continue
const target = r.target_file
if (target === undefined) {
issues.push({
severity: 'P0',
rule: 'artifact-completeness',
url: r.exact_key,
message: `产物完整性:路由 ${r.exact_key} 的 html 行缺 target_file(manifest 数据面破裂)`,
evidence: { route: r.exact_key, target_file: null },
})
continue
}
if (!fileSet.has(target)) {
issues.push({
severity: 'P0',
rule: 'artifact-completeness',
url: r.exact_key,
message: `产物完整性:路由 ${r.exact_key} 的 target_file ${target} 不在 dist`,
evidence: { route: r.exact_key, target_file: target },
})
}
}
return { pass: !issues.some((i) => i.severity === 'P0'), issues }
}
+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')
}