120 lines
5.5 KiB
Python
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())
|