103 lines
7.1 KiB
Python
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")}))
|