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`);