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
+54 -10
View File
@@ -15,8 +15,9 @@
// data:/纯片段/相对形态无路径可比对,原样越过不计(与 media-writer 的 data: 口径一致)。
//
// canonical JSON 确定性:六文件全部以 canonicalJson(键递归字典序、无空白)+ 换行落盘
// ——文件字节即哈希输入(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256),
// 同输入(含 generatedAt 时点参数)两次物化字节一致。集合排序在 JS 侧钉死(UTF-16
// ——文件字节(含尾换行)即哈希输入(content/route/asset 三哈希 = 各自**文件字节**的
// sha256——哈希链与落盘文件互证),同输入(含 generatedAt 时点参数)两次物化字节一致。
// 集合排序在 JS 侧钉死(UTF-16
// 序,不依赖 PG collation):内容按 (typeKey, slug, locale, contentId)、分类按 (key,
// objectType)、term 按 (sourceId, slug, id)、路由按 (routePath, queryKey nulls-first,
// host)、资产按 publicPath。
@@ -29,7 +30,7 @@ import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm'
import { and, desc, eq, inArray, isNotNull, ne } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import {
ADAPTER_VERSION,
@@ -121,12 +122,15 @@ export interface RewriteStats {
/**
* bodyHtml 的 src/href URL 重写(正则提取——清洗信任链上游已保证属性形态规整;
* 门禁期的 HTML 解析复用清洗管线是 Task 6 职责,此处不引入解析器依赖)。
* 属性名边界锚定(审查 Fix 5):前缀必须是空白或串首——`data-src`/`data-href`
* 等连字属性不匹配不卷入(\b 会把 data-src 的 '-s' 交界误判为词边界;懒加载
* 属性的改写是渲染域决策,不是快照域义务)。
* 只替换属性值本身,引号/空白原样保留;命中值与原值相等时计数但零改写。
*/
export function rewriteBodyHtml(html: string, index: Map<string, string>): { html: string; stats: RewriteStats } {
const stats: RewriteStats = { rewritten: 0, unresolved: 0 }
const ATTR_RE = /\b(src|href)\s*=\s*(["'])([^"']*)\2/gi
const out = html.replace(ATTR_RE, (full, _attr: string, quote: string, value: string) => {
const ATTR_RE = /(^|\s)(src|href)\s*=\s*(["'])([^"']*)\3/gi
const out = html.replace(ATTR_RE, (full, _prefix: string, _attr: string, quote: string, value: string) => {
const path = comparablePathOf(value)
if (path === null) return full
const hit = index.get(path)
@@ -184,9 +188,9 @@ function cmpNull(a: string | null, b: string | null): number {
return cmp(a, b)
}
/** sha256 of canonicalJson(三哈希的单一算法来源) */
/** 三哈希的单一算法来源:sha256(文件字节)——canonicalJson + 尾换行后散列(与落盘文件互证) */
function hashOf(payload: unknown): string {
return createHash('sha256').update(canonicalJson(payload)).digest('hex')
return createHash('sha256').update(`${canonicalJson(payload)}\n`).digest('hex')
}
/** 仓库存储根的 incoming 媒体兜底目录(真实数据:storage/incoming/<目录>/<扁平 GUID 文件>) */
@@ -258,7 +262,11 @@ export async function materializeRenderSnapshot(
assetRows.sort((a, b) => cmp(a.publicPath, b.publicPath))
const mediaIndex = buildMediaPathIndex(assetRows)
// published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)
// published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)。
// 确定性(审查 Fix 2):fieldPath/publicPath 双键 orderBy 钉死 first-wins 的"首"——
// 同 fieldPath 多资产引用(PK 是 资产×修订×fieldPath,跨资产可重)时取字典序首位,
// 其余丢弃并记 issue(§7.6 禁静默);只 join ready 资产(media 块只呈现可服务引用,
// 审查 Fix 6)——非 ready 资产的残留引用单独查出版 issue,不进块。
const publishedIds = published.map((r) => r.revisionId!)
const usageMap = new Map<string, Map<string, string>>()
if (publishedIds.length > 0) {
@@ -270,14 +278,50 @@ export async function materializeRenderSnapshot(
})
.from(mediaUsages)
.innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id))
.where(and(eq(mediaUsages.siteId, input.siteId), inArray(mediaUsages.contentRevisionId, publishedIds)))
.where(
and(
eq(mediaUsages.siteId, input.siteId),
inArray(mediaUsages.contentRevisionId, publishedIds),
eq(mediaAssets.status, 'ready'),
),
)
.orderBy(mediaUsages.fieldPath, mediaAssets.publicPath)
for (const u of usages) {
let byField = usageMap.get(u.contentRevisionId)
if (byField === undefined) {
byField = new Map()
usageMap.set(u.contentRevisionId, byField)
}
byField.set(u.fieldPath, u.publicPath)
const first = byField.get(u.fieldPath)
if (first === undefined) {
byField.set(u.fieldPath, u.publicPath)
} else if (first !== u.publicPath) {
issues.push(
`同 fieldPath 多资产引用(revision=${u.contentRevisionId} fieldPath=${u.fieldPath}):first-wins 取 ${first},丢弃 ${u.publicPath}`,
)
}
}
// 非 ready 资产的残留引用(登记后状态翻转等数据面事实):不进 media 块,如实记 issue
const nonReady = await db
.select({
contentRevisionId: mediaUsages.contentRevisionId,
fieldPath: mediaUsages.fieldPath,
publicPath: mediaAssets.publicPath,
status: mediaAssets.status,
})
.from(mediaUsages)
.innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id))
.where(
and(
eq(mediaUsages.siteId, input.siteId),
inArray(mediaUsages.contentRevisionId, publishedIds),
ne(mediaAssets.status, 'ready'),
),
)
for (const u of nonReady) {
issues.push(
`media 引用非 ready 资产(revision=${u.contentRevisionId} fieldPath=${u.fieldPath} public_path=${u.publicPath} status=${u.status})——该引用不进 content.json media 块`,
)
}
}