From fa27a3a308b83c0759bc6580f9a18a62b2cb252f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 22 Oct 2026 13:07:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(build):=20validation=5Fissues=20=E5=88=86?= =?UTF-8?q?=E6=89=B9=E6=8F=92=E5=85=A5=EF=BC=9Bimport/build=20=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=86=99=E5=BA=8F=E5=AF=B9=E9=BD=90=20HTML=20?= =?UTF-8?q?=E5=85=88=EF=BC=9Bcanonical=20mismatch=20evidence=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validation_issues 落库改分批(VALIDATION_ISSUES_CHUNK=1000 行/批循环): 单 INSERT 占位符上限(pg ~65535 ÷ 6 列)会被超大 issue 集撞穿; run 行先落、批次间无序依赖。新增测试:1101 坏链注入 → 全量 1101 行 落库、收口 VALIDATION_FAILED(非 GATE_INTERNAL) - import 域报告写序换 HTML 先(与 build 域对齐——json 在则 html 必在) - gates canonical 指错分支 evidence 改 kind:'canonical-mismatch' (mismatch 语义不再误用 missing 键;缺席分支保持 missing:'canonical') - build.html 尾换行对齐 import 域 - 计划文档 Task 9 补审查遗留清单(helper 迁移/Plan 5 前置门禁面) Co-Authored-By: Claude Code --- apps/api/src/modules/build/build.service.ts | 28 ++++++----- apps/api/src/modules/build/gates.ts | 6 +-- apps/api/src/modules/import/import.service.ts | 3 +- .../2026-10-21-slice-build-preview-phase4.md | 1 + tests/integration/build-gates.test.ts | 47 ++++++++++++++++++- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/apps/api/src/modules/build/build.service.ts b/apps/api/src/modules/build/build.service.ts index d4c0305..b583df9 100644 --- a/apps/api/src/modules/build/build.service.ts +++ b/apps/api/src/modules/build/build.service.ts @@ -222,6 +222,9 @@ async function writeAtomically(path: string, content: string): Promise { } } +/** validation_issues 分批插入的批大小(单 INSERT 占位符上限防线——1000 行 × 6 列远低于上限) */ +const VALIDATION_ISSUES_CHUNK = 1000 + /** 既有 job 的复用查询(不动行;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */ async function reuseExisting( db: BuildDb, @@ -499,17 +502,20 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P }) .returning({ id: validationRuns.id }) validationRunId = runRows[0]!.id + // 分批插入(审查 fast-follow):单条 INSERT 参数上限(pg ~65535 占位符 × 列数) + // 会先被超大 issue 集撞穿——每 1000 行一批循环落库;run 行已先落,批次间无序依赖 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', - })), - ) + const rows = gates.issues.map((i) => ({ + validationRunId, + severity: i.severity, + rule: i.rule, + url: i.url, + evidence: { ...i.evidence, message: i.message }, + resolutionStatus: 'open', + })) + for (let offset = 0; offset < rows.length; offset += VALIDATION_ISSUES_CHUNK) { + await db.insert(validationIssues).values(rows.slice(offset, offset + VALIDATION_ISSUES_CHUNK)) + } } } catch (err) { throw new BuildError( @@ -586,7 +592,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(reportHtmlPath, `${renderBuildReportHtml(report)}\n`) await writeAtomically(reportPath, reportText) } catch (err) { throw new BuildError( diff --git a/apps/api/src/modules/build/gates.ts b/apps/api/src/modules/build/gates.ts index bf4a2ca..2ffb17d 100644 --- a/apps/api/src/modules/build/gates.ts +++ b/apps/api/src/modules/build/gates.ts @@ -223,7 +223,7 @@ export async function runGates(input: RunGatesInput): Promise { const urlOf = (rel: string): string => routeByTargetFile.get(rel) ?? `/${rel}` // ① internal-links:根相对目标全命中(html/asset 文件在场,或**非文件类路由**—— - // redirect/gone 落点无产物文件由服务端响应,不算死链;html/asset 行不进兜底:其 + // 非文件类路由(redirect/gone/dynamic/unresolved)落点无产物文件由服务端响应,不算死链;html/asset 行不进兜底:其 // 产物缺席时死链与完整性双报告——链接级真相不因路由行在场而掩盖) const nonFileRoutePaths = new Set( manifest.routes @@ -326,7 +326,7 @@ export async function runGates(input: RunGatesInput): Promise { rule: 'seo-baseline', url, message: `SEO 基线:页面 ${url} canonical 指向 ${canonical}(期望 ${expected})`, - evidence: { page: page.rel, missing: 'canonical', expected, actual: canonical }, + evidence: { page: page.rel, kind: 'canonical-mismatch', expected, actual: canonical }, }) } } else if (!canonical.startsWith(origin)) { @@ -336,7 +336,7 @@ export async function runGates(input: RunGatesInput): Promise { rule: 'seo-baseline', url, message: `SEO 基线:页面 ${url} canonical 非 origin 起头(${canonical})`, - evidence: { page: page.rel, missing: 'canonical', expected: null, actual: canonical }, + evidence: { page: page.rel, kind: 'canonical-mismatch', expected: null, actual: canonical }, }) } if (page.h1Count !== 1) { diff --git a/apps/api/src/modules/import/import.service.ts b/apps/api/src/modules/import/import.service.ts index 1a0b512..54b6683 100644 --- a/apps/api/src/modules/import/import.service.ts +++ b/apps/api/src/modules/import/import.service.ts @@ -567,8 +567,9 @@ export async function createImportJob( let reportWritten = true try { await mkdir(reportDir, { recursive: true }) - await writeAtomically(reportPath, `${JSON.stringify(report, null, 2)}\n`) + // HTML 先写(与 build 域对齐——json 在则 html 必在:JSON 落盘即"双件齐备"信号) await writeAtomically(htmlPath, `${renderImportReportHtml(report)}\n`) + await writeAtomically(reportPath, `${JSON.stringify(report, null, 2)}\n`) } catch (err) { reportWritten = false failed = { diff --git a/docs/superpowers/plans/2026-10-21-slice-build-preview-phase4.md b/docs/superpowers/plans/2026-10-21-slice-build-preview-phase4.md index 5a42a89..d05ed22 100644 --- a/docs/superpowers/plans/2026-10-21-slice-build-preview-phase4.md +++ b/docs/superpowers/plans/2026-10-21-slice-build-preview-phase4.md @@ -109,6 +109,7 @@ scripts/sf.ts # + content:publish / build:create / preview:star ### Task 9: 收尾 - [ ] ADR-018(Astro 5 选型与许可证/渲染信任链——bodyHtml 不二次清洗决策/分页 V1 不做及路由登记义务移交/P0 发布门禁偏离/编码路径贯穿规则/预览零依赖);README(preview 截图/命令链 publish→build→preview);typecheck 链含 themes/pilot-minimal 与 preview-server(如 TS);lint;三绿全量 +- [ ] **Task 6 审查遗留**:① cleanupSiteRows/makeWork/seedPublishedSite 三份测试 helper 迁 tests/integration/helpers.ts(schema 再演进防漂移)② build.html 尾换行与 import 域对齐(若 Task 6 快修未覆盖)③ Plan 5 前置:绝对 origin 链接归一纳入死链检查;redirect 环/OG/hreflang 完整门禁;build:validate CLI;sitemap 重复 loc multiset 语义 - [ ] 移交 Plan 5 清单:SEO 三方 diff(source_rendered 抓取需求)/完整门禁(redirect 环/OG/hreflang)/可复现双构建/部署前置(836 nginx conf 输出);续传 Task 0.2 备份 - [ ] commit + push 由用户执行 diff --git a/tests/integration/build-gates.test.ts b/tests/integration/build-gates.test.ts index 518320d..38ad0e1 100644 --- a/tests/integration/build-gates.test.ts +++ b/tests/integration/build-gates.test.ts @@ -288,7 +288,9 @@ describe('最小门禁(纯产物夹具直调)', () => { 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') + const canonicalIssue = r2.issues.find( + (i) => i.rule === 'seo-baseline' && i.evidence.kind === 'canonical-mismatch', + ) expect(canonicalIssue).toBeDefined() expect(canonicalIssue!.severity).toBe('P0') expect(canonicalIssue!.url).toBe('/about/') @@ -487,6 +489,49 @@ describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () = }) }) + it('issues 分批落库:>1100 条 P0(1101 坏链注入)→ 全量落库不 GATE_INTERNAL、job failed(VALIDATION_FAILED)', { timeout: 300_000 }, async () => { + await withTestDb(async (db) => { + const w = await makeWork('sf-gate-batch') + try { + const { key, siteId } = await seedPublishedSite(db, w) + try { + const artifactsRoot = join(w.work, 'artifacts') + let batchJobId = '' + // 1101 条互异坏链(超 1000 分批界——两批:1000+101) + const badLinks = Array.from({ length: 1101 }, (_, n) => `bad-${n}`).join('') + const err: unknown = await createBuildJob(db, { + siteKey: key, + artifactsRoot, + idempotencyKey: `build:test:gate-batch:${rand()}`, + onStatus: (s, jobId) => { + if (s === 'validating') { + batchJobId = jobId + const index = join(artifactsRoot, 'jobs', jobId, 'dist', 'index.html') + writeFileSync(index, `${readFileSync(index, 'utf8')}${badLinks}`, 'utf8') + } + }, + }).then( + () => { + throw new Error('不应成功') + }, + (e: unknown) => e, + ) + // 门禁正常判定收口(分批落库破裂会变 GATE_INTERNAL——此断言即防回归) + expect(err).toBeInstanceOf(BuildError) + expect((err as BuildError).code).toBe('VALIDATION_FAILED') + const rows = (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 = '${batchJobId}'`)) as any + expect(Number(rows[0].n)).toBe(1101) + } 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')