fix(build): validation_issues 分批插入;import/build 报告写序对齐 HTML 先;canonical mismatch evidence 修正
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -222,6 +222,9 @@ async function writeAtomically(path: string, content: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(
|
||||
|
||||
@@ -223,7 +223,7 @@ export async function runGates(input: RunGatesInput): Promise<RunGatesResult> {
|
||||
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<RunGatesResult> {
|
||||
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<RunGatesResult> {
|
||||
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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 由用户执行
|
||||
|
||||
|
||||
@@ -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) => `<a href="/no-such-${n}/">bad-${n}</a>`).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')
|
||||
|
||||
Reference in New Issue
Block a user