新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。 问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。 兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。 验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
233 lines
7.4 KiB
JavaScript
233 lines
7.4 KiB
JavaScript
import crypto from "node:crypto";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
import {
|
||
createDocxLayoutVisualMatrixCases,
|
||
readBundledThemeMatrixDefinitions
|
||
} from "./docx-layout-visual-matrix-cases.mjs";
|
||
import { validateDocxRealWorldCorpus } from "./docx-real-world-corpus.mjs";
|
||
|
||
export const DOCX_RELEASE_GATE_CASE_COUNT = 140;
|
||
export const DOCX_RELEASE_GATE_SCHEMA_VERSION = 2;
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) {
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
export function createDocxReleaseGateSuites(repositoryDirectory) {
|
||
const validation = validateDocxRealWorldCorpus(repositoryDirectory);
|
||
assert(
|
||
validation.failures.length === 0,
|
||
`真实语料无效:${validation.failures.join(";")}`
|
||
);
|
||
const themes = readBundledThemeMatrixDefinitions(
|
||
path.join(repositoryDirectory, "themes")
|
||
);
|
||
const cases = createDocxLayoutVisualMatrixCases(themes);
|
||
assert(
|
||
cases.length === DOCX_RELEASE_GATE_CASE_COUNT,
|
||
`单套矩阵应为 ${DOCX_RELEASE_GATE_CASE_COUNT},实际 ${cases.length}`
|
||
);
|
||
const caseIds = cases.map((entry) => entry.id);
|
||
return [
|
||
{
|
||
id: "synthetic-baseline-140",
|
||
label: "标准合成基线 140",
|
||
kind: "synthetic",
|
||
order: 0,
|
||
outputDirectoryName: "synthetic-baseline-140",
|
||
expectedCaseCount: caseIds.length,
|
||
caseIds
|
||
},
|
||
...validation.corpus.map((entry, index) => ({
|
||
id: entry.id,
|
||
label: `${entry.label}(${entry.byteLength} bytes)`,
|
||
kind: "corpus",
|
||
order: index + 1,
|
||
outputDirectoryName:
|
||
`corpus-${String(index + 1).padStart(2, "0")}-${entry.byteLength}-${entry.id}`,
|
||
expectedCaseCount: caseIds.length,
|
||
caseIds,
|
||
markdownPath: entry.markdownPath,
|
||
absoluteMarkdownPath: entry.absoluteMarkdownPath,
|
||
markdownByteLength: entry.byteLength,
|
||
requiredFeatures: entry.requiredFeatures
|
||
}))
|
||
];
|
||
}
|
||
|
||
export function isSuiteAggregatePassed(aggregate, suite) {
|
||
return Boolean(
|
||
aggregate &&
|
||
aggregate.suite?.id === suite.id &&
|
||
aggregate.execution?.expectedCaseCount === suite.expectedCaseCount &&
|
||
aggregate.execution?.completedCaseCount === suite.expectedCaseCount &&
|
||
aggregate.execution?.passedCaseCount === suite.expectedCaseCount &&
|
||
aggregate.gatePassed === true
|
||
);
|
||
}
|
||
|
||
export function resolveDocxReleaseGateSuite({
|
||
suites,
|
||
requestedId,
|
||
aggregates,
|
||
allowOutOfOrder = false
|
||
}) {
|
||
assert(Array.isArray(suites) && suites.length === 4, "发布门禁必须包含四套独立矩阵");
|
||
const target = !requestedId || requestedId === "next"
|
||
? suites.find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite))
|
||
: suites.find((suite) => suite.id === requestedId);
|
||
if (!target) {
|
||
if (!requestedId || requestedId === "next") {
|
||
return undefined;
|
||
}
|
||
throw new Error(`未知发布门禁套件:${requestedId}`);
|
||
}
|
||
if (!allowOutOfOrder || !requestedId || requestedId === "next") {
|
||
const unmetPrerequisite = suites
|
||
.slice(0, target.order)
|
||
.find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite));
|
||
assert(
|
||
!unmetPrerequisite,
|
||
`必须先通过前置套件 ${unmetPrerequisite?.id}`
|
||
);
|
||
}
|
||
return target;
|
||
}
|
||
|
||
function collectFingerprintFiles(repositoryDirectory, suite) {
|
||
const roots = [
|
||
"themes",
|
||
"apps/server/scripts",
|
||
"apps/server/src",
|
||
"packages/application/src",
|
||
"packages/core/src",
|
||
"packages/document-visual-diff/src",
|
||
"packages/docx-engine/assets",
|
||
"packages/docx-engine/src",
|
||
"packages/preview-engine/src",
|
||
"packages/renderer/src"
|
||
];
|
||
const files = [
|
||
"package-lock.json",
|
||
"scripts/docx-layout-visual-matrix-cases.mjs",
|
||
"scripts/docx-real-world-corpus.mjs",
|
||
"scripts/docx-release-gate-suites.mjs",
|
||
"scripts/verify-docx-layout-visual-matrix.mjs",
|
||
"scripts/verify-docx-release-gate-suite.mjs"
|
||
];
|
||
const allowedExtension = /\.(?:css|js|json|lua|mjs|ts)$/iu;
|
||
const visit = (relativeDirectory) => {
|
||
const absoluteDirectory = path.join(repositoryDirectory, relativeDirectory);
|
||
if (!fs.existsSync(absoluteDirectory)) {
|
||
return;
|
||
}
|
||
for (const entry of fs.readdirSync(absoluteDirectory, { withFileTypes: true })) {
|
||
const relativePath = path.join(relativeDirectory, entry.name);
|
||
if (entry.isDirectory()) {
|
||
visit(relativePath);
|
||
} else if (allowedExtension.test(entry.name)) {
|
||
files.push(relativePath);
|
||
}
|
||
}
|
||
};
|
||
for (const root of roots) {
|
||
visit(root);
|
||
}
|
||
if (suite.markdownPath) {
|
||
files.push(suite.markdownPath);
|
||
}
|
||
return [...new Set(files)].sort((left, right) => left.localeCompare(right, "en"));
|
||
}
|
||
|
||
export function createDocxReleaseGateFingerprint(repositoryDirectory, suite) {
|
||
const hash = crypto.createHash("sha256");
|
||
hash.update(`schema:${DOCX_RELEASE_GATE_SCHEMA_VERSION}\n`);
|
||
hash.update(`suite:${suite.id}\n`);
|
||
for (const relativePath of collectFingerprintFiles(repositoryDirectory, suite)) {
|
||
const absolutePath = path.join(repositoryDirectory, relativePath);
|
||
if (!fs.existsSync(absolutePath)) {
|
||
hash.update(`${relativePath}:missing\n`);
|
||
continue;
|
||
}
|
||
hash.update(`${relativePath}\0`);
|
||
hash.update(fs.readFileSync(absolutePath));
|
||
hash.update("\0");
|
||
}
|
||
return hash.digest("hex");
|
||
}
|
||
|
||
export function readJsonIfExists(filePath) {
|
||
if (!fs.existsSync(filePath)) {
|
||
return undefined;
|
||
}
|
||
try {
|
||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
||
|
||
function sleepSync(milliseconds) {
|
||
Atomics.wait(
|
||
new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)),
|
||
0,
|
||
0,
|
||
milliseconds
|
||
);
|
||
}
|
||
|
||
export function writeJsonAtomic(filePath, value, options = {}) {
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||
const rename = options.rename ?? fs.renameSync;
|
||
const sleep = options.sleep ?? sleepSync;
|
||
const retryDeadline = Date.now() + (options.retryWindowMs ?? 3_000);
|
||
let attempt = 0;
|
||
while (true) {
|
||
try {
|
||
rename(temporaryPath, filePath);
|
||
return;
|
||
} catch (error) {
|
||
if (
|
||
!WINDOWS_RENAME_RETRY_CODES.has(error?.code) ||
|
||
Date.now() >= retryDeadline
|
||
) {
|
||
fs.rmSync(temporaryPath, { force: true });
|
||
throw error;
|
||
}
|
||
attempt += 1;
|
||
sleep(Math.min(100, 10 * attempt));
|
||
}
|
||
}
|
||
}
|
||
|
||
export function summarizeSuiteProgress({ suite, fingerprint, records }) {
|
||
const currentRecords = suite.caseIds
|
||
.map((caseId) => records[caseId])
|
||
.filter((record) => record?.fingerprint === fingerprint);
|
||
const passedCaseCount = currentRecords.filter((record) => record.status === "passed").length;
|
||
const gateFailedCaseCount = currentRecords.filter((record) => record.status === "gate-failed").length;
|
||
const errorCaseCount = currentRecords.filter((record) => record.status === "error").length;
|
||
const completedCaseCount = currentRecords.length;
|
||
return {
|
||
expectedCaseCount: suite.expectedCaseCount,
|
||
completedCaseCount,
|
||
pendingCaseCount: suite.expectedCaseCount - completedCaseCount,
|
||
passedCaseCount,
|
||
gateFailedCaseCount,
|
||
errorCaseCount,
|
||
complete: completedCaseCount === suite.expectedCaseCount,
|
||
gatePassed:
|
||
passedCaseCount === suite.expectedCaseCount &&
|
||
gateFailedCaseCount === 0 &&
|
||
errorCaseCount === 0
|
||
};
|
||
}
|