146 lines
7.4 KiB
JavaScript
146 lines
7.4 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 sourceRoot = path.join(root, "blender-5.2.0/source/blender");
|
|
const defaultOutput = path.join(root, "tests/golden/WBV2/dynamic-inventory.json");
|
|
const outputArgument = process.argv.indexOf("--output");
|
|
const outputPath = path.resolve(outputArgument >= 0 ? process.argv[outputArgument + 1] : defaultOutput);
|
|
const coverageBindingsPath = path.join(root, "tests/golden/WBV2/dynamic-coverage-bindings.json");
|
|
const sourceExtensions = /\.(?:c|cc|cpp|h|hh)$/u;
|
|
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
|
const sourceRelative = (file) => path.relative(sourceRoot, file).replaceAll(path.sep, "/");
|
|
const compare = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
const sourceDirectories = fs.readdirSync(sourceRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && entry.name !== "tests" && entry.name !== "test")
|
|
.map((entry) => entry.name)
|
|
.sort(compare);
|
|
|
|
function collectFiles(directory, files) {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => compare(left.name, right.name))) {
|
|
const file = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === "tests" || entry.name === "test") continue;
|
|
collectFiles(file, files);
|
|
}
|
|
else if (entry.isFile() && sourceExtensions.test(entry.name)) files.push(file);
|
|
}
|
|
}
|
|
|
|
const files = [];
|
|
for (const directory of sourceDirectories) {
|
|
const absolute = path.join(sourceRoot, directory);
|
|
if (fs.existsSync(absolute)) collectFiles(absolute, files);
|
|
}
|
|
files.sort((left, right) => compare(sourceRelative(left), sourceRelative(right)));
|
|
|
|
const fileRecords = files.map((file) => {
|
|
const bytes = fs.readFileSync(file);
|
|
return { path: relative(file), sourcePath: sourceRelative(file), bytes: bytes.length, sha256: sha256(bytes), text: bytes.toString("utf8") };
|
|
});
|
|
const sourceTreeSha256 = sha256(fileRecords.map((file) => `${file.sourcePath}\0${file.sha256}\n`).join(""));
|
|
const units = new Map();
|
|
|
|
function familyFor(file, kind) {
|
|
if (kind === "web-command") return "WEB_ENGINE";
|
|
if (file.startsWith("editors/")) return "EDITOR";
|
|
if (file.startsWith("makesrna/")) return "RNA";
|
|
if (file.startsWith("nodes/")) return "NODES";
|
|
if (file.startsWith("modifiers/")) return "MODIFIER";
|
|
if (file.startsWith("animrig/")) return "ANIMATION";
|
|
if (file.startsWith("sequencer/")) return "SEQUENCER";
|
|
if (file.startsWith("blenkernel/")) return "CORE";
|
|
if (file.startsWith("windowmanager/")) return "WINDOW_MANAGER";
|
|
if (file.startsWith("web_engine/")) return "WEB_ENGINE";
|
|
return "OTHER";
|
|
}
|
|
|
|
function addUnit(kind, identifier, file, offset, evidence) {
|
|
const sourcePath = file.sourcePath;
|
|
const key = `${kind}:${identifier}`;
|
|
const location = { path: file.path, line: file.text.slice(0, offset).split("\n").length, evidence };
|
|
const existing = units.get(key);
|
|
if (existing) existing.locations.push(location);
|
|
else units.set(key, { id: key, kind, identifier, family: familyFor(sourcePath, kind), locations: [location] });
|
|
}
|
|
|
|
for (const file of fileRecords) {
|
|
for (const match of file.text.matchAll(/WM_operatortype_append\(\s*([A-Za-z0-9_]+)\s*\)/gu)) addUnit("operator", match[1], file, match.index, "WM_operatortype_append");
|
|
for (const match of file.text.matchAll(/WM_operatortype_append_macro\(\s*"([A-Za-z0-9_]+)"/gu)) addUnit("operator-macro", match[1], file, match.index, "WM_operatortype_append_macro");
|
|
for (const match of file.text.matchAll(/RNA_def_struct\s*\(\s*[^,]+,\s*"([^"]+)"/gu)) addUnit("rna-struct", match[1], file, match.index, "RNA_def_struct");
|
|
for (const match of file.text.matchAll(/(?:sh|cmp|geo|bke|common)_node_type_base(?:_custom)?\s*\([^,]+,\s*"([^"]+)"(?:_ustr)?/gu)) addUnit("node", match[1], file, match.index, "node_type_base");
|
|
// Only the public API dispatcher defines WebEngine command values. Other
|
|
// WebEngine sources compare `type` for shader/node metadata and must not be
|
|
// misclassified as transport commands.
|
|
if (file.sourcePath === "web_engine/web_engine_api.cpp") {
|
|
for (const match of file.text.matchAll(/type\s*==\s*"([A-Za-z][A-Za-z0-9_]*)"/gu)) addUnit("web-command", match[1], file, match.index, "command-dispatch");
|
|
}
|
|
if (file.sourcePath.startsWith("modifiers/intern/") && /^MOD_[A-Za-z0-9_]+\.cc$/u.test(path.basename(file.sourcePath))) {
|
|
addUnit("modifier-source", path.basename(file.sourcePath, ".cc"), file, 0, "modifier-source-file");
|
|
}
|
|
}
|
|
|
|
const sortedUnits = [...units.values()].map((unit) => ({
|
|
...unit,
|
|
locations: unit.locations.sort((left, right) => compare(`${left.path}:${left.line}`, `${right.path}:${right.line}`)),
|
|
coverageStatus: "UNVERIFIED",
|
|
})).sort((left, right) => compare(left.id, right.id));
|
|
const coverageBindings = fs.existsSync(coverageBindingsPath)
|
|
? JSON.parse(fs.readFileSync(coverageBindingsPath, "utf8"))
|
|
: [];
|
|
const bindingsByUnit = new Map(coverageBindings.map((binding) => [binding.unitId, binding]));
|
|
const coveredUnits = sortedUnits.map((unit) => bindingsByUnit.has(unit.id)
|
|
? { ...unit, coverageStatus: "VERIFIED", coverage: bindingsByUnit.get(unit.id) }
|
|
: unit);
|
|
const byKind = {};
|
|
const byFamily = {};
|
|
for (const unit of coveredUnits) {
|
|
byKind[unit.kind] = (byKind[unit.kind] ?? 0) + 1;
|
|
byFamily[unit.family] = (byFamily[unit.family] ?? 0) + 1;
|
|
}
|
|
const sourceBytes = fileRecords.reduce((total, file) => total + file.bytes, 0);
|
|
const inventory = {
|
|
schemaVersion: 1,
|
|
operation: "BLENDER_52_SOURCE_DYNAMIC_INVENTORY",
|
|
status: coverageBindings.length > 0 ? "SOURCE_DERIVED_WITH_EXPLICIT_COVERAGE" : "SOURCE_DERIVED_UNVERIFIED",
|
|
authority: {
|
|
sourceRoot: "blender-5.2.0/source/blender",
|
|
sourceTreeSha256,
|
|
sourceDirectories,
|
|
excludedDirectories: ["tests", "test"],
|
|
},
|
|
policy: {
|
|
initialLegacyEstimate: 6900,
|
|
planningEstimate: 5200,
|
|
hardTarget: false,
|
|
unitDefinition: "deduplicated source declarations for registered operators, RNA structs, node types, modifier source modules and WebEngine command dispatch values",
|
|
qualityGate: "source mapping and behavioral evidence are required before a unit is covered",
|
|
},
|
|
summary: {
|
|
sourceFiles: fileRecords.length,
|
|
sourceBytes,
|
|
unitCount: coveredUnits.length,
|
|
byKind,
|
|
byFamily,
|
|
planningEstimate: 5200,
|
|
estimateDelta: coveredUnits.length - 5200,
|
|
},
|
|
coverage: {
|
|
verifiedUnits: coverageBindings.length,
|
|
unverifiedUnits: coveredUnits.length - coverageBindings.length,
|
|
sourceMappingRequired: coveredUnits.length,
|
|
inventoryMutation: coverageBindings.length > 0 ? "explicit_source_case_bindings_only" : "forbidden",
|
|
...(coverageBindings.length > 0 ? { lastReconciledAt: "2026-09-01" } : {}),
|
|
},
|
|
sourceFiles: fileRecords.map(({ path: filePath, sourcePath, bytes, sha256: digest }) => ({ path: filePath, sourcePath, bytes, sha256: digest })),
|
|
units: coveredUnits,
|
|
coverageLedger: coverageBindings,
|
|
};
|
|
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, `${JSON.stringify(inventory, null, 2)}\n`);
|
|
process.stdout.write(`source-dynamic-inventory-generated files=${fileRecords.length} units=${sortedUnits.length} planningEstimate=5200 sourceTree=${sourceTreeSha256} output=${relative(outputPath)}\n`);
|