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 stagedRoot = path.resolve(root, process.argv[process.argv.indexOf("--staged-root") + 1] ?? "tests/golden/corrective/C0-002/staged"); const outputRoot = path.resolve(root, process.argv[process.argv.indexOf("--output-root") + 1] ?? "tests/golden/corrective/C0-003"); const read = (file) => fs.readFileSync(file, "utf8"); const json = (file) => JSON.parse(read(file)); const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); const arg = (name) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; }; const outputPath = (name) => path.join(outputRoot, name); const registry = json(path.join(stagedRoot, "completed-gap-tasks.candidate.json")); const plan = json(path.join(stagedRoot, "next-task-plan.candidate.json")); const planById = new Map(plan.tasks.map((task) => [task.id, task])); const taskDirs = fs.readdirSync(path.join(stagedRoot, "resigned-manifests")).filter((entry) => /^M16-GAP-\d{5}$/u.test(entry)).filter((entry) => json(path.join(stagedRoot, "resigned-manifests", entry, "manifest.json")).status === "done").sort(); const positiveToken = (value) => typeof value === "string" && value.length > 0 && !/(?:^|_)(?:NONE|CANCELLED|UNSUPPORTED|ALREADY|EMPTY|NOOP)(?:$|_)/u.test(value); const reportPathFor = (manifest, name) => manifest.artifacts?.[name]?.path ? path.join(root, manifest.artifacts[name].path) : null; const productionKeys = ["reader", "nativeStub", "api", "protocol", "sceneIrProtocol", "wasm", "wasmJs", "wasmPublic", "wasmJsPublic"]; const capabilityFor = (gapId, ownerFamily, operation) => { if (gapId?.startsWith("operator:asset.")) return "CAP:asset.catalog.operator"; if (gapId?.startsWith("operator:anim.")) return "CAP:animation.operator"; if (gapId?.startsWith("operator:armature.")) return "CAP:armature.operator"; if (gapId?.startsWith("modifier:")) return "CAP:modifier.snapshot"; if (gapId?.startsWith("datablock:")) return "CAP:datablock.read"; return `CAP:${String(ownerFamily || operation || "legacy").toLowerCase()}`; }; const classify = ({ manifest, desktop, web, task }) => { const desktopMutation = desktop?.mainMutation; const desktopPositive = Boolean(desktop?.poll === true && desktop?.operatorStatus === "FINISHED" && positiveToken(desktopMutation)); const wasmMutation = web?.wasm?.mainMutation ?? web?.wasm?.mutation ?? web?.wasm?.commandResult?.mainMutation ?? null; const beforeAfterChanged = web?.wasm?.before !== undefined && web?.wasm?.after !== undefined && JSON.stringify(web.wasm.before) !== JSON.stringify(web.wasm.after); const explicitAfterMutation = Boolean(web?.wasm?.after && Object.keys(web.wasm.after).some((key) => /(?:inserted|deleted|cleared|changed|added|removed|moved|duplicated|replaced|updated|set|assigned|unassigned|created|selected)/iu.test(key))); const wasmPositiveMutation = positiveToken(wasmMutation) || beforeAfterChanged || explicitAfterMutation || (Number.isFinite(web?.wasm?.revisionAfter) && Number.isFinite(web?.wasm?.revisionBefore) && web.wasm.revisionAfter !== web.wasm.revisionBefore); const saveReopen = web?.saveReopen ?? desktop?.saveReopen ?? "NOT_REPORTED"; const negative = web?.negative && Object.keys(web.negative).length > 0; const exact = (web?.status === "EXACT" || web?.desktop?.status === "EXACT" || web?.wasm?.status === "EXACT") && saveReopen === "EXACT"; let completionClass = "BLOCKED"; let evidenceLevel = "L0"; let mappingState = "BLOCKED"; let reason = "missing or non-authoritative mutation evidence"; if (desktopPositive && wasmPositiveMutation && exact) { completionClass = "FEATURE_PARITY"; evidenceLevel = saveReopen === "EXACT" ? "L3" : "L2"; mappingState = "CANDIDATE"; reason = "desktop positive oracle and explicit WASM/Main mutation with persistence evidence"; } else if (negative && !desktopPositive && !wasmPositiveMutation) { completionClass = "NEGATIVE_BOUNDARY"; evidenceLevel = exact ? "L1" : "L0"; mappingState = "RETAINED_NEGATIVE"; reason = "evidence is cancellation, unsupported, resource, or malformed-input boundary"; } else if (exact && !desktopPositive && !wasmPositiveMutation) { completionClass = web?.wasm && Object.keys(web.wasm).length > 1 ? "METADATA_ONLY" : "READ_COMPATIBILITY"; evidenceLevel = Object.keys(web?.wasm ?? {}).length > 1 ? "L1" : "L0"; mappingState = "CANDIDATE"; reason = completionClass === "METADATA_ONLY" ? "exact snapshot fields without equivalent mutation" : "read/observe evidence without mutation"; } if (manifest.status !== "done") { completionClass = "BLOCKED"; mappingState = "BLOCKED"; reason = "legacy manifest is not done"; } return { completionClass, evidenceLevel, mappingState, reason, desktopPositive, wasmPositiveMutation, readerChanged: Boolean(manifest.artifacts?.reader || manifest.artifacts?.nativeStub || manifest.artifacts?.api), wasmChanged: Boolean(manifest.artifacts?.wasm || manifest.artifacts?.wasmJs || manifest.artifacts?.wasmPublic || manifest.artifacts?.wasmJsPublic), saveReopen }; }; const records = []; for (const task of taskDirs) { const manifestPath = path.join(stagedRoot, "resigned-manifests", task, "manifest.json"); const manifest = json(manifestPath); const taskPlan = planById.get(task); const desktopPath = reportPathFor(manifest, "desktopReport"); const webPath = reportPathFor(manifest, "webReport"); const desktop = desktopPath && fs.existsSync(desktopPath) ? json(desktopPath) : null; const web = webPath && fs.existsSync(webPath) ? json(webPath) : null; const classification = classify({ manifest, desktop, web, task }); const artifactPaths = [relative(manifestPath), desktopPath && relative(desktopPath), webPath && relative(webPath)].filter(Boolean); for (const key of productionKeys) { const pathValue = manifest.artifacts?.[key]?.path; if (pathValue && fs.existsSync(path.join(root, pathValue))) artifactPaths.push(pathValue); } const sourceHashes = Object.fromEntries([...new Set(artifactPaths)].map((file) => [file, sha256(path.join(root, file))])); const gapId = taskPlan?.gapId ?? null; const candidateCapabilityId = capabilityFor(gapId, taskPlan?.ownerFamily, manifest.operation); records.push({ task, gapId, evidenceLevel: classification.evidenceLevel, completionClass: classification.completionClass, desktopPositive: classification.desktopPositive, wasmPositiveMutation: classification.wasmPositiveMutation, readerChanged: classification.readerChanged, wasmChanged: classification.wasmChanged, saveReopen: classification.saveReopen, reason: classification.reason, candidateCapabilityId, coverageCapabilityIds: [candidateCapabilityId], mappingState: classification.mappingState, evidenceRefs: { manifest: relative(manifestPath), desktopReport: desktopPath ? relative(desktopPath) : null, webReport: webPath ? relative(webPath) : null, command: `node tools/web/check-generated-gap.mjs --task ${task}` }, sourceHashes }); } const clusters = new Map(); for (const record of records) { const key = `${record.completionClass}:${record.candidateCapabilityId}`; const value = clusters.get(key) ?? { clusterId: key, completionClass: record.completionClass, capabilityId: record.candidateCapabilityId, taskCount: 0, tasks: [] }; value.taskCount += 1; value.tasks.push(record.task); clusters.set(key, value); } const classificationSource = { stagedRoot: relative(stagedRoot), registry: relative(path.join(stagedRoot, "completed-gap-tasks.candidate.json")), registrySha256: sha256(path.join(stagedRoot, "completed-gap-tasks.candidate.json")), plan: relative(path.join(stagedRoot, "next-task-plan.candidate.json")), planSha256: sha256(path.join(stagedRoot, "next-task-plan.candidate.json")) }; const classification = { schemaVersion: 1, operation: "CORRECTIVE_LEGACY_273_CLASSIFICATION", status: records.length === 273 && records.every((record) => Object.values(record.sourceHashes).every(Boolean)) ? "PASS" : "BLOCKED", queueMutation: false, source: classificationSource, sourceHashes: { [classificationSource.registry]: classificationSource.registrySha256, [classificationSource.plan]: classificationSource.planSha256 }, inputDigest: crypto.createHash("sha256").update(JSON.stringify({ source: classificationSource, records })).digest("hex"), taskCount: records.length, records }; const clusterSummary = { schemaVersion: 1, operation: "CORRECTIVE_LEGACY_CLUSTER_SUMMARY", status: "PASS", queueMutation: false, clusters: [...clusters.values()].map((cluster) => ({ ...cluster, tasks: cluster.tasks.sort() })).sort((a, b) => a.clusterId.localeCompare(b.clusterId)), counts: Object.fromEntries([...new Set(records.map((record) => record.completionClass))].sort().map((key) => [key, records.filter((record) => record.completionClass === key).length])) }; const reportLines = ["# C0-003 Legacy Classification Report", "", `status: ${classification.status}`, `taskCount: ${records.length}`, "queueMutation: false", "", "Classification is fail-closed: only explicit desktop positive, explicit WASM/Main mutation, and persistence evidence can produce FEATURE_PARITY.", "", "| Class | Count |", "| --- | ---: |", ...Object.entries(clusterSummary.counts).map(([key, value]) => `| ${key} | ${value} |`), "", "| Task | Gap | Level | Class | Desktop | WASM mutation | Save/reopen | Mapping |", "| --- | --- | --- | --- | --- | --- | --- | --- |", ...records.map((record) => `| ${record.task} | ${record.gapId} | ${record.evidenceLevel} | ${record.completionClass} | ${record.desktopPositive} | ${record.wasmPositiveMutation} | ${record.saveReopen} | ${record.mappingState} |`), ""]; fs.mkdirSync(outputRoot, { recursive: true }); fs.writeFileSync(outputPath("legacy-gap-classification.json"), `${JSON.stringify(classification, null, 2)}\n`); fs.writeFileSync(outputPath("cluster-summary.json"), `${JSON.stringify(clusterSummary, null, 2)}\n`); fs.writeFileSync(outputPath("classification-report.md"), reportLines.join("\n")); process.stdout.write(`corrective-classification-${classification.status.toLowerCase()} tasks=${records.length} clusters=${clusterSummary.clusters.length} output=${relative(outputRoot)}\n`);