Files
FreeCADWasmDev/tools/reconcile_business_acceptance.py

103 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Explain every business requirement without promoting unrelated manual coverage."""
import json
from collections import Counter
from datetime import datetime, timezone
from business_acceptance import ROOT, LEDGER, accepted_ids, digest, fingerprint, read, requirements
from prepare_business_acceptance_batch import contract_ids
OUTPUT = ROOT / "docs/current/business-acceptance-reconciliation.json"
def reconcile(rows=None):
rows = requirements() if rows is None else rows
accepted = accepted_ids(rows)
ledger = read(LEDGER).get("records", {})
covered = {module: contract_ids(module) for module in {row["module"] for row in rows}}
manual_path = ROOT / "docs/current/user-tests-20260915/manual-coverage.json"
manual = read(manual_path)
mapping_data = read(ROOT / "tools/manual-business-mapping.json")
mapping = mapping_data.get("records", {})
references = {key: set(value) for key, value in mapping.items()}
for manual_id, requirement_ids in mapping_data.get("supportingOnly", {}).items():
for requirement_id in requirement_ids:
references.setdefault(requirement_id, set()).add(manual_id)
manual_rows = {row["id"]: row for row in manual.get("coverage", [])}
records = []
for row in rows:
record_id = row["id"]
entry = ledger.get(record_id, {})
executable = record_id in covered[row["module"]]
if record_id in accepted:
status, reason = "passed", "Current source, artifact, case dependencies and result evidence match."
elif entry.get("status") == "failed":
status = "failed" if entry.get("lastFailureFingerprint") == fingerprint(row) else "historical_failure"
reason = entry.get("lastFailure", "Recorded business failure")
elif entry.get("status") == "passed":
status = "stale_evidence"
reason = ("Source or artifact fingerprint changed." if entry.get("fingerprint") != fingerprint(row)
else "Evidence file, assertions or case dependency fingerprints no longer match.")
elif executable:
status, reason = "ready_to_run", "Declared executable case has no current passing evidence."
else:
status, reason = "needs_case", "No executable business case declares this requirement. Route registration is not acceptance."
records.append({"id": record_id, "module": row["module"], "command": row.get("command"),
"status": status, "reason": reason, "executable": executable,
"evidencePath": entry.get("lastFailureEvidencePath") if status in {"failed", "historical_failure"} else entry.get("evidencePath"),
"updatedAt": entry.get("updatedAt"),
"manualReferences": [{"id": key, "status": manual_rows.get(key, {}).get("status", "not_run"),
"evidence": manual_rows.get(key, {}).get("evidence", [])}
for key in sorted(references.get(record_id, []))]})
counts = dict(Counter(row["status"] for row in records))
modules = [{"module": module, "total": sum(row["module"] == module for row in records),
"counts": dict(Counter(row["status"] for row in records if row["module"] == module))}
for module in sorted(covered)]
known_ids = {row["id"] for row in rows}
unmapped_manual = [key for key in manual_rows if not any(key in ids for ids in references.values())]
unknown_mappings = sorted(set(references) - known_ids)
return {"schema": "freecad-web-business-acceptance-reconciliation/v1",
"generatedAt": datetime.now(timezone.utc).isoformat(), "total": len(rows),
"reconciliationComplete": len(known_ids) == len(rows) and not unmapped_manual and not unknown_mappings,
"businessAcceptanceComplete": len(accepted) == len(rows) and bool(rows),
"integrity": {"duplicateRequirementIds": len(rows) - len(known_ids),
"unmappedManualItems": unmapped_manual, "unknownMappedRequirements": unknown_mappings},
"passed": len(accepted), "remaining": len(rows) - len(accepted), "counts": counts,
"executable": sum(row["executable"] for row in records),
"missingCases": sum(not row["executable"] and row["status"] != "passed" for row in records),
"manualCoverage": {"path": str(manual_path.relative_to(ROOT)), "sha256": digest(manual_path),
"counts": manual.get("counts", {}), "mappedRequirements": len(mapping),
"items": [{"id": key, "title": row.get("title"), "status": row.get("status"),
"relatedRequirements": sorted(record_id for record_id, ids in references.items() if key in ids),
"evidence": row.get("evidence", []),
"acceptanceEffect": "reference_only; current business proof checked separately"}
for key, row in manual_rows.items()],
"note": "Cumulative manual outcomes are references only; acceptance requires the current fingerprinted business runner evidence."},
"modules": modules, "records": records}
def write_report(report):
OUTPUT.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
lines = ["# Business Acceptance Reconciliation", "", f"Updated: {report['generatedAt']}", "",
f"Passed {report['passed']}/{report['total']}; remaining {report['remaining']}; missing executable cases {report['missingCases']}.",
"", "The 21-item manual is a separate sample suite. Module entry checks and build receipts never close command acceptance.",
"", "| Module | Total | Passed | Failed | Historical failure | Stale | Ready | Needs case |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"]
for module in report["modules"]:
counts = module["counts"]
lines.append(f"| {module['module']} | {module['total']} | " + " | ".join(str(counts.get(key, 0)) for key in
("passed", "failed", "historical_failure", "stale_evidence", "ready_to_run", "needs_case")) + " |")
lines.extend(["", "Every requirement, reason, evidence path and manual reference is retained in business-acceptance-reconciliation.json.",
"Failed and untested items remain in the denominator. No scope reduction or automatic boundary acceptance is applied."])
lines.extend(["", "## Manual Crosswalk", "", "These are supporting references, not additional acceptance passes.", "",
"| Manual item | Recorded status | Related requirements |", "| --- | --- | --- |"])
for item in report["manualCoverage"]["items"]:
lines.append(f"| {item['id']} | {item['status']} | {', '.join(item['relatedRequirements']) or 'unmapped'} |")
OUTPUT.with_suffix(".md").write_text("\n".join(lines) + "\n")
if __name__ == "__main__":
result = reconcile()
write_report(result)
print(json.dumps({key: result[key] for key in ("total", "passed", "remaining", "counts", "executable", "missingCases")}))