[DTP-Worker] Sprint 7 · Générateur Audit 4Big qualité (95+/100 sur 100% deliverables) (QA · roadmap L69)
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>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user