250 lines
10 KiB
Python
250 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""Aggregate the source-backed P6 gates without upgrading boundaries to parity.
|
||
|
||
The detailed object, format, Python, workbench, view, document and quality
|
||
artifacts remain authoritative. This gate only verifies their coverage and
|
||
combines their terminal dispositions; it never synthesizes native results.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import subprocess
|
||
from collections import Counter
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DOCS = ROOT / "docs"
|
||
P6 = DOCS / "p6"
|
||
STATE_PATH = DOCS / "current" / "task-state.json"
|
||
|
||
|
||
def sha256(path: Path) -> str:
|
||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||
|
||
|
||
def load_json(relative: str) -> tuple[dict, dict]:
|
||
path = ROOT / relative
|
||
value = json.loads(path.read_text(encoding="utf-8"))
|
||
return value, {
|
||
"path": relative,
|
||
"sha256": sha256(path),
|
||
}
|
||
|
||
|
||
def source_revision() -> str:
|
||
try:
|
||
return subprocess.run(
|
||
["git", "-C", str(ROOT / "freecad-source"), "rev-parse", "HEAD"],
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
).stdout.strip()
|
||
except (OSError, subprocess.CalledProcessError):
|
||
return "unknown"
|
||
|
||
|
||
def status_from_counts(counts: dict[str, int], boundaries: list[str]) -> str:
|
||
unresolved = sum(
|
||
value
|
||
for key, value in counts.items()
|
||
if key in {"blocked", "not_implemented", "restricted", "planned", "failed"}
|
||
)
|
||
if unresolved:
|
||
return "pending"
|
||
# Explicit boundaries are conclusions, but are not approved parity.
|
||
return "pending" if boundaries else "passed"
|
||
|
||
|
||
def area(
|
||
area_id: str,
|
||
gate_file: str,
|
||
inventory_records: int | None,
|
||
count_keys: tuple[str, ...],
|
||
) -> tuple[dict, dict]:
|
||
gate, artifact = load_json(gate_file)
|
||
coverage = gate.get("coverage", {})
|
||
counts = coverage.get("counts") or coverage.get("dispositions") or {}
|
||
counts = {str(key): int(value) for key, value in counts.items()}
|
||
terminal = coverage.get("terminalRecords")
|
||
if terminal is None:
|
||
terminal = coverage.get("records")
|
||
if isinstance(terminal, list):
|
||
terminal = len(terminal)
|
||
if terminal is None and inventory_records is not None:
|
||
terminal = inventory_records
|
||
boundaries = [str(item) for item in gate.get("boundaries", [])]
|
||
expected = inventory_records if inventory_records is not None else coverage.get("inventoryRecords")
|
||
complete_records = (
|
||
terminal == expected
|
||
if expected is not None
|
||
else gate.get("result") in {"pass", "pass_with_boundaries", "pass_with_blocked_records"}
|
||
)
|
||
normalized = {key: counts.get(key, 0) for key in count_keys}
|
||
unresolved = sum(
|
||
value
|
||
for key, value in counts.items()
|
||
if key in {"blocked", "not_implemented", "restricted", "planned", "failed"}
|
||
)
|
||
result = {
|
||
"id": area_id,
|
||
"taskId": gate.get("taskId"),
|
||
"gateResult": gate.get("result"),
|
||
"status": status_from_counts(counts, boundaries),
|
||
"inventoryRecords": expected,
|
||
"terminalRecords": terminal,
|
||
"allRecordsTerminal": complete_records,
|
||
"counts": normalized,
|
||
"unresolvedRecords": unresolved,
|
||
"boundaries": boundaries,
|
||
"approvedBoundaries": [],
|
||
"unapprovedBoundaries": boundaries,
|
||
"evidence": [artifact],
|
||
}
|
||
return result, artifact
|
||
|
||
|
||
def main() -> int:
|
||
state = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||
audit, audit_artifact = load_json("docs/p6/full-parity-audit.json")
|
||
|
||
area_specs = [
|
||
("core-shell", "docs/p6/core-shell-gate.json", None, ()),
|
||
("document-persistence", "docs/p6/document-contract-gate.json", None, ()),
|
||
("object-coverage", "docs/p6/object-contract-gate.json", 403, ("passed", "passed_boundary", "blocked")),
|
||
("format-coverage", "docs/p6/format-coverage-gate.json", 236, ("passed", "passed_boundary", "blocked")),
|
||
("python-api", "docs/p6/python-api-full-gate.json", 13385, ("exposed", "restricted", "not_implemented")),
|
||
("workbench-gui", "docs/p6/workbench-command-gate.json", 778, ("native_handler", "restricted", "not_implemented")),
|
||
("rendering-interaction", "docs/p6/view-interaction-gate.json", None, ()),
|
||
("quality-release", "docs/p6/quality-gate.json", None, ()),
|
||
]
|
||
areas: list[dict] = []
|
||
evidence: list[dict] = [audit_artifact]
|
||
for spec in area_specs:
|
||
item, artifact = area(*spec)
|
||
areas.append(item)
|
||
evidence.append(artifact)
|
||
|
||
task_counts = Counter(state.get("tasks", {}).values())
|
||
fpa_tasks = {
|
||
key: value
|
||
for key, value in state.get("tasks", {}).items()
|
||
if key.startswith("FW-P6-FPA-")
|
||
}
|
||
blocked_tasks = sorted(key for key, value in fpa_tasks.items() if value == "blocked")
|
||
planned_recovery_tasks = sorted(
|
||
key for key, value in fpa_tasks.items() if value == "planned" and ("-R" in key or "-FEM" in key or "-MESH" in key)
|
||
)
|
||
|
||
unresolved = sum(int(item["unresolvedRecords"]) for item in areas)
|
||
boundary_count = sum(len(item["unapprovedBoundaries"]) for item in areas)
|
||
all_terminal = all(bool(item["allRecordsTerminal"]) for item in areas)
|
||
parity_ready = unresolved == 0 and boundary_count == 0 and all_terminal and not blocked_tasks
|
||
result = "passed" if parity_ready else "pending"
|
||
report = {
|
||
"schema": "freecad-web-full-parity-gate/v1",
|
||
"taskId": "FW-P6-FPA-Y",
|
||
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
||
"sourceRevision": source_revision(),
|
||
"result": result,
|
||
"parityReady": parity_ready,
|
||
"rule": "Full Parity requires every inventory record to have an implemented native conclusion and zero unapproved boundaries; module completed flags are not sufficient.",
|
||
"baseline": audit.get("baseline", {}),
|
||
"areas": areas,
|
||
"summary": {
|
||
"areas": len(areas),
|
||
"areasPassingSourceGate": sum(item["gateResult"] in {"pass", "pass_with_boundaries"} for item in areas),
|
||
"areasPendingFullParity": sum(item["status"] != "passed" for item in areas),
|
||
"unresolvedRecords": unresolved,
|
||
"unapprovedBoundaryCount": boundary_count,
|
||
"allRecordsTerminal": all_terminal,
|
||
},
|
||
"ledger": {
|
||
"activeTask": state.get("active_task"),
|
||
"fpaTaskCounts": dict(task_counts),
|
||
"blockedTasks": blocked_tasks,
|
||
"plannedRecoveryTasks": planned_recovery_tasks,
|
||
},
|
||
"evidence": evidence,
|
||
"boundaries": [
|
||
"This aggregate does not replace detailed per-record native evidence.",
|
||
"passed_boundary, restricted, not_implemented and blocked dispositions remain unresolved for Full Parity until an explicit approved boundary policy exists.",
|
||
"Fixed Google Chrome evidence is not extended to other browsers or desktop Qt behavior.",
|
||
],
|
||
}
|
||
out_json = P6 / "full-parity-gate.json"
|
||
out_md = P6 / "full-parity-gate.md"
|
||
out_log = DOCS / "freecad-p6-full-parity-y.log"
|
||
out_json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
lines = [
|
||
"# FreeCAD Web Full Parity Gate",
|
||
"",
|
||
f"- 生成时间:`{report['generatedAt']}`",
|
||
f"- 源码 revision:`{report['sourceRevision']}`",
|
||
f"- 结论:`{result}`;`parityReady={str(parity_ready).lower()}`。",
|
||
"- 判据:每条 inventory 记录必须有已实现的 native 结论,且未批准边界必须为 0;模块 `completed` 不能替代逐条证据。",
|
||
"",
|
||
"## 覆盖汇总",
|
||
"",
|
||
f"- 区域:{len(areas)};源码 gate 通过/带边界:{report['summary']['areasPassingSourceGate']};Full Parity 仍待处理:{report['summary']['areasPendingFullParity']}。",
|
||
f"- 未解决记录:{unresolved};未批准边界:{boundary_count};记录终态完整:`{str(all_terminal).lower()}`。",
|
||
"",
|
||
"| 区域 | Gate | 记录 | 未解决 | 未批准边界 | 结论 |",
|
||
"|---|---|---:|---:|---:|---|",
|
||
]
|
||
for item in areas:
|
||
records = f"{item['terminalRecords']}/{item['inventoryRecords']}" if item["inventoryRecords"] is not None else "n/a"
|
||
lines.append(f"| `{item['id']}` | `{item['gateResult']}` | {records} | {item['unresolvedRecords']} | {len(item['unapprovedBoundaries'])} | `{item['status']}` |")
|
||
lines.extend([
|
||
"",
|
||
"## 账本阻断",
|
||
"",
|
||
f"- active task:`{state.get('active_task')}`",
|
||
f"- blocked FPA tasks:{', '.join(f'`{item}`' for item in blocked_tasks) if blocked_tasks else 'none'}",
|
||
f"- planned recovery tasks:{', '.join(f'`{item}`' for item in planned_recovery_tasks) if planned_recovery_tasks else 'none'}",
|
||
"",
|
||
"## 证据",
|
||
"",
|
||
])
|
||
for item in evidence:
|
||
lines.append(f"- `{item['path']}` SHA-256 `{item['sha256']}`")
|
||
lines.extend([
|
||
"",
|
||
"## 边界",
|
||
"",
|
||
"- `passed_boundary`、`restricted`、`not_implemented` 和 `blocked` 均保留为未解决项,不升级为 Full Parity。",
|
||
"- 详细逐条记录和 native dispatcher 证据仍以各 area gate 为准;本聚合不生成几何、API 或浏览器结果。",
|
||
"",
|
||
"## 自动续接状态",
|
||
"",
|
||
"- `nextTask`: Y gate 保持 `pending`,先由恢复卡处理未解决记录,再重新运行本 gate。",
|
||
"- `nextAction`: 优先执行账本中的 D1/D2 blocked family recovery;不得声明 Full Parity。",
|
||
"- `contextRotation`: 结果已落盘;轮换后只读取新 manifest 的 `mode=read` 文件。",
|
||
"- `resumeCommand`: `python3 tools/check_continuation_state.py && python3 tools/validate_web_context.py && python3 tools/prepare_web_task_context.py`",
|
||
])
|
||
out_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
out_log.write_text(
|
||
json.dumps(
|
||
{
|
||
"result": result,
|
||
"parityReady": parity_ready,
|
||
"unresolvedRecords": unresolved,
|
||
"unapprovedBoundaryCount": boundary_count,
|
||
"blockedTasks": blocked_tasks,
|
||
"evidenceCount": len(evidence),
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
+ "\n",
|
||
encoding="utf-8",
|
||
)
|
||
print(json.dumps({"result": result, "summary": report["summary"], "blockedTasks": blocked_tasks}, ensure_ascii=False))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|