Files
FreeCADWasmDev/tools/validate_web_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

248 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Validate bounded task context and cross-document state consistency."""
from __future__ import annotations
import hashlib
import json
import re
import sys
from pathlib import Path
from web_context import context_manifest, load_cards, predecessor_ids
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
CURRENT = DOCS / "current"
TASKS = DOCS / "tasks"
BUDGET = CURRENT / "FREECAD_WEB_CONTEXT_BUDGET.json"
STATE = CURRENT / "task-state.json"
PROGRESS = CURRENT / "FREECAD_WEB_PROGRESS.md"
PLAN = CURRENT / "FREECAD_WEB_EXECUTION_PLAN.md"
MIN_CONTEXT = CURRENT / "FREECAD_WEB_CONTEXT_MIN.md"
P6_INDEX = TASKS / "p6" / "INDEX.md"
MATRIX = DOCS / "web-parity-matrix.json"
MATRIX_MD = DOCS / "web-parity-matrix.md"
CARD_ID = re.compile(r"^#\s+(FW-[^:: ]+)", re.MULTILINE)
CARD_STATUS = re.compile(r"^-\s*状态:`([^`]+)`", re.MULTILINE)
ACTIVE = re.compile(r"^\|\s*当前任务\s*\|\s*`([^`]+)`", re.MULTILINE)
STATUS = {"planned", "in_progress", "blocked", "completed"}
def main() -> int:
errors: list[str] = []
budget = json.loads(BUDGET.read_text(encoding="utf-8"))
limits = budget["limits"]
state = json.loads(STATE.read_text(encoding="utf-8"))
matrix = json.loads(MATRIX.read_text(encoding="utf-8"))
for path, limit_key in (
(CURRENT / "INDEX.md", "handoff_bytes"),
(CURRENT / "FREECAD_WEB_HANDOFF.md", "handoff_bytes"),
(MIN_CONTEXT, "handoff_bytes"),
(PLAN, "execution_index_bytes"),
(P6_INDEX, "task_index_bytes"),
):
if path.stat().st_size > limits[limit_key]:
errors.append(f"{path.relative_to(ROOT)} exceeds {limit_key}")
parsed_cards = load_cards()
cards: dict[str, tuple[Path, str]] = {}
runnable_statuses: dict[str, str] = {}
card_predecessors: dict[str, list[str]] = {}
max_card_bytes = 0
max_context_bytes = 0
for card_id, (path, card_status, text) in parsed_cards.items():
cards[card_id] = (path, card_status)
if card_status not in STATUS:
errors.append(f"{card_id}: invalid status {card_status!r}")
if path.stat().st_size > limits["task_card_bytes"]:
errors.append(f"{path.relative_to(ROOT)} exceeds task_card_bytes")
max_card_bytes = max(max_card_bytes, path.stat().st_size)
# P6 module package indexes live directly under p6 and are containers;
# only cards below a module directory count toward the active-task limit.
if "/p6/" in str(path) and path.parent != TASKS / "p6":
runnable_statuses[card_id] = card_status
ids = predecessor_ids(text)
card_predecessors[card_id] = ids
count = len(ids)
if count > limits["direct_prerequisite_count"]:
errors.append(f"{card_id}: {count} direct prerequisites exceeds limit")
for card_id, (path, _) in cards.items():
context_bytes = path.stat().st_size
for predecessor_id in card_predecessors.get(card_id, []):
predecessor = cards.get(predecessor_id)
if predecessor:
context_bytes += predecessor[0].stat().st_size
# Raw dependency-card size is diagnostic only. context_manifest()
# enforces the real execution budget and explicitly excludes completed
# prerequisite bodies that do not fit, while retaining dependency IDs.
max_context_bytes = max(max_context_bytes, context_bytes)
# The executable context is the materialized closure, not just the sum of
# Markdown card sizes. This includes the L0 handoff and explicitly named
# evidence while excluding logs, source trees, and broad inventories.
packet_max_bytes = 0
packet_max_task = None
for card_id in cards:
try:
packet = context_manifest(card_id, cards=parsed_cards)
totals = packet["totals"]
packet_limits = packet["budget"]
if totals["bytes"] > packet_limits["total_bytes_limit"]:
errors.append(f"{card_id}: materialized context exceeds task_context_bytes")
if totals["evidence_bytes"] > packet_limits["evidence_bytes_limit"]:
errors.append(f"{card_id}: direct evidence exceeds direct_evidence_bytes")
if totals["evidence_files"] > packet_limits["evidence_file_limit"]:
errors.append(f"{card_id}: direct evidence exceeds file-count limit")
for item in packet["excluded"]:
if item["reason"] == "missing prerequisite card":
errors.append(f"{card_id}: missing prerequisite card {item['path']}")
if totals["bytes"] > packet_max_bytes:
packet_max_bytes, packet_max_task = totals["bytes"], card_id
except (KeyError, OSError, json.JSONDecodeError) as exc:
errors.append(f"{card_id}: context packet generation failed: {exc}")
active_for_packet = state.get("active_task")
active_packet = None
if active_for_packet in cards:
try:
active_packet = context_manifest(active_for_packet, cards=parsed_cards)
totals = active_packet["totals"]
packet_limits = active_packet["budget"]
if totals["bytes"] > packet_limits["total_bytes_limit"]:
errors.append(f"{active_for_packet}: materialized context exceeds task_context_bytes")
if totals["evidence_bytes"] > packet_limits["evidence_bytes_limit"]:
errors.append(f"{active_for_packet}: direct evidence exceeds direct_evidence_bytes")
if totals["evidence_files"] > packet_limits["evidence_file_limit"]:
errors.append(f"{active_for_packet}: direct evidence exceeds file-count limit")
for item in active_packet["excluded"]:
if item["reason"] == "missing prerequisite card":
errors.append(f"{active_for_packet}: missing prerequisite card {item['path']}")
except (KeyError, OSError, json.JSONDecodeError) as exc:
errors.append(f"{active_for_packet}: context packet generation failed: {exc}")
active = state.get("active_task")
terminal = active is None and state.get("module_status") and all(
status == "completed" for status in state["module_status"].values()
)
if active is None and not terminal:
errors.append("active task may be empty only after all module gates are completed")
progress_match = ACTIVE.search(PROGRESS.read_text(encoding="utf-8"))
progress_active = progress_match.group(1) if progress_match else None
if progress_active == "none":
progress_active = None
if active != progress_active:
errors.append(f"active task mismatch: state={active!r}, progress={progress_active!r}")
handoff_text = (CURRENT / "FREECAD_WEB_HANDOFF.md").read_text(encoding="utf-8", errors="replace")
handoff_active_match = re.search(r"^\|\s*`active_task`\s*\|\s*`([^`]+)`", handoff_text, re.MULTILINE)
handoff_active = handoff_active_match.group(1) if handoff_active_match else None
if handoff_active == "none":
handoff_active = None
if handoff_active != active:
errors.append(f"active task mismatch: state={active!r}, handoff={handoff_active!r}")
handoff_status_match = re.search(r"^\|\s*`status`\s*\|\s*`([^`]+)`", handoff_text, re.MULTILINE)
expected_handoff_status = "completed" if terminal else "in_progress"
if not handoff_status_match or handoff_status_match.group(1) != expected_handoff_status:
errors.append(f"handoff status must be `{expected_handoff_status}`")
handoff_card_match = re.search(r"^\|\s*`task_card`\s*\|\s*.*\(([^)]+)\)", handoff_text, re.MULTILINE)
expected_card_suffix = f"../tasks/{cards[active][0].relative_to(TASKS).as_posix()}" if active in cards else None
if not terminal and (not handoff_card_match or expected_card_suffix not in handoff_card_match.group(1)):
errors.append(f"handoff task_card link does not point to {active}")
handoff_context_match = re.search(r"^\|\s*`context_manifest`\s*\|\s*.*\(([^)]+)\)", handoff_text, re.MULTILINE)
expected_context_suffix = f"context/{active}.json"
if not terminal and (not handoff_context_match or expected_context_suffix not in handoff_context_match.group(1)):
errors.append(f"handoff context_manifest link does not point to {active}")
if active in cards:
expected_predecessors = predecessor_ids(parsed_cards[active][2])
handoff_predecessor_match = re.search(r"^\|\s*`direct_prerequisites`\s*\|\s*(.*?)\s*\|$", handoff_text, re.MULTILINE)
actual_predecessors = re.findall(r"FW-[A-Z0-9-]+", handoff_predecessor_match.group(1)) if handoff_predecessor_match else []
if actual_predecessors != expected_predecessors:
errors.append(f"handoff direct_prerequisites mismatch for {active}")
manifest_path = CURRENT / "context" / f"{active}.json"
if active_packet and not manifest_path.exists():
errors.append(f"missing materialized context manifest: {manifest_path.relative_to(ROOT)}")
elif active_packet and manifest_path.exists():
try:
saved_packet = json.loads(manifest_path.read_text(encoding="utf-8"))
if saved_packet.get("task_id") != active or saved_packet.get("totals") != active_packet.get("totals"):
errors.append(f"stale context manifest for {active}; rerun prepare_web_task_context.py")
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"invalid context manifest for {active}: {exc}")
if not MIN_CONTEXT.exists():
errors.append("missing minimal continuation summary; rerun prepare_web_task_context.py")
elif terminal:
summary = MIN_CONTEXT.read_text(encoding="utf-8", errors="replace")
if "- 当前任务:`none`(`completed`)" not in summary:
errors.append("terminal minimal continuation summary is stale")
else:
summary = MIN_CONTEXT.read_text(encoding="utf-8", errors="replace")
summary_match = re.search(r"^- 当前任务:`([^`]+)`", summary, re.MULTILINE)
if not summary_match or summary_match.group(1) != active:
errors.append(f"minimal continuation summary points to {summary_match.group(1) if summary_match else None!r}")
manifest_hash_match = re.search(r"^- 清单 hash:`([^`]+)`", summary, re.MULTILINE)
if not manifest_hash_match:
errors.append("minimal continuation summary is missing manifest hash")
elif manifest_path.exists():
actual_hash = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
if manifest_hash_match.group(1) != actual_hash:
errors.append(f"stale minimal continuation summary for {active}; rerun prepare_web_task_context.py")
active_card = cards.get(active)
if not terminal and not active_card:
errors.append(f"active task {active!r} has no task card")
elif not terminal and active_card[1] != "in_progress":
errors.append(f"active task {active} card status is {active_card[1]!r}")
active_count = sum(value == "in_progress" for value in runnable_statuses.values())
if terminal and active_count != 0:
errors.append(f"terminal state has {active_count} in_progress runnable cards")
elif active_count > limits["max_active_tasks"]:
errors.append(f"{active_count} runnable cards are in_progress")
state_tasks = state.get("tasks", {})
for card_id, (_, card_status) in cards.items():
if state_tasks.get(card_id) != card_status:
errors.append(f"state/card mismatch for {card_id}: {state_tasks.get(card_id)!r} != {card_status}")
missing_state = set(cards) - set(state_tasks)
if missing_state:
errors.append(f"cards missing from task-state.json: {sorted(missing_state)[:5]}")
stale_state = set(state_tasks) - set(cards)
if stale_state:
errors.append(f"task-state.json contains stale cards: {sorted(stale_state)[:5]}")
matrix_by_module = {row["module"]: row["web_status"] for row in matrix.get("rows", [])}
if state.get("module_status") != matrix_by_module:
errors.append("task-state.json module_status does not match web-parity-matrix.json")
matrix_hash = hashlib.sha256(MATRIX.read_bytes()).hexdigest()
if state.get("source_matrix_sha256") != matrix_hash:
errors.append("task-state.json source_matrix_sha256 is stale")
markdown = MATRIX_MD.read_text(encoding="utf-8")
markdown_by_module = {}
for line in markdown.splitlines():
match = re.match(r"^\| `([^`]+)` \|.*\| `(pending|in_progress|blocked|completed)` \|", line)
if match:
markdown_by_module[match.group(1)] = match.group(2)
if markdown_by_module != matrix_by_module:
errors.append("web-parity-matrix.md statuses do not match web-parity-matrix.json")
if errors:
print("CONTEXT VALIDATION FAILED")
for error in errors:
print(f"- {error}")
return 1
packet_totals = active_packet["totals"] if active_packet else {"bytes": 0, "estimated_tokens": 0}
print(
f"CONTEXT VALIDATION PASSED: {len(cards)} cards, active={active}, "
f"max_card={max_card_bytes}B, max_direct_context={max_context_bytes}B, "
f"max_packet={packet_max_bytes}B ({packet_max_task}), "
f"active_packet={packet_totals['bytes']}B/~{packet_totals['estimated_tokens']} tokens, "
f"budget={limits['task_context_tokens']} estimated tokens"
)
return 0
if __name__ == "__main__":
sys.exit(main())