#!/usr/bin/env python3 """Générateur de l'audit 4Big qualité · Sprint 7 · QA. Roadmap Sprint 7 · QA : « Audit 4Big niveau 95+/100 sur 100% deliverables ». Ce générateur est un audit de MÉTA-NIVEAU : il note la qualité 4Big de TOUS les livrables du mandat à partir de FAITS du dépôt (documentation, contrat de sortie schema, couverture de tests, CLI reproductible, intégrité du hand-off out/) et exige une note ≥ 95/100 sur chacun (CLAUDE.md #5). Le périmètre « 100% des livrables » est PROUVÉ, pas déclaré : le registre est recoupé avec les `working-directory` du CI Gitea (moins l'auditeur lui-même, séparation des pouvoirs · ISA 315). Toute divergence casse le verdict. ANTI-INVENTION (#6) : aucune note n'est saisie à la main — chaque note est RECALCULÉE depuis des faits vérifiables (présence de fichiers, taille, comptage des méthodes test_*, validité JSON du hand-off). Un module dont un critère applicable échoue tombe sous 95 → FAIL global (l'audit est un GATE). Sous-commandes : build [-o OUT] → écrit quality_report.json + MANIFEST.json validate → (re)génère en mémoire, valide schéma + invariants ; sort en erreur si un module < 95, si la couverture n'est pas 100%, ou si un invariant casse. Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable. """ from __future__ import annotations import argparse import json import os import sys _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _HERE) from q4lib import builder, scoring # noqa: E402 from q4lib.deps import validate as maison_validate # noqa: E402 _SPEC_PATH = os.path.join(_HERE, "quality_spec.json") _SCHEMA_PATH = os.path.join(_HERE, "quality.schema.json") _DEFAULT_OUT = os.path.join(_HERE, "out") _KNOWN_CRITERIA = {"DOC", "CONTRAT", "TESTS", "CLI", "HANDOFF"} def _eprint(*args) -> None: print(*args, file=sys.stderr) def _load(path: str) -> dict: with open(path, encoding="utf-8") as fh: return json.load(fh) def _write_json(path: str, data) -> None: with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) fh.write("\n") def _manifest(spec: dict, report: dict) -> dict: return { "generated_from": "quality_spec.json", "generator": "audit_4big_gen.py", "audit": report["audit"], "version": report["version"], "verdict": report["verdict"], "modules_audited": report["totals"]["modules"], "min_score": report["totals"]["min_score"], "pass_score": report["pass_score"], "coverage_ok": report["coverage"]["ok"], "artifacts": ["quality_report.json"], "roadmap": "Sprint 7 · QA · Audit 4Big 95+/100 sur 100% deliverables", "hors_perimetre_worker": ( "Publication du rapport dans le desk ERPNext + branchement du gate " "4Big sur le pipeline de release VPS → agent QA / DevOps (#8)." ), } def check_invariants(report: dict, spec: dict) -> list[str]: """Invariants de cohérence 4Big. Retourne la liste des violations.""" errs: list[str] = [] # 1. Schéma de sortie. schema = _load(_SCHEMA_PATH) errs += [f"schema: {e}" for e in maison_validate(report, schema)] # 2. Poids des critères = 100 (base avant renormalisation). total_w = sum(c["weight"] for c in spec["criteria"]) if total_w != 100: errs.append(f"INV2 somme des poids critères = {total_w} ≠ 100") # 3. Archétypes bien formés (applicable non vide ⊂ critères connus). for name, arch in spec["archetypes"].items(): appl = set(arch["applicable"]) if not appl: errs.append(f"INV3 archétype {name} sans critère applicable") if not appl <= _KNOWN_CRITERIA: errs.append(f"INV3 archétype {name} critères inconnus " f"{sorted(appl - _KNOWN_CRITERIA)}") # 4. L'auditeur ne s'auto-audite pas. self_mod = spec["self_module"] if any(m["path"] == self_mod for m in report["modules"]): errs.append(f"INV4 auditeur {self_mod} présent dans le périmètre audité") # 5. Couverture 100% (bijection registre ↔ CI, tous dans le gate). cov = report["coverage"] if not cov["ok"]: errs.append( f"INV5 couverture incomplète : missing_in_registry=" f"{cov['missing_in_registry']} missing_in_ci={cov['missing_in_ci']} " f"not_in_gate={cov['not_in_gate']}") if not (cov["ci_modules_count"] == cov["registry_modules_count"] == len(report["modules"]) == len(spec["modules"])): errs.append("INV5 comptages couverture/registre/rapport incohérents") # 6. Par module : recompute note + cohérence des checks avec l'archétype. for m in report["modules"]: appl = set(spec["archetypes"][m["archetype"]]["applicable"]) got = {c["criterion"] for c in m["checks"]} if got != appl: errs.append(f"INV6 {m['id']} checks {sorted(got)} ≠ applicables " f"{sorted(appl)}") wsum = sum(c["weight"] for c in m["checks"]) if wsum != m["applicable_weight"]: errs.append(f"INV6 {m['id']} Σpoids checks {wsum} ≠ applicable_weight " f"{m['applicable_weight']}") earned = sum(c["weight"] for c in m["checks"] if c["passed"]) if earned != m["earned_weight"]: errs.append(f"INV6 {m['id']} earned recomputé {earned} ≠ " f"{m['earned_weight']}") recomputed = scoring._round_half_up(100.0 * earned / m["applicable_weight"]) if recomputed != m["score"]: errs.append(f"INV6 {m['id']} note recomputée {recomputed} ≠ " f"{m['score']} (note fabriquée ?)") # 7. Le gate : chaque module ≥ pass_score et verdict cohérent. ps = spec["thresholds"]["pass_score"] for m in report["modules"]: expect = "PASS" if m["score"] >= ps else "FAIL" if m["verdict"] != expect: errs.append(f"INV7 {m['id']} verdict {m['verdict']} ≠ attendu {expect}") if m["verdict"] != "PASS": errs.append(f"INV7 {m['id']} note {m['score']} < seuil 4Big {ps}") # 8. Totaux cohérents. t = report["totals"] scores = [m["score"] for m in report["modules"]] if t["pass"] + t["fail"] != t["modules"]: errs.append("INV8 pass+fail ≠ modules") if scores and (t["min_score"] != min(scores) or t["max_score"] != max(scores)): errs.append("INV8 min/max score incohérents") # 9. Verdict global. expect_global = "PASS" if (cov["ok"] and t["fail"] == 0) else "FAIL" if report["verdict"] != expect_global: errs.append(f"INV9 verdict global {report['verdict']} ≠ {expect_global}") return errs def cmd_build(args: argparse.Namespace) -> int: spec = _load(_SPEC_PATH) report = builder.build(spec) errs = check_invariants(report, spec) if errs: _eprint("❌ Invariants violés — build refusé :") for e in errs: _eprint(f" · {e}") return 1 out_dir = args.out or _DEFAULT_OUT os.makedirs(out_dir, exist_ok=True) _write_json(os.path.join(out_dir, "quality_report.json"), report) _write_json(os.path.join(out_dir, "MANIFEST.json"), _manifest(spec, report)) print(f"✅ Audit 4Big écrit dans {out_dir}/ — verdict {report['verdict']} · " f"{report['totals']['pass']}/{report['totals']['modules']} modules ≥ " f"{report['pass_score']} (min {report['totals']['min_score']}).") return 0 def cmd_validate(_args: argparse.Namespace) -> int: spec = _load(_SPEC_PATH) report = builder.build(spec) errs = check_invariants(report, spec) if errs: _eprint("❌ Audit 4Big NON conforme :") for e in errs: _eprint(f" · {e}") return 1 print(f"✅ Audit 4Big conforme — verdict {report['verdict']} · couverture " f"{report['coverage']['registry_modules_count']} modules · min " f"{report['totals']['min_score']}/100 (seuil {report['pass_score']}).") return 0 def build_argparser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="audit_4big_gen", description=__doc__.splitlines()[0]) sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("build", help="écrit quality_report.json + MANIFEST.json") p.add_argument("-o", "--out", default=None, help="répertoire de sortie") p.set_defaults(func=cmd_build) p = sub.add_parser("validate", help="valide schéma + invariants (sans écrire)") p.set_defaults(func=cmd_validate) return ap def main(argv: list[str] | None = None) -> int: ns = build_argparser().parse_args(argv) return ns.func(ns) if __name__ == "__main__": raise SystemExit(main())