#!/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())