Reorganize Blender Web execution around capabilities
This commit is contained in:
90
tools/web/capture-corrective-baseline.mjs
Normal file
90
tools/web/capture-corrective-baseline.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
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 read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");
|
||||
const sha256 = (relative) => crypto.createHash("sha256").update(read(relative)).digest("hex");
|
||||
const parseJson = (relative) => JSON.parse(read(relative));
|
||||
const relative = (absolute) => path.relative(root, absolute).replaceAll(path.sep, "/");
|
||||
|
||||
function argument(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function queueCurrentTask() {
|
||||
const source = read("docs/EXECUTION_QUEUE.md");
|
||||
return source.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1]
|
||||
?? source.match(/当前任务\s*[::]\s*`([^`]+)`/u)?.[1];
|
||||
}
|
||||
|
||||
function countLegacyEvidence() {
|
||||
const rootPath = path.join(root, "tests/golden");
|
||||
return fs.readdirSync(rootPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^M16-GAP-\d{5}$/u.test(entry.name))
|
||||
.length;
|
||||
}
|
||||
|
||||
const task = argument("--task") ?? queueCurrentTask();
|
||||
const output = argument("--output");
|
||||
if (!task || !output) {
|
||||
throw new Error("usage: node tools/web/capture-corrective-baseline.mjs --task M16-GAP-xxxxx --output path");
|
||||
}
|
||||
|
||||
const queue = read("docs/EXECUTION_QUEUE.md");
|
||||
const parentManifestPath = queue.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
if (!parentManifestPath) throw new Error("EXECUTION_QUEUE.md does not declare parent manifest");
|
||||
const parentManifest = parseJson(parentManifestPath);
|
||||
if (queueCurrentTask() !== task) throw new Error(`queue current task mismatch: ${queueCurrentTask()} != ${task}`);
|
||||
if (parentManifest.nextTask !== task) throw new Error(`parent nextTask mismatch: ${parentManifest.nextTask} != ${task}`);
|
||||
const parentStatusPath = `docs/status/${parentManifest.task}.md`;
|
||||
if (!fs.existsSync(path.join(root, parentStatusPath))) throw new Error(`missing parent status: ${parentStatusPath}`);
|
||||
|
||||
const plan = parseJson("tests/golden/M15-03A/next-task-plan.json");
|
||||
const catalog = read("tests/golden/M15-03A/task-catalog.jsonl");
|
||||
const activeIds = catalog.split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
||||
.filter((entry) => entry.state === "active").map((entry) => entry.id);
|
||||
const sourcePaths = [
|
||||
"docs/EXECUTION_QUEUE.md",
|
||||
parentManifestPath,
|
||||
parentStatusPath,
|
||||
"tests/golden/M15-03A/completed-gap-tasks.json",
|
||||
"tests/golden/M15-03A/task-catalog.jsonl",
|
||||
"tests/golden/M15-03A/task-index.json",
|
||||
"tests/golden/M15-03A/next-task-plan.json",
|
||||
];
|
||||
const baseline = {
|
||||
schemaVersion: 1,
|
||||
task,
|
||||
queue: {
|
||||
currentTask: queueCurrentTask(),
|
||||
parentManifest: parentManifestPath,
|
||||
parentNextTask: parentManifest.nextTask,
|
||||
},
|
||||
generatedState: {
|
||||
catalogActiveTask: activeIds.length === 1 ? activeIds[0] : null,
|
||||
catalogActiveIds: activeIds,
|
||||
planFirstTask: plan.firstTask,
|
||||
planNextTask: plan.nextTask,
|
||||
completionCount: parseJson("tests/golden/M15-03A/completed-gap-tasks.json").length,
|
||||
legacyEvidenceDirectoryCount: countLegacyEvidence(),
|
||||
},
|
||||
sourceHashes: Object.fromEntries(sourcePaths.map((sourcePath) => [sourcePath, sha256(sourcePath)])),
|
||||
invariants: {
|
||||
queueMutationAllowed: false,
|
||||
parentPointerMatchesCurrent: parentManifest.nextTask === task,
|
||||
exactlyOneCatalogActive: activeIds.length === 1,
|
||||
catalogActiveMatchesPlan: activeIds[0] === plan.firstTask,
|
||||
},
|
||||
};
|
||||
if (!baseline.invariants.parentPointerMatchesCurrent || !baseline.invariants.exactlyOneCatalogActive || !baseline.invariants.catalogActiveMatchesPlan) {
|
||||
throw new Error(`generated state is inconsistent: ${JSON.stringify(baseline.invariants)}`);
|
||||
}
|
||||
|
||||
const outputPath = path.resolve(root, output);
|
||||
if (!outputPath.startsWith(`${root}${path.sep}`)) throw new Error("baseline output must stay inside the repository");
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(baseline, null, 2)}\n`);
|
||||
process.stdout.write(`corrective-baseline-ok task=${task} active=${activeIds[0]} parentNext=${parentManifest.nextTask} output=${relative(outputPath)}\n`);
|
||||
86
tools/web/check-action-asset-clear-single-desktop.py
Normal file
86
tools/web/check-action-asset-clear-single-desktop.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def library_state():
|
||||
libraries = list(bpy.data.libraries)
|
||||
if len(libraries) != 1:
|
||||
raise RuntimeError(f"expected one asset-clear-single library, found {len(libraries)}")
|
||||
library = libraries[0]
|
||||
return {"name": library.name, "filepath": library.filepath, "packed": library.packed_file is not None}
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --factory-startup --python "
|
||||
"check-action-asset-clear-single-desktop.py -- FIXTURE REPORT"
|
||||
)
|
||||
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
|
||||
output.unlink(missing_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
|
||||
before = library_state()
|
||||
accepted_errors = (
|
||||
"context is incorrect",
|
||||
"Data-block is not marked as asset",
|
||||
"No data-block selected that is marked as asset",
|
||||
"No asset data-blocks selected",
|
||||
"current file selected",
|
||||
)
|
||||
try:
|
||||
poll = bool(bpy.ops.asset.clear_single.poll())
|
||||
except RuntimeError as error:
|
||||
if not any(token in str(error) for token in accepted_errors):
|
||||
raise
|
||||
poll = False
|
||||
if poll:
|
||||
raise RuntimeError("asset.clear_single unexpectedly polled true")
|
||||
try:
|
||||
bpy.ops.asset.clear_single(set_fake_user=False)
|
||||
except RuntimeError as error:
|
||||
if not any(token in str(error) for token in accepted_errors):
|
||||
raise
|
||||
operator_status = "CANCELLED"
|
||||
else:
|
||||
raise RuntimeError("asset.clear_single unexpectedly finished")
|
||||
after_cancel = library_state()
|
||||
if after_cancel != before:
|
||||
raise RuntimeError(f"asset.clear_single cancellation changed Main data: {before} != {after_cancel}")
|
||||
with tempfile.NamedTemporaryFile(prefix="m16-asset-clear-single-reopen-", suffix=".blend", dir=fixture.parent) as temporary:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=temporary.name, check_existing=False, compress=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=temporary.name, load_ui=False)
|
||||
after = library_state()
|
||||
if after != before:
|
||||
raise RuntimeError(f"asset.clear_single save/reopen drift: {before} != {after}")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M16-GAP-00276",
|
||||
"operation": "ASSET_CLEAR_SINGLE_DESKTOP",
|
||||
"fixture": str(fixture),
|
||||
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"poll": poll,
|
||||
"operatorStatus": operator_status,
|
||||
"mainMutation": "NONE",
|
||||
"saveReopen": "EXACT",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("asset-clear-single-desktop-ok poll=false status=CANCELLED mainMutation=none saveReopen=exact")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"asset-clear-single-desktop-failed: {error}")
|
||||
raise SystemExit(1)
|
||||
81
tools/web/check-action-asset-library-refresh-desktop.py
Normal file
81
tools/web/check-action-asset-library-refresh-desktop.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def library_state():
|
||||
libraries = list(bpy.data.libraries)
|
||||
if len(libraries) != 1:
|
||||
raise RuntimeError(f"expected one asset-library-refresh library, found {len(libraries)}")
|
||||
library = libraries[0]
|
||||
return {"name": library.name, "filepath": library.filepath, "packed": library.packed_file is not None}
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --factory-startup --python "
|
||||
"check-action-asset-library-refresh-desktop.py -- FIXTURE REPORT"
|
||||
)
|
||||
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
|
||||
output.unlink(missing_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
|
||||
before = library_state()
|
||||
try:
|
||||
poll = bool(bpy.ops.asset.library_refresh.poll())
|
||||
except RuntimeError as error:
|
||||
if "context is incorrect" not in str(error):
|
||||
raise
|
||||
poll = False
|
||||
if poll:
|
||||
raise RuntimeError("asset.library_refresh unexpectedly polled true")
|
||||
try:
|
||||
bpy.ops.asset.library_refresh()
|
||||
except RuntimeError as error:
|
||||
if "context is incorrect" not in str(error):
|
||||
raise
|
||||
operator_status = "CANCELLED"
|
||||
else:
|
||||
raise RuntimeError("asset.library_refresh unexpectedly finished")
|
||||
after_cancel = library_state()
|
||||
if after_cancel != before:
|
||||
raise RuntimeError(f"asset.library_refresh cancellation changed Main data: {before} != {after_cancel}")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="m16-asset-library-refresh-reopen-", suffix=".blend", dir=fixture.parent
|
||||
) as temporary:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=temporary.name, check_existing=False, compress=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=temporary.name, load_ui=False)
|
||||
after = library_state()
|
||||
if after != before:
|
||||
raise RuntimeError(f"asset.library_refresh save/reopen drift: {before} != {after}")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M16-GAP-00277",
|
||||
"operation": "ASSET_LIBRARY_REFRESH_DESKTOP",
|
||||
"fixture": str(fixture),
|
||||
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"poll": poll,
|
||||
"operatorStatus": operator_status,
|
||||
"mainMutation": "NONE",
|
||||
"saveReopen": "EXACT",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("asset-library-refresh-desktop-ok poll=false status=CANCELLED mainMutation=none saveReopen=exact")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"asset-library-refresh-desktop-failed: {error}")
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def library_state():
|
||||
libraries = list(bpy.data.libraries)
|
||||
if len(libraries) != 1:
|
||||
raise RuntimeError(f"expected one asset-library-reload-listing library, found {len(libraries)}")
|
||||
library = libraries[0]
|
||||
return {"name": library.name, "filepath": library.filepath, "packed": library.packed_file is not None}
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --factory-startup --python "
|
||||
"check-action-asset-library-reload-listing-desktop.py -- FIXTURE REPORT"
|
||||
)
|
||||
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
|
||||
output.unlink(missing_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
|
||||
before = library_state()
|
||||
try:
|
||||
poll = bool(bpy.ops.asset.library_reload_listing.poll())
|
||||
except RuntimeError as error:
|
||||
if "context is incorrect" not in str(error) and "not a remote library" not in str(error):
|
||||
raise
|
||||
poll = False
|
||||
if poll:
|
||||
raise RuntimeError("asset.library_reload_listing unexpectedly polled true")
|
||||
try:
|
||||
bpy.ops.asset.library_reload_listing()
|
||||
except RuntimeError as error:
|
||||
if "context is incorrect" not in str(error) and "not a remote library" not in str(error):
|
||||
raise
|
||||
operator_status = "CANCELLED"
|
||||
else:
|
||||
raise RuntimeError("asset.library_reload_listing unexpectedly finished")
|
||||
after_cancel = library_state()
|
||||
if after_cancel != before:
|
||||
raise RuntimeError(
|
||||
f"asset.library_reload_listing cancellation changed Main data: {before} != {after_cancel}"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="m16-asset-library-reload-listing-reopen-", suffix=".blend", dir=fixture.parent
|
||||
) as temporary:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=temporary.name, check_existing=False, compress=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=temporary.name, load_ui=False)
|
||||
after = library_state()
|
||||
if after != before:
|
||||
raise RuntimeError(f"asset.library_reload_listing save/reopen drift: {before} != {after}")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M16-GAP-00278",
|
||||
"operation": "ASSET_LIBRARY_RELOAD_LISTING_DESKTOP",
|
||||
"fixture": str(fixture),
|
||||
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"poll": poll,
|
||||
"operatorStatus": operator_status,
|
||||
"mainMutation": "NONE",
|
||||
"saveReopen": "EXACT",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("asset-library-reload-listing-desktop-ok poll=false status=CANCELLED mainMutation=none saveReopen=exact")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"asset-library-reload-listing-desktop-failed: {error}")
|
||||
raise SystemExit(1)
|
||||
95
tools/web/check-action-asset-mark-desktop.py
Normal file
95
tools/web/check-action-asset-mark-desktop.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
OBJECT_NAME = "WebGapAssetMarkObject"
|
||||
|
||||
|
||||
def object_state():
|
||||
obj = bpy.data.objects.get(OBJECT_NAME)
|
||||
if obj is None:
|
||||
raise RuntimeError("asset mark fixture object is missing")
|
||||
metadata = obj.asset_data
|
||||
return {
|
||||
"name": obj.name,
|
||||
"assetMarked": metadata is not None,
|
||||
"author": metadata.author if metadata is not None else "",
|
||||
"description": metadata.description if metadata is not None else "",
|
||||
"tags": sorted(tag.name for tag in metadata.tags) if metadata is not None else [],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --factory-startup --python "
|
||||
"check-action-asset-mark-desktop.py -- FIXTURE REPORT"
|
||||
)
|
||||
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
|
||||
output.unlink(missing_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
|
||||
before = object_state()
|
||||
accepted_errors = (
|
||||
"No data-block selected",
|
||||
"context is incorrect",
|
||||
"supports asset operations",
|
||||
)
|
||||
try:
|
||||
poll = bool(bpy.ops.asset.mark.poll())
|
||||
except RuntimeError as error:
|
||||
if not any(token in str(error) for token in accepted_errors):
|
||||
raise
|
||||
poll = False
|
||||
if poll:
|
||||
raise RuntimeError("asset.mark unexpectedly polled true without an editor ID context")
|
||||
try:
|
||||
bpy.ops.asset.mark()
|
||||
except RuntimeError as error:
|
||||
if not any(token in str(error) for token in accepted_errors):
|
||||
raise
|
||||
operator_status = "CANCELLED"
|
||||
else:
|
||||
raise RuntimeError("asset.mark unexpectedly finished without an editor ID context")
|
||||
after_cancel = object_state()
|
||||
if after_cancel != before:
|
||||
raise RuntimeError(f"asset.mark cancellation changed Main data: {before} != {after_cancel}")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="m16-asset-mark-reopen-", suffix=".blend", dir=fixture.parent
|
||||
) as temporary:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=temporary.name, check_existing=False, compress=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=temporary.name, load_ui=False)
|
||||
after = object_state()
|
||||
if after != before:
|
||||
raise RuntimeError(f"asset.mark save/reopen drift: {before} != {after}")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M16-GAP-00279",
|
||||
"operation": "ASSET_MARK_DESKTOP",
|
||||
"fixture": str(fixture),
|
||||
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"poll": poll,
|
||||
"operatorStatus": operator_status,
|
||||
"mainMutation": "NONE",
|
||||
"saveReopen": "EXACT",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("asset-mark-desktop-ok poll=false status=CANCELLED mainMutation=none saveReopen=exact")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"asset-mark-desktop-failed: {error}")
|
||||
raise SystemExit(1)
|
||||
95
tools/web/check-action-asset-mark-single-desktop.py
Normal file
95
tools/web/check-action-asset-mark-single-desktop.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
OBJECT_NAME = "WebGapAssetMarkSingleObject"
|
||||
|
||||
|
||||
def object_state():
|
||||
obj = bpy.data.objects.get(OBJECT_NAME)
|
||||
if obj is None:
|
||||
raise RuntimeError("asset mark single fixture object is missing")
|
||||
metadata = obj.asset_data
|
||||
return {
|
||||
"name": obj.name,
|
||||
"assetMarked": metadata is not None,
|
||||
"author": metadata.author if metadata is not None else "",
|
||||
"description": metadata.description if metadata is not None else "",
|
||||
"tags": sorted(tag.name for tag in metadata.tags) if metadata is not None else [],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --factory-startup --python "
|
||||
"check-action-asset-mark-single-desktop.py -- FIXTURE REPORT"
|
||||
)
|
||||
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
|
||||
output.unlink(missing_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
|
||||
before = object_state()
|
||||
accepted_errors = (
|
||||
"No data-block selected",
|
||||
"context is incorrect",
|
||||
"supports asset operations",
|
||||
)
|
||||
try:
|
||||
poll = bool(bpy.ops.asset.mark_single.poll())
|
||||
except RuntimeError as error:
|
||||
if not any(token in str(error) for token in accepted_errors):
|
||||
raise
|
||||
poll = False
|
||||
if poll:
|
||||
raise RuntimeError("asset.mark_single unexpectedly polled true without an editor ID context")
|
||||
try:
|
||||
bpy.ops.asset.mark_single()
|
||||
except RuntimeError as error:
|
||||
if not any(token in str(error) for token in accepted_errors):
|
||||
raise
|
||||
operator_status = "CANCELLED"
|
||||
else:
|
||||
raise RuntimeError("asset.mark_single unexpectedly finished without an editor ID context")
|
||||
after_cancel = object_state()
|
||||
if after_cancel != before:
|
||||
raise RuntimeError(f"asset.mark_single cancellation changed Main data: {before} != {after_cancel}")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="m16-asset-mark-single-reopen-", suffix=".blend", dir=fixture.parent
|
||||
) as temporary:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=temporary.name, check_existing=False, compress=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=temporary.name, load_ui=False)
|
||||
after = object_state()
|
||||
if after != before:
|
||||
raise RuntimeError(f"asset.mark_single save/reopen drift: {before} != {after}")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M16-GAP-00280",
|
||||
"operation": "ASSET_MARK_SINGLE_DESKTOP",
|
||||
"fixture": str(fixture),
|
||||
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"poll": poll,
|
||||
"operatorStatus": operator_status,
|
||||
"mainMutation": "NONE",
|
||||
"saveReopen": "EXACT",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("asset-mark-single-desktop-ok poll=false status=CANCELLED mainMutation=none saveReopen=exact")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"asset-mark-single-desktop-failed: {error}")
|
||||
raise SystemExit(1)
|
||||
76
tools/web/check-action-open-containing-blend-file-desktop.py
Normal file
76
tools/web/check-action-open-containing-blend-file-desktop.py
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def library_state():
|
||||
libraries = list(bpy.data.libraries)
|
||||
if len(libraries) != 1:
|
||||
raise RuntimeError(f"expected one open-containing library, found {len(libraries)}")
|
||||
library = libraries[0]
|
||||
return {"name": library.name, "filepath": library.filepath, "packed": library.packed_file is not None}
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit("usage: blender -b --python check-action-open-containing-blend-file-desktop.py -- FIXTURE REPORT")
|
||||
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
|
||||
output.unlink(missing_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
|
||||
before = library_state()
|
||||
try:
|
||||
poll = bool(bpy.ops.asset.open_containing_blend_file.poll())
|
||||
except RuntimeError as error:
|
||||
if "context is incorrect" not in str(error) and "No asset selected" not in str(error):
|
||||
raise
|
||||
poll = False
|
||||
if poll:
|
||||
raise RuntimeError("asset.open_containing_blend_file unexpectedly polled true")
|
||||
try:
|
||||
bpy.ops.asset.open_containing_blend_file()
|
||||
except RuntimeError as error:
|
||||
if "context is incorrect" not in str(error) and "No asset selected" not in str(error):
|
||||
raise
|
||||
operator_status = "CANCELLED"
|
||||
else:
|
||||
raise RuntimeError("asset.open_containing_blend_file unexpectedly finished")
|
||||
after_cancel = library_state()
|
||||
if after_cancel != before:
|
||||
raise RuntimeError(f"open-containing cancellation changed Main data: {before} != {after_cancel}")
|
||||
with tempfile.NamedTemporaryFile(prefix="m16-open-containing-reopen-", suffix=".blend", dir=fixture.parent) as temporary:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=temporary.name, check_existing=False, compress=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=temporary.name, load_ui=False)
|
||||
after = library_state()
|
||||
if after != before:
|
||||
raise RuntimeError(f"open-containing save/reopen drift: {before} != {after}")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M16-GAP-00281",
|
||||
"operation": "ASSET_OPEN_CONTAINING_BLEND_FILE_DESKTOP",
|
||||
"fixture": str(fixture),
|
||||
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"poll": poll,
|
||||
"operatorStatus": operator_status,
|
||||
"mainMutation": "NONE",
|
||||
"saveReopen": "EXACT",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("asset-open-containing-desktop-ok poll=false status=CANCELLED mainMutation=none saveReopen=exact")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"asset-open-containing-desktop-failed: {error}")
|
||||
raise SystemExit(1)
|
||||
24
tools/web/check-corrective-baseline.mjs
Normal file
24
tools/web/check-corrective-baseline.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
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)), "../..");
|
||||
function arg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
const baselinePath = arg("--baseline");
|
||||
const sourceHashesPath = arg("--source-hashes");
|
||||
if (!baselinePath || !sourceHashesPath) throw new Error("usage: node tools/web/check-corrective-baseline.mjs --baseline path --source-hashes path");
|
||||
const baseline = JSON.parse(fs.readFileSync(path.resolve(root, baselinePath), "utf8"));
|
||||
const expected = Object.fromEntries(fs.readFileSync(path.resolve(root, sourceHashesPath), "utf8").trim().split("\n").filter(Boolean).map((line) => {
|
||||
const [sha256, relative] = line.trim().split(/\s+/, 2);
|
||||
return [relative, sha256];
|
||||
}));
|
||||
assert.deepEqual(baseline.sourceHashes, expected, "baseline source hash map differs from source-hashes.txt");
|
||||
assert.equal(baseline.invariants.queueMutationAllowed, false);
|
||||
assert.equal(baseline.invariants.parentPointerMatchesCurrent, true);
|
||||
assert.equal(baseline.invariants.exactlyOneCatalogActive, true);
|
||||
assert.equal(baseline.invariants.catalogActiveMatchesPlan, true);
|
||||
process.stdout.write(`corrective-baseline-check-ok task=${baseline.task} sources=${Object.keys(expected).length} queueMutation=false\n`);
|
||||
92
tools/web/check-corrective-plan.mjs
Normal file
92
tools/web/check-corrective-plan.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");
|
||||
const json = (relative) => JSON.parse(read(relative));
|
||||
const exists = (relative) => fs.existsSync(path.join(root, relative));
|
||||
|
||||
const planPath = "docs/BLENDER_WASM_CORRECTIVE_TASK_PLAN.json";
|
||||
const matrixPath = "docs/BLENDER_WASM_CAPABILITY_MATRIX_TEMPLATE.json";
|
||||
const plan = json(planPath);
|
||||
const matrix = json(matrixPath);
|
||||
const queue = read("docs/EXECUTION_QUEUE.md");
|
||||
const handoff = read("nextTask1.md");
|
||||
const currentTask = queue.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
||||
const parentPath = queue.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
const parent = json(parentPath);
|
||||
const currentCorrectiveTask = handoff.match(/current corrective task\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
|
||||
function isLegacyCheckpointAncestor(checkpointTask) {
|
||||
if (checkpointTask === currentTask) return true;
|
||||
let cursor = parent;
|
||||
const visited = new Set();
|
||||
while (cursor?.task && !visited.has(cursor.task)) {
|
||||
visited.add(cursor.task);
|
||||
if (cursor.task === checkpointTask) return true;
|
||||
if (!cursor.parentTask) return false;
|
||||
const manifestPath = path.join(root, "tests/golden", cursor.parentTask, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) return false;
|
||||
cursor = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
assert.equal(plan.schemaVersion, 2, "corrective task plan schema must be v2");
|
||||
assert.equal(plan.queuePolicy.mode, "READ_ONLY_UNTIL_C6_003");
|
||||
assert.ok(isLegacyCheckpointAncestor(plan.queuePolicy.currentTask), "corrective queue checkpoint must be current or an ancestor");
|
||||
assert.ok(isLegacyCheckpointAncestor(plan.queuePolicy.parentNextTask), "corrective parent checkpoint must be current or an ancestor");
|
||||
assert.equal(plan.queuePolicy.currentTask, plan.currentCheckpoint.queueCurrentTask);
|
||||
assert.equal(plan.queuePolicy.parentNextTask, plan.currentCheckpoint.queueParentNextTask);
|
||||
assert.notEqual(plan.currentCheckpoint.queueParentNextTask, plan.currentCheckpoint.reservedSuccessor);
|
||||
assert.equal(parent.nextTask, currentTask);
|
||||
assert.ok(currentCorrectiveTask, "nextTask1 must identify the current corrective task");
|
||||
assert.equal(plan.tasks.filter((task) => task.ready === true).map((task) => task.id).join(","), currentCorrectiveTask);
|
||||
assert.equal(plan.tasks.find((task) => task.id === "C0-001").status, "done");
|
||||
assert.equal(plan.tasks.find((task) => task.id === "C0-002").status, "done");
|
||||
assert.ok(["planned", "in_progress", "done"].includes(plan.tasks.find((task) => task.id === "C0-003").status));
|
||||
assert.ok(["planned", "in_progress"].includes(plan.tasks.find((task) => task.id === currentCorrectiveTask).status));
|
||||
|
||||
const taskIds = new Set(plan.tasks.map((task) => task.id));
|
||||
assert.equal(taskIds.size, plan.tasks.length, "corrective task IDs must be unique");
|
||||
const lifecycleStatuses = new Set(plan.taskContract.lifecycleStatuses);
|
||||
for (const task of plan.tasks) {
|
||||
assert.ok(lifecycleStatuses.has(task.status ?? "planned"), `${task.id} lifecycle status is invalid`);
|
||||
if (task.ready === true) assert.ok(["planned", "in_progress"].includes(task.status ?? "planned"), `${task.id} ready task has invalid lifecycle status`);
|
||||
if (task.ready === true || task.phase === "P0") {
|
||||
assert.ok(task.card && exists(task.card), `${task.id} task card is missing`);
|
||||
const card = read(task.card);
|
||||
const cardStatus = card.match(/(?:lifecycleStatus|状态)\s*[::]\s*`?([a-z_]+|done|blocked|planned|in_progress)/iu)?.[1]?.toLowerCase();
|
||||
if (task.status) assert.equal(cardStatus, task.status, `${task.id} card status is stale`);
|
||||
if (task.id === "C6-003") assert.match(card, /queueMutationAllowed\s*[::]\s*`?(?:false|true)/iu, `${task.id} card must declare queue mutation policy`);
|
||||
else assert.match(card, /queueMutationAllowed\s*[::]\s*`?false/iu, `${task.id} card must forbid queue mutation`);
|
||||
}
|
||||
assert.ok(task.gate && plan.gates.some((gate) => gate.id === task.gate), `${task.id} gate is invalid`);
|
||||
for (const dependency of task.dependsOn ?? []) assert.ok(taskIds.has(dependency), `${task.id} dependency is missing: ${dependency}`);
|
||||
assert.equal(task.queueMutationAllowed ?? false, task.id === "C6-003", `${task.id} queue mutation policy is invalid`);
|
||||
}
|
||||
|
||||
const allowedLevels = new Set(matrix.allowedParityLevels);
|
||||
const allowedClasses = new Set(matrix.allowedCompletionClasses);
|
||||
const allowedProvenanceStates = new Set(matrix.allowedProvenanceStates);
|
||||
assert.equal(matrix.schemaVersion, 2, "capability matrix schema must be v2");
|
||||
assert.equal(matrix.queuePolicy.mode, "SHADOW_ONLY");
|
||||
for (const entry of matrix.entries) {
|
||||
assert.ok(allowedLevels.has(entry.parityLevel), `${entry.capabilityId} parity level is invalid`);
|
||||
assert.ok(allowedLevels.has(entry.targetParityLevel), `${entry.capabilityId} target parity level is invalid`);
|
||||
assert.ok(allowedClasses.has(entry.completionClass), `${entry.capabilityId} completion class is invalid`);
|
||||
assert.ok(["planned", "in_progress", "blocked", "done"].includes(entry.lifecycleStatus), `${entry.capabilityId} lifecycle status is invalid`);
|
||||
if (entry.lifecycleStatus !== "done") assert.notEqual(entry.completionClass, "FEATURE_PARITY", `${entry.capabilityId} cannot claim feature parity before done`);
|
||||
if (entry.lifecycleStatus === "planned") assert.equal(entry.parityLevel, "L0", `${entry.capabilityId} planned entry must not claim proven parity`);
|
||||
assert.ok(["PASS", "FAIL", "BLOCKED", "NOT_RUN"].includes(entry.evidence?.status), `${entry.capabilityId} evidence status is invalid`);
|
||||
assert.ok(allowedProvenanceStates.has(entry.evidence?.provenanceState), `${entry.capabilityId} provenance state is invalid`);
|
||||
assert.ok(entry.evidence && Array.isArray(entry.evidence.required), `${entry.capabilityId} evidence contract is missing`);
|
||||
assert.equal(entry.migration?.legacyCompletionPreserved, true, `${entry.capabilityId} must preserve legacy completion records`);
|
||||
}
|
||||
|
||||
assert.match(read("docs/BLENDER_WASM_CORRECTIVE_EXECUTION_RUNBOOK.md"), /C0-001.*capture-corrective-baseline/isu);
|
||||
assert.match(read("docs/BLENDER_WASM_CORRECTIVE_MIGRATION_PLAN.md"), /MIGRATION_SHADOW_ONLY/iu);
|
||||
assert.equal(plan.tasks.find((task) => task.id === currentCorrectiveTask).ready, true);
|
||||
process.stdout.write(`corrective-plan-ok tasks=${plan.tasks.length} capabilities=${matrix.entries.length} queue=${currentTask} parentNext=${parent.nextTask} corrective=${currentCorrectiveTask}\n`);
|
||||
89
tools/web/check-corrective-resign.mjs
Normal file
89
tools/web/check-corrective-resign.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 arg = (name) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; };
|
||||
const staged = path.resolve(root, arg("--staged-root") ?? "tests/golden/corrective/C0-002/staged");
|
||||
const reportPath = path.resolve(root, arg("--report") ?? "tests/golden/corrective/C0-002/resign-check.json");
|
||||
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 check = [];
|
||||
const ok = (name, value, detail = {}) => { assert.ok(value, name); check.push({ name, status: "PASS", ...detail }); };
|
||||
|
||||
const registryPath = path.join(staged, "completed-gap-tasks.candidate.json");
|
||||
const planPath = path.join(staged, "next-task-plan.candidate.json");
|
||||
const indexPath = path.join(staged, "task-index", "task-index.json");
|
||||
const catalogPath = path.join(staged, "task-index", "task-catalog.jsonl");
|
||||
const snapshotPath = path.join(staged, "source-snapshot", "snapshot-manifest.json");
|
||||
const registry = json(registryPath);
|
||||
const plan = json(planPath);
|
||||
const index = json(indexPath);
|
||||
const snapshot = json(snapshotPath);
|
||||
ok("registry-count", registry.length === 273, { value: registry.length });
|
||||
ok("registry-unique", new Set(registry).size === registry.length);
|
||||
ok("plan-shape", plan.tasks.length === 6900 && plan.summary.completed === 273 && plan.summary.active === 1 && plan.firstTask === "M16-GAP-00274");
|
||||
ok("plan-source-hash", plan.sources.completions.sha256 === sha256(registryPath));
|
||||
ok("index-plan-hash", index.source.sha256 === sha256(planPath));
|
||||
ok("index-catalog-hash", index.catalog.sha256 === sha256(catalogPath));
|
||||
ok("index-active", index.activeTask === plan.firstTask);
|
||||
ok("snapshot-nonempty", snapshot.files.length > 0, { value: snapshot.files.length });
|
||||
|
||||
const snapshotByPath = new Map();
|
||||
for (const file of snapshot.files) {
|
||||
const source = path.join(root, file.path);
|
||||
const copy = path.join(root, file.snapshotPath);
|
||||
assert.equal(sha256(source), file.sha256, file.path);
|
||||
assert.equal(sha256(copy), file.sha256, file.snapshotPath);
|
||||
snapshotByPath.set(file.path, file.sha256);
|
||||
}
|
||||
check.push({ name: "snapshot-hashes", status: "PASS", files: snapshot.files.length, bytes: snapshot.files.reduce((sum, file) => sum + file.bytes, 0) });
|
||||
|
||||
const manifestRoot = path.join(staged, "resigned-manifests");
|
||||
const manifestTasks = fs.readdirSync(manifestRoot).filter((entry) => /^M(?:15|16)-/u.test(entry)).sort();
|
||||
const m16Tasks = manifestTasks.filter((entry) => /^M16-GAP-\d{5}$/u.test(entry));
|
||||
ok("resigned-manifest-count", m16Tasks.length === 274, { value: m16Tasks.length });
|
||||
let doneCount = 0;
|
||||
for (const task of m16Tasks) {
|
||||
const manifest = json(path.join(manifestRoot, task, "manifest.json"));
|
||||
if (manifest.status === "done") doneCount += 1;
|
||||
for (const artifact of Object.values(manifest.artifacts ?? {})) {
|
||||
if (!artifact?.path || !artifact?.sha256) continue;
|
||||
assert.equal(snapshotByPath.get(artifact.path), artifact.sha256, `${task}:${artifact.path}`);
|
||||
}
|
||||
}
|
||||
ok("resigned-manifest-done-count", doneCount === 273, { value: doneCount });
|
||||
const rootParent = json(path.join(manifestRoot, "M15-03E", "manifest.json"));
|
||||
ok("root-parent-repaired", rootParent.nextTask === "M16-GAP-00001");
|
||||
|
||||
const contextRoot = path.join(staged, "resigned-context");
|
||||
const contexts = fs.readdirSync(contextRoot).filter((entry) => /^M16-GAP-\d{5}$/u.test(entry)).sort();
|
||||
ok("repaired-context-count", contexts.length === 24, { value: contexts.length });
|
||||
for (const task of contexts) {
|
||||
const context = json(path.join(contextRoot, task, "task-context.json"));
|
||||
assert.equal(context.task, task);
|
||||
assert.equal(context.parentTask, json(path.join(manifestRoot, task, "manifest.json")).parentTask);
|
||||
const parent = json(path.join(manifestRoot, context.parentTask, "manifest.json"));
|
||||
assert.equal(parent.nextTask, task, `${task}:parent-nextTask`);
|
||||
}
|
||||
check.push({ name: "repaired-context-links", status: "PASS", files: contexts.length });
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "corrective-resign-check-"));
|
||||
const regenerated = path.join(tempRoot, "plan.json");
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(root, "tools/web/generate-blender-next-task-plan.mjs"), regenerated, "--completion-path", registryPath], { cwd: root, stdio: "ignore" });
|
||||
ok("candidate-plan-deterministic", fs.readFileSync(regenerated).equals(fs.readFileSync(planPath)));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const result = { schemaVersion: 1, operation: "CORRECTIVE_STATE_RECONCILIATION_RESIGN_CHECK", status: "PASS", queueMutation: false, stagedRoot: relative(staged), checks: check, artifactCount: snapshot.files.length, planTaskCount: plan.tasks.length, completedCount: doneCount, activeTask: plan.firstTask };
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
process.stdout.write(`corrective-resign-check-ok files=${snapshot.files.length} manifests=${m16Tasks.length} contexts=${contexts.length} active=${plan.firstTask}\n`);
|
||||
141
tools/web/check-execution-control-plane.mjs
Normal file
141
tools/web/check-execution-control-plane.mjs
Normal file
@@ -0,0 +1,141 @@
|
||||
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 allowUnrunnable = process.argv.includes("--allow-unrunnable");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8");
|
||||
const json = (relativePath) => JSON.parse(read(relativePath));
|
||||
const sha256 = (bytes) => {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
};
|
||||
|
||||
function parseQueue() {
|
||||
const source = read("docs/EXECUTION_QUEUE.md");
|
||||
const currentTask = source.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
||||
const parentManifest = source.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
const coverageTask = source.match(/\|\s*legacy coverage checkpoint\s*\|\s*`([^`]+)`/iu)?.[1] ?? currentTask;
|
||||
const coverageParentManifest = source.match(/\|\s*legacy coverage parent\s*\|\s*`([^`]+)`/iu)?.[1] ?? (currentTask.startsWith("M") ? parentManifest : null);
|
||||
if (!currentTask || !parentManifest || !coverageTask || !coverageParentManifest) throw new Error("queue pointer is incomplete");
|
||||
return { currentTask, parentManifest, coverageTask, coverageParentManifest };
|
||||
}
|
||||
|
||||
function addIssue(issues, code, detail) {
|
||||
issues.push({ code, detail });
|
||||
}
|
||||
|
||||
const issues = [];
|
||||
let queue = null;
|
||||
let parent = null;
|
||||
let index = null;
|
||||
let catalog = [];
|
||||
let plan = null;
|
||||
let matrix = null;
|
||||
let completed = [];
|
||||
|
||||
try {
|
||||
queue = parseQueue();
|
||||
const parentPath = path.join(root, queue.parentManifest);
|
||||
if (!fs.existsSync(parentPath)) addIssue(issues, "PARENT_MANIFEST_MISSING", queue.parentManifest);
|
||||
else {
|
||||
parent = JSON.parse(fs.readFileSync(parentPath, "utf8"));
|
||||
if (parent.nextTask !== queue.currentTask) addIssue(issues, "PARENT_NEXT_TASK_MISMATCH", `${parent.nextTask ?? "NONE"}!=${queue.currentTask}`);
|
||||
}
|
||||
|
||||
const capabilityIndex = queue.currentTask.startsWith("WBV2-");
|
||||
index = json(capabilityIndex ? "tests/golden/WBV2/task-index.json" : "tests/golden/M15-03A/task-index.json");
|
||||
if (index.activeTask !== queue.currentTask) addIssue(issues, "INDEX_ACTIVE_TASK_MISMATCH", `${index.activeTask}!=${queue.currentTask}`);
|
||||
const catalogPath = path.join(root, index.catalog.path);
|
||||
if (!fs.existsSync(catalogPath)) addIssue(issues, "CATALOG_MISSING", index.catalog.path);
|
||||
else {
|
||||
catalog = fs.readFileSync(catalogPath, "utf8").trimEnd().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
||||
const active = catalog.filter((entry) => entry.state === "active");
|
||||
if (active.length !== 1) addIssue(issues, "ACTIVE_TASK_COUNT", `count=${active.length}`);
|
||||
else if (active[0].id !== queue.currentTask) addIssue(issues, "CATALOG_ACTIVE_TASK_MISMATCH", `${active[0].id}!=${queue.currentTask}`);
|
||||
}
|
||||
|
||||
plan = json("tests/golden/M15-03A/next-task-plan.json");
|
||||
matrix = json("docs/BLENDER_WASM_CAPABILITY_MATRIX_TEMPLATE.json");
|
||||
completed = json("tests/golden/M15-03A/completed-gap-tasks.json");
|
||||
} catch (error) {
|
||||
addIssue(issues, "CONTROL_PLANE_READ_ERROR", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const active = catalog.find((entry) => entry.state === "active") ?? null;
|
||||
let legacyCoverage = null;
|
||||
try {
|
||||
const legacyIndex = json("tests/golden/M15-03A/task-index.json");
|
||||
const legacyCatalog = fs.readFileSync(path.join(root, legacyIndex.catalog.path), "utf8").trimEnd().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
||||
legacyCoverage = legacyCatalog.find((entry) => entry.id === queue.coverageTask) ?? null;
|
||||
if (!legacyCoverage) addIssue(issues, "LEGACY_COVERAGE_TASK_MISSING", queue.coverageTask);
|
||||
} catch (error) {
|
||||
addIssue(issues, "LEGACY_COVERAGE_INDEX_ERROR", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const legacySummary = plan?.summary ?? {};
|
||||
const capabilities = matrix?.entries ?? [];
|
||||
const mappedGapIds = [...new Set(capabilities.flatMap((entry) => entry.sourceGaps ?? []))];
|
||||
const capabilityClusters = Object.fromEntries(capabilities.map((entry) => [entry.family, {
|
||||
capabilityId: entry.capabilityId,
|
||||
sourceGaps: (entry.sourceGaps ?? []).length,
|
||||
parityLevel: entry.parityLevel,
|
||||
targetParityLevel: entry.targetParityLevel,
|
||||
lifecycleStatus: entry.lifecycleStatus,
|
||||
}]));
|
||||
const runnableInputs = active ? {
|
||||
taskCard: fs.existsSync(path.join(root, "docs/tasks", `${active.id}.md`)),
|
||||
capabilityTask: active.id.startsWith("WBV2-"),
|
||||
fixture: active.id.startsWith("WBV2-") ? null : fs.existsSync(path.join(root, "tests/files/web/generated", `${active.id}-${active.gapId.replace(/[^A-Za-z0-9_.-]+/gu, "-")}.blend`)),
|
||||
generator: active.id.startsWith("WBV2-") ? null : fs.existsSync(path.join(root, "tools/web/generated", `${active.id}.py`)),
|
||||
} : null;
|
||||
if (!allowUnrunnable && runnableInputs && (!runnableInputs.taskCard || (!runnableInputs.capabilityTask && (!runnableInputs.fixture || !runnableInputs.generator)))) {
|
||||
addIssue(issues, "ACTIVE_TASK_NOT_RUNNABLE", JSON.stringify(runnableInputs));
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
operation: "BLENDER_WEB_EXECUTION_CONTROL_PLANE",
|
||||
status: issues.length === 0 ? "PASS" : "BLOCKED",
|
||||
authority: {
|
||||
executionPointer: "docs/EXECUTION_QUEUE.md",
|
||||
productCapabilities: "docs/BLENDER_WASM_CAPABILITY_MATRIX_TEMPLATE.json",
|
||||
legacyCoverage: "tests/golden/M15-03A/next-task-plan.json",
|
||||
historicalEvidence: "tests/golden/corrective/",
|
||||
},
|
||||
queue: queue ? {
|
||||
currentTask: queue.currentTask,
|
||||
parentManifest: queue.parentManifest,
|
||||
parentTask: parent?.task ?? null,
|
||||
parentNextTask: parent?.nextTask ?? null,
|
||||
activeCatalogTask: active?.id ?? null,
|
||||
legacyCoverageTask: queue.coverageTask,
|
||||
legacyCoverageParentManifest: queue.coverageParentManifest,
|
||||
legacyCoverageState: legacyCoverage?.state ?? null,
|
||||
runnableInputs,
|
||||
} : null,
|
||||
efficiency: {
|
||||
legacyGapTasks: legacySummary.taskCount ?? catalog.length,
|
||||
legacyCompleted: completed.length,
|
||||
legacyPending: legacySummary.pending ?? null,
|
||||
capabilityClusters: capabilities.length,
|
||||
mappedLegacyGaps: mappedGapIds.length,
|
||||
averageGapsPerCapability: capabilities.length === 0 ? 0 : Number((mappedGapIds.length / capabilities.length).toFixed(2)),
|
||||
productiveLane: capabilities.filter((entry) => entry.lifecycleStatus === "done" && ["L2", "L3", "L4", "L5"].includes(entry.parityLevel)).length,
|
||||
rule: "one capability task may close multiple legacy cases; legacy cases remain regression coverage",
|
||||
},
|
||||
capabilityClusters,
|
||||
issues,
|
||||
remediation: issues.map(({ code }) => ({
|
||||
PARENT_NEXT_TASK_MISMATCH: "repair generated queue state from the completion registry",
|
||||
INDEX_ACTIVE_TASK_MISMATCH: "run generate-task-index.mjs after regenerating the plan",
|
||||
CATALOG_ACTIVE_TASK_MISMATCH: "run generate-task-index.mjs; do not edit catalog offsets",
|
||||
ACTIVE_TASK_COUNT: "leave exactly one active task in the generated catalog",
|
||||
ACTIVE_TASK_NOT_RUNNABLE: "generate the task card and fixture/generator before Blender/WASM startup",
|
||||
}[code] ?? "inspect the control-plane issue and preserve its evidence")),
|
||||
allowUnrunnable,
|
||||
inputDigest: sha256(JSON.stringify({ queue, parent: parent?.nextTask, active: active?.id, completed: completed.length, capabilities: capabilities.length })),
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
if (issues.length > 0) process.exitCode = 2;
|
||||
@@ -50,8 +50,11 @@ try {
|
||||
if (context.context.task !== task) add("TASK_CONTEXT_MISMATCH", `${context.context.task}!=${task}`);
|
||||
}
|
||||
|
||||
for (const issue of collectGeneratedGapPreflight({ root: repoRoot, task: requestedTask ?? queue.currentTask, entry })) {
|
||||
add(issue.code, issue.detail);
|
||||
const checkedTask = requestedTask ?? queue.currentTask;
|
||||
if (/^M\d+-GAP-\d{5}$/u.test(checkedTask)) {
|
||||
for (const issue of collectGeneratedGapPreflight({ root: repoRoot, task: checkedTask, entry })) {
|
||||
add(issue.code, issue.detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { collectGeneratedGapPreflight, formatGeneratedGapPreflight } from "./gen
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const taskArgument = process.argv.indexOf("--task");
|
||||
const task = taskArgument >= 0 ? process.argv[taskArgument + 1] : undefined;
|
||||
verifyTaskIndex();
|
||||
verifyTaskIndex(task);
|
||||
const entry = readIndexedTask(task);
|
||||
const preflightIssues = collectGeneratedGapPreflight({ root, task, entry });
|
||||
if (preflightIssues.length !== 0) {
|
||||
@@ -8204,6 +8204,85 @@ if (task === "M16-GAP-00275") {
|
||||
const report = { schemaVersion: 1, task, operation: "ASSET_CLEAR_LOCAL_EXACT", fixture: { path: path.relative(root, fixture).replaceAll(path.sep, "/"), sha256: desktop.fixtureSha256 }, desktop: { status: "EXACT", report: path.relative(root, desktopPath).replaceAll(path.sep, "/"), poll: desktop.poll, operatorStatus: desktop.operatorStatus, mainMutation: desktop.mainMutation }, wasm: { status: "EXACT", libraryId: library.id, sourcePath: library.sourcePath, libraryStatus: library.status, dependencyIds: library.dependencyIds }, saveReopen: "EXACT", negative: { malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION" }, nextTask: "M16-GAP-00276" };
|
||||
const reportPath = path.join(root, "tests/golden/M16-GAP-00275/asset-clear-local-exact-report.json"); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`generated-gap-ok task=${task} library=${library.id} status=${library.status} poll=false operator=${desktop.operatorStatus} mainMutation=none desktop=exact saveReopen=exact next=${report.nextTask}\n`); process.exit(0);
|
||||
}
|
||||
if (task === "M16-GAP-00276") {
|
||||
const fixture = path.join(root, "tests/files/web/generated/M16-GAP-00276-operator-asset.clear_single.blend");
|
||||
const desktopPath = path.join(root, "tests/golden/M16-GAP-00276/asset-clear-single-desktop-report.json"); fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
|
||||
const run = spawnSync(process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"), ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-action-asset-clear-single-desktop.py"), "--", fixture, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
|
||||
const desktop = JSON.parse(fs.readFileSync(desktopPath, "utf8")); assert.equal(desktop.saveReopen, "EXACT"); assert.equal(desktop.poll, false); assert.equal(desktop.operatorStatus, "CANCELLED"); assert.equal(desktop.mainMutation, "NONE"); assert.deepEqual(desktop.after, desktop.before);
|
||||
const engine = await factory({ wasmBinary }); const handle = engine._web_engine_create(); open(engine, handle, fs.readFileSync(fixture)); const before = snapshot(engine, handle); assert.equal(before.libraryStatus, "AVAILABLE"); assert.equal(before.libraries.length, 1); const library = before.libraries[0];
|
||||
assert.equal(library.id, `library:${desktop.before.name}`); assert.equal(library.name, desktop.before.name); assert.equal(library.sourcePath, "//WebGapAssetClearSingle.blend"); assert.equal(library.packed, false); assert.equal(library.status, "EXTERNAL_REQUIRED"); assert.equal(library.errorCode, "LINKED_LIBRARY_RESOURCE_REQUIRED"); assert.deepEqual(library.dependencyIds, []); assert.equal(library.readOnly, true);
|
||||
const saved = output(engine, handle, engine._web_engine_save_blend, true); const reopened = engine._web_engine_create(); open(engine, reopened, saved); const after = snapshot(engine, reopened); assert.deepEqual(after.libraries, before.libraries); assert.equal(after.libraryStatus, before.libraryStatus); const malformed = new Uint8Array([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]); const pointer = engine._malloc(malformed.byteLength); let result; try { engine.HEAPU8.set(malformed, pointer); result = engine._web_engine_open_blend(handle, pointer, malformed.byteLength); } finally { engine._free(pointer); } assert.notEqual(result, 0); assert.deepEqual(snapshot(engine, handle).libraries, before.libraries); engine._web_engine_destroy(handle); engine._web_engine_destroy(reopened);
|
||||
const report = { schemaVersion: 1, task, operation: "ASSET_CLEAR_SINGLE_LOCAL_EXACT", fixture: { path: path.relative(root, fixture).replaceAll(path.sep, "/"), sha256: desktop.fixtureSha256 }, desktop: { status: "EXACT", report: path.relative(root, desktopPath).replaceAll(path.sep, "/"), poll: desktop.poll, operatorStatus: desktop.operatorStatus, mainMutation: desktop.mainMutation }, wasm: { status: "EXACT", libraryId: library.id, sourcePath: library.sourcePath, libraryStatus: library.status, dependencyIds: library.dependencyIds }, saveReopen: "EXACT", negative: { malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION" }, nextTask: "M16-GAP-00277" };
|
||||
const reportPath = path.join(root, "tests/golden/M16-GAP-00276/asset-clear-single-local-exact-report.json"); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`generated-gap-ok task=${task} library=${library.id} status=${library.status} poll=false operator=${desktop.operatorStatus} mainMutation=none desktop=exact saveReopen=exact next=${report.nextTask}\n`); process.exit(0);
|
||||
}
|
||||
if (task === "M16-GAP-00277") {
|
||||
const fixture = path.join(root, "tests/files/web/generated/M16-GAP-00277-operator-asset.library_refresh.blend");
|
||||
const desktopPath = path.join(root, "tests/golden/M16-GAP-00277/asset-library-refresh-desktop-report.json"); fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
|
||||
const run = spawnSync(process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"), ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-action-asset-library-refresh-desktop.py"), "--", fixture, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
|
||||
const desktop = JSON.parse(fs.readFileSync(desktopPath, "utf8")); assert.equal(desktop.saveReopen, "EXACT"); assert.equal(desktop.poll, false); assert.equal(desktop.operatorStatus, "CANCELLED"); assert.equal(desktop.mainMutation, "NONE"); assert.deepEqual(desktop.after, desktop.before);
|
||||
const engine = await factory({ wasmBinary }); const handle = engine._web_engine_create(); open(engine, handle, fs.readFileSync(fixture)); const before = snapshot(engine, handle); assert.equal(before.libraryStatus, "AVAILABLE"); assert.equal(before.libraries.length, 1); const library = before.libraries[0];
|
||||
assert.equal(library.id, `library:${desktop.before.name}`); assert.equal(library.name, desktop.before.name); assert.equal(library.sourcePath, "//WebGapAssetLibraryRefresh.blend"); assert.equal(library.packed, false); assert.equal(library.status, "EXTERNAL_REQUIRED"); assert.equal(library.errorCode, "LINKED_LIBRARY_RESOURCE_REQUIRED"); assert.deepEqual(library.dependencyIds, []); assert.equal(library.readOnly, true);
|
||||
const saved = output(engine, handle, engine._web_engine_save_blend, true); const reopened = engine._web_engine_create(); open(engine, reopened, saved); const after = snapshot(engine, reopened); assert.deepEqual(after.libraries, before.libraries); assert.equal(after.libraryStatus, before.libraryStatus); const malformed = new Uint8Array([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]); const pointer = engine._malloc(malformed.byteLength); let result; try { engine.HEAPU8.set(malformed, pointer); result = engine._web_engine_open_blend(handle, pointer, malformed.byteLength); } finally { engine._free(pointer); } assert.notEqual(result, 0); assert.deepEqual(snapshot(engine, handle).libraries, before.libraries); engine._web_engine_destroy(handle); engine._web_engine_destroy(reopened);
|
||||
const report = { schemaVersion: 1, task, operation: "ASSET_LIBRARY_REFRESH_LOCAL_EXACT", fixture: { path: path.relative(root, fixture).replaceAll(path.sep, "/"), sha256: desktop.fixtureSha256 }, desktop: { status: "EXACT", report: path.relative(root, desktopPath).replaceAll(path.sep, "/"), poll: desktop.poll, operatorStatus: desktop.operatorStatus, mainMutation: desktop.mainMutation }, wasm: { status: "EXACT", libraryId: library.id, sourcePath: library.sourcePath, libraryStatus: library.status, dependencyIds: library.dependencyIds }, saveReopen: "EXACT", negative: { malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION" }, nextTask: "M16-GAP-00278" };
|
||||
const reportPath = path.join(root, "tests/golden/M16-GAP-00277/asset-library-refresh-local-exact-report.json"); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`generated-gap-ok task=${task} library=${library.id} status=${library.status} poll=false operator=${desktop.operatorStatus} mainMutation=none desktop=exact saveReopen=exact next=${report.nextTask}\n`); process.exit(0);
|
||||
}
|
||||
if (task === "M16-GAP-00278") {
|
||||
const fixture = path.join(root, "tests/files/web/generated/M16-GAP-00278-operator-asset.library_reload_listing.blend");
|
||||
const desktopPath = path.join(root, "tests/golden/M16-GAP-00278/asset-library-reload-listing-desktop-report.json"); fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
|
||||
const run = spawnSync(process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"), ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-action-asset-library-reload-listing-desktop.py"), "--", fixture, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
|
||||
const desktop = JSON.parse(fs.readFileSync(desktopPath, "utf8")); assert.equal(desktop.saveReopen, "EXACT"); assert.equal(desktop.poll, false); assert.equal(desktop.operatorStatus, "CANCELLED"); assert.equal(desktop.mainMutation, "NONE"); assert.deepEqual(desktop.after, desktop.before);
|
||||
const engine = await factory({ wasmBinary }); const handle = engine._web_engine_create(); open(engine, handle, fs.readFileSync(fixture)); const before = snapshot(engine, handle); assert.equal(before.libraryStatus, "AVAILABLE"); assert.equal(before.libraries.length, 1); const library = before.libraries[0];
|
||||
assert.equal(library.id, `library:${desktop.before.name}`); assert.equal(library.name, desktop.before.name); assert.equal(library.sourcePath, "//WebGapAssetLibraryReloadListing.blend"); assert.equal(library.packed, false); assert.equal(library.status, "EXTERNAL_REQUIRED"); assert.equal(library.errorCode, "LINKED_LIBRARY_RESOURCE_REQUIRED"); assert.deepEqual(library.dependencyIds, []); assert.equal(library.readOnly, true);
|
||||
const saved = output(engine, handle, engine._web_engine_save_blend, true); const reopened = engine._web_engine_create(); open(engine, reopened, saved); const after = snapshot(engine, reopened); assert.deepEqual(after.libraries, before.libraries); assert.equal(after.libraryStatus, before.libraryStatus); const malformed = new Uint8Array([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]); const pointer = engine._malloc(malformed.byteLength); let result; try { engine.HEAPU8.set(malformed, pointer); result = engine._web_engine_open_blend(handle, pointer, malformed.byteLength); } finally { engine._free(pointer); } assert.notEqual(result, 0); assert.deepEqual(snapshot(engine, handle).libraries, before.libraries); engine._web_engine_destroy(handle); engine._web_engine_destroy(reopened);
|
||||
const report = { schemaVersion: 1, task, operation: "ASSET_LIBRARY_RELOAD_LISTING_LOCAL_EXACT", fixture: { path: path.relative(root, fixture).replaceAll(path.sep, "/"), sha256: desktop.fixtureSha256 }, desktop: { status: "EXACT", report: path.relative(root, desktopPath).replaceAll(path.sep, "/"), poll: desktop.poll, operatorStatus: desktop.operatorStatus, mainMutation: desktop.mainMutation }, wasm: { status: "EXACT", libraryId: library.id, sourcePath: library.sourcePath, libraryStatus: library.status, dependencyIds: library.dependencyIds }, saveReopen: "EXACT", negative: { malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION" }, nextTask: "M16-GAP-00279" };
|
||||
const reportPath = path.join(root, "tests/golden/M16-GAP-00278/asset-library-reload-listing-local-exact-report.json"); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`generated-gap-ok task=${task} library=${library.id} status=${library.status} poll=false operator=${desktop.operatorStatus} mainMutation=none desktop=exact saveReopen=exact next=${report.nextTask}\n`); process.exit(0);
|
||||
}
|
||||
if (task === "M16-GAP-00279") {
|
||||
const fixture = path.join(root, "tests/files/web/generated/M16-GAP-00279-operator-asset.mark.blend");
|
||||
const desktopPath = path.join(root, "tests/golden/M16-GAP-00279/asset-mark-desktop-report.json");
|
||||
fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
|
||||
const run = spawnSync(process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"), ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-action-asset-mark-desktop.py"), "--", fixture, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
|
||||
const desktop = JSON.parse(fs.readFileSync(desktopPath, "utf8"));
|
||||
assert.equal(desktop.saveReopen, "EXACT"); assert.equal(desktop.poll, false); assert.equal(desktop.operatorStatus, "CANCELLED"); assert.equal(desktop.mainMutation, "NONE"); assert.deepEqual(desktop.after, desktop.before); assert.equal(desktop.before.assetMarked, true);
|
||||
const engine = await factory({ wasmBinary }); const handle = engine._web_engine_create(); open(engine, handle, fs.readFileSync(fixture)); const before = snapshot(engine, handle); const object = before.nodes?.find((value) => value.id === "object:WebGapAssetMarkObject"); assert.ok(object);
|
||||
assert.equal(object.assetData?.marked, true); assert.equal(object.assetData?.author, desktop.before.author); assert.equal(object.assetData?.description, desktop.before.description); assert.deepEqual(object.assetData?.tags, desktop.before.tags);
|
||||
const saved = output(engine, handle, engine._web_engine_save_blend, true); const reopened = engine._web_engine_create(); open(engine, reopened, saved); const after = snapshot(engine, reopened).nodes?.find((value) => value.id === object.id); assert.deepEqual(after, object);
|
||||
const malformed = new Uint8Array([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]); const pointer = engine._malloc(malformed.byteLength); let result; try { engine.HEAPU8.set(malformed, pointer); result = engine._web_engine_open_blend(handle, pointer, malformed.byteLength); } finally { engine._free(pointer); } assert.notEqual(result, 0); assert.deepEqual(snapshot(engine, handle).nodes?.find((value) => value.id === object.id), object); engine._web_engine_destroy(handle); engine._web_engine_destroy(reopened);
|
||||
const report = { schemaVersion: 1, task, operation: "ASSET_MARK_LOCAL_EXACT", fixture: { path: path.relative(root, fixture).replaceAll(path.sep, "/"), sha256: desktop.fixtureSha256 }, desktop: { status: "EXACT", report: path.relative(root, desktopPath).replaceAll(path.sep, "/"), poll: desktop.poll, operatorStatus: desktop.operatorStatus, mainMutation: desktop.mainMutation }, wasm: { status: "EXACT", objectId: object.id, assetData: object.assetData }, saveReopen: "EXACT", negative: { malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION" }, nextTask: "M16-GAP-00280" };
|
||||
const reportPath = path.join(root, "tests/golden/M16-GAP-00279/asset-mark-local-exact-report.json"); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`generated-gap-ok task=${task} object=${object.id} marked=true operator=${desktop.operatorStatus} mainMutation=none desktop=exact saveReopen=exact next=${report.nextTask}\n`); process.exit(0);
|
||||
}
|
||||
if (task === "M16-GAP-00280") {
|
||||
const fixture = path.join(root, "tests/files/web/generated/M16-GAP-00280-operator-asset.mark_single.blend");
|
||||
const desktopPath = path.join(root, "tests/golden/M16-GAP-00280/asset-mark-single-desktop-report.json");
|
||||
fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
|
||||
const run = spawnSync(process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"), ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-action-asset-mark-single-desktop.py"), "--", fixture, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
|
||||
const desktop = JSON.parse(fs.readFileSync(desktopPath, "utf8"));
|
||||
assert.equal(desktop.saveReopen, "EXACT"); assert.equal(desktop.poll, false); assert.equal(desktop.operatorStatus, "CANCELLED"); assert.equal(desktop.mainMutation, "NONE"); assert.deepEqual(desktop.after, desktop.before); assert.equal(desktop.before.assetMarked, true);
|
||||
const engine = await factory({ wasmBinary }); const handle = engine._web_engine_create(); open(engine, handle, fs.readFileSync(fixture)); const before = snapshot(engine, handle); const object = before.nodes?.find((value) => value.id === "object:WebGapAssetMarkSingleObject"); assert.ok(object);
|
||||
assert.equal(object.assetData?.marked, true); assert.equal(object.assetData?.author, desktop.before.author); assert.equal(object.assetData?.description, desktop.before.description); assert.deepEqual(object.assetData?.tags, desktop.before.tags);
|
||||
const saved = output(engine, handle, engine._web_engine_save_blend, true); const reopened = engine._web_engine_create(); open(engine, reopened, saved); const after = snapshot(engine, reopened).nodes?.find((value) => value.id === object.id); assert.deepEqual(after, object);
|
||||
const malformed = new Uint8Array([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]); const pointer = engine._malloc(malformed.byteLength); let result; try { engine.HEAPU8.set(malformed, pointer); result = engine._web_engine_open_blend(handle, pointer, malformed.byteLength); } finally { engine._free(pointer); } assert.notEqual(result, 0); assert.deepEqual(snapshot(engine, handle).nodes?.find((value) => value.id === object.id), object); engine._web_engine_destroy(handle); engine._web_engine_destroy(reopened);
|
||||
const report = { schemaVersion: 1, task, operation: "ASSET_MARK_SINGLE_LOCAL_EXACT", fixture: { path: path.relative(root, fixture).replaceAll(path.sep, "/"), sha256: desktop.fixtureSha256 }, desktop: { status: "EXACT", report: path.relative(root, desktopPath).replaceAll(path.sep, "/"), poll: desktop.poll, operatorStatus: desktop.operatorStatus, mainMutation: desktop.mainMutation }, wasm: { status: "EXACT", objectId: object.id, assetData: object.assetData }, saveReopen: "EXACT", negative: { malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION" }, nextTask: "M16-GAP-00281" };
|
||||
const reportPath = path.join(root, "tests/golden/M16-GAP-00280/asset-mark-single-local-exact-report.json"); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`generated-gap-ok task=${task} object=${object.id} marked=true operator=${desktop.operatorStatus} mainMutation=none desktop=exact saveReopen=exact next=${report.nextTask}\n`); process.exit(0);
|
||||
}
|
||||
if (task === "M16-GAP-00281") {
|
||||
const fixture = path.join(root, "tests/files/web/generated/M16-GAP-00281-operator-asset.open_containing_blend_file.blend");
|
||||
const desktopPath = path.join(root, "tests/golden/M16-GAP-00281/open-containing-desktop-report.json");
|
||||
fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
|
||||
const run = spawnSync(process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"), ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-action-open-containing-blend-file-desktop.py"), "--", fixture, desktopPath], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
|
||||
const desktop = JSON.parse(fs.readFileSync(desktopPath, "utf8"));
|
||||
assert.equal(desktop.saveReopen, "EXACT"); assert.equal(desktop.poll, false); assert.equal(desktop.operatorStatus, "CANCELLED"); assert.equal(desktop.mainMutation, "NONE"); assert.deepEqual(desktop.after, desktop.before);
|
||||
const engine = await factory({ wasmBinary }); const handle = engine._web_engine_create(); open(engine, handle, fs.readFileSync(fixture)); const before = snapshot(engine, handle);
|
||||
assert.equal(before.libraryStatus, "AVAILABLE"); assert.ok(Array.isArray(before.libraries)); assert.equal(before.libraries.length, 1); const library = before.libraries[0];
|
||||
assert.equal(library.id, `library:${desktop.before.name}`); assert.equal(library.name, desktop.before.name); assert.equal(library.sourcePath, "//WebGapOpenContaining.blend"); assert.equal(library.packed, false); assert.equal(library.status, "EXTERNAL_REQUIRED"); assert.equal(library.errorCode, "LINKED_LIBRARY_RESOURCE_REQUIRED"); assert.deepEqual(library.dependencyIds, []); assert.equal(library.readOnly, true);
|
||||
const saved = output(engine, handle, engine._web_engine_save_blend, true); const reopened = engine._web_engine_create(); open(engine, reopened, saved); const after = snapshot(engine, reopened); assert.deepEqual(after.libraries, before.libraries); assert.equal(after.libraryStatus, before.libraryStatus);
|
||||
const malformed = new Uint8Array([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]); const pointer = engine._malloc(malformed.byteLength); let result; try { engine.HEAPU8.set(malformed, pointer); result = engine._web_engine_open_blend(handle, pointer, malformed.byteLength); } finally { engine._free(pointer); } assert.notEqual(result, 0); assert.deepEqual(snapshot(engine, handle).libraries, before.libraries); engine._web_engine_destroy(handle); engine._web_engine_destroy(reopened);
|
||||
const report = { schemaVersion: 1, task, operation: "ASSET_OPEN_CONTAINING_BLEND_FILE_LOCAL_EXACT", fixture: { path: path.relative(root, fixture).replaceAll(path.sep, "/"), sha256: desktop.fixtureSha256 }, desktop: { status: "EXACT", report: path.relative(root, desktopPath).replaceAll(path.sep, "/"), poll: desktop.poll, operatorStatus: desktop.operatorStatus, mainMutation: desktop.mainMutation }, wasm: { status: "EXACT", libraryId: library.id, sourcePath: library.sourcePath, libraryStatus: library.status, dependencyIds: library.dependencyIds }, saveReopen: "EXACT", negative: { malformedBlend: "REJECTED_WITHOUT_MAIN_MUTATION" }, nextTask: "M16-GAP-00282" };
|
||||
const reportPath = path.join(root, "tests/golden/M16-GAP-00281/open-containing-local-exact-report.json"); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`generated-gap-ok task=${task} library=${library.id} status=${library.status} poll=false operator=${desktop.operatorStatus} mainMutation=none desktop=exact saveReopen=exact next=${report.nextTask}\n`); process.exit(0);
|
||||
}
|
||||
if (task === "M16-GAP-00205") {
|
||||
const fixture = path.join(root, "tests/files/web/generated/M16-GAP-00205-operator-anim.separate_slots.blend");
|
||||
const desktopPath = path.join(root, "tests/golden/M16-GAP-00205/anim-separate-slots-desktop-report.json");
|
||||
|
||||
32
tools/web/check-v2-handoff.mjs
Normal file
32
tools/web/check-v2-handoff.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const queue = read("docs/EXECUTION_QUEUE.md");
|
||||
const requested = process.argv.indexOf("--task");
|
||||
const current = requested >= 0 ? process.argv[requested + 1] : queue.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
||||
assert.match(current ?? "", /^WBV2-P\d-\d{3}$/u);
|
||||
const currentCard = `docs/tasks/${current}.md`;
|
||||
assert.ok(fs.existsSync(path.join(root, currentCard)), `${currentCard} must exist`);
|
||||
const parentPath = requested >= 0
|
||||
? `tests/golden/${JSON.parse(read(`tests/golden/${current}/manifest.json`)).parentTask}/manifest.json`
|
||||
: queue.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
const currentManifestPath = `tests/golden/${current}/manifest.json`;
|
||||
const currentManifest = fs.existsSync(path.join(root, currentManifestPath)) ? JSON.parse(read(currentManifestPath)) : null;
|
||||
if (currentManifest) {
|
||||
assert.equal(currentManifest.task, current);
|
||||
assert.ok(["done", "in_progress", "blocked"].includes(currentManifest.status));
|
||||
}
|
||||
const expectedParent = currentManifest?.parentTask ?? path.basename(path.dirname(parentPath ?? ""));
|
||||
assert.equal(parentPath, `tests/golden/${expectedParent}/manifest.json`);
|
||||
const parent = JSON.parse(read(parentPath));
|
||||
assert.equal(parent.nextTask, current);
|
||||
for (const [name, artifact] of Object.entries(currentManifest?.artifacts ?? {})) {
|
||||
assert.equal(sha256(artifact.path), artifact.sha256, `${name} hash drift: ${artifact.path}`);
|
||||
}
|
||||
process.stdout.write(`v2-handoff-ok task=${current} parent=${expectedParent} status=${currentManifest?.status ?? "awaiting_handoff"} artifacts=${Object.keys(currentManifest?.artifacts ?? {}).length}\n`);
|
||||
31
tools/web/check-v2-task-index.mjs
Normal file
31
tools/web/check-v2-task-index.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");
|
||||
const json = (relative) => JSON.parse(read(relative));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const plan = json("docs/BLENDER_WEB_PARITY_TASK_PLAN_V2.json");
|
||||
const index = json("tests/golden/WBV2/task-index.json");
|
||||
const catalogBytes = fs.readFileSync(path.join(root, index.catalog.path));
|
||||
|
||||
assert.equal(index.schemaVersion, 1);
|
||||
assert.equal(index.operation, "BLENDER_WEB_CAPABILITY_TASK_INDEX");
|
||||
assert.equal(sha256(read(index.source.path)), index.source.sha256);
|
||||
assert.equal(sha256(catalogBytes), index.catalog.sha256);
|
||||
assert.equal(index.taskCount, plan.tasks.length);
|
||||
const ready = plan.tasks.filter((task) => task.ready === true);
|
||||
assert.equal(ready.length, 1);
|
||||
assert.equal(index.activeTask, ready[0].id);
|
||||
const lines = catalogBytes.toString("utf8").trimEnd().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
||||
assert.equal(lines.length, plan.tasks.length);
|
||||
assert.equal(lines.filter((task) => task.state === "active").length, 1);
|
||||
assert.equal(lines.find((task) => task.state === "active")?.id, index.activeTask);
|
||||
for (const task of plan.tasks.filter((candidate) => candidate.ready || candidate.status === "done")) {
|
||||
assert.ok(index.entries[task.id], `${task.id} must have an index entry`);
|
||||
assert.ok(fs.existsSync(path.join(root, task.card)), `${task.id} card must exist before activation`);
|
||||
}
|
||||
process.stdout.write(`v2-task-index-ok tasks=${index.taskCount} active=${index.activeTask} catalog=${index.catalog.path}\n`);
|
||||
89
tools/web/check-web-parity-v2-plan.mjs
Normal file
89
tools/web/check-web-parity-v2-plan.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");
|
||||
const json = (relative) => JSON.parse(read(relative));
|
||||
const planPath = "docs/BLENDER_WEB_PARITY_TASK_PLAN_V2.json";
|
||||
const plan = json(planPath);
|
||||
const checkpoint = plan.correctiveCheckpoint;
|
||||
const handoff = json(checkpoint.handoff);
|
||||
const queue = read("docs/EXECUTION_QUEUE.md");
|
||||
const queueTask = queue.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
||||
const parentPath = queue.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
assert.ok(queueTask && parentPath, "legacy queue must expose current task and parent manifest");
|
||||
const parent = json(parentPath);
|
||||
|
||||
function isCheckpointAncestor(checkpointTask) {
|
||||
if (checkpointTask === queueTask) return true;
|
||||
let cursor = parent;
|
||||
const visited = new Set();
|
||||
while (cursor?.task && !visited.has(cursor.task)) {
|
||||
visited.add(cursor.task);
|
||||
if (cursor.task === checkpointTask) return true;
|
||||
if (!cursor.parentTask) return false;
|
||||
const manifestPath = path.join(root, "tests/golden", cursor.parentTask, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) return false;
|
||||
cursor = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
assert.equal(plan.schemaVersion, 1, "V2 plan schema must be 1");
|
||||
assert.equal(plan.planId, "BLENDER_WEB_PARITY_V2");
|
||||
assert.equal(plan.status, "ACTIVE_CAPABILITY_QUEUE");
|
||||
assert.equal(plan.authority, "queue_reactivated_after_owner_review");
|
||||
assert.equal(plan.executionPointer, "docs/EXECUTION_QUEUE.md");
|
||||
assert.equal(checkpoint.task, "C6-003");
|
||||
assert.equal(checkpoint.status, "active");
|
||||
assert.equal(checkpoint.queueMutation, true);
|
||||
assert.equal(checkpoint.blocker, null);
|
||||
assert.ok(isCheckpointAncestor(checkpoint.legacyTask), "V2 checkpoint must be current or an ancestor of the legacy queue");
|
||||
assert.ok(isCheckpointAncestor(checkpoint.legacyParentNextTask), "V2 parent checkpoint must be current or an ancestor of the legacy queue");
|
||||
assert.equal(parent.nextTask, queueTask);
|
||||
assert.equal(handoff.task, checkpoint.task);
|
||||
assert.equal(handoff.status, "in_progress");
|
||||
assert.equal(handoff.queueMutation, true);
|
||||
assert.equal(handoff.nextCorrectiveTask, checkpoint.task);
|
||||
|
||||
const readyTasks = plan.tasks.filter((task) => task.ready === true);
|
||||
assert.equal(readyTasks.length, 1, "V2 plan must have exactly one ready task");
|
||||
assert.equal(readyTasks[0].id, queueTask, "V2 ready task must match the execution queue");
|
||||
|
||||
const taskIds = new Set();
|
||||
const tasksById = new Map();
|
||||
for (const task of plan.tasks) {
|
||||
assert.match(task.id, /^WBV2-P[0-7]-\d{3}$/u, `${task.id} has invalid ID`);
|
||||
assert.equal(taskIds.has(task.id), false, `${task.id} is duplicated`);
|
||||
taskIds.add(task.id);
|
||||
tasksById.set(task.id, task);
|
||||
assert.ok(plan.gates.some((gate) => gate.id === task.gate), `${task.id} gate is missing`);
|
||||
if (task.ready) assert.equal(task.status, "in_progress", `${task.id} ready task must be in_progress`);
|
||||
assert.equal(task.queueMutationAllowed, false, `${task.id} cannot mutate queue`);
|
||||
assert.ok(task.behavior && task.acceptance?.length && task.outputs?.length, `${task.id} contract is incomplete`);
|
||||
for (const dependency of task.dependsOn ?? []) assert.ok(taskIds.has(dependency) || plan.tasks.some((candidate) => candidate.id === dependency), `${task.id} dependency is missing: ${dependency}`);
|
||||
}
|
||||
for (const wave of plan.waves) {
|
||||
assert.ok(plan.gates.some((gate) => gate.id === wave.gate), `${wave.id} gate is missing`);
|
||||
for (const taskId of wave.tasks) {
|
||||
const task = tasksById.get(taskId);
|
||||
assert.ok(task, `${wave.id} references unknown task ${taskId}`);
|
||||
assert.equal(task.phase, wave.id, `${taskId} is assigned to the wrong wave`);
|
||||
}
|
||||
}
|
||||
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
function visit(taskId) {
|
||||
if (visiting.has(taskId)) throw new Error(`dependency cycle includes ${taskId}`);
|
||||
if (visited.has(taskId)) return;
|
||||
visiting.add(taskId);
|
||||
for (const dependency of tasksById.get(taskId).dependsOn ?? []) visit(dependency);
|
||||
visiting.delete(taskId);
|
||||
visited.add(taskId);
|
||||
}
|
||||
for (const taskId of taskIds) visit(taskId);
|
||||
|
||||
process.stdout.write(`web-parity-v2-plan-ok status=${plan.status} tasks=${plan.tasks.length} waves=${plan.waves.length} checkpoint=${checkpoint.task} queue=${queueTask} queueMutation=${checkpoint.queueMutation}\n`);
|
||||
106
tools/web/classify-corrective-legacy.mjs
Normal file
106
tools/web/classify-corrective-legacy.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
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`);
|
||||
@@ -5,7 +5,7 @@ import { readIndexedTask, verifyTaskIndex, root } from './task-context-lib.mjs';
|
||||
const taskArg = process.argv.indexOf('--task');
|
||||
const task = taskArg >= 0 ? process.argv[taskArg + 1] : undefined;
|
||||
if (!task) throw new Error('usage: node tools/web/generate-task-card.mjs --task <task-id>');
|
||||
verifyTaskIndex();
|
||||
verifyTaskIndex(task);
|
||||
const entry = readIndexedTask(task);
|
||||
if (!entry) throw new Error('unknown indexed task: ' + task);
|
||||
const cardPath = path.join(root, 'docs/tasks', task + '.md');
|
||||
|
||||
59
tools/web/generate-v2-task-index.mjs
Normal file
59
tools/web/generate-v2-task-index.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
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 planPath = path.join(root, "docs/BLENDER_WEB_PARITY_TASK_PLAN_V2.json");
|
||||
const outputDir = path.join(root, "tests/golden/WBV2");
|
||||
const catalogPath = path.join(outputDir, "task-catalog.jsonl");
|
||||
const indexPath = path.join(outputDir, "task-index.json");
|
||||
const planBytes = fs.readFileSync(planPath);
|
||||
const plan = JSON.parse(planBytes);
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
|
||||
const active = plan.tasks.filter((task) => task.ready === true && task.status === "in_progress");
|
||||
if (active.length !== 1) throw new Error(`V2 plan must have exactly one ready in_progress task; found ${active.length}`);
|
||||
|
||||
const lines = [];
|
||||
const entries = {};
|
||||
let offset = 0;
|
||||
for (let index = 0; index < plan.tasks.length; index += 1) {
|
||||
const task = plan.tasks[index];
|
||||
const compact = {
|
||||
id: task.id,
|
||||
gapId: task.parityId ?? `workflow:${task.title}`,
|
||||
ownerFamily: task.owner,
|
||||
sourceTask: task.phase,
|
||||
state: task.status === "done" ? "completed" : task.ready === true ? "active" : "pending",
|
||||
targetImplementationClass: task.implementationClass,
|
||||
exitCriteria: task.acceptance,
|
||||
};
|
||||
const line = `${JSON.stringify(compact)}\n`;
|
||||
const length = Buffer.byteLength(line);
|
||||
lines.push(line);
|
||||
entries[task.id] = {
|
||||
line: index,
|
||||
offset,
|
||||
length,
|
||||
previous: task.dependsOn.at(-1) ?? null,
|
||||
next: plan.tasks[index + 1]?.id ?? null,
|
||||
};
|
||||
offset += length;
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const catalogBytes = Buffer.from(lines.join(""));
|
||||
fs.writeFileSync(catalogPath, catalogBytes);
|
||||
const index = {
|
||||
schemaVersion: 1,
|
||||
operation: "BLENDER_WEB_CAPABILITY_TASK_INDEX",
|
||||
source: { path: relative(planPath), sha256: sha256(planBytes) },
|
||||
catalog: { path: relative(catalogPath), sha256: sha256(catalogBytes) },
|
||||
taskCount: plan.tasks.length,
|
||||
activeTask: active[0].id,
|
||||
entries,
|
||||
};
|
||||
fs.writeFileSync(indexPath, `${JSON.stringify(index)}\n`);
|
||||
process.stdout.write(`v2-task-index-generated tasks=${plan.tasks.length} active=${index.activeTask} output=${relative(indexPath)}\n`);
|
||||
37
tools/web/generated/M16-GAP-00276.py
Normal file
37
tools/web/generated/M16-GAP-00276.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
LIBRARY_NAME = "WebGapAssetClearSingle.blend"
|
||||
OBJECT_NAME = "WebGapAssetClearSingleObject"
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00276.py -- OUTPUT")
|
||||
output = pathlib.Path(arguments[0]).resolve()
|
||||
source = output.with_name(LIBRARY_NAME)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new("WebGapAssetClearSingleMesh")
|
||||
mesh.from_pydata([(-1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
|
||||
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(source), check_existing=False, compress=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to):
|
||||
if OBJECT_NAME not in data_from.objects:
|
||||
raise RuntimeError("asset-clear-single source object is missing")
|
||||
data_to.objects = [OBJECT_NAME]
|
||||
linked = data_to.objects[0]
|
||||
if linked is None:
|
||||
raise RuntimeError("asset-clear-single linked object is missing")
|
||||
bpy.context.scene.collection.objects.link(linked)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False, compress=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
tools/web/generated/M16-GAP-00277.py
Normal file
37
tools/web/generated/M16-GAP-00277.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
LIBRARY_NAME = "WebGapAssetLibraryRefresh.blend"
|
||||
OBJECT_NAME = "WebGapAssetLibraryRefreshObject"
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00277.py -- OUTPUT")
|
||||
output = pathlib.Path(arguments[0]).resolve()
|
||||
source = output.with_name(LIBRARY_NAME)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new("WebGapAssetLibraryRefreshMesh")
|
||||
mesh.from_pydata([(-1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
|
||||
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(source), check_existing=False, compress=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to):
|
||||
if OBJECT_NAME not in data_from.objects:
|
||||
raise RuntimeError("asset-library-refresh source object is missing")
|
||||
data_to.objects = [OBJECT_NAME]
|
||||
linked = data_to.objects[0]
|
||||
if linked is None:
|
||||
raise RuntimeError("asset-library-refresh linked object is missing")
|
||||
bpy.context.scene.collection.objects.link(linked)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False, compress=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
tools/web/generated/M16-GAP-00278.py
Normal file
37
tools/web/generated/M16-GAP-00278.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
LIBRARY_NAME = "WebGapAssetLibraryReloadListing.blend"
|
||||
OBJECT_NAME = "WebGapAssetLibraryReloadListingObject"
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00278.py -- OUTPUT")
|
||||
output = pathlib.Path(arguments[0]).resolve()
|
||||
source = output.with_name(LIBRARY_NAME)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new("WebGapAssetLibraryReloadListingMesh")
|
||||
mesh.from_pydata([(-1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
|
||||
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(source), check_existing=False, compress=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to):
|
||||
if OBJECT_NAME not in data_from.objects:
|
||||
raise RuntimeError("asset-library-reload-listing source object is missing")
|
||||
data_to.objects = [OBJECT_NAME]
|
||||
linked = data_to.objects[0]
|
||||
if linked is None:
|
||||
raise RuntimeError("asset-library-reload-listing linked object is missing")
|
||||
bpy.context.scene.collection.objects.link(linked)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False, compress=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
36
tools/web/generated/M16-GAP-00279.py
Normal file
36
tools/web/generated/M16-GAP-00279.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
OBJECT_NAME = "WebGapAssetMarkObject"
|
||||
MESH_NAME = "WebGapAssetMarkMesh"
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --factory-startup --python M16-GAP-00279.py -- OUTPUT"
|
||||
)
|
||||
output = pathlib.Path(arguments[0]).resolve()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new(MESH_NAME)
|
||||
mesh.from_pydata(((-1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), (), ((0, 1, 2),))
|
||||
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
|
||||
# Background mode has no editor ID context for the operator. Seed the persisted asset record
|
||||
# with the same data API used by Blender's asset system.
|
||||
obj.asset_mark()
|
||||
obj.asset_data.author = "M16 Fixture"
|
||||
obj.asset_data.description = "Asset mark operator fixture"
|
||||
obj.asset_data.tags.new("operator-mark")
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False, compress=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
tools/web/generated/M16-GAP-00280.py
Normal file
35
tools/web/generated/M16-GAP-00280.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
OBJECT_NAME = "WebGapAssetMarkSingleObject"
|
||||
MESH_NAME = "WebGapAssetMarkSingleMesh"
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --factory-startup --python M16-GAP-00280.py -- OUTPUT"
|
||||
)
|
||||
output = pathlib.Path(arguments[0]).resolve()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new(MESH_NAME)
|
||||
mesh.from_pydata(((-1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), (), ((0, 1, 2),))
|
||||
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
|
||||
# Background mode has no editor ID context; seed the persisted single-asset record.
|
||||
obj.asset_mark()
|
||||
obj.asset_data.author = "M16 Fixture"
|
||||
obj.asset_data.description = "Asset mark single operator fixture"
|
||||
obj.asset_data.tags.new("operator-mark-single")
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False, compress=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
50
tools/web/generated/M16-GAP-00281.py
Normal file
50
tools/web/generated/M16-GAP-00281.py
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
LIBRARY_NAME = "WebGapOpenContaining.blend"
|
||||
OBJECT_NAME = "WebGapOpenContainingObject"
|
||||
|
||||
|
||||
def reset():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
def create_source(path):
|
||||
reset()
|
||||
mesh = bpy.data.meshes.new("WebGapOpenContainingMesh")
|
||||
mesh.from_pydata([(-1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
|
||||
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False, compress=True)
|
||||
|
||||
|
||||
def create_fixture(output):
|
||||
source = output.with_name(LIBRARY_NAME)
|
||||
create_source(source)
|
||||
reset()
|
||||
with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to):
|
||||
if OBJECT_NAME not in data_from.objects:
|
||||
raise RuntimeError("open-containing source object is missing")
|
||||
data_to.objects = [OBJECT_NAME]
|
||||
linked = data_to.objects[0]
|
||||
if linked is None:
|
||||
raise RuntimeError("open-containing linked object is missing")
|
||||
bpy.context.scene.collection.objects.link(linked)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False, compress=True)
|
||||
|
||||
|
||||
def main():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1 :]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00281.py -- OUTPUT")
|
||||
output = pathlib.Path(arguments[0]).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
create_fixture(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
tools/web/reactivate-legacy-queue.mjs
Normal file
102
tools/web/reactivate-legacy-queue.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||||
const reviewPath = path.join(root, "tests/golden/corrective/C6-003/owner-review.json");
|
||||
const dryRunPath = path.join(root, "tests/golden/corrective/C6-003/queue-reactivation-dry-run.json");
|
||||
const writePreviewPath = path.join(root, "tests/golden/corrective/C6-003/queue-reactivation-write-preview.json");
|
||||
const currentManifestPath = path.join(root, "tests/golden/M16-GAP-00276/manifest.json");
|
||||
const currentStatusPath = path.join(root, "docs/status/M16-GAP-00276.md");
|
||||
const completionPath = path.join(root, "tests/golden/M15-03A/completed-gap-tasks.json");
|
||||
const nextPlanPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
||||
const catalogPath = path.join(root, "tests/golden/M15-03A/task-catalog.jsonl");
|
||||
const indexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
||||
const evidenceRoot = path.join(root, "tests/golden/corrective/C6-003");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const writeJson = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
const write = process.argv.includes("--write");
|
||||
if (!write) throw new Error("queue reactivation is write-only after review; pass --write explicitly");
|
||||
|
||||
const review = readJson(reviewPath);
|
||||
assert.equal(review.task, "C6-003");
|
||||
assert.equal(review.status, "APPROVED");
|
||||
assert.equal(review.authorization?.approved, true);
|
||||
assert.equal(review.decision?.allowQueueMutation, true);
|
||||
assert.equal(review.decision?.targetTask, "M16-GAP-00277");
|
||||
|
||||
const dryRunBytes = fs.readFileSync(dryRunPath);
|
||||
const writePreviewBytes = fs.readFileSync(writePreviewPath);
|
||||
assert.equal(sha256(dryRunBytes), review.reviewedEvidence.dryRun.sha256);
|
||||
assert.equal(sha256(writePreviewBytes), review.reviewedEvidence.writePreview.sha256);
|
||||
assert.deepEqual(dryRunBytes, writePreviewBytes, "dry-run and write-preview bytes differ");
|
||||
const dryRun = JSON.parse(dryRunBytes);
|
||||
assert.equal(dryRun.status, "BLOCKED_OWNER_REVIEW");
|
||||
assert.equal(dryRun.queueMutation, false);
|
||||
assert.equal(dryRun.legacyQueue.currentTask, "M16-GAP-00276");
|
||||
assert.equal(dryRun.legacyQueue.parentManifest, "tests/golden/M16-GAP-00275/manifest.json");
|
||||
|
||||
const currentManifest = readJson(currentManifestPath);
|
||||
assert.equal(currentManifest.task, "M16-GAP-00276");
|
||||
assert.equal(currentManifest.status, "done", "complete the focused legacy task before queue write");
|
||||
assert.equal(currentManifest.nextTask, "M16-GAP-00277");
|
||||
assert.match(fs.readFileSync(currentStatusPath, "utf8"), /^status:\s*done\s*$/mu);
|
||||
assert.ok(readJson(completionPath).includes("operator:asset.clear_single"), "completion registry is not closed");
|
||||
const nextPlan = readJson(nextPlanPath);
|
||||
assert.equal(nextPlan.firstTask, "M16-GAP-00277");
|
||||
assert.equal(nextPlan.tasks.find((entry) => entry.id === "M16-GAP-00276")?.state, "completed");
|
||||
assert.equal(nextPlan.tasks.find((entry) => entry.id === "M16-GAP-00277")?.state, "active");
|
||||
const taskIndex = readJson(indexPath);
|
||||
assert.equal(taskIndex.activeTask, "M16-GAP-00277");
|
||||
const catalogRows = fs.readFileSync(catalogPath, "utf8").trimEnd().split("\n").map((line) => JSON.parse(line));
|
||||
assert.equal(catalogRows.find((entry) => entry.id === "M16-GAP-00276")?.state, "completed");
|
||||
assert.equal(catalogRows.find((entry) => entry.id === "M16-GAP-00277")?.state, "active");
|
||||
|
||||
const queueBefore = fs.readFileSync(queuePath);
|
||||
assert.equal(sha256(queueBefore), review.reviewedEvidence.protectedHashes["docs/EXECUTION_QUEUE.md"]);
|
||||
const parentManifestPath = path.join(root, "tests/golden/M16-GAP-00275/manifest.json");
|
||||
assert.equal(sha256(fs.readFileSync(parentManifestPath)), review.reviewedEvidence.protectedHashes["tests/golden/M16-GAP-00275/manifest.json"]);
|
||||
const queueSource = queueBefore.toString("utf8");
|
||||
assert.match(queueSource, /\|\s*当前任务\s*\|\s*`M16-GAP-00276`/u);
|
||||
assert.match(queueSource, /\|\s*parent manifest\s*\|\s*`tests\/golden\/M16-GAP-00275\/manifest\.json`/u);
|
||||
const queueAfter = queueSource
|
||||
.replace("| 当前任务 | `M16-GAP-00276`", "| 当前任务 | `M16-GAP-00277`")
|
||||
.replace("| parent manifest | `tests/golden/M16-GAP-00275/manifest.json`", "| parent manifest | `tests/golden/M16-GAP-00276/manifest.json`")
|
||||
.replace("| 任务卡 | [`tasks/M16-GAP-00276.md`](tasks/M16-GAP-00276.md)", "| 任务卡 | [`tasks/M16-GAP-00277.md`](tasks/M16-GAP-00277.md)")
|
||||
.replace("| 专项验收 | `npm --prefix web run test:generated-gap -- --task M16-GAP-00276`", "| 专项验收 | `npm --prefix web run test:generated-gap -- --task M16-GAP-00277`");
|
||||
assert.notEqual(queueAfter, queueSource);
|
||||
assert.match(queueAfter, /\|\s*当前任务\s*\|\s*`M16-GAP-00277`/u);
|
||||
assert.match(queueAfter, /\|\s*parent manifest\s*\|\s*`tests\/golden\/M16-GAP-00276\/manifest\.json`/u);
|
||||
|
||||
const beforePath = path.join(evidenceRoot, "queue-before.md");
|
||||
const afterPath = path.join(evidenceRoot, "queue-after.md");
|
||||
fs.writeFileSync(beforePath, queueBefore);
|
||||
fs.writeFileSync(afterPath, queueAfter);
|
||||
const temporary = `${queuePath}.c6-003-${process.pid}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, queueAfter, { mode: fs.statSync(queuePath).mode });
|
||||
fs.renameSync(temporary, queuePath);
|
||||
} finally {
|
||||
if (fs.existsSync(temporary)) fs.rmSync(temporary);
|
||||
}
|
||||
const observedAfter = fs.readFileSync(queuePath);
|
||||
assert.deepEqual(observedAfter, Buffer.from(queueAfter));
|
||||
const receipt = {
|
||||
schemaVersion: 1,
|
||||
task: "C6-003",
|
||||
operation: "CORRECTIVE_QUEUE_REACTIVATION_WRITE",
|
||||
status: "APPLIED",
|
||||
queueMutation: true,
|
||||
authorization: { review: relative(reviewPath), approved: true },
|
||||
before: { path: relative(beforePath), sha256: sha256(queueBefore), currentTask: "M16-GAP-00276", parentManifest: "tests/golden/M16-GAP-00275/manifest.json" },
|
||||
after: { path: relative(afterPath), sha256: sha256(observedAfter), currentTask: "M16-GAP-00277", parentManifest: "tests/golden/M16-GAP-00276/manifest.json" },
|
||||
changedFields: ["currentTask", "parentManifest", "taskCard", "focusedCommand"],
|
||||
rollback: { action: "restore before.path atomically", source: relative(beforePath) },
|
||||
};
|
||||
writeJson(path.join(evidenceRoot, "queue-reactivation-write-receipt.json"), receipt);
|
||||
process.stdout.write(`queue-reactivation-ok task=C6-003 queue=M16-GAP-00277 before=${receipt.before.sha256} after=${receipt.after.sha256}\n`);
|
||||
244
tools/web/reconcile-corrective-resign.mjs
Normal file
244
tools/web/reconcile-corrective-resign.mjs
Normal file
@@ -0,0 +1,244 @@
|
||||
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";
|
||||
import { buildTaskContext } from "./task-context-lib.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const correctiveRoot = path.join(root, "tests/golden/corrective");
|
||||
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 digest = (value) => crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const inside = (file, parent) => file === parent || file.startsWith(`${parent}${path.sep}`);
|
||||
|
||||
function arg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const evidenceRootArg = arg("--evidence-root");
|
||||
const outputRootArg = arg("--output-root");
|
||||
const reportArg = arg("--report");
|
||||
const approved = process.argv.includes("--approve-current-worktree");
|
||||
if (!evidenceRootArg || !outputRootArg || !reportArg || !approved) {
|
||||
throw new Error("usage: node tools/web/reconcile-corrective-resign.mjs --evidence-root tests/golden --output-root tests/golden/corrective/C0-002/staged --report path --approve-current-worktree");
|
||||
}
|
||||
|
||||
const evidenceRoot = path.resolve(root, evidenceRootArg);
|
||||
const outputRoot = path.resolve(root, outputRootArg);
|
||||
const reportPath = path.resolve(root, reportArg);
|
||||
if (!inside(evidenceRoot, root) || !inside(outputRoot, correctiveRoot) || !inside(reportPath, correctiveRoot)) {
|
||||
throw new Error("evidence, output and report paths are outside the allowed repository roots");
|
||||
}
|
||||
|
||||
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||||
const queue = read(queuePath);
|
||||
const queueTask = queue.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
||||
const parentManifestPath = queue.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
if (!queueTask || !parentManifestPath) throw new Error("queue must declare current task and parent manifest");
|
||||
const parentManifest = json(path.join(root, parentManifestPath));
|
||||
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
||||
const plan = json(planPath);
|
||||
const taskToGap = new Map(plan.tasks.map((task) => [task.id, task.gapId]));
|
||||
|
||||
const issueCounts = new Map();
|
||||
const issueTasks = new Map();
|
||||
const issuePaths = new Map();
|
||||
const issues = [];
|
||||
const addIssue = (code, fields = {}) => {
|
||||
issues.push({ code, ...fields });
|
||||
issueCounts.set(code, (issueCounts.get(code) ?? 0) + 1);
|
||||
if (fields.task) {
|
||||
const values = issueTasks.get(code) ?? new Set();
|
||||
values.add(fields.task);
|
||||
issueTasks.set(code, values);
|
||||
}
|
||||
if (fields.path) {
|
||||
const values = issuePaths.get(code) ?? new Set();
|
||||
values.add(fields.path);
|
||||
issuePaths.set(code, values);
|
||||
}
|
||||
};
|
||||
|
||||
const taskDirs = fs.readdirSync(evidenceRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^M16-GAP-\d{5}$/u.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const records = [];
|
||||
const resignedManifests = [];
|
||||
const repairedContexts = [];
|
||||
const repairedParents = [];
|
||||
const snapshotPaths = new Set();
|
||||
|
||||
function taskCardFields(task) {
|
||||
const cardPath = path.join(root, "docs/tasks", `${task}.md`);
|
||||
const source = fs.existsSync(cardPath) ? read(cardPath) : "";
|
||||
const get = (name) => source.match(new RegExp(`^[-*]?\\s*\\x60?${name}\\x60?\\s*[::]\\s*\\x60?([^\\n\\x60]+)`, "imu"))?.[1]?.trim();
|
||||
return { source, path: cardPath, task: get("task"), parent: get("parent"), status: get("status"), gap: get("gap"), ownerFamily: get("ownerFamily"), implementationClass: get("targetImplementationClass") };
|
||||
}
|
||||
|
||||
function syntheticContext(task, manifest, parent) {
|
||||
const fields = taskCardFields(task);
|
||||
const entry = plan.tasks.find((value) => value.id === task);
|
||||
const taskPath = fields.path;
|
||||
const parentPath = path.join(root, "tests/golden", parent.task, "manifest.json");
|
||||
const parentStatusPath = path.join(root, "docs/status", `${parent.task}.md`);
|
||||
const fixture = entry?.fixture?.path ?? null;
|
||||
const context = {
|
||||
schemaVersion: 1,
|
||||
task,
|
||||
parentTask: manifest.parentTask,
|
||||
status: fields.status ?? entry?.state ?? "pending",
|
||||
goal: fields.source.match(/^##\s+目标\s*\n([\s\S]*?)(?=^##\s|$)/mu)?.[1].trim() ?? `完成 ${entry?.gapId ?? task} 的最小可观察切片`,
|
||||
scope: {
|
||||
gap: fields.gap ?? entry?.gapId ?? "NOT_APPLICABLE",
|
||||
ownerFamily: fields.ownerFamily ?? entry?.ownerFamily ?? "NOT_APPLICABLE",
|
||||
implementationClass: fields.implementationClass ?? entry?.targetImplementationClass ?? "NOT_APPLICABLE",
|
||||
},
|
||||
commands: [entry?.desktopCommand, entry?.webCommand, entry?.comparator].filter(Boolean),
|
||||
inputPaths: [],
|
||||
inputSelection: { schemaVersion: 1, limits: { maxFiles: 12, evidenceBytes: 8192, contextRemainingBytes: 0, contextRemainingTokens: 0 }, source: { bytes: Buffer.byteLength(queue) + Buffer.byteLength(fields.source) + Buffer.byteLength(read(parentPath)), tokens: 0 }, selected: [], excluded: fixture ? [{ path: fixture, bytes: null, reason: "EVIDENCE_BYTE_BUDGET" }] : [], totals: { files: 0, bytes: 0, tokens: 0 } },
|
||||
exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound"],
|
||||
nextTask: manifest.nextTask ?? entry?.id ?? null,
|
||||
sourceDocuments: { queue: relative(queuePath), taskCard: relative(taskPath), taskIndex: "tests/golden/M15-03A/task-index.json", parentManifest: relative(parentPath), parentStatus: fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE" },
|
||||
readPolicy: { required: [relative(queuePath), relative(taskPath), relative(parentPath), fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE"], machineOnly: ["tests/golden/M15-03A/task-index.json", "tests/golden/M15-03A/task-catalog.jsonl"], optionalByNeed: [], forbiddenByDefault: [] },
|
||||
parent: { manifest: { schemaVersion: parent.schemaVersion, task: parent.task, parentTask: parent.parentTask, status: parent.status ?? "done", nextTask: task, artifactCount: Object.keys(parent.artifacts ?? {}).length }, statusSummary: fs.existsSync(parentStatusPath) ? read(parentStatusPath).split("\n").filter(Boolean).slice(0, 8) : [] },
|
||||
budgets: { queueBytes: 4096, taskBytes: 8192, parentManifestBytes: 24576, parentStatusBytes: 6144, evidenceFiles: 12, evidenceBytes: 8192, contextTokens: 3500, contextEnvelopeTokens: 1024, taskLines: fields.source ? fields.source.split("\n").length : 0, taskTokens: Math.ceil(Buffer.byteLength(fields.source) / 4) },
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
for (const task of taskDirs) {
|
||||
const directory = path.join(evidenceRoot, task);
|
||||
const manifestPath = path.join(directory, "manifest.json");
|
||||
const manifest = json(manifestPath);
|
||||
const statusPath = path.join(root, "docs/status", `${task}.md`);
|
||||
const contextPath = path.join(directory, "task-context.json");
|
||||
if (!fs.existsSync(statusPath)) addIssue("MISSING_STATUS", { task });
|
||||
if (!fs.existsSync(contextPath)) addIssue("MISSING_TASK_CONTEXT", { task, repaired: true });
|
||||
const candidate = structuredClone(manifest);
|
||||
const drift = [];
|
||||
for (const artifact of Object.values(candidate.artifacts ?? {})) {
|
||||
if (!artifact?.path || !artifact?.sha256) continue;
|
||||
const artifactPath = path.join(root, artifact.path);
|
||||
if (!fs.existsSync(artifactPath)) {
|
||||
addIssue("ARTIFACT_MISSING", { task, path: artifact.path });
|
||||
continue;
|
||||
}
|
||||
const actual = sha256(artifactPath);
|
||||
snapshotPaths.add(artifact.path);
|
||||
if (actual !== artifact.sha256) {
|
||||
drift.push({ path: artifact.path, originalSha256: artifact.sha256, resignedSha256: actual });
|
||||
artifact.sha256 = actual;
|
||||
addIssue("ARTIFACT_HASH_RESIGNED", { task, path: artifact.path });
|
||||
}
|
||||
}
|
||||
if (manifest.parentTask) {
|
||||
const parentPath = path.join(root, "tests/golden", manifest.parentTask, "manifest.json");
|
||||
if (fs.existsSync(parentPath)) {
|
||||
const parent = json(parentPath);
|
||||
if (parent.nextTask !== task) {
|
||||
repairedParents.push({ task, parentTask: manifest.parentTask, originalNextTask: parent.nextTask, resignedNextTask: task });
|
||||
addIssue("PARENT_POINTER_RESIGNED", { task, parent: manifest.parentTask, originalNextTask: parent.nextTask });
|
||||
}
|
||||
}
|
||||
}
|
||||
candidate.reconciliation = { mode: "CURRENT_WORKTREE_RESIGN", authorized: true, artifactDrift: drift };
|
||||
resignedManifests.push({ task, manifest: candidate });
|
||||
let context;
|
||||
if (fs.existsSync(contextPath)) {
|
||||
context = json(contextPath);
|
||||
} else {
|
||||
try {
|
||||
context = buildTaskContext(task).context;
|
||||
} catch {
|
||||
const parent = manifest.parentTask ? json(path.join(root, "tests/golden", manifest.parentTask, "manifest.json")) : manifest;
|
||||
context = syntheticContext(task, manifest, parent);
|
||||
}
|
||||
repairedContexts.push({ task, context });
|
||||
}
|
||||
records.push({ task, gapId: taskToGap.get(task) ?? null, parentTask: manifest.parentTask, status: manifest.status, completeEvidence: manifest.status === "done" && fs.existsSync(statusPath), resigned: true });
|
||||
}
|
||||
|
||||
if (parentManifest.nextTask !== queueTask) addIssue("PARENT_NEXT_TASK_MISMATCH", { detail: `${parentManifest.nextTask}!=${queueTask}` });
|
||||
const active = records.filter((record) => record.status === "in_progress");
|
||||
if (active.length !== 1) addIssue("ACTIVE_TASK_COUNT", { detail: `count=${active.length}` });
|
||||
if (active[0]?.task !== queueTask) addIssue("ACTIVE_TASK_MISMATCH", { detail: `${active[0]?.task ?? "NONE"}!=${queueTask}` });
|
||||
|
||||
const eligibleCompletedTaskIds = records.filter((record) => record.completeEvidence).map((record) => record.task).sort();
|
||||
const eligibleCompletedGaps = records.filter((record) => record.completeEvidence && record.gapId).map((record) => record.gapId).sort();
|
||||
const issueSummary = [...issueCounts.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([code, count]) => ({ code, count, taskCount: issueTasks.get(code)?.size ?? 0, sampleTasks: [...(issueTasks.get(code) ?? [])].sort().slice(0, 5), pathCount: issuePaths.get(code)?.size ?? 0, samplePaths: [...(issuePaths.get(code) ?? [])].sort().slice(0, 5) }));
|
||||
const sourcePaths = [queuePath, path.join(root, parentManifestPath), path.join(root, "tests/golden/M15-03A/completed-gap-tasks.json"), planPath, path.join(root, "tests/golden/M15-03A/task-catalog.jsonl"), path.join(root, "tests/golden/M15-03A/task-index.json")];
|
||||
const sourceHashes = Object.fromEntries(sourcePaths.map((file) => [relative(file), sha256(file)]));
|
||||
const inputDigest = digest({ sourceHashes, records, eligibleCompletedTaskIds, eligibleCompletedGaps, resignedManifests: resignedManifests.map(({ task, manifest }) => ({ task, artifacts: manifest.artifacts, reconciliation: manifest.reconciliation })), repairedContexts: repairedContexts.map(({ task }) => task), repairedParents });
|
||||
|
||||
const report = { schemaVersion: 1, operation: "CORRECTIVE_STATE_RECONCILIATION_RESIGN", mode: "write", status: "BLOCKED", queueMutation: false, authorization: { mode: "CURRENT_WORKTREE_RESIGN", userApproved: true, legacyFilesOverwritten: false }, queue: { currentTask: queueTask, parentManifest: parentManifestPath, parentNextTask: parentManifest.nextTask }, inputTaskCount: records.length, candidateActiveTask: active.length === 1 ? active[0].task : null, sourceHashes, inputDigest, issueSummary, issueCount: issues.length, issues, registryComparison: { candidateCompletedTaskCount: eligibleCompletedTaskIds.length, candidateCompletedGapCount: eligibleCompletedGaps.length, previousRegistryPath: "tests/golden/M15-03A/completed-gap-tasks.json" }, repairPlan: { approvalRequired: false, queueMutationAllowed: false, actions: ["re-sign current artifact hashes in staged manifests", "repair contexts with valid indexed parent data", "record M16-GAP-00001 parent pointer as a staged repair", "regenerate registry, plan, catalog, index and active card in staging"] }, writeOutcome: "NOT_STARTED", commandExitCodes: { resign: null, validation: null, planCheck: null, indexCheck: null, contextCheck: null, governanceCheck: null, diffCheck: null } };
|
||||
|
||||
for (const entry of ["resigned-manifests", "resigned-context", "source-snapshot", "completed-gap-tasks.candidate.json", "next-task-plan.candidate.json", "next-task-plan.candidate.second.json", "task-index", "task-cards"]) {
|
||||
fs.rmSync(path.join(outputRoot, entry), { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(outputRoot, { recursive: true });
|
||||
const manifestRoot = path.join(outputRoot, "resigned-manifests");
|
||||
for (const { task, manifest } of resignedManifests) {
|
||||
const file = path.join(manifestRoot, task, "manifest.json");
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
}
|
||||
for (const parentRepair of repairedParents) {
|
||||
const source = path.join(root, "tests/golden", parentRepair.parentTask, "manifest.json");
|
||||
const candidate = json(source);
|
||||
candidate.nextTask = parentRepair.resignedNextTask;
|
||||
candidate.reconciliation = { mode: "CURRENT_WORKTREE_RESIGN", authorized: true, parentPointerRepair: parentRepair };
|
||||
const file = path.join(manifestRoot, parentRepair.parentTask, "manifest.json");
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(candidate, null, 2)}\n`);
|
||||
}
|
||||
const contextRoot = path.join(outputRoot, "resigned-context");
|
||||
for (const { task, context } of repairedContexts) {
|
||||
const file = path.join(contextRoot, task, "task-context.json");
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(context, null, 2)}\n`);
|
||||
}
|
||||
const completionPath = path.join(outputRoot, "completed-gap-tasks.candidate.json");
|
||||
fs.writeFileSync(completionPath, `${JSON.stringify(eligibleCompletedGaps, null, 2)}\n`);
|
||||
const snapshotRoot = path.join(outputRoot, "source-snapshot");
|
||||
const snapshotFiles = [];
|
||||
for (const artifactPath of [...snapshotPaths].sort()) {
|
||||
const source = path.join(root, artifactPath);
|
||||
const destination = path.join(snapshotRoot, "files", artifactPath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(source, destination);
|
||||
snapshotFiles.push({ path: artifactPath, snapshotPath: relative(destination), sha256: sha256(source), bytes: fs.statSync(source).size });
|
||||
}
|
||||
fs.writeFileSync(path.join(snapshotRoot, "snapshot-manifest.json"), `${JSON.stringify({ schemaVersion: 1, mode: "CURRENT_WORKTREE_RESIGN", authorized: true, files: snapshotFiles }, null, 2)}\n`);
|
||||
const candidatePlan = path.join(outputRoot, "next-task-plan.candidate.json");
|
||||
const candidatePlanSecond = path.join(outputRoot, "next-task-plan.candidate.second.json");
|
||||
const generator = path.join(root, "tools/web/generate-blender-next-task-plan.mjs");
|
||||
execFileSync(process.execPath, [generator, candidatePlan, "--completion-path", completionPath], { cwd: root, stdio: "ignore" });
|
||||
execFileSync(process.execPath, [generator, candidatePlanSecond, "--completion-path", completionPath], { cwd: root, stdio: "ignore" });
|
||||
if (!fs.readFileSync(candidatePlan).equals(fs.readFileSync(candidatePlanSecond))) {
|
||||
addIssue("CANDIDATE_PLAN_NONDETERMINISTIC");
|
||||
} else {
|
||||
fs.rmSync(candidatePlanSecond, { force: true });
|
||||
}
|
||||
const indexDir = path.join(outputRoot, "task-index");
|
||||
execFileSync(process.execPath, [path.join(root, "tools/web/generate-task-index.mjs"), "--plan-path", candidatePlan, "--output-dir", indexDir], { cwd: root, stdio: "ignore" });
|
||||
const candidatePlanData = json(candidatePlan);
|
||||
const activeTask = candidatePlanData.firstTask;
|
||||
const activeCardSource = activeTask && fs.existsSync(path.join(root, "docs/tasks", `${activeTask}.md`)) ? read(path.join(root, "docs/tasks", `${activeTask}.md`)) : "";
|
||||
if (activeTask && activeCardSource) {
|
||||
const cardPath = path.join(outputRoot, "task-cards", `${activeTask}.md`);
|
||||
fs.mkdirSync(path.dirname(cardPath), { recursive: true });
|
||||
fs.writeFileSync(cardPath, activeCardSource);
|
||||
}
|
||||
report.status = issues.some((issue) => issue.code === "ARTIFACT_MISSING" || issue.code === "CANDIDATE_PLAN_NONDETERMINISTIC") ? "BLOCKED" : "PASS_WITH_REPAIR";
|
||||
report.writeOutcome = report.status === "PASS_WITH_REPAIR" ? "STAGED_RE_SIGN_CANDIDATE" : "NO_FILES_WRITTEN";
|
||||
report.issueSummary = [...issueCounts.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([code, count]) => ({ code, count, taskCount: issueTasks.get(code)?.size ?? 0, sampleTasks: [...(issueTasks.get(code) ?? [])].sort().slice(0, 5), pathCount: issuePaths.get(code)?.size ?? 0, samplePaths: [...(issuePaths.get(code) ?? [])].sort().slice(0, 5) }));
|
||||
report.issueCount = issues.length;
|
||||
report.candidates = { completionRegistry: relative(completionPath), plan: relative(candidatePlan), taskIndex: relative(path.join(indexDir, "task-index.json")), catalog: relative(path.join(indexDir, "task-catalog.jsonl")), activeTaskCard: activeTask ? relative(path.join(outputRoot, "task-cards", `${activeTask}.md`)) : null, sourceSnapshot: relative(path.join(snapshotRoot, "snapshot-manifest.json")), sourceSnapshotFileCount: snapshotFiles.length, resignedManifestCount: resignedManifests.length, repairedContextCount: repairedContexts.length, repairedParentCount: repairedParents.length };
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`corrective-resign-${report.status.toLowerCase()} tasks=${records.length} completed=${eligibleCompletedGaps.length} issues=${issues.length} output=${relative(outputRoot)}\n`);
|
||||
209
tools/web/reconcile-corrective-state.mjs
Normal file
209
tools/web/reconcile-corrective-state.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
import crypto from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||||
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
||||
const catalogPath = path.join(root, "tests/golden/M15-03A/task-catalog.jsonl");
|
||||
const indexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
||||
const completionPath = path.join(root, "tests/golden/M15-03A/completed-gap-tasks.json");
|
||||
const correctivePlanPath = path.join(root, "docs/BLENDER_WASM_CORRECTIVE_TASK_PLAN.json");
|
||||
const read = (file) => fs.readFileSync(file, "utf8");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const json = (file) => JSON.parse(read(file));
|
||||
|
||||
function arg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const evidenceRootArg = arg("--evidence-root");
|
||||
const reportArg = arg("--report");
|
||||
const outputRootArg = arg("--output-root");
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
const write = process.argv.includes("--write");
|
||||
if (!evidenceRootArg || !reportArg || dryRun === write) {
|
||||
throw new Error("usage: node tools/web/reconcile-corrective-state.mjs --evidence-root tests/golden --dry-run|--write --report path [--output-root path]");
|
||||
}
|
||||
|
||||
const evidenceRoot = path.resolve(root, evidenceRootArg);
|
||||
const reportPath = path.resolve(root, reportArg);
|
||||
const correctiveRoot = path.join(root, "tests/golden/corrective");
|
||||
const inside = (file, parent) => file === parent || file.startsWith(`${parent}${path.sep}`);
|
||||
if (!inside(reportPath, correctiveRoot)) throw new Error("report must stay under tests/golden/corrective");
|
||||
if (write && !outputRootArg) throw new Error("--write requires --output-root");
|
||||
const outputRoot = outputRootArg ? path.resolve(root, outputRootArg) : null;
|
||||
if (outputRoot && !inside(outputRoot, correctiveRoot)) throw new Error("output-root must stay under tests/golden/corrective");
|
||||
|
||||
const queue = read(queuePath);
|
||||
const queueTask = queue.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
||||
const parentManifestPath = queue.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/iu)?.[1];
|
||||
if (!queueTask || !parentManifestPath) throw new Error("queue must declare current task and parent manifest");
|
||||
const parentManifest = json(path.join(root, parentManifestPath));
|
||||
const taskPlan = json(planPath);
|
||||
const taskToGap = new Map(taskPlan.tasks.map((task) => [task.id, task.gapId]));
|
||||
const sourcePaths = [
|
||||
queuePath,
|
||||
path.join(root, parentManifestPath),
|
||||
completionPath,
|
||||
planPath,
|
||||
catalogPath,
|
||||
indexPath,
|
||||
correctivePlanPath,
|
||||
];
|
||||
const sourceHashes = Object.fromEntries(sourcePaths.map((file) => [relative(file), sha256(file)]));
|
||||
|
||||
const issues = [];
|
||||
const issueCounts = new Map();
|
||||
const issueTasks = new Map();
|
||||
const issuePaths = new Map();
|
||||
const addIssue = (issue) => {
|
||||
issues.push(issue);
|
||||
issueCounts.set(issue.code, (issueCounts.get(issue.code) ?? 0) + 1);
|
||||
if (issue.task) {
|
||||
const tasks = issueTasks.get(issue.code) ?? new Set();
|
||||
tasks.add(issue.task);
|
||||
issueTasks.set(issue.code, tasks);
|
||||
}
|
||||
if (issue.path) {
|
||||
const paths = issuePaths.get(issue.code) ?? new Set();
|
||||
paths.add(issue.path);
|
||||
issuePaths.set(issue.code, paths);
|
||||
}
|
||||
};
|
||||
const records = [];
|
||||
const taskDirs = fs.readdirSync(evidenceRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^M16-GAP-\d{5}$/u.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
for (const directoryName of taskDirs) {
|
||||
const directory = path.join(evidenceRoot, directoryName);
|
||||
const manifestPath = path.join(directory, "manifest.json");
|
||||
let manifest;
|
||||
try { manifest = json(manifestPath); } catch (error) {
|
||||
addIssue({ code: "MANIFEST_INVALID", task: directoryName, detail: error.message });
|
||||
continue;
|
||||
}
|
||||
const task = manifest.task;
|
||||
if (task !== directoryName) addIssue({ code: "TASK_DIRECTORY_MISMATCH", task: directoryName, detail: manifest.task });
|
||||
const statusPath = path.join(root, "docs/status", `${task}.md`);
|
||||
const contextPath = path.join(directory, "task-context.json");
|
||||
const statusExists = fs.existsSync(statusPath);
|
||||
const contextExists = fs.existsSync(contextPath);
|
||||
if (!statusExists) addIssue({ code: "MISSING_STATUS", task });
|
||||
if (!contextExists) addIssue({ code: "MISSING_TASK_CONTEXT", task });
|
||||
if (statusExists) {
|
||||
const statusText = read(statusPath);
|
||||
if (!new RegExp(`status\\s*:\\s*${manifest.status}`, "iu").test(statusText)) {
|
||||
addIssue({ code: "STATUS_MISMATCH", task, detail: `manifest=${manifest.status}` });
|
||||
}
|
||||
}
|
||||
if (contextExists) {
|
||||
try {
|
||||
const context = json(contextPath);
|
||||
if (context.task !== task) addIssue({ code: "CONTEXT_TASK_MISMATCH", task, detail: context.task });
|
||||
if (context.parentTask !== manifest.parentTask) addIssue({ code: "CONTEXT_PARENT_MISMATCH", task, detail: `${context.parentTask}!=${manifest.parentTask}` });
|
||||
} catch (error) {
|
||||
addIssue({ code: "CONTEXT_INVALID", task, detail: error.message });
|
||||
}
|
||||
}
|
||||
const artifactIssues = [];
|
||||
for (const artifact of Object.values(manifest.artifacts ?? {})) {
|
||||
if (!artifact?.path || !artifact?.sha256) {
|
||||
artifactIssues.push({ code: "ARTIFACT_DECLARATION_INVALID" });
|
||||
continue;
|
||||
}
|
||||
const artifactPath = path.join(root, artifact.path);
|
||||
if (!fs.existsSync(artifactPath)) artifactIssues.push({ code: "ARTIFACT_MISSING", path: artifact.path });
|
||||
else if (sha256(artifactPath) !== artifact.sha256) artifactIssues.push({ code: "ARTIFACT_HASH_DRIFT", path: artifact.path });
|
||||
}
|
||||
for (const issue of artifactIssues) addIssue({ ...issue, task });
|
||||
const completeEvidence = manifest.status === "done" && statusExists && contextExists && artifactIssues.length === 0;
|
||||
records.push({ task, gapId: taskToGap.get(task) ?? null, parentTask: manifest.parentTask, status: manifest.status, completeEvidence });
|
||||
}
|
||||
|
||||
const active = records.filter((record) => record.status === "in_progress");
|
||||
if (active.length !== 1) addIssue({ code: "ACTIVE_TASK_COUNT", detail: `count=${active.length}` });
|
||||
if (parentManifest.nextTask !== queueTask) addIssue({ code: "PARENT_NEXT_TASK_MISMATCH", detail: `${parentManifest.nextTask}!=${queueTask}` });
|
||||
if (active[0]?.task !== queueTask) addIssue({ code: "ACTIVE_TASK_MISMATCH", detail: `${active[0]?.task ?? "NONE"}!=${queueTask}` });
|
||||
|
||||
const generatedPlanTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "blender-corrective-plan-"));
|
||||
const generatedPlanTemp = path.join(generatedPlanTempRoot, "next-task-plan.json");
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(root, "tools/web/generate-blender-next-task-plan.mjs"), generatedPlanTemp], { cwd: root, stdio: "ignore" });
|
||||
if (!fs.readFileSync(generatedPlanTemp).equals(fs.readFileSync(planPath))) {
|
||||
addIssue({ code: "PLAN_NONDETERMINISTIC", path: relative(planPath), detail: "generator output differs from committed plan" });
|
||||
}
|
||||
} catch (error) {
|
||||
addIssue({ code: "PLAN_GENERATION_FAILED", detail: error.message });
|
||||
} finally {
|
||||
fs.rmSync(generatedPlanTempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const eligibleCompletedTaskIds = records.filter((record) => record.completeEvidence).map((record) => record.task).sort();
|
||||
const eligibleCompletedGaps = records.filter((record) => record.completeEvidence && record.gapId).map((record) => record.gapId).sort();
|
||||
const registeredCompletedTasks = json(completionPath);
|
||||
const eligibleSet = new Set(eligibleCompletedGaps);
|
||||
const registeredSet = new Set(registeredCompletedTasks);
|
||||
const manifestDoneTasks = records.filter((record) => record.status === "done").map((record) => record.task).sort();
|
||||
const manifestDoneGaps = records.filter((record) => record.status === "done" && record.gapId).map((record) => record.gapId).sort();
|
||||
const issueSummary = [...issueCounts.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([code, count]) => ({
|
||||
code,
|
||||
count,
|
||||
taskCount: issueTasks.get(code)?.size ?? 0,
|
||||
sampleTasks: [...(issueTasks.get(code) ?? [])].sort().slice(0, 5),
|
||||
pathCount: issuePaths.get(code)?.size ?? 0,
|
||||
samplePaths: [...(issuePaths.get(code) ?? [])].sort().slice(0, 5),
|
||||
}));
|
||||
const repairPlan = {
|
||||
actions: [
|
||||
{ code: "MISSING_TASK_CONTEXT", priority: "P1", action: "repair only when the task parent pointer is independently valid; otherwise preserve BLOCKED", writes: ["corrective evidence only"] },
|
||||
{ code: "ARTIFACT_HASH_DRIFT", priority: "P0", action: "obtain an immutable source snapshot or classify the task BLOCKED; never rewrite historical manifests", writes: [] },
|
||||
{ code: "STATUS_MISMATCH", priority: "P0", action: "compare manifest/status provenance and stop; do not normalize by hand", writes: [] },
|
||||
{ code: "PARENT_NEXT_TASK_MISMATCH", priority: "P0", action: "repair through the repository generator only after evidence review", writes: [] },
|
||||
{ code: "PLAN_NONDETERMINISTIC", priority: "P0", action: "rebuild generator inputs in staging and compare canonical bytes", writes: ["corrective evidence only"] },
|
||||
],
|
||||
approvalRequired: true,
|
||||
queueMutationAllowed: false,
|
||||
};
|
||||
const digestInput = JSON.stringify({ sourceHashes, records, issueSummary, eligibleCompletedTaskIds, eligibleCompletedGaps, registeredCompletedTasks });
|
||||
const inputDigest = crypto.createHash("sha256").update(digestInput).digest("hex");
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
operation: "CORRECTIVE_STATE_RECONCILIATION",
|
||||
mode: dryRun ? "dry-run" : "write",
|
||||
status: issues.length === 0 ? "PASS" : "BLOCKED",
|
||||
queueMutation: false,
|
||||
queue: { currentTask: queueTask, parentManifest: parentManifestPath, parentNextTask: parentManifest.nextTask },
|
||||
inputTaskCount: records.length,
|
||||
manifestDoneCount: manifestDoneTasks.length,
|
||||
registeredCompletedCount: registeredCompletedTasks.length,
|
||||
eligibleCompletedTaskIds,
|
||||
eligibleCompletedGaps,
|
||||
registryComparison: {
|
||||
eligibleNotRegistered: eligibleCompletedGaps.filter((gap) => !registeredSet.has(gap)),
|
||||
registeredNotEligible: registeredCompletedTasks.filter((gap) => !eligibleSet.has(gap)),
|
||||
manifestDoneNotEligible: manifestDoneGaps.filter((gap) => !eligibleSet.has(gap)),
|
||||
},
|
||||
candidateActiveTask: active.length === 1 ? active[0].task : null,
|
||||
sourceHashes,
|
||||
inputDigest,
|
||||
issueSummary,
|
||||
issueCount: issues.length,
|
||||
issues,
|
||||
repairPlan,
|
||||
writeOutcome: write ? (issues.length === 0 ? "CANDIDATE_NOT_IMPLEMENTED" : "NO_FILES_WRITTEN") : "DRY_RUN_ONLY",
|
||||
};
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
if (write && issues.length === 0) {
|
||||
fs.mkdirSync(outputRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(outputRoot, "completion-registry.candidate.json"), `${JSON.stringify({ schemaVersion: 1, tasks: eligibleCompletedGaps }, null, 2)}\n`);
|
||||
}
|
||||
process.stdout.write(`corrective-reconcile-${report.status.toLowerCase()} mode=${report.mode} tasks=${records.length} eligible=${eligibleCompletedGaps.length} issues=${issues.length} report=${relative(reportPath)}\n`);
|
||||
if (issues.length !== 0) process.exitCode = 2;
|
||||
@@ -24,19 +24,20 @@ const tokenEstimate = (value) => Math.ceil(Buffer.byteLength(value, "utf8") / 4)
|
||||
|
||||
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||||
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
||||
const taskIndexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
||||
const legacyTaskIndexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
||||
const capabilityTaskIndexPath = path.join(root, "tests/golden/WBV2/task-index.json");
|
||||
const taskIndexPathFor = (task) => task?.startsWith("WBV2-") ? capabilityTaskIndexPath : legacyTaskIndexPath;
|
||||
const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
let cachedTaskIndex;
|
||||
let taskIndexLoaded = false;
|
||||
const cachedTaskIndexes = new Map();
|
||||
const safeName = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "gap";
|
||||
const expandTask = (task) => ({
|
||||
...task,
|
||||
fixture: { path: `tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`, state: "REQUIRED" },
|
||||
desktopCommand: `build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/${task.id}.py -- tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`,
|
||||
webCommand: `npm --prefix web run test:generated-gap -- --task ${task.id}`,
|
||||
comparator: `node tools/web/check-generated-gap.mjs --task ${task.id}`,
|
||||
exitCriteria: ["desktop fixture evidence exists", "WASM uses the same fixture", "comparator passes", "save/reopen preserves Main", "manifest hashes all artifacts"],
|
||||
});
|
||||
const expandTask = (task) => task.id.startsWith("WBV2-") ? task : ({
|
||||
...task,
|
||||
fixture: { path: `tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`, state: "REQUIRED" },
|
||||
desktopCommand: `build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/${task.id}.py -- tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`,
|
||||
webCommand: `npm --prefix web run test:generated-gap -- --task ${task.id}`,
|
||||
comparator: `node tools/web/check-generated-gap.mjs --task ${task.id}`,
|
||||
exitCriteria: ["desktop fixture evidence exists", "WASM uses the same fixture", "comparator passes", "save/reopen preserves Main", "manifest hashes all artifacts"],
|
||||
});
|
||||
|
||||
export function parseQueue() {
|
||||
const source = read(queuePath);
|
||||
@@ -48,23 +49,25 @@ export function parseQueue() {
|
||||
return { path: queuePath, source, currentTask: current, parentManifest };
|
||||
}
|
||||
|
||||
function loadTaskIndex() {
|
||||
if (taskIndexLoaded) return cachedTaskIndex;
|
||||
taskIndexLoaded = true;
|
||||
function loadTaskIndex(task = parseQueue().currentTask) {
|
||||
const taskIndexPath = taskIndexPathFor(task);
|
||||
if (cachedTaskIndexes.has(taskIndexPath)) return cachedTaskIndexes.get(taskIndexPath);
|
||||
if (!fs.existsSync(taskIndexPath)) {
|
||||
if (fs.existsSync(planPath)) throw new Error(`missing task index; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`);
|
||||
const command = task?.startsWith("WBV2-") ? "node tools/web/generate-v2-task-index.mjs" : "node tools/web/generate-task-index.mjs";
|
||||
if (fs.existsSync(planPath)) throw new Error(`missing task index; run ${command} (${relative(taskIndexPath)})`);
|
||||
return null;
|
||||
}
|
||||
const index = json(taskIndexPath);
|
||||
if (index.schemaVersion !== 1 || !index.source?.path || !index.catalog?.path || !index.entries) {
|
||||
throw new Error(`invalid task index: ${relative(taskIndexPath)}`);
|
||||
}
|
||||
cachedTaskIndex = index;
|
||||
return cachedTaskIndex;
|
||||
cachedTaskIndexes.set(taskIndexPath, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
export function verifyTaskIndex() {
|
||||
const index = loadTaskIndex();
|
||||
export function verifyTaskIndex(task) {
|
||||
const taskIndexPath = taskIndexPathFor(task ?? parseQueue().currentTask);
|
||||
const index = loadTaskIndex(task);
|
||||
if (!index) return null;
|
||||
const sourcePath = path.join(root, index.source.path);
|
||||
const catalogPath = path.join(root, index.catalog.path);
|
||||
@@ -77,7 +80,7 @@ export function verifyTaskIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
function indexedEntry(task, index = loadTaskIndex()) {
|
||||
function indexedEntry(task, index = loadTaskIndex(task)) {
|
||||
const metadata = index?.entries?.[task];
|
||||
if (!metadata) return null;
|
||||
const catalogPath = path.join(root, index.catalog.path);
|
||||
@@ -105,7 +108,7 @@ function planEntry(task) {
|
||||
}
|
||||
|
||||
function planParent(task) {
|
||||
const index = loadTaskIndex();
|
||||
const index = loadTaskIndex(task);
|
||||
if (index?.entries?.[task]) return index.entries[task].previous;
|
||||
return null;
|
||||
}
|
||||
@@ -308,15 +311,16 @@ export function compactTaskContext(context) {
|
||||
}
|
||||
|
||||
function nextFromPlan(task) {
|
||||
const index = loadTaskIndex();
|
||||
const index = loadTaskIndex(task);
|
||||
if (index?.entries?.[task]) return index.entries[task].next;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTaskContext(requestedTask) {
|
||||
verifyTaskIndex();
|
||||
const queue = parseQueue();
|
||||
const task = requestedTask ?? queue.currentTask;
|
||||
const taskIndexPath = taskIndexPathFor(task);
|
||||
verifyTaskIndex(task);
|
||||
const taskPath = path.join(root, "docs/tasks", `${task}.md`);
|
||||
const entry = planEntry(task);
|
||||
const taskExists = fs.existsSync(taskPath);
|
||||
|
||||
74
tools/web/write-corrective-handoff.mjs
Normal file
74
tools/web/write-corrective-handoff.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
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)), "../..");
|
||||
|
||||
function arg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const task = arg("--task");
|
||||
const status = arg("--status");
|
||||
const gate = arg("--gate");
|
||||
const next = arg("--next");
|
||||
const evidenceRootArg = arg("--evidence-root");
|
||||
const sourceHashesArg = arg("--source-hashes");
|
||||
const commandExitCodesArg = arg("--command-exit-codes");
|
||||
const resolutionArg = arg("--resolution");
|
||||
const queueMutationArg = arg("--queue-mutation");
|
||||
const queueMutation = queueMutationArg === undefined ? false : queueMutationArg === "true";
|
||||
if (!task || !status || !gate || !next || !evidenceRootArg) {
|
||||
throw new Error("usage: node tools/web/write-corrective-handoff.mjs --task C0-xxx --status done --gate G0 --next C0-xxx --evidence-root tests/golden/corrective/C0-xxx [--queue-mutation true]");
|
||||
}
|
||||
|
||||
const evidenceRoot = path.resolve(root, evidenceRootArg);
|
||||
if (!evidenceRoot.startsWith(`${root}${path.sep}`)) throw new Error("evidence root must stay inside the repository");
|
||||
if (!fs.existsSync(evidenceRoot) || !fs.statSync(evidenceRoot).isDirectory()) throw new Error(`missing evidence root: ${evidenceRootArg}`);
|
||||
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const reports = ["dry-run.json", "write.json"]
|
||||
.map((name) => path.join(evidenceRoot, name))
|
||||
.filter((file) => fs.existsSync(file))
|
||||
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
const resolution = resolutionArg && fs.existsSync(path.resolve(root, resolutionArg)) ? JSON.parse(fs.readFileSync(path.resolve(root, resolutionArg), "utf8")) : null;
|
||||
const inputDigests = [...new Set([...reports.map((report) => report.inputDigest), resolution?.inputDigest].filter(Boolean))];
|
||||
if (inputDigests.length > 1 && !resolution) throw new Error("dry-run and write inputDigest differ");
|
||||
const sourceHashValue = sourceHashesArg && fs.existsSync(path.resolve(root, sourceHashesArg)) ? JSON.parse(fs.readFileSync(path.resolve(root, sourceHashesArg), "utf8")) : resolution?.sourceHashes ?? reports.find((report) => report.sourceHashes)?.sourceHashes ?? null;
|
||||
const sourceHashes = sourceHashValue?.sourceHashes ?? sourceHashValue;
|
||||
const commandExitValue = commandExitCodesArg && fs.existsSync(path.resolve(root, commandExitCodesArg)) ? JSON.parse(fs.readFileSync(path.resolve(root, commandExitCodesArg), "utf8")) : resolution?.commandExitCodes ?? null;
|
||||
const commandExitCodes = commandExitValue?.commandExitCodes ?? commandExitValue;
|
||||
const artifacts = [];
|
||||
const stack = [evidenceRoot];
|
||||
while (stack.length) {
|
||||
const directory = stack.pop();
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) stack.push(file);
|
||||
else if (entry.isFile() && entry.name !== "manifest.json" && entry.name !== "handoff.json") {
|
||||
artifacts.push({ path: relative(file), sha256: sha256(file), bytes: fs.statSync(file).size });
|
||||
}
|
||||
}
|
||||
}
|
||||
artifacts.sort((a, b) => a.path.localeCompare(b.path));
|
||||
const handoff = {
|
||||
schemaVersion: 1,
|
||||
task,
|
||||
status,
|
||||
gate,
|
||||
queueMutation,
|
||||
evidenceRoot: relative(evidenceRoot),
|
||||
artifactCount: artifacts.length,
|
||||
artifacts,
|
||||
...(resolution?.inputDigest ? { inputDigest: resolution.inputDigest } : inputDigests.length === 1 ? { inputDigest: inputDigests[0] } : {}),
|
||||
...(sourceHashes ? { sourceHashes } : {}),
|
||||
...(commandExitCodes ? { commandExitCodes } : {}),
|
||||
...(resolution ? { resolution: { status: resolution.status, writeOutcome: resolution.writeOutcome, authorization: resolution.authorization, candidate: resolution.candidate ?? null } } : {}),
|
||||
nextCorrectiveTask: next,
|
||||
};
|
||||
fs.writeFileSync(path.join(evidenceRoot, "handoff.json"), `${JSON.stringify(handoff, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(evidenceRoot, "manifest.json"), `${JSON.stringify(handoff, null, 2)}\n`);
|
||||
process.stdout.write(`corrective-handoff-ok task=${task} status=${status} gate=${gate} artifacts=${artifacts.length} next=${next}\n`);
|
||||
Reference in New Issue
Block a user