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')
}
+3
View File
@@ -48,6 +48,9 @@ importers:
drizzle-orm:
specifier: ^0.45.4
version: 0.45.4(postgres@3.4.9)
htmlparser2:
specifier: 12.0.0
version: 12.0.0
postgres:
specifier: ^3.4.9
version: 3.4.9
+535
View File
@@ -0,0 +1,535 @@
// Plan 4 Task 6 失败测试(先于实现):最小门禁 + 完整 build 状态机(spec §13.4/§16.1)。
// 覆盖契约(对照计划 Task 6):
// 1. 五门禁逐项一过一 fail(纯产物夹具直调 runGates——无 DB 无 spawn):
// ① internal-links:dist 内 a[href]/img[src] 根相对目标全命中(html 产物或 asset 文件
// 或 manifest 路由——redirect 落点无文件不算死链);外域/锚点/绝对 origin/协议相对/
// mailto: 等非根相对形态豁免——注入坏链 → P0 + issue 定位(页面 + href);
// ② sitemap-consistency:sitemap 条目 = manifest indexable html 集——多页(extra loc)/
// 删页(缺 loc)→ P0;
// ③ seo-baseline:每 html 有非空 title/meta description/canonical(manifest 页精确 =
// origin+自身路径;非 manifest 页(404.html)前缀匹配)/恰一 H1——缺 title/canonical
// 指错/双 H1 → P0;缺 description → P2(主题契约"有则合规"——报告不阻断);
// ④ case-collision:dist 全路径 lowercase 无重复(Windows 盘不敏感防线)——NTFS 大小写
// 不敏感无法物化冲突对,纯函数 findCaseCollisions 直测;
// ⑤ artifact-completeness:route-manifest html 行 target_file 全存在——缺席 → P0;
// 2. P0 阻断语义(mini 全链 1 spawn):validating 挂钩注入坏链 → createBuildJob 以
// VALIDATION_FAILED 拒绝 → job failed + validation_runs FAIL 行 + validation_issues
// 行(severity/rule/url/evidence)+ dist 清场(failed 无产物语义);
// 3. 全过语义(mini 全链 1 spawn):真实主题产物过五门禁 → validation_runs PASS 行 +
// job succeeded + 报告 json/html 双落盘(HTML 先写——build.json 在则 build.html 必在)。
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { RouteManifest } from '@sf/contracts'
import { createBuildJob, BuildError } from '../../apps/api/src/modules/build/build.service.js'
import {
findCaseCollisions,
runGates,
GATE_RULE_VERSION,
type GateIssue,
} from '../../apps/api/src/modules/build/gates.js'
import { createImportJob } from '../../apps/api/src/modules/import/import.service.js'
import { publishSiteContent } from '../../apps/api/src/modules/publish/publish.service.js'
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = join(FIXTURES_ROOT, 'wxr', 'mini.xml')
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const ORIGIN = 'https://gate-fixture.example'
// ---- 纯产物夹具(手构 dist + manifest + sitemap——runGates 直调面) ----
/** 合规 html 页(title/desc/canonical/恰一 H1 + 可配链接面) */
function pageHtml(opts: { canonical: string; links?: string[] }): string {
const links = (opts.links ?? []).map((l) => ` ${l}`).join('\n')
return [
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
'<title>Fixture Page</title>',
'<meta name="description" content="fixture description"/>',
`<link rel="canonical" href="${opts.canonical}"/>`,
'</head><body>',
'<h1>Heading One</h1>',
links,
'</body></html>',
'',
].join('\n')
}
const BASE_LINKS = [
'<a href="/about/">About</a>',
'<a href="/old-path/">Redirect</a>',
'<img src="/wp-content/uploads/2026/10/a.png" alt="a"/>',
// 豁免面:外域 / 锚点 / 绝对 origin / 协议相对 / mailto / javascript / 相对形态
'<a href="https://external.example/x">ext</a>',
`<a href="${ORIGIN}/about/">abs-origin</a>`,
'<a href="#top">anchor</a>',
'<a href="//cdn.example/x.png">proto-rel</a>',
'<a href="mailto:a@b.example">mail</a>',
'<a href="javascript:void(0)">js</a>',
'<img src="data:image/png;base64,AAA" alt="data"/>',
]
function baseManifest(): RouteManifest {
return {
manifest_version: 1,
site_id: '00000000-0000-0000-0000-000000000001',
build_job_id: '00000000-0000-0000-0000-000000000002',
origin: ORIGIN,
generated_from_route_snapshot_hash: 'ab'.repeat(32),
routes: [
{
exact_key: '/',
query_policy: 'ignore',
query_key: null,
kind: 'html',
target_file: 'index.html',
status: 200,
content_type: 'text/html',
indexable: true,
},
{
exact_key: '/about/',
query_policy: 'ignore',
query_key: null,
kind: 'html',
target_file: 'about/index.html',
status: 200,
content_type: 'text/html',
indexable: true,
},
{
exact_key: '/wp-content/uploads/2026/10/a.png',
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: '/wp-content/uploads/2026/10/a.png',
status: 200,
content_type: 'image/png',
indexable: false,
},
{
exact_key: '/old-path/',
query_policy: 'ignore',
query_key: null,
kind: 'redirect',
status: 301,
location: '/',
indexable: false,
},
],
}
}
function baseSitemapLocs(): string[] {
return [`${ORIGIN}/`, `${ORIGIN}/about/`]
}
function sitemapXml(locs: string[]): string {
const items = locs.map((l) => ` <url><loc>${l.replaceAll('&', '&amp;')}</loc></url>`)
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items.join('\n')}\n</urlset>\n`
}
interface GateFixture {
distDir: string
sitemapPath: string
work: string
}
/** 合规基线夹具:index + about + 404(非 manifest 页)+ asset + redirect 落点路由 */
async function makeGateFixture(prefix: string): Promise<GateFixture> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
const distDir = join(work, 'dist')
await mkdir(join(distDir, 'about'), { recursive: true })
await mkdir(join(distDir, 'wp-content', 'uploads', '2026', '10'), { recursive: true })
await writeFile(join(distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
await writeFile(
join(distDir, 'about', 'index.html'),
pageHtml({ canonical: `${ORIGIN}/about/`, links: ['<a href="/">Home</a>'] }),
'utf8',
)
// 404.html:非 manifest 页(站点兜底非内容路由)——canonical 前缀匹配(origin 起头)
await writeFile(
join(distDir, '404.html'),
pageHtml({ canonical: `${ORIGIN}/404`, links: ['<a href="/">Home</a>'] }),
'utf8',
)
await writeFile(join(distDir, 'wp-content', 'uploads', '2026', '10', 'a.png'), PNG_SIG)
const sitemapPath = join(distDir, 'sitemap.xml')
await writeFile(sitemapPath, sitemapXml(baseSitemapLocs()), 'utf8')
return { distDir, sitemapPath, work }
}
async function runFixture(
fx: GateFixture,
manifest: RouteManifest = baseManifest(),
): Promise<{ pass: boolean; issues: GateIssue[] }> {
return runGates({ distDir: fx.distDir, routeManifest: manifest, sitemapPath: fx.sitemapPath, origin: ORIGIN })
}
function rulesOf(issues: GateIssue[]): string[] {
return [...new Set(issues.map((i) => i.rule))]
}
describe('最小门禁(纯产物夹具直调)', () => {
it('全过基线:合规产物 → pass + 零 issue(豁免面:外域/锚点/绝对 origin/协议相对/mailto/相对形态)', async () => {
const fx = await makeGateFixture('sf-gate-base')
try {
const r = await runFixture(fx)
expect(r.pass).toBe(true)
expect(r.issues).toStrictEqual([])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('① internal-links fail:坏 a[href]/img[src] → P0 + 定位(页面 + href);redirect 落点路由不算死链', async () => {
const fx = await makeGateFixture('sf-gate-link')
try {
// 注入坏链页:a 指向不存在页面、img 指向不存在 asset(/old-path/ 是 redirect——命中)
await writeFile(
join(fx.distDir, 'about', 'index.html'),
pageHtml({
canonical: `${ORIGIN}/about/`,
links: ['<a href="/missing-page/">bad</a>', '<img src="/wp-content/uploads/no-such.png" alt="bad"/>', '<a href="/old-path/">ok-redirect</a>', '<a href="/missing-page/?ver=2#frag">bad-query</a>'],
}),
'utf8',
)
const r = await runFixture(fx)
expect(r.pass).toBe(false)
const linkIssues = r.issues.filter((i) => i.rule === 'internal-links')
expect(linkIssues).toHaveLength(3)
for (const i of linkIssues) {
expect(i.severity).toBe('P0')
expect(i.url).toBe('/about/')
}
const hrefs = linkIssues.map((i) => i.evidence.href)
expect(hrefs).toStrictEqual(['/missing-page/', '/wp-content/uploads/no-such.png', '/missing-page/?ver=2#frag'])
expect(linkIssues[0]!.evidence.page).toBe('about/index.html')
// 其余门禁不受牵连
expect(rulesOf(r.issues)).toStrictEqual(['internal-links'])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('② sitemap-consistency fail:多页(extra loc)与删页(缺 loc)→ P0(evidence 带 expected/actual 差异)', async () => {
const fx = await makeGateFixture('sf-gate-sitemap')
try {
// 多页:sitemap 多出一条幽灵 loc
await writeFile(fx.sitemapPath, sitemapXml([...baseSitemapLocs(), `${ORIGIN}/ghost/`]), 'utf8')
const extra = await runFixture(fx)
expect(extra.pass).toBe(false)
const extraIssues = extra.issues.filter((i) => i.rule === 'sitemap-consistency')
expect(extraIssues).toHaveLength(1)
expect(extraIssues[0]!.severity).toBe('P0')
expect(extraIssues[0]!.evidence.extra).toStrictEqual([`${ORIGIN}/ghost/`])
expect(extraIssues[0]!.evidence.missing).toStrictEqual([])
// 删页:sitemap 缺一条 indexable html
await writeFile(fx.sitemapPath, sitemapXml([`${ORIGIN}/about/`]), 'utf8')
const missing = await runFixture(fx)
expect(missing.pass).toBe(false)
const missIssues = missing.issues.filter((i) => i.rule === 'sitemap-consistency')
expect(missIssues).toHaveLength(1)
expect(missIssues[0]!.evidence.missing).toStrictEqual([`${ORIGIN}/`])
expect(missIssues[0]!.evidence.extra).toStrictEqual([])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('③ seo-baseline fail:缺 title/canonical 指错/双 H1 → P0;缺 description → P2 不阻断', async () => {
const fx = await makeGateFixture('sf-gate-seo')
try {
// 缺 title 页(canonical 换行注入保持其余合规)
const noTitle = pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace('<title>Fixture Page</title>\n', '')
// 双 H1 + canonical 指错 + 缺 description(一个页面对应一类——分别注入三页更清晰,
// 这里单页三错以验证同类合并多证据,另以缺 description 单独验证 P2 通道)
const messy = [
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>',
'<title>Messy</title>',
`<link rel="canonical" href="${ORIGIN}/wrong-path/"/>`,
'</head><body>',
'<h1>First</h1><h1>Second</h1>',
'</body></html>',
'',
].join('\n')
const noDesc = pageHtml({ canonical: `${ORIGIN}/`, links: [] }).replace(
'<meta name="description" content="fixture description"/>\n',
'',
)
await writeFile(join(fx.distDir, 'about', 'index.html'), noTitle, 'utf8')
await writeFile(join(fx.distDir, '404.html'), messy, 'utf8')
// index.html 换成缺 description(canonical 保持 /)
await writeFile(join(fx.distDir, 'index.html'), noDesc, 'utf8')
const r = await runFixture(fx)
const seo = r.issues.filter((i) => i.rule === 'seo-baseline')
// 缺 title(about)+ canonical 指错(404.html 非 manifest 页——前缀匹配通过?否:
// /wrong-path/ 仍以 origin 起头,前缀匹配放行——故 canonical 指错须用 manifest 页验证)
expect(seo.some((i) => i.severity === 'P0' && i.url === '/about/' && i.evidence.missing === 'title')).toBe(true)
// 双 H1(404.html)
expect(seo.some((i) => i.severity === 'P0' && i.evidence.page === '404.html' && i.evidence.h1Count === 2)).toBe(true)
// 缺 description → P2(不阻断——pass 只看 P0)
expect(seo.some((i) => i.severity === 'P2' && i.evidence.page === 'index.html' && i.evidence.missing === 'description')).toBe(true)
expect(r.pass).toBe(false) // 但缺 title/双 H1 的 P0 在场 → 阻断
// canonical 精确匹配验证:manifest 页指错(/about/ 页 canonical 指到 /)→ P0
await writeFile(
join(fx.distDir, 'about', 'index.html'),
pageHtml({ canonical: `${ORIGIN}/`, links: [] }),
'utf8',
)
await writeFile(join(fx.distDir, '404.html'), pageHtml({ canonical: `${ORIGIN}/404`, links: [] }), 'utf8')
await writeFile(join(fx.distDir, 'index.html'), pageHtml({ canonical: `${ORIGIN}/`, links: BASE_LINKS }), 'utf8')
const r2 = await runFixture(fx)
const canonicalIssue = r2.issues.find((i) => i.rule === 'seo-baseline' && i.evidence.missing === 'canonical')
expect(canonicalIssue).toBeDefined()
expect(canonicalIssue!.severity).toBe('P0')
expect(canonicalIssue!.url).toBe('/about/')
expect(canonicalIssue!.evidence.actual).toBe(`${ORIGIN}/`)
expect(canonicalIssue!.evidence.expected).toBe(`${ORIGIN}/about/`)
expect(r2.pass).toBe(false)
// 纯 P2 面(只缺 description)不阻断:全页恢复、仅 about 缺 description
await writeFile(
join(fx.distDir, 'about', 'index.html'),
pageHtml({ canonical: `${ORIGIN}/about/`, links: [] }).replace(
'<meta name="description" content="fixture description"/>\n',
'',
),
'utf8',
)
const r3 = await runFixture(fx)
expect(r3.pass).toBe(true)
expect(r3.issues.every((i) => i.severity === 'P2')).toBe(true)
expect(r3.issues).toHaveLength(1)
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
it('④ case-collision:lowercase 归一后重复 → 冲突对(纯函数直测——NTFS 大小写不敏感无法物化冲突对)', async () => {
// 过:无重复
expect(findCaseCollisions(['index.html', 'about/index.html', 'A.png', 'b/c.png'])).toStrictEqual([])
// fail:仅大小写不同的两个路径 → 冲突对(目录段与文件名段都参与归一)
expect(findCaseCollisions(['Docs/x.html', 'docs/x.html'])).toStrictEqual([
{ lower: 'docs/x.html', paths: ['Docs/x.html', 'docs/x.html'] },
])
expect(findCaseCollisions(['a/B.html', 'A/b.html', 'A/B.html'])).toStrictEqual([
{ lower: 'a/b.html', paths: ['A/B.html', 'A/b.html', 'a/B.html'] },
])
})
it('⑤ artifact-completeness fail:manifest html 行 target_file 缺席 → P0(evidence 带 route 与 target_file)', async () => {
const fx = await makeGateFixture('sf-gate-comp')
try {
// 物理删除 about 产物(模拟主题落盘缺席)
await rm(join(fx.distDir, 'about'), { recursive: true, force: true })
const r = await runFixture(fx)
expect(r.pass).toBe(false)
const comp = r.issues.filter((i) => i.rule === 'artifact-completeness')
expect(comp).toHaveLength(1)
expect(comp[0]!.severity).toBe('P0')
expect(comp[0]!.url).toBe('/about/')
expect(comp[0]!.evidence.target_file).toBe('about/index.html')
// 死链面同时感知缺席(index→about 链接死);SEO 面只查在场文件——缺席文件不产 issue
expect(rulesOf(r.issues)).toStrictEqual(['internal-links', 'artifact-completeness'])
} finally {
await rm(fx.work, { recursive: true, force: true })
}
})
})
// ---- mini 全链(DB + 真实 astro 构建——P0 阻断/全过语义) ----
interface Work {
work: string
storageRoot: string
allowedRoots: string[]
}
async function makeWork(prefix: string): Promise<Work> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
const mediaDir = join(work, 'media')
await mkdir(join(mediaDir, '2026', '10'), { recursive: true })
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-a-${rand()}`)]))
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-b-${rand()}`)]))
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work] }
}
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
const key = `sfgate-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
mediaDir: join(w.work, 'media'),
allowedRoots: w.allowedRoots,
storageRoot: w.storageRoot,
reportRoot: join(w.work, 'artifacts', 'register'),
})
const result = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') })
expect(result.status).toBe('succeeded_with_issues')
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
expect(run.publishedCount).toBe(3)
return { key, siteId: reg.siteId }
}
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs)+ 全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from validation_issues where validation_run_id in
(select id from validation_runs where site_id = '${siteId}')`)
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
await db.execute(`delete from build_artifacts where build_job_id in
(select id from build_jobs where site_id = '${siteId}')`)
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from build_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`)
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
await db.execute(`delete from terms where site_id = '${siteId}'`)
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
await db.execute(`delete from source_records where site_id = '${siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
await db.execute(`delete from sites where id = '${siteId}'`)
}
describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () => {
it('任一 P0 → validation_runs FAIL + validation_issues 落库(severity/rule/url/evidence)+ job failed + dist 清场', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-gate-block')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
const artifactsRoot = join(w.work, 'artifacts')
let blockedJobId = ''
const err: unknown = await createBuildJob(db, {
siteKey: key,
artifactsRoot,
idempotencyKey: `build:test:gate-block:${rand()}`,
onStatus: (s, jobId) => {
// validating 挂钩点:构建产物已拷出、门禁未跑——向 index.html 注入坏链
if (s === 'validating') {
blockedJobId = jobId
const index = join(artifactsRoot, 'jobs', jobId, 'dist', 'index.html')
const html = readFileSync(index, 'utf8')
writeFileSync(index, `${html}<a href="/no-such-page/">injected</a>`, 'utf8')
}
},
}).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(BuildError)
expect((err as BuildError).code).toBe('VALIDATION_FAILED')
expect((err as BuildError).message).toContain('no-such-page')
// job 行:failed 终态 + VALIDATION_FAILED 码
const job = (await db.execute(`select status, error_code, error_summary from build_jobs
where id = '${blockedJobId}'`)) as any
expect(job).toHaveLength(1)
expect(job[0].status).toBe('failed')
expect(job[0].error_code).toBe('VALIDATION_FAILED')
expect(job[0].error_summary).toContain('no-such-page')
// validation_runs:FAIL 行(site/job 关联 + rule_version + 起止时间戳)
const run = (await db.execute(`select vr.status, vr.rule_version, vr.build_job_id,
extract(epoch from vr.started_at)::float8 as s_ts,
extract(epoch from vr.finished_at)::float8 as f_ts
from validation_runs vr where vr.build_job_id = '${blockedJobId}'`)) as any
expect(run).toHaveLength(1)
expect(run[0].status).toBe('FAIL')
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
expect(run[0].s_ts).toBeGreaterThan(0)
expect(run[0].f_ts).toBeGreaterThanOrEqual(run[0].s_ts)
// validation_issues:每 issue 一行(severity/rule/url/evidence)
const issues = (await db.execute(`select vi.severity, vi.rule, vi.url, vi.evidence, vi.resolution_status
from validation_issues vi join validation_runs vr on vi.validation_run_id = vr.id
where vr.build_job_id = '${blockedJobId}' order by vi.rule, vi.url`)) as any
expect(issues.length).toBeGreaterThanOrEqual(1)
const link = issues.find((i: any) => i.rule === 'internal-links')
expect(link).toBeDefined()
expect(link.severity).toBe('P0')
expect(link.url).toBe('/')
expect(link.evidence.href).toBe('/no-such-page/')
expect(link.evidence.page).toBe('index.html')
for (const i of issues) {
expect(['P0', 'P2']).toContain(i.severity)
expect(i.resolution_status).toBe('open')
expect(typeof i.rule).toBe('string')
}
// failed 无产物语义:dist 清场(issue 证据已在库——调试面在 validation_issues)
expect(existsSync(join(artifactsRoot, 'jobs', blockedJobId, 'dist'))).toBe(false)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('全过 → validation_runs PASS + 零 issue 行 + job succeeded + 报告 json/html 双落盘(HTML 先写)', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-gate-pass')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
const statuses: string[] = []
const result = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(w.work, 'artifacts'),
onStatus: (s) => statuses.push(s),
})
expect(result.status).toBe('succeeded')
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
// validation_runs:PASS 行 + 零 issue
const run = (await db.execute(`select status, rule_version from validation_runs
where build_job_id = '${result.jobId}'`)) as any
expect(run).toHaveLength(1)
expect(run[0].status).toBe('PASS')
expect(run[0].rule_version).toBe(GATE_RULE_VERSION)
const issueCount = (await db.execute(`select count(*)::int as n from validation_issues vi
join validation_runs vr on vi.validation_run_id = vr.id
where vr.build_job_id = '${result.jobId}'`)) as any
expect(Number(issueCount[0].n)).toBe(0)
// 报告双落盘:build.json 在则 build.html 必在(HTML 先写窄窗口修);
// html 为转义模板渲染(含 jobId 与 PASS 面、无 script)
const reportJson = JSON.parse(await readFile(result.reportPath, 'utf8'))
expect(reportJson.validation.status).toBe('PASS')
expect(reportJson.validation.ruleVersion).toBe(GATE_RULE_VERSION)
const reportHtmlPath = join(w.work, 'artifacts', 'jobs', result.jobId, 'reports', 'build.html')
expect(statSync(reportHtmlPath).isFile()).toBe(true)
const html = await readFile(reportHtmlPath, 'utf8')
expect(html).toContain(result.jobId)
expect(html).toContain('PASS')
expect(html).not.toContain('<script')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})
+5 -1
View File
@@ -105,8 +105,12 @@ async function scanThemeSrc(): Promise<Map<string, string>> {
return out
}
/** 本文件写入的行清场:构建域(build_artifacts 先于 build_jobs——FK)+ 全链反序 */
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→
* build_jobs;Task 6 起成功构建必落 validation_runs 行)+ 构建域 + 全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from validation_issues where validation_run_id in
(select id from validation_runs where site_id = '${siteId}')`)
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
await db.execute(`delete from build_artifacts where build_job_id in
(select id from build_jobs where site_id = '${siteId}')`)
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
+65 -102
View File
@@ -1,7 +1,7 @@
// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。
// 覆盖契约(对照计划 Task 2):
// 1. build job 状态机:queued→snapshotting→building(Task 5 真实构建)→validating
// (空,Task 6 填)→succeeded +
// (Task 6 最小门禁)→succeeded +
// build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+
// 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、
// theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+
@@ -71,8 +71,12 @@ async function countOf(db: Db, sql: string): Promise<number> {
return Number(rows[0].n)
}
/** 本文件写入的行清场:构建域(build_artifacts 先于 build_jobs——FK;不可变快照先行)+ 发布/导入/来源全链反序 */
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs;
// Task 6 起成功构建必落 validation_runs 行)+ 构建域(不可变快照先行)+ 全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from validation_issues where validation_run_id in
(select id from validation_runs where site_id = '${siteId}')`)
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
await db.execute(`delete from build_artifacts where build_job_id in
(select id from build_jobs where site_id = '${siteId}')`)
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
@@ -135,18 +139,17 @@ async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId
return { key, siteId: reg.siteId }
}
/** 读取六输入文件为解析对象 */
async function readInputFiles(inputDir: string): Promise<Record<string, any>> {
const names = ['site', 'content', 'taxonomies', 'routes', 'assets', 'render-manifest']
/** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */
function parsedFiles(m: { files: Record<string, string> }): Record<string, any> {
const out: Record<string, any> = {}
for (const n of names) {
out[n] = JSON.parse(await readFile(join(inputDir, `${n}.json`), 'utf8'))
for (const [name, content] of Object.entries(m.files)) {
out[name.replace(/\.json$/, '')] = JSON.parse(content)
}
return out
}
describe('build job + Render Snapshot 物化', () => {
it('状态机 queued→snapshotting→validating(空)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', async () => {
it('状态机 queued→snapshotting→building→validating(门禁)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-sm')
try {
@@ -160,8 +163,8 @@ describe('build job + Render Snapshot 物化', () => {
})
expect(result.reused).toBe(false)
expect(result.status).toBe('succeeded')
// 状态机序列(Task 5 起 snapshotting 后进 building:真实 astro 构建管线;
// validating 空:Task 6 填真实门禁)
// 状态机序列:snapshotting 后进 building(真实 astro 构建管线)→ validating
// (Task 6 最小门禁——mini 夹具产物过五门禁)→ succeeded
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', 'validating', 'succeeded'])
// build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言;
@@ -238,22 +241,18 @@ describe('build job + Render Snapshot 物化', () => {
})
})
it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)', async () => {
it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-files')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
const result = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(w.work, 'artifacts'),
})
const inputDir = result.inputDir
// 六文件全在(RENDER_SNAPSHOT_FILES 冻结清单)
for (const name of ['site.json', 'content.json', 'taxonomies.json', 'routes.json', 'assets.json', 'render-manifest.json']) {
expect(existsSync(join(inputDir, name))).toBe(true)
}
const files = await readInputFiles(inputDir)
const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
// 六文件面齐备(RENDER_SNAPSHOT_FILES 冻结清单——物化产物键即清单)
expect(Object.keys(m.files).sort()).toStrictEqual(
['assets.json', 'content.json', 'render-manifest.json', 'routes.json', 'site.json', 'taxonomies.json'],
)
const files = parsedFiles(m)
// site.json = config v1 数据(Task 1 首版生成的 {name,origin,locale,brand})
expect(files.site).toStrictEqual({
@@ -369,7 +368,7 @@ describe('build job + Render Snapshot 物化', () => {
routes: 12,
assets: 2,
})
expect(files['render-manifest'].sourceHashes).toStrictEqual(result.sourceHashes)
expect(files['render-manifest'].sourceHashes).toStrictEqual(m.sourceHashes)
expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 })
expect(typeof files['render-manifest'].generatedAt).toBe('string')
} finally {
@@ -381,14 +380,13 @@ describe('build job + Render Snapshot 物化', () => {
})
})
it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计', async () => {
it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-rewrite')
try {
const { key, siteId } = await seedPublishedSite(db, w)
const { siteId } = await seedPublishedSite(db, w)
try {
const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
const files = await readInputFiles(result.inputDir)
const files = parsedFiles(await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }))
const news = files.content.find((c: any) => c.slug === 'fixture-news-a')
// 库内 published 修订的 bodyHtml(重写前真相):绝对 URL img + 根相对内链 a
@@ -489,15 +487,14 @@ describe('build job + Render Snapshot 物化', () => {
})
})
it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json 仍为 A(published 冻结)', async () => {
it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 再物化 → content.json 仍为 A(published 冻结)【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-adr010')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
// 第一次 build:published=A 物化
const first = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
expect(first.reused).toBe(false)
// 第一次物化:published=A
const first = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
// 改内容再导入:post 101 正文加 V2 标记 → 新修订 B、current=B(published 仍 A)
const wxrText = await readFile(WXR_FIXTURE, 'utf8')
@@ -518,18 +515,12 @@ describe('build job + Render Snapshot 物化', () => {
where site_id = '${siteId}' and current_revision_id is distinct from published_revision_id`)) as any)[0].n
expect(Number(forked)).toBe(1)
// 新幂等键强制新 job(默认键按 published 集合派生会复用——复用语义另测)
const second = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(w.work, 'artifacts'),
idempotencyKey: `build:test:adr010:${rand()}`,
})
expect(second.reused).toBe(false)
expect(second.jobId).not.toBe(first.jobId)
// 再物化(等价于"新 key 再 build"的输入面——job/幂等键语义在谱系测试覆盖)
const second = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
// content.json 仍为 A:字节级等于第一次物化(published 冻结语义)
const contentA = await readFile(join(first.inputDir, 'content.json'), 'utf8')
const contentB = await readFile(join(second.inputDir, 'content.json'), 'utf8')
const contentA = first.files['content.json']
const contentB = second.files['content.json']
expect(contentB).toBe(contentA)
expect(contentB).not.toContain('V2-BODY')
expect(contentB).toContain('fixture-news-a')
@@ -584,60 +575,9 @@ describe('build job + Render Snapshot 物化', () => {
})
})
it('job 复用只认 succeeded:failed job + 默认键重跑 → #a2 新行成功、旧行保持 failed(审查 Fix 3)', async () => {
it('job 谱系:崩溃残留 stale 宣判 → #a2 落败不复用 → #a3 重跑成功(复用只认 succeeded;审查 Fix 3 两场景并谱——spawn 缩减共享单次成功构建)', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-failed')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
// 迫使快照落盘失败:artifactsRoot 指到普通文件之下 → mkdir ENOTDIR
const blocker = join(w.work, 'blocker')
await writeFile(blocker, 'x', 'utf8')
const err: unknown = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(blocker, 'artifacts'),
}).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(Error)
const afterFail = plain(
(await db.execute(`select status, error_code, attempt from build_jobs
where site_id = '${siteId}'`)) as any,
)
expect(afterFail).toHaveLength(1)
expect(afterFail[0].status).toBe('failed')
expect(afterFail[0].error_code).toBe('SNAPSHOT_FAILED')
expect(Number(afterFail[0].attempt)).toBe(1)
// 默认键重跑(好根):不复用 failed 行 → 后缀键新行成功
const rerun = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
expect(rerun.reused).toBe(false)
expect(rerun.status).toBe('succeeded')
expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(2)
// 旧行保持 failed;新行键带 #a2 后缀、attempt 延续 = 2(重试谱系)
const rows = plain(
(await db.execute(`select status, idempotency_key, attempt from build_jobs
where site_id = '${siteId}' order by attempt`)) as any,
)
expect(rows[0].status).toBe('failed')
expect(rows[1].status).toBe('succeeded')
expect(String(rows[1].idempotency_key)).toContain('#a2')
expect(Number(rows[1].attempt)).toBe(2)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('崩溃残留回收:滞留 snapshotting 的默认键行 → stale 宣判 + 新 job 物化成功(审查 Fix 3)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-stale')
const w = await makeWork('sf-build-lineage')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
@@ -650,21 +590,44 @@ describe('build job + Render Snapshot 物化', () => {
await db.execute(`insert into build_jobs (site_id, status, idempotency_key, attempt, started_at)
values ('${siteId}', 'snapshotting', '${defKey}', 1, now())`)
const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
expect(result.reused).toBe(false)
expect(result.status).toBe('succeeded')
// 旧行宣判 stale;新行成功——两行并存(键空间让位)
// 撞悬行跑失败:残留行宣判 stale 让位 → #a2 新行;再迫使快照落盘失败
// (artifactsRoot 指到普通文件之下 → mkdir ENOTDIR)
const blocker = join(w.work, 'blocker')
await writeFile(blocker, 'x', 'utf8')
const err: unknown = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(blocker, 'artifacts'),
}).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(Error)
// 默认键重跑(好根):default → stale(终态死行)→ #a2 → failed(终态死行)
// → #a3 新行成功——failed 不复用、attempt 延续(重试谱系)
const rerun = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
expect(rerun.reused).toBe(false)
expect(rerun.status).toBe('succeeded')
expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(3)
const rows = plain(
(await db.execute(`select status, idempotency_key from build_jobs
(await db.execute(`select status, idempotency_key, attempt, error_code from build_jobs
where site_id = '${siteId}' order by attempt`)) as any,
)
expect(rows).toHaveLength(2)
// 谱系三行:stale(宣判)→ failed(SNAPSHOT_FAILED 保持终态)→ succeeded(#a3)
expect(rows[0].status).toBe('stale')
expect(rows[0].idempotency_key).toBe(defKey)
expect(rows[1].status).toBe('succeeded')
expect(rows[1].idempotency_key).toBe(`${defKey}#a2`)
expect(Number(rows[0].attempt)).toBe(1)
expect(rows[1].status).toBe('failed')
expect(rows[1].error_code).toBe('SNAPSHOT_FAILED')
expect(String(rows[1].idempotency_key)).toBe(`${defKey}#a2`)
expect(Number(rows[1].attempt)).toBe(2)
expect(rows[2].status).toBe('succeeded')
expect(String(rows[2].idempotency_key)).toBe(`${defKey}#a3`)
expect(Number(rows[2].attempt)).toBe(3)
// 新 job 物化完整
expect(existsSync(join(result.inputDir, 'render-manifest.json'))).toBe(true)
expect(existsSync(join(rerun.inputDir, 'render-manifest.json'))).toBe(true)
} finally {
await cleanupSiteRows(db, siteId)
}