diff --git a/apps/api/package.json b/apps/api/package.json index 6566400..32da9bf 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/modules/build/build.service.ts b/apps/api/src/modules/build/build.service.ts index 533510d..d4c0305 100644 --- a/apps/api/src/modules/build/build.service.ts +++ b/apps/api/src/modules/build/build.service.ts @@ -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; -// 报告落 /jobs//reports/build.json + build_artifacts report 行 -// (temp+rename 原子写、**先于终态落行**——import 同款纪律)。 +// dist/manifest 登记)**→ validating(**Task 6:gates.ts 最小门禁五项**——validation_ +// runs/issues 落库,P0 → failed(VALIDATION_FAILED))→ succeeded; +// 报告落 /jobs//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' /** 本文件位于 /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 /** 报告路径 /jobs//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) diff --git a/apps/api/src/modules/build/gates.ts b/apps/api/src/modules/build/gates.ts new file mode 100644 index 0000000..bf4a2ca --- /dev/null +++ b/apps/api/src/modules/build/gates.ts @@ -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 集 = 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 +} + +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 文本集(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() + 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 { + const rels: string[] = [] + const walk = async (d: string): Promise => { + 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 { + 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() + 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} 缺少非空 `, + 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 } +} diff --git a/apps/api/src/modules/build/report-html.ts b/apps/api/src/modules/build/report-html.ts new file mode 100644 index 0000000..c485159 --- /dev/null +++ b/apps/api/src/modules/build/report-html.ts @@ -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 '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + default: + return ''' + } + }) +} + +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') +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 754f113..2f4676c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tests/integration/build-gates.test.ts b/tests/integration/build-gates.test.ts new file mode 100644 index 0000000..518320d --- /dev/null +++ b/tests/integration/build-gates.test.ts @@ -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', + '', + ``, + '', + '

Heading One

', + links, + '', + '', + ].join('\n') +} + +const BASE_LINKS = [ + 'About', + 'Redirect', + 'a', + // 豁免面:外域 / 锚点 / 绝对 origin / 协议相对 / mailto / javascript / 相对形态 + 'ext', + `abs-origin`, + 'anchor', + 'proto-rel', + 'mail', + 'js', + '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) => ` ${l.replaceAll('&', '&')}`) + return `\n\n${items.join('\n')}\n\n` +} + +interface GateFixture { + distDir: string + sitemapPath: string + work: string +} + +/** 合规基线夹具:index + about + 404(非 manifest 页)+ asset + redirect 落点路由 */ +async function makeGateFixture(prefix: string): Promise { + 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: ['Home'] }), + 'utf8', + ) + // 404.html:非 manifest 页(站点兜底非内容路由)——canonical 前缀匹配(origin 起头) + await writeFile( + join(distDir, '404.html'), + pageHtml({ canonical: `${ORIGIN}/404`, links: ['Home'] }), + '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: ['bad', 'bad', 'ok-redirect', 'bad-query'], + }), + '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('Fixture Page\n', '') + // 双 H1 + canonical 指错 + 缺 description(一个页面对应一类——分别注入三页更清晰, + // 这里单页三错以验证同类合并多证据,另以缺 description 单独验证 P2 通道) + const messy = [ + '', + 'Messy', + ``, + '', + '

First

Second

', + '', + '', + ].join('\n') + const noDesc = pageHtml({ canonical: `${ORIGIN}/`, links: [] }).replace( + '\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( + '\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 { + 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 { + 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}injected`, '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('> { 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 { + 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}'`) diff --git a/tests/integration/build-snapshot.test.ts b/tests/integration/build-snapshot.test.ts index f356082..7b09273 100644 --- a/tests/integration/build-snapshot.test.ts +++ b/tests/integration/build-snapshot.test.ts @@ -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 { 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 { + 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> { - const names = ['site', 'content', 'taxonomies', 'routes', 'assets', 'render-manifest'] +/** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */ +function parsedFiles(m: { files: Record }): Record { const out: Record = {} - 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) }