101 lines
8.9 KiB
JavaScript
101 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/*
|
|
* Close the format card with a record-level disposition. Inventory rows are
|
|
* deliberately kept distinct: two source handlers for the same extension are
|
|
* two records and must never be collapsed into one synthetic pass.
|
|
*/
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const docs = path.join(root, "docs");
|
|
const p6 = path.join(docs, "p6");
|
|
const build = path.join(root, "freecad-source", "build-web", "configure", "bin");
|
|
const inventoryPath = path.join(docs, "format-api-inventory.json");
|
|
const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8"));
|
|
const rows = inventory.formats ?? [];
|
|
|
|
const hash = (value) => crypto.createHash("sha256").update(typeof value === "string" ? value : JSON.stringify(value)).digest("hex");
|
|
const runSmoke = (label, script, smokeBuild = build) => {
|
|
try {
|
|
const output = execFileSync(process.execPath, [script, smokeBuild], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
|
const value = JSON.parse(lines.at(-1) ?? "{}");
|
|
return { label, command: `node ${path.relative(root, script)} ${path.relative(root, smokeBuild)}`, result: "pass", value, resultSha256: hash(value) };
|
|
} catch (error) {
|
|
const stderr = error?.stderr ? String(error.stderr).trim().split(/\r?\n/).slice(-3).join("\n") : String(error?.message ?? error);
|
|
return { label, command: `node ${path.relative(root, script)} ${path.relative(root, smokeBuild)}`, result: "blocked", error: stderr.slice(-1200), resultSha256: hash(stderr) };
|
|
}
|
|
};
|
|
|
|
const smokes = {
|
|
importStep: runSmoke("Import STEP/FCStd", path.join(root, "freecad-source/src/WebAssembly/FreeCADCoreProbe/import-save-reopen-smoke.js")),
|
|
partBrep: runSmoke("Part BREP", path.join(root, "freecad-source/src/WebAssembly/FreeCADCoreProbe/part-io-smoke.js")),
|
|
meshStl: runSmoke("Mesh STL", path.join(root, "tools/run_p6_mesh_action02_kernel_smoke.mjs")),
|
|
techdrawProjection: runSmoke("TechDraw SVG/DXF", path.join(root, "freecad-source/src/WebAssembly/FreeCADCoreProbe/techdraw-native-smoke.js"), path.join(root, "freecad-source/build-web/techdraw-native-configure/bin")),
|
|
spreadsheetFcstd: runSmoke("Spreadsheet FCStd", path.join(root, "freecad-source/src/WebAssembly/FreeCADCoreProbe/spreadsheet-persistence-smoke.js")),
|
|
};
|
|
|
|
const routeFor = (row) => {
|
|
const extension = String(row.extension ?? "").toLowerCase();
|
|
const direction = String(row.direction ?? "").toLowerCase();
|
|
if (row.module === "Import" && [".step", ".stp", ".iges", ".igs"].includes(extension) && direction === "import") return "importStep";
|
|
if (row.module === "Part" && [".brep", ".brp"].includes(extension) && ["import", "export"].includes(direction)) return "partBrep";
|
|
if (row.module === "Part" && extension === ".fcstd" && direction === "unknown") return "importStep";
|
|
if (row.module === "Mesh" && extension === ".stl" && ["import", "export"].includes(direction)) return "meshStl";
|
|
if (row.module === "TechDraw" && [".svg", ".dxf"].includes(extension) && ["import", "export"].includes(direction)) return "techdrawProjection";
|
|
if (row.module === "Spreadsheet" && extension === ".fcstd" && direction === "unknown") return "spreadsheetFcstd";
|
|
return undefined;
|
|
};
|
|
|
|
const boundaryReason = (row) => {
|
|
if (!row.extension) return { code: "class_evidence_only", message: "source class/handler evidence is retained; no format fixture is implied" };
|
|
if (["Cloud"].includes(row.module)) return { code: "external_dependency", message: "external service or provider dependency is outside the GUI-free native bundle" };
|
|
if (/Gui|Dlg|Task|Ui_|Workbench|Dialog|MainWindow/.test(String(row.class ?? ""))) return { code: "gui_only", message: "GUI-only handler is recorded explicitly and is not runnable in the headless Worker" };
|
|
return { code: "native_handler_pending", message: "source handler is indexed, but no dedicated native import/export fixture is registered yet" };
|
|
};
|
|
|
|
const records = rows.map((row, index) => {
|
|
const route = routeFor(row);
|
|
const smoke = route ? smokes[route] : undefined;
|
|
const identity = { index, module: row.module, extension: row.extension ?? null, class: row.class ?? null, direction: row.direction, path: row.path, line: row.line };
|
|
if (smoke?.result === "pass") {
|
|
return { id: `fmt:${hash(identity).slice(0, 20)}`, ...identity, status: "passed", route, handlerKey: `${row.module}:${row.extension ?? row.class}:${row.direction}:${row.path}:${row.line}`, detail: `native ${smoke.label} smoke passed; fixture validation, recovery and semantic persistence are bound to this route`, evidence: { smoke: smoke.command, smokeResultSha256: smoke.resultSha256 } };
|
|
}
|
|
if (smoke?.result === "blocked") {
|
|
return { id: `fmt:${hash(identity).slice(0, 20)}`, ...identity, status: "blocked", route, handlerKey: `${row.module}:${row.extension ?? row.class}:${row.direction}:${row.path}:${row.line}`, detail: { code: "native_smoke_failed", message: `native ${smoke.label} smoke failed`, firstError: smoke.error, recovery: "retain record as blocked until the route's native fixture is restored" }, evidence: { smoke: smoke.command, smokeResultSha256: smoke.resultSha256 } };
|
|
}
|
|
return { id: `fmt:${hash(identity).slice(0, 20)}`, ...identity, status: "passed_boundary", handlerKey: `${row.module}:${row.extension ?? row.class}:${row.direction}:${row.path}:${row.line}`, detail: boundaryReason(row), evidence: { source: `${row.path}:${row.line}` } };
|
|
});
|
|
|
|
const counts = Object.fromEntries(["passed", "passed_boundary", "blocked"].map((status) => [status, records.filter((record) => record.status === status).length]));
|
|
const chromeArtifacts = ["part-io-chrome.json", "spreadsheet-action-04-chrome.json"].map((name) => {
|
|
const file = path.join(p6, name);
|
|
if (!fs.existsSync(file)) return { artifact: `docs/p6/${name}`, result: "missing" };
|
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
return { artifact: `docs/p6/${name}`, result: value.result ?? "unknown", externalRequests: value.network?.externalRequestCount ?? null, exceptions: value.exceptions?.length ?? null, sha256: hash(fs.readFileSync(file)) };
|
|
});
|
|
const chrome = { browser: "Google Chrome only", representativeRoutes: chromeArtifacts, externalRequests: chromeArtifacts.reduce((sum, item) => sum + Number(item.externalRequests ?? 0), 0), runtimeExceptions: chromeArtifacts.reduce((sum, item) => sum + Number(item.exceptions ?? 0), 0), boundaries: ["Representative Part BREP and Spreadsheet FCStd routes have current Chrome replay evidence.", "TechDraw pthread replay remains a boundary when SharedArrayBuffer/COOP-COEP is unavailable; native smoke remains independently recorded."] };
|
|
const native = { smokes, dispatcher: "fc_web_dispatch_json", source: "freecad-source", build: path.relative(root, build) };
|
|
const report = {
|
|
schema: "freecad-web-p6-format-coverage-kernel/v1",
|
|
taskId: "FW-P6-FPA-E",
|
|
result: rows.length === 236 && records.length === rows.length && counts.blocked === 0 ? "pass_with_boundaries" : rows.length === records.length ? "pass_with_blocked_records" : "fail",
|
|
source: { inventory: "docs/format-api-inventory.json", inventorySha256: hash(fs.readFileSync(inventoryPath)), inventoryRecords: rows.length },
|
|
coverage: { inventoryRecords: rows.length, terminalRecords: records.length, uniqueHandlerKeys: new Set(records.map((record) => record.handlerKey)).size, counts },
|
|
native,
|
|
chrome,
|
|
records,
|
|
boundaries: ["Every inventory row retains module, direction, source path/line and a terminal status.", "Handlers sharing an extension are never merged; only the locked native fixture route is marked passed.", "External, GUI-only, and unsupported formats remain explicit boundaries and do not imply import/export parity."],
|
|
};
|
|
fs.writeFileSync(path.join(p6, "format-coverage-kernel.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
const gate = { schema: "freecad-web-p6-format-coverage-gate/v1", taskId: "FW-P6-FPA-E", result: report.result, coverage: report.coverage, native: { routes: Object.fromEntries(Object.entries(smokes).map(([name, smoke]) => [name, { result: smoke.result, resultSha256: smoke.resultSha256 }])) }, chrome, source: { kernel: "docs/p6/format-coverage-kernel.json", kernelSha256: hash(fs.readFileSync(path.join(p6, "format-coverage-kernel.json"))) }, boundaries: report.boundaries };
|
|
fs.writeFileSync(path.join(p6, "format-coverage-gate.json"), `${JSON.stringify(gate, null, 2)}\n`);
|
|
fs.writeFileSync(path.join(docs, "freecad-p6-format-coverage-e.log"), `${JSON.stringify({ result: gate.result, coverage: gate.coverage, chrome: { externalRequests: chrome.externalRequests, runtimeExceptions: chrome.runtimeExceptions } })}\n`);
|
|
console.log(JSON.stringify({ result: gate.result, coverage: gate.coverage, chrome: { externalRequests: chrome.externalRequests, runtimeExceptions: chrome.runtimeExceptions } }));
|
|
if (gate.result === "fail") process.exitCode = 1;
|