fix(build): 哈希链含尾换行;media 块 first-wins 确定性;job 复用只认 succeeded+崩溃回收;重写器边界锚定

审查六修(Fix 2/3 先于 Task 3 消费六文件落形):
- Fix 1 三哈希 = sha256(文件字节含尾换行)——哈希链与落盘文件互证;测试改按
  原始文件字节独立复算对拍
- Fix 2 media_usages orderBy(fieldPath, publicPath) + first-wins:同 fieldPath
  多资产引用取字典序首位、其余丢弃记 issue;两次物化字节一致有测试钉死
- Fix 3 job 复用只认 succeeded;failed/cancelled/stale 死行与非终态崩溃残留
  (旧行置 stale 宣判)都以 <key>#a<attempt+1> 后缀键建新行自动重跑(attempt
  延续谱系);失败终态化时清走半途 input/ 目录;两路径各有测试
- Fix 4 git rev-parse 等环境探测移出快照事务(单次探测传入)
- Fix 5 属性正则边界锚定 (^|\s):data-src 等连字属性不卷入(\b 会误判 '-s'
  交界)——data-src 命中索引也不改写的用例钉死
- Fix 6 media 引用面只 join ready 资产;非 ready 残留引用单独查出记 issue、
  不进 content.json media 块(quarantined 翻转测试)

354/354(350+4);typecheck 四段、lint 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 08:12:59 +08:00
parent 8f42c838c1
commit 5434afefff
3 changed files with 336 additions and 77 deletions
+177 -7
View File
@@ -2,9 +2,11 @@
// 覆盖契约(对照计划 Task 2):
// 1. build job 状态机:queued→snapshotting→validating(空,Task 6 填)→succeeded(dist 尚空
// ——Task 5 接真实构建时新建 job 不改行,build_input_snapshots 有不可变触发器)+
// build_input_snapshots 行(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256、
// site_config_revision_id 指向 config v1、theme_key='pilot-minimal'、node_version 等版本
// 信息列真实读 process.versions)+ 幂等键复用(同 published 集合 → reused 不重跑);
// build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+
// 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、
// theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+
// 幂等键复用(同 published 集合 → reused 不重跑;只有 succeeded 可复用——failed/
// 崩溃残留行走 stale 宣判+#a 后缀新行自动恢复);
// 2. 六文件物化(artifacts/jobs/<jobId>/input/):site.json(config v1)/content.json
// (published 修订数组,data 全量 + media 引用面,slug 原样)/taxonomies.json(双源全量,
// source_id 原样含 auto:)/routes.json(html+asset+redirect 并入,query_key/
@@ -38,6 +40,7 @@ import { registerSource } from '../../apps/api/src/modules/source/register.servi
import {
BuildError,
createBuildJob,
defaultBuildIdempotencyKey,
} from '../../apps/api/src/modules/build/build.service.js'
import {
buildMediaPathIndex,
@@ -185,14 +188,14 @@ describe('build job + Render Snapshot 物化', () => {
expect(s.content_snapshot_hash).toBe(manifest.sourceHashes.content)
expect(s.route_snapshot_hash).toBe(manifest.sourceHashes.route)
expect(s.asset_manifest_hash).toBe(manifest.sourceHashes.asset)
// 三哈希独立可复算:各文件 canonicalJson 的 sha256(测试独立重算对拍)
// 三哈希独立可复算:sha256(文件字节含尾换行)——哈希链与落盘文件互证(审查 Fix 1)
for (const [col, file] of [
['content_snapshot_hash', 'content.json'],
['route_snapshot_hash', 'routes.json'],
['asset_manifest_hash', 'assets.json'],
] as const) {
const payload = JSON.parse(await readFile(join(result.inputDir, file), 'utf8'))
expect(s[col]).toBe(sha256of(canonicalJson(payload)))
const raw = await readFile(join(result.inputDir, file), 'utf8')
expect(s[col]).toBe(sha256of(raw))
}
const config = (await db.execute(`select scr.id from site_config_revisions scr
where scr.site_id = '${siteId}'`)) as any
@@ -537,6 +540,168 @@ describe('build job + Render Snapshot 物化', () => {
}
})
})
it('media 块确定性:同 fieldPath 多资产引用 → first-wins 单值 + issue;两次物化字节一致(审查 Fix 2)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-dup')
try {
const { siteId } = await seedPublishedSite(db, w)
try {
const aPub = '/wp-content/uploads/2026/10/fixture-a.png'
const bPub = '/wp-content/uploads/2026/10/fixture-b.png'
// 数据面异常注入:news published 修订的 featured 字段再挂 fixture-b
//(media_usages PK = 资产×修订×fieldPath——跨资产同 fieldPath 可重)
await db.execute(`insert into media_usages (site_id, media_asset_id, content_revision_id, field_path, source_url)
select '${siteId}',
(select id from media_assets where site_id = '${siteId}' and public_path = '${bPub}'),
(select el.published_revision_id from entry_locales el
join content_entries ce on ce.id = el.content_entry_id
where el.site_id = '${siteId}' and ce.type_key = 'news_article'),
'attachmentRefs.featured',
'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png'`)
const fixedAt = '2026-10-22T00:00:00.000Z'
const m1 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt })
const m2 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt })
const content1 = JSON.parse(m1.files['content.json'])
const news = content1.find((c: any) => c.slug === 'fixture-news-a')
// first-wins:orderBy(fieldPath, publicPath) 字典序首位 = fixture-a 稳定胜出
expect(news.media['attachmentRefs.featured']).toBe(aPub)
expect(
m1.issues.some((i) => i.includes('同 fieldPath 多资产引用') && i.includes('attachmentRefs.featured')),
).toBe(true)
// 两次物化字节一致 + issue 面一致(first-wins 确定性)
expect(m2.files['content.json']).toBe(m1.files['content.json'])
expect(m2.issues).toStrictEqual(m1.issues)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('job 复用只认 succeeded:failed job + 默认键重跑 → #a2 新行成功、旧行保持 failed(审查 Fix 3)', 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')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
// 手构崩溃残留:上个进程中断留下的 snapshotting 悬行(默认键、attempt=1)
const pubIds = plain(
(await db.execute(`select published_revision_id from entry_locales
where site_id = '${siteId}' and published_revision_id is not null`)) as any,
).map((r: any) => r.published_revision_id)
const defKey = defaultBuildIdempotencyKey(key, pubIds)
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;新行成功——两行并存(键空间让位)
const rows = plain(
(await db.execute(`select status, idempotency_key from build_jobs
where site_id = '${siteId}' order by attempt`)) as any,
)
expect(rows).toHaveLength(2)
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`)
// 新 job 物化完整
expect(existsSync(join(result.inputDir, 'render-manifest.json'))).toBe(true)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('media 引用非 ready 资产:引用不进 media 块 + issue 如实报告;assets 面同步收窄(审查 Fix 6)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-nonready')
try {
const { siteId } = await seedPublishedSite(db, w)
try {
// 登记后状态翻转:fixture-b(product 的 featured 引用)→ quarantined
await db.execute(`update media_assets set status = 'quarantined'
where site_id = '${siteId}' and public_path = '/wp-content/uploads/2026/10/fixture-b.png'`)
const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
const content = JSON.parse(m.files['content.json'])
const product = content.find((c: any) => c.slug === 'fixture-product')
// 引用不进 media 块(product 的 featured 是 fixture-b)+ issue 如实报告
expect(product.media).toBeUndefined()
expect(
m.issues.some(
(i) => i.includes('非 ready') && i.includes('attachmentRefs.featured') && i.includes('quarantined'),
),
).toBe(true)
// ready 集合同步收窄:assets 面只剩 fixture-a
const assets = JSON.parse(m.files['assets.json'])
expect(assets).toHaveLength(1)
expect(assets[0].publicPath).toBe('/wp-content/uploads/2026/10/fixture-a.png')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})
describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双源)', () => {
@@ -570,7 +735,7 @@ describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双
expect(r.stats.rewritten).toBe(1)
})
it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计', () => {
it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计;连字属性不卷入(审查 Fix 5)', () => {
const r = rewriteBodyHtml(
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
index,
@@ -579,5 +744,10 @@ describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
)
expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 })
// 边界锚定:data-src 的 URL 即使命中索引也不改写不计(懒加载属性是渲染域决策)
//(宽松 \b 前缀会把 data-src 的 '-s' 交界误判为词边界——此用例钉死收紧语义)
const lazy = rewriteBodyHtml(`<img data-src="https://old.example.com${A}"/>`, index)
expect(lazy.html).toBe(`<img data-src="https://old.example.com${A}"/>`)
expect(lazy.stats).toStrictEqual({ rewritten: 0, unresolved: 0 })
})
})