162 lines
9.9 KiB
Python
162 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Reuse completed implementations in bounded, executable business workflows."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from collections import Counter
|
|
from datetime import datetime, timezone
|
|
|
|
from business_acceptance import ROOT, accepted_ids, read, requirements
|
|
from implementation_first import load_registry
|
|
from prepare_feature_capability_batch import MODULE_ORDER
|
|
from scheduler_progress import cooling_ids, retry_details
|
|
from migration_workflows import DELIVERY_BATCH_SIZE, DELIVERY_MODULE_ORDER, PRODUCT_ORDER, delivery_route, desktop_command, workflow_family, workflow_order
|
|
|
|
|
|
def contract_ids(module: str) -> set[str]:
|
|
contract = read(ROOT / f"docs/business-acceptance/{module}.json")
|
|
command = contract.get("command", [])
|
|
if not isinstance(command, list) or len(command) < 2 or command[0] not in {"node", "python3"}:
|
|
return set()
|
|
script = (ROOT / str(command[1])).resolve()
|
|
if not script.is_relative_to(ROOT) or not script.is_file():
|
|
return set()
|
|
return {value for value in contract.get("coveredIds", []) if isinstance(value, str)}
|
|
|
|
|
|
def inventory() -> dict:
|
|
rows = requirements()
|
|
accepted = accepted_ids(rows)
|
|
receipts = load_registry().get("records", {})
|
|
ready = {record_id for record_id, entry in receipts.items()
|
|
if entry.get("status") in {"implementation_ready", "implemented_pending_use"}}
|
|
pending = [row for row in rows if row.get("implementationStatus", "routed") == "routed"
|
|
and row["id"] not in accepted and (row["id"] in ready or row.get("status") == "passed")]
|
|
covered = {module: contract_ids(module) for module in {row["module"] for row in pending}}
|
|
build_overrides = {module: read(ROOT / f"docs/business-acceptance/{module}.json").get("buildOverrides", {}) for module in covered}
|
|
cooling = cooling_ids(candidates={row["id"] for row in pending})
|
|
return {"rows": rows, "accepted": accepted, "pending": pending, "covered": covered,
|
|
"cooling": cooling, "receipts": receipts, "buildOverrides": build_overrides}
|
|
|
|
|
|
def workflow(row: dict, receipts: dict) -> str:
|
|
if desktop_command(row):
|
|
return receipts.get(row["id"], {}).get("family") or "desktop-command"
|
|
return workflow_family(row)
|
|
|
|
|
|
def candidates(state: dict, covered_only: bool = False, delivery_only: bool = False, uncovered_only: bool = False,
|
|
exclude_module: str | None = None) -> list[dict]:
|
|
groups = {}
|
|
for row in state["pending"]:
|
|
if row["module"] == exclude_module:
|
|
continue
|
|
if delivery_only and not delivery_route(row):
|
|
continue
|
|
if row["id"] in state["cooling"]:
|
|
continue
|
|
executable = row["id"] in state["covered"][row["module"]]
|
|
if covered_only and not executable:
|
|
continue
|
|
if uncovered_only and executable:
|
|
continue
|
|
family = "existing-business-cases" if executable else workflow(row, state["receipts"])
|
|
override = state.get("buildOverrides", {}).get(row["module"], {}).get(row["id"]) if executable else None
|
|
key = (row["module"], family, json.dumps(override, sort_keys=True))
|
|
groups.setdefault(key, {"module": row["module"], "family": family,
|
|
"executable": executable, "delivery": delivery_route(row), "rows": []})["rows"].append(row)
|
|
groups[key]["delivery"] = groups[key]["delivery"] or delivery_route(row)
|
|
def priority(group):
|
|
family = group["family"]
|
|
return (not group["executable"], not group["delivery"], family == "supporting-business-route",
|
|
DELIVERY_MODULE_ORDER.get(group["module"], 20 + MODULE_ORDER.get(group["module"], 50)) if group["delivery"] else MODULE_ORDER.get(group["module"], 50),
|
|
group["module"], workflow_order(family))
|
|
return sorted(groups.values(), key=priority)
|
|
|
|
|
|
def plan(state: dict, covered_only: bool = False, delivery_only: bool = False, uncovered_only: bool = False,
|
|
exclude_module: str | None = None) -> dict:
|
|
groups = candidates(state, covered_only, delivery_only, uncovered_only, exclude_module)
|
|
deferred = {row["id"] for row in state["pending"] if row["id"] in state["cooling"]
|
|
and (not delivery_only or delivery_route(row))
|
|
and (not covered_only or row["id"] in state["covered"][row["module"]])
|
|
and (not uncovered_only or row["id"] not in state["covered"][row["module"]])
|
|
and row["module"] != exclude_module}
|
|
if not groups:
|
|
return {"schema": "freecad-web-module-business-batch/v1", "status": "no_ready_batch", "records": [],
|
|
"reason": "no business acceptance batch ready", **retry_details(deferred)}
|
|
group = groups[0]
|
|
module = group["module"]
|
|
limit = 16 if group["executable"] else DELIVERY_BATCH_SIZE
|
|
selected = sorted(group["rows"], key=lambda row: (PRODUCT_ORDER.get(row["id"], 100), row["id"]))[:limit]
|
|
identity = "|".join([module, "business-acceptance", "business-acceptance", *(row["id"] for row in selected)])
|
|
return {
|
|
"schema": "freecad-web-module-business-batch/v1", "status": "ready",
|
|
"strategy": "module-business-first-deferred-validation",
|
|
"taskId": read(ROOT / "docs/current/task-state.json").get("active_task"),
|
|
"batchId": hashlib.sha256(identity.encode()).hexdigest()[:24],
|
|
"minimumRecords": 1, "singletonFallback": len(selected) == 1,
|
|
"selected": {"module": module, "tier": "business-acceptance", "family": "business-acceptance", "recordCount": len(selected)},
|
|
"businessWorkflow": group["family"], "acceptanceReady": group["executable"],
|
|
"businessLane": "development" if uncovered_only else "acceptance",
|
|
"selectionScope": "delivery" if delivery_only else "full",
|
|
"fields": ["id", "command", "status", "source", "sourceLine"],
|
|
"records": [[row["id"], row["command"], "acceptance_required", row.get("sourcePath") or row.get("source") or "web/src/module-router.ts", row.get("sourceLine", 1)] for row in selected],
|
|
"acceptanceContract": f"docs/business-acceptance/{module}.json",
|
|
"queueGroupCount": len(groups),
|
|
"pendingBusinessCount": len(state["pending"]),
|
|
"queue": [{"module": item["module"], "family": item["family"], "unresolved": len(item["rows"]),
|
|
"acceptanceReady": item["executable"]} for item in groups[:8]],
|
|
}
|
|
|
|
|
|
def write_plan_report(state: dict) -> dict:
|
|
total = Counter(row["module"] for row in state["rows"])
|
|
accepted = Counter(row["module"] for row in state["rows"] if row["id"] in state["accepted"])
|
|
pending = Counter(row["module"] for row in state["pending"])
|
|
executable = Counter(row["module"] for row in state["pending"] if row["id"] in state["covered"][row["module"]])
|
|
modules = [{"module": module, "total": total[module], "accepted": accepted[module],
|
|
"reusablePending": pending[module], "executablePending": executable[module],
|
|
"needsCases": pending[module] - executable[module],
|
|
"implementationGap": total[module] - accepted[module] - pending[module]}
|
|
for module in sorted(total, key=lambda name: (MODULE_ORDER.get(name, 50), name))]
|
|
report = {"generatedAt": datetime.now(timezone.utc).isoformat(), "total": len(state["rows"]),
|
|
"accepted": len(state["accepted"]), "reusablePending": len(state["pending"]),
|
|
"executablePending": sum(executable.values()), "modules": modules,
|
|
"nextBatch": plan(state), "policy": "executable-cases-first; alternate acceptance-development and implementation"}
|
|
output = ROOT / "docs/current/business-acceptance-plan.json"
|
|
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
lines = ["# Business Acceptance Plan", "", f"Accepted: {report['accepted']}/{report['total']}. Reusable pending: {report['reusablePending']}; executable pending: {report['executablePending']}.",
|
|
"", "Implementation receipts are candidates for acceptance, not proof of functional completion.", "",
|
|
"| Module | Total | Accepted | Reusable Pending | Executable | Needs Cases | Implementation Gap |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"]
|
|
lines.extend("| " + " | ".join(str(row[key]) for key in ("module", "total", "accepted", "reusablePending", "executablePending", "needsCases", "implementationGap")) + " |" for row in modules)
|
|
output.with_suffix(".md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return report
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--covered-only", action="store_true")
|
|
parser.add_argument("--delivery-only", action="store_true")
|
|
parser.add_argument("--uncovered-only", action="store_true", help="select existing implementations that need real business cases")
|
|
parser.add_argument("--exclude-module", help="leave the current blocked module intact while another module advances")
|
|
parser.add_argument("--report-only", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
if args.covered_only and args.uncovered_only:
|
|
parser.error("--covered-only and --uncovered-only are mutually exclusive")
|
|
state = inventory()
|
|
if args.report_only:
|
|
report = write_plan_report(state)
|
|
print(json.dumps({key: report[key] for key in ("accepted", "total", "reusablePending", "executablePending")}))
|
|
return 0
|
|
payload = plan(state, args.covered_only, args.delivery_only, args.uncovered_only, args.exclude_module)
|
|
payload["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
(ROOT / "docs/current/module-batch-manifest.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps({"status": payload["status"], "remaining": len(state["pending"]), "selected": payload.get("selected"),
|
|
"acceptanceReady": payload.get("acceptanceReady")}))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|