248 lines
13 KiB
Python
248 lines
13 KiB
Python
#!/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())
|