Files
MorphDoc/scripts/docx-release-gate-suites.mjs
T
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 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 均已生成并校验。
2026-08-26 10:50:20 +08:00

233 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};
}