a517619432
Audit de méta-niveau + gate : note la qualité 4Big de 100% des livrables gated et bloque (FAIL) si un module < 95/100 (CLAUDE.md #5). Couverture PROUVÉE par recoupement bijectif registre ↔ working-directory du CI (moins l'auditeur · SoD ISA 315). 5 critères déterministes (DOC/CONTRAT/TESTS/CLI/HANDOFF) renormalisés par archétype. Anti-invention (#6) : chaque note est recalculée depuis des faits du dépôt, jamais saisie ; un invariant recompute chaque note. Résultat : PASS · 17/17 modules à 100/100. Régression 442 tests verts (+34). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
8.8 KiB
Python
219 lines
8.8 KiB
Python
#!/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())
|