Files
FreeCADWasmDev/tools/validate_incremental_context.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

120 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Validate only the active task's incremental batch/context contract.
This deliberately does not scan the full task graph, API inventory, matrix or
full-parity gate. Those checks are reserved for an explicit post-implementation
full-validation command.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CURRENT = ROOT / "docs/current"
STATE = CURRENT / "task-state.json"
CHECKPOINT = CURRENT / "continuation-state.json"
BATCH = CURRENT / "module-batch-manifest.json"
CONTEXT_DIR = CURRENT / "context"
BUDGET = CURRENT / "FREECAD_WEB_CONTEXT_BUDGET.json"
def load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args()
errors: list[str] = []
try:
state = load(STATE)
checkpoint = load(CHECKPOINT)
batch = load(BATCH)
budget = load(BUDGET)
except (OSError, json.JSONDecodeError) as exc:
print(f"INCREMENTAL_CONTEXT_INVALID: {exc}")
return 1
active = state.get("active_task")
counts = state.get("counts", {}) if isinstance(state.get("counts"), dict) else {}
if not active:
if counts.get("planned", 0) or counts.get("in_progress", 0) or counts.get("blocked", 0):
errors.append("active_task is empty while non-terminal work remains")
else:
if checkpoint.get("activeTask") != active:
errors.append("checkpoint activeTask does not match task ledger")
if counts.get("in_progress") != 1:
errors.append("incremental execution requires exactly one in_progress task")
if batch.get("taskId") not in (None, active):
errors.append("batch taskId does not match active task")
if batch.get("status") == "ready":
selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {}
records = batch.get("records") if isinstance(batch.get("records"), list) else []
minimum = int(checkpoint.get("batchPolicy", {}).get("minRecords", 2))
record_count = int(selected.get("recordCount", len(records)) or 0)
if record_count != len(records):
errors.append("batch selected.recordCount does not match records length")
if record_count < minimum and batch.get("singletonFallback") is not True:
errors.append("batch is below minimum without singletonFallback")
ids = [row[0] for row in records if isinstance(row, list) and row]
if len(ids) != len(set(ids)):
errors.append("batch contains duplicate record ids")
if checkpoint.get("batchId") not in (None, batch.get("batchId")):
errors.append("checkpoint batchId does not match active batch")
elif batch.get("status") not in (None, "no_ready_batch"):
errors.append("batch status is invalid")
manifest_path = CONTEXT_DIR / f"{active}.json"
try:
manifest = load(manifest_path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"active context manifest unavailable: {exc}")
manifest = {}
if manifest:
if manifest.get("task_id") != active or manifest.get("active_task") != active:
errors.append("context manifest task identity mismatch")
limits = manifest.get("budget", {}) if isinstance(manifest.get("budget"), dict) else {}
totals = manifest.get("totals", {}) if isinstance(manifest.get("totals"), dict) else {}
for total_key, limit_key in (("bytes", "total_bytes_limit"), ("evidence_bytes", "evidence_bytes_limit"), ("evidence_files", "evidence_file_limit")):
if limit_key in limits and int(totals.get(total_key, 0) or 0) > int(limits[limit_key]):
errors.append(f"context {total_key} exceeds incremental budget")
for entry in manifest.get("files", []):
if not isinstance(entry, dict) or entry.get("mode") != "read":
continue
path = ROOT / str(entry.get("path", ""))
if not path.is_file():
errors.append(f"context input missing: {entry.get('path')}")
else:
stat = path.stat()
recorded_size = entry.get("bytes")
recorded_mtime = entry.get("mtime_ns")
if recorded_size is not None and recorded_mtime is not None:
unchanged = stat.st_size == int(recorded_size) and stat.st_mtime_ns == int(recorded_mtime)
else:
unchanged = False
if not unchanged and hashlib.sha256(path.read_bytes()).hexdigest() != entry.get("sha256"):
errors.append(f"context input hash changed: {entry.get('path')}")
if errors:
if not args.quiet:
print("INCREMENTAL_CONTEXT_INVALID")
for error in errors:
print(f"- {error}")
return 1
selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {}
if not args.quiet:
print(
f"INCREMENTAL_CONTEXT_READY: active={active or 'none'} "
f"batch={batch.get('batchId') or 'none'} records={selected.get('recordCount', 0)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())