210 lines
11 KiB
JavaScript
210 lines
11 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const defaultOutput = path.join(root, "tests/golden/WBV2-P0-002");
|
|
const outputDir = path.resolve(process.argv[2] ?? defaultOutput);
|
|
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
|
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
const readJson = (relativePath) => {
|
|
const file = path.join(root, relativePath);
|
|
return { path: relativePath, file, bytes: fs.readFileSync(file), value: JSON.parse(fs.readFileSync(file, "utf8")) };
|
|
};
|
|
|
|
const sources = [
|
|
readJson("tests/golden/M15-02A/blender-parity-map.json"),
|
|
readJson("tests/golden/M15-03A/next-task-plan.json"),
|
|
readJson("tests/golden/M15-03A/completed-gap-tasks.json"),
|
|
readJson("docs/BLENDER_WASM_CAPABILITY_MATRIX_TEMPLATE.json"),
|
|
readJson("docs/BLENDER_WEB_PARITY_TASK_PLAN_V2.json"),
|
|
readJson("tests/golden/WBV2-P0-001/manifest.json"),
|
|
];
|
|
|
|
const sourceHashes = Object.fromEntries(sources.map((source) => [source.path, sha256(source.bytes)]));
|
|
const sourceRefs = Object.fromEntries(sources.map((source) => [source.path, { path: source.path, sha256: sourceHashes[source.path] }]));
|
|
const parityMap = sources[0].value;
|
|
const legacyPlan = sources[1].value;
|
|
const capabilityMatrix = sources[3].value;
|
|
const taskByGapId = new Map(legacyPlan.tasks.map((task) => [task.gapId, task]));
|
|
const matrixByFamily = new Map((capabilityMatrix.entries ?? []).map((entry) => [entry.family, entry]));
|
|
const evidenceByTask = new Map((parityMap.sourceInventories ? Object.keys(parityMap.sourceInventories) : []).map((task) => [
|
|
task,
|
|
`tests/golden/${task}/manifest.json`,
|
|
]));
|
|
|
|
const coreSceneNames = new Set(["Scene", "Collection", "Object", "ViewLayer", "LayerCollection", "Window", "WindowManager", "Screen", "RenderSettings", "World", "Camera", "Light", "Material", "NodeTree"]);
|
|
const coreMeshNames = new Set(["Mesh", "Attribute", "AttributeGroupMesh", "BoolAttribute", "ByteColorAttribute", "FloatAttribute", "IntAttribute"]);
|
|
|
|
const familySpec = {
|
|
CORE_DNA_RNA: { layer: "CORE", targetParityLevel: "L2" },
|
|
CORE_MESH: { layer: "CORE", targetParityLevel: "L3" },
|
|
CORE_OBJECT: { layer: "CORE", targetParityLevel: "L3" },
|
|
CORE_SCENE_GRAPH: { layer: "CORE", targetParityLevel: "L3" },
|
|
MESH_EDITING: { layer: "FEATURE", targetParityLevel: "L3" },
|
|
OBJECT_OPERATIONS: { layer: "FEATURE", targetParityLevel: "L3" },
|
|
MATERIAL_RENDER: { layer: "FEATURE", targetParityLevel: "L3" },
|
|
MODIFIER_SYSTEM: { layer: "FEATURE", targetParityLevel: "L3" },
|
|
NODES_GRAPH: { layer: "FEATURE", targetParityLevel: "L3" },
|
|
RIG_ANIMATION: { layer: "FEATURE", targetParityLevel: "L3" },
|
|
SIMULATION: { layer: "FEATURE", targetParityLevel: "L2" },
|
|
SCULPT_PAINT: { layer: "FEATURE", targetParityLevel: "L2" },
|
|
MEDIA_IO: { layer: "FEATURE", targetParityLevel: "L3" },
|
|
UI_EDITOR: { layer: "UI", targetParityLevel: "L2" },
|
|
OTHER_OPERATOR: { layer: "FEATURE", targetParityLevel: "L1" },
|
|
UNKNOWN: { layer: "UNCLASSIFIED", targetParityLevel: "L0" },
|
|
};
|
|
|
|
function classify(entry) {
|
|
const id = entry.id;
|
|
const lower = id.toLowerCase();
|
|
const suffix = id.includes(":") ? id.slice(id.indexOf(":") + 1) : id;
|
|
const name = suffix.split(":").at(-1) ?? suffix;
|
|
if (id.startsWith("datablock:")) {
|
|
if (name === "Mesh") return "CORE_MESH";
|
|
if (name === "Object") return "CORE_OBJECT";
|
|
if (coreSceneNames.has(name)) return "CORE_SCENE_GRAPH";
|
|
return "CORE_DNA_RNA";
|
|
}
|
|
if (id.startsWith("rna:")) {
|
|
if (entry.ownerFamily === "Mesh" || coreMeshNames.has(name)) return "CORE_MESH";
|
|
if (name === "Object") return "CORE_OBJECT";
|
|
if (entry.ownerFamily === "Scene" || entry.ownerFamily === "Main" || entry.ownerFamily === "Depsgraph" || coreSceneNames.has(name)) return "CORE_SCENE_GRAPH";
|
|
return "CORE_DNA_RNA";
|
|
}
|
|
if (id.startsWith("modifier:")) return "MODIFIER_SYSTEM";
|
|
if (id.startsWith("constraint:")) return "RIG_ANIMATION";
|
|
if (id.startsWith("node:")) return lower.includes("shader") ? "MATERIAL_RENDER" : "NODES_GRAPH";
|
|
if (id.startsWith("physics:")) return "SIMULATION";
|
|
if (id.startsWith("strip:")) return "MEDIA_IO";
|
|
if (id.startsWith("io:")) return "MEDIA_IO";
|
|
if (/^(area|region|space|space-class|workspace|keymap|keymap-item):/u.test(id)) return "UI_EDITOR";
|
|
if (id.startsWith("operator:")) {
|
|
const module = id.slice("operator:".length).split(".")[0];
|
|
if (["node", "geometry"].includes(module)) return "NODES_GRAPH";
|
|
if (["grease_pencil", "gpencil", "curve", "curves", "font", "lattice", "mball", "pointcloud", "surface", "geometry"].includes(module)) return "MESH_EDITING";
|
|
if (["sequencer", "sound", "image", "clip", "mask", "text", "file", "wm", "import_scene", "export_scene", "import_anim", "export_anim", "import_curve", "asset", "collection", "catalog"].includes(module)) return "MEDIA_IO";
|
|
if (["graph", "anim", "nla", "poselib", "marker", "action", "armature", "pose"].includes(module)) return "RIG_ANIMATION";
|
|
if (["view3d", "view2d", "outliner", "screen", "ui", "preferences", "console", "info", "buttons", "spreadsheet", "uilist", "workspace", "ed", "text_editor"].includes(module)) return "UI_EDITOR";
|
|
if (["paint", "sculpt", "brush", "paintcurve", "sculpt_curves"].includes(module)) return "SCULPT_PAINT";
|
|
if (["rigidbody", "boid", "ptcache", "cloth", "dpaint", "cachefile"].includes(module)) return "SIMULATION";
|
|
if (["cycles", "render", "camera", "light"].includes(module)) return "MATERIAL_RENDER";
|
|
if (["scene", "transform", "lattice"].includes(module)) return "OBJECT_OPERATIONS";
|
|
if (lower.includes("asset.catalog")) return "MEDIA_IO";
|
|
if (lower.includes("mesh.")) return "MESH_EDITING";
|
|
if (lower.includes("object.")) return "OBJECT_OPERATIONS";
|
|
if (/(material|shader|render|light|world)\./u.test(lower)) return "MATERIAL_RENDER";
|
|
if (/(armature|pose|action|keyframe|animation|constraint)\./u.test(lower)) return "RIG_ANIMATION";
|
|
if (/(physics|fluid|particle|simulation|bake)\./u.test(lower)) return "SIMULATION";
|
|
if (/(import|export|save|open|library|catalog)\./u.test(lower)) return "MEDIA_IO";
|
|
return "OTHER_OPERATOR";
|
|
}
|
|
return "UNKNOWN";
|
|
}
|
|
|
|
function completionFor(state) {
|
|
// Inventory state is deliberately separate from parity. A completed legacy
|
|
// receipt is useful coverage evidence, but it does not prove browser parity.
|
|
return { completionClass: "METADATA_ONLY", parityLevel: "L0", evidenceStatus: "NOT_RUN", state };
|
|
}
|
|
|
|
const entries = parityMap.entries.map((entry) => {
|
|
const task = taskByGapId.get(entry.id);
|
|
if (!task) throw new Error(`legacy plan is missing inventory entry ${entry.id}`);
|
|
const family = classify(entry);
|
|
const spec = familySpec[family];
|
|
const legacyState = task.state === "completed" ? "completed" : task.state === "active" ? "active" : "pending";
|
|
return {
|
|
id: entry.id,
|
|
sourceTask: entry.task,
|
|
kind: entry.id.split(":", 1)[0],
|
|
ownerFamily: entry.ownerFamily,
|
|
capabilityId: `CAP-${family.replaceAll("_", "-")}`,
|
|
family,
|
|
layer: spec.layer,
|
|
classificationStatus: family === "UNKNOWN" ? "UNKNOWN_EXPLICIT" : "MAPPED",
|
|
legacyState,
|
|
targetImplementationClass: task.targetImplementationClass,
|
|
...completionFor(legacyState),
|
|
targetParityLevel: spec.targetParityLevel,
|
|
parityClaim: "NONE",
|
|
evidence: {
|
|
sourceManifest: evidenceByTask.get(entry.task) ?? null,
|
|
legacyTask: task.id,
|
|
representativeOnly: true,
|
|
},
|
|
};
|
|
}).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
|
|
const ids = entries.map((entry) => entry.id);
|
|
if (new Set(ids).size !== ids.length) throw new Error("duplicate inventory IDs");
|
|
|
|
const allFamilies = [...new Set(entries.map((entry) => entry.family))].sort();
|
|
const capabilities = allFamilies.map((family) => {
|
|
const members = entries.filter((entry) => entry.family === family);
|
|
const spec = familySpec[family];
|
|
const completed = members.filter((entry) => entry.legacyState === "completed").length;
|
|
const pending = members.filter((entry) => entry.legacyState === "pending").length;
|
|
const active = members.filter((entry) => entry.legacyState === "active").length;
|
|
const matrixEntry = [...matrixByFamily.values()].find((entry) => entry.family === family);
|
|
return {
|
|
capabilityId: `CAP-${family.replaceAll("_", "-")}`,
|
|
family,
|
|
layer: spec.layer,
|
|
entryCount: members.length,
|
|
legacyCompleted: completed,
|
|
legacyPending: pending,
|
|
legacyActive: active,
|
|
completionClass: "METADATA_ONLY",
|
|
parityLevel: "L0",
|
|
targetParityLevel: spec.targetParityLevel,
|
|
lifecycleStatus: "planned",
|
|
evidenceStatus: "NOT_RUN",
|
|
sourceCapability: matrixEntry?.capabilityId ?? null,
|
|
claim: "inventory_only_no_feature_parity",
|
|
};
|
|
});
|
|
|
|
const summary = {
|
|
inventoryEntries: entries.length,
|
|
capabilityCount: capabilities.length,
|
|
unknownEntries: entries.filter((entry) => entry.family === "UNKNOWN").length,
|
|
completedLegacyEntries: entries.filter((entry) => entry.legacyState === "completed").length,
|
|
pendingLegacyEntries: entries.filter((entry) => entry.legacyState === "pending").length,
|
|
activeLegacyEntries: entries.filter((entry) => entry.legacyState === "active").length,
|
|
featureParityClaims: entries.filter((entry) => entry.parityClaim !== "NONE" || entry.parityLevel !== "L0").length,
|
|
byKind: Object.fromEntries([...new Set(entries.map((entry) => entry.kind))].sort().map((kind) => [kind, entries.filter((entry) => entry.kind === kind).length])),
|
|
byLayer: Object.fromEntries([...new Set(entries.map((entry) => entry.layer))].sort().map((layer) => [layer, entries.filter((entry) => entry.layer === layer).length])),
|
|
};
|
|
|
|
const inventory = {
|
|
schemaVersion: 1,
|
|
task: "WBV2-P0-002",
|
|
operation: "BLENDER_52_INVENTORY",
|
|
status: "INVENTORY_ONLY",
|
|
runtime: "Blender 5.2 baseline inventories",
|
|
sourceHashes,
|
|
summary,
|
|
entries: entries.map(({ id, sourceTask, kind, ownerFamily }) => ({ id, sourceTask, kind, ownerFamily })),
|
|
nextTask: "WBV2-P0-003",
|
|
};
|
|
const parity = {
|
|
schemaVersion: 1,
|
|
task: "WBV2-P0-002",
|
|
operation: "BLENDER_52_CAPABILITY_PARITY_MAP",
|
|
statusAxis: "INVENTORY_ONLY",
|
|
implementationClasses: ["METADATA_ONLY"],
|
|
sourceInventories: sourceRefs,
|
|
summary,
|
|
capabilities,
|
|
entries,
|
|
nextTask: "WBV2-P0-003",
|
|
};
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
fs.writeFileSync(path.join(outputDir, "source-hashes.json"), `${JSON.stringify({ schemaVersion: 1, task: "WBV2-P0-002", sources: sourceHashes }, null, 2)}\n`);
|
|
fs.writeFileSync(path.join(outputDir, "inventory.json"), `${JSON.stringify(inventory, null, 2)}\n`);
|
|
fs.writeFileSync(path.join(outputDir, "parity-map.json"), `${JSON.stringify(parity, null, 2)}\n`);
|
|
process.stdout.write(`wbv2-inventory-generated entries=${entries.length} capabilities=${capabilities.length} unknown=${summary.unknownEntries} output=${relative(outputDir)}\n`);
|