From a517619432538347cff04637080aa7302fff6cc8 Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 10:11:02 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=207=20=C2=B7=20G=C3=A9n?= =?UTF-8?q?=C3=A9rateur=20Audit=204Big=20qualit=C3=A9=20(95+/100=20sur=201?= =?UTF-8?q?00%=20deliverables)=20(QA=20=C2=B7=20roadmap=20L69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/ci.yml | 23 +- 05_activity_log/2026-07-30.md | 54 ++ .../daily_reports/2026-07-30-session20.md | 91 +++ 05_deliverables_mvp/qa/audit_4big/.gitignore | 2 + 05_deliverables_mvp/qa/audit_4big/README.md | 80 ++ .../qa/audit_4big/audit_4big_gen.py | 218 ++++++ .../qa/audit_4big/out/MANIFEST.json | 16 + .../qa/audit_4big/out/quality_report.json | 736 ++++++++++++++++++ .../qa/audit_4big/q4lib/__init__.py | 12 + .../qa/audit_4big/q4lib/builder.py | 52 ++ .../qa/audit_4big/q4lib/criteria.py | 122 +++ .../qa/audit_4big/q4lib/deps.py | 31 + .../qa/audit_4big/q4lib/registry.py | 107 +++ .../qa/audit_4big/q4lib/scoring.py | 83 ++ .../qa/audit_4big/quality.schema.json | 95 +++ .../qa/audit_4big/quality_spec.json | 87 +++ .../qa/audit_4big/tests/test_audit_4big.py | 349 +++++++++ 17 files changed, 2157 insertions(+), 1 deletion(-) create mode 100644 05_deliverables_mvp/daily_reports/2026-07-30-session20.md create mode 100644 05_deliverables_mvp/qa/audit_4big/.gitignore create mode 100644 05_deliverables_mvp/qa/audit_4big/README.md create mode 100644 05_deliverables_mvp/qa/audit_4big/audit_4big_gen.py create mode 100644 05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json create mode 100644 05_deliverables_mvp/qa/audit_4big/out/quality_report.json create mode 100644 05_deliverables_mvp/qa/audit_4big/q4lib/__init__.py create mode 100644 05_deliverables_mvp/qa/audit_4big/q4lib/builder.py create mode 100644 05_deliverables_mvp/qa/audit_4big/q4lib/criteria.py create mode 100644 05_deliverables_mvp/qa/audit_4big/q4lib/deps.py create mode 100644 05_deliverables_mvp/qa/audit_4big/q4lib/registry.py create mode 100644 05_deliverables_mvp/qa/audit_4big/q4lib/scoring.py create mode 100644 05_deliverables_mvp/qa/audit_4big/quality.schema.json create mode 100644 05_deliverables_mvp/qa/audit_4big/quality_spec.json create mode 100644 05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index aecc9a2..ca6ec41 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -341,10 +341,31 @@ jobs: - name: Tests générateur Chat OTOIA run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Audit 4Big qualité (Sprint 7 · QA · roadmap L69 « Audit 4Big niveau 95+/100 + # sur 100% deliverables »). Audit de MÉTA-NIVEAU : note la qualité 4Big de TOUS + # les livrables gated (documentation, contrat schema, couverture de tests, CLI + # reproductible, intégrité du hand-off out/) et exige ≥ 95/100 sur chacun + # (CLAUDE.md #5). Le périmètre « 100% » est PROUVÉ : le registre est recoupé + # bijectivement avec les working-directory du CI (moins l'auditeur · SoD ISA + # 315). Anti-invention (#6) : chaque note est RECALCULÉE depuis des faits du + # dépôt, jamais saisie. Schéma de sortie + invariants. stdlib pur, zéro pip. + # -------------------------------------------------------------------------- + qa-audit-4big-tests: + name: QA · Audit 4Big (95+/100 sur 100% deliverables) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/qa/audit_4big + steps: + - uses: actions/checkout@v4 + - name: Tests générateur Audit 4Big + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md index 1329f99..90d393e 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,59 @@ # Activity Log · 2026-07-30 · Claude Code DTP +## Session `20260730_095728` (session 20) + +**Tâche** : **Sprint 7 · QA** — Générateur de l'**Audit 4Big qualité** (roadmap +**L69** « QA : **Audit 4Big niveau 95+/100 sur 100% deliverables** »). Premier +volet Sprint 7 réalisable en repo — les volets polish `otov7.com` (Frontend) et +scénarios démo P07/P05 (CRM+Faisabilité) dépendent du site live / `data_room` +réelle (hors périmètre worker · #8). + +**Décision d'architecture** : audit de **MÉTA-NIVEAU** et **gate** (pas rapport +indicatif). Il note la qualité 4Big de **100 % des livrables** et **bloque** +(verdict `FAIL`) si un module tombe sous **95/100** (CLAUDE.md #5) ou si la +couverture est incomplète. La couverture est **prouvée, pas déclarée** : le +registre est recoupé **bijectivement** avec les `working-directory` du CI Gitea, +moins l'auditeur lui-même (**séparation des pouvoirs** · ISA 315). 5 critères +déterministes : `DOC` · `CONTRAT` (schema) · `TESTS` (≥8 méthodes) · `CLI` +(argparse+`__main__`) · `HANDOFF` (`out/` intègre), renormalisés par archétype. + +**Fichiers créés** — `05_deliverables_mvp/qa/audit_4big/` : +- `quality_spec.json` (rubrique 5 critères + poids · seuil 95 verbatim · 4 + archétypes · registre 17 modules sourcés) +- `q4lib/{__init__,deps,registry,criteria,scoring,builder}.py` (`deps` + **réutilise** le validateur maison Publiciste · `registry` parse `ci.yml` et + prouve la couverture · `criteria` purs lus depuis le dépôt · `scoring` + renormalise par archétype · `builder` déterministe) +- `audit_4big_gen.py` (CLI `build`/`validate` · **9 familles d'invariants**) +- `quality.schema.json` (contrat de sortie draft-07) +- `out/{quality_report,MANIFEST}.json` (hand-off) · `tests/test_audit_4big.py` + (**34 tests** dont 15 injections négatives sur arbre synthétique) · `README.md` + · `.gitignore` + +**Fichiers modifiés** : +- `.gitea/workflows/ci.yml` : job `qa-audit-4big-tests` + ajout au `gate`. + +**Anti-invention (cœur · #6)** : une note **ne peut pas être fabriquée « pour +faire 95 »** — elle est **recalculée** depuis des faits (fichiers, taille, +comptage `test_*`, validité JSON du hand-off) ; un invariant re-somme les poids et +recompute chaque note (`INV6`) ; l'auditeur est **hors de son propre périmètre** +(SoD · `INV4`) ; seuil 95 repris verbatim de CLAUDE.md #5. + +**Résultat** : verdict `PASS` — **17/17 modules à 100/100** · pass-rate 100 % · +couverture 100 % des livrables gated. Valeur = **gate anti-régression** (15 tests +négatifs prouvent la chute sous 95 en cas de dégradation). + +**Vérifs** : 34/34 tests ; gate CI local vert (guard + JSON + docs + YAML) ; +régression **442 tests verts** au total (408 → +34) ; build déterministe. + +**Hors périmètre worker (VPS · #8)** : publication du rapport dans le desk ERPNext ++ branchement du gate 4Big sur le pipeline de release VPS → agent QA / DevOps. + +**Détail complet** : voir +[`05_deliverables_mvp/daily_reports/2026-07-30-session20.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session20.md). + +**Auto-score 4Big** : 96/100. + ## Session `20260730_092724` (session 19) **Tâche** : **Sprint 6 · ERPNext Backend** — Générateur du **Chat OTOIA embarqué diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session20.md b/05_deliverables_mvp/daily_reports/2026-07-30-session20.md new file mode 100644 index 0000000..f3eb74d --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session20.md @@ -0,0 +1,91 @@ +# Rapport de session · 2026-07-30 · session 20 (`20260730_095728`) + +## Tâche + +**Sprint 7 · QA** — Générateur de l'**Audit 4Big qualité** (roadmap +`04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` l.69 : « QA : **Audit 4Big niveau +95+/100 sur 100% deliverables** »). + +Sprint 6 étant clos côté repo (SEO trilingue session 18, Chat OTOIA session 19 ; +le volet OTOIA voice Amélie dépend d'API/desk VPS · #8), c'est le **premier volet +Sprint 7 réalisable en repo**. Les deux autres volets Sprint 7 — polish +`otov7.com` (Frontend) et scénarios démo P07/P05 (CRM+Faisabilité) — dépendent du +site live et de `data_room/PXX` réelle (hors périmètre worker · #8). + +## Décision d'architecture + +Audit de **méta-niveau** et **gate**, pas rapport indicatif : il note la qualité +4Big de **100 % des livrables** du mandat et **bloque** (verdict `FAIL`) si un +seul module tombe sous **95/100** (CLAUDE.md #5) ou si la couverture n'est pas +complète. + +**Preuve de couverture, pas déclaration** : le registre (`quality_spec.json`) est +recoupé **bijectivement** avec les `working-directory` du CI Gitea +(`.gitea/workflows/ci.yml`) — moins l'auditeur lui-même (**séparation des +pouvoirs**, ISA 315 : un auditeur ne s'auto-note pas). Tout livrable ajouté au CI +sans entrée de registre casse la couverture → gate rouge. + +## Fichiers créés — `05_deliverables_mvp/qa/audit_4big/` + +- `quality_spec.json` — rubrique 4Big (5 critères + poids), seuils (pass_score=95 + **verbatim** CLAUDE.md #5), 4 archétypes (critères applicables), registre des 17 + modules audités (chacun `source`-é vers roadmap + job CI). +- `q4lib/{__init__,deps,registry,criteria,scoring,builder}.py` : + - `deps` **réutilise** (#5) le validateur maison Publiciste (zéro pip). + - `registry` parse `ci.yml` (YAML minimal stdlib) et **prouve** la couverture + bijective registre ↔ CI + alimentation du `gate`. + - `criteria` = 5 critères **purs et déterministes** lus depuis le dépôt : `DOC` + (README/SPEC ≥ 400 o), `CONTRAT` (`*.schema.json`), `TESTS` (≥ 8 méthodes + `test_*`), `CLI` (`__main__` + argparse), `HANDOFF` (`out/MANIFEST.json` + + artefact JSON valide non vide). + - `scoring` renormalise par archétype (critère non applicable exclu du + dénominateur → pas de pénalité pour un hand-off non attendu) ; arrondi + demi-supérieur explicite (déterministe). + - `builder` assemble couverture + notes + totaux + verdict. +- `audit_4big_gen.py` — CLI `build`/`validate` · **9 familles d'invariants**. +- `quality.schema.json` — contrat de sortie draft-07. +- `out/{quality_report,MANIFEST}.json` (hand-off) · `tests/test_audit_4big.py` + (**34 tests** dont 15 injections négatives sur arbre de module synthétique) · + `README.md` · `.gitignore`. + +## Fichiers modifiés + +- `.gitea/workflows/ci.yml` : job `qa-audit-4big-tests` + ajout au `gate`. + +## Anti-invention (cœur · #6) + +Une note **ne peut pas être fabriquée « pour faire 95 »** : elle est **recalculée** +à partir de faits vérifiables (présence de fichiers, taille, comptage `test_*`, +validité JSON du hand-off). Un invariant re-somme les poids et **recompute** la +note de chaque module — une note qui ne se déduit pas de ses `checks` est rejetée +(`INV6`). L'auditeur est **hors de son propre périmètre** (SoD · `INV4`). Le seuil +95 est repris verbatim de CLAUDE.md #5 ; aucun chiffre de projet n'est touché. + +## Renormalisation par archétype + +| Archétype | Critères applicables | Modules | +|---|---|---| +| `generator` | DOC·CONTRAT·TESTS·CLI·HANDOFF | 13 | +| `data_room` | DOC·CONTRAT·TESTS·CLI | 2 (faisabilité generator, bancable) | +| `parser` | DOC·TESTS·CLI | 1 (publiciste) | +| `contract` | DOC·CONTRAT·TESTS | 1 (rbac) | + +## Résultat + +Verdict **`PASS`** — **17/17** modules à **100/100** (min 100), pass-rate **100 %**, +couverture **100 %** des livrables gated (hors auditeur). Valeur durable : **gate +anti-régression** — retirer des tests, casser un `MANIFEST` ou supprimer un schéma +fait chuter le module sous 95 → `FAIL` (démontré par 15 tests négatifs). + +## Vérifications + +- 34/34 tests du module ; `build` + `validate` verts. +- Gate CI local vert : guard contraintes · JSON bien formés · liens docs · YAML OK. +- Régression **442 tests verts** au total (408 → +34) ; build déterministe. + +## Hors périmètre worker (VPS · #8) + +Publication du rapport dans le desk ERPNext + branchement du gate 4Big sur le +pipeline de release VPS → agent QA / DevOps. + +## Auto-score 4Big : 96/100 diff --git a/05_deliverables_mvp/qa/audit_4big/.gitignore b/05_deliverables_mvp/qa/audit_4big/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/05_deliverables_mvp/qa/audit_4big/README.md b/05_deliverables_mvp/qa/audit_4big/README.md new file mode 100644 index 0000000..9ee3a60 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/README.md @@ -0,0 +1,80 @@ +# Audit 4Big qualité · Sprint 7 · QA + +> Roadmap Sprint 7 · QA — **« Audit 4Big niveau 95+/100 sur 100% deliverables »** +> · CLAUDE.md #5 (score minimum **95/100**, niveau 4Big obligatoire sur tout livrable). + +Générateur d'un **audit de méta-niveau** : il note la qualité 4Big de **100 % des +livrables** du mandat et **bloque** (verdict `FAIL`) si un seul module tombe sous +**95/100** ou si la couverture n'est pas complète. C'est un **gate**, pas un +rapport indicatif. + +## Ce qu'il fait + +1. **Découvre le périmètre et le prouve.** Le registre (`quality_spec.json`) est + recoupé **bijectivement** avec les `working-directory` du CI Gitea + (`.gitea/workflows/ci.yml`) — moins l'auditeur lui-même (**séparation des + pouvoirs**, ISA 315 : un auditeur ne s'auto-note pas). Tout livrable ajouté au + CI sans entrée de registre casse la couverture → le gate rougit. Aucune + omission silencieuse possible. +2. **Note chaque module sur 5 critères 4Big déterministes**, tous **calculés + depuis des faits du dépôt** (jamais saisis) : + + | Critère | Poids | Fait vérifié | + |---|---|---| + | `DOC` | 20 | un `*.md` (README/SPEC) ≥ 400 octets à la racine | + | `CONTRAT` | 20 | un contrat de sortie `*.schema.json` | + | `TESTS` | 25 | ≥ 8 méthodes `def test_*` dans `tests/` | + | `CLI` | 15 | un entrypoint `__main__` + sous-commandes argparse | + | `HANDOFF` | 20 | `out/MANIFEST.json` + ≥ 1 artefact JSON valide non vide | + +3. **Renormalise par archétype.** Un critère non applicable est **exclu du + dénominateur** (un module de *contrat* sans hand-off n'est pas pénalisé pour un + `out/` qu'il n'a jamais vocation à produire). Archétypes : + `generator` (5 critères) · `data_room` (sans HANDOFF) · `parser` (sans CONTRAT + local ni HANDOFF) · `contract` (sans CLI ni HANDOFF). + + > `note = round(100 × Σ poids(critères applicables PASS) / Σ poids(applicables))` + +4. **Rend un verdict global.** `PASS` **ssi** couverture 100 % **et** les 17 + modules ≥ 95/100 ; sinon `FAIL`. + +## Anti-invention (#6) + +Une note **ne peut pas être fabriquée « pour faire 95 »** : elle est **recalculée** +à partir de faits vérifiables (présence de fichiers, taille, comptage des méthodes +`test_*`, validité JSON du hand-off). Un invariant re-somme les poids et recompute +la note de chaque module ; une note qui ne se déduit pas de ses `checks` est +rejetée. L'auditeur est **hors de son propre périmètre** (SoD). + +## Utilisation + +```bash +python3 audit_4big_gen.py build # écrit out/quality_report.json + MANIFEST.json +python3 audit_4big_gen.py validate # schéma + invariants (sort en erreur si un module < 95) +python3 -m unittest discover -s tests -v +``` + +## Sorties (`out/`, hand-off VPS) + +- `quality_report.json` — couverture, note + `checks` par module, totaux, verdict. +- `MANIFEST.json` — métadonnées de génération. + +Sortie **déterministe** (tri stable, aucun horodatage) → diffable et +re-générable ; validée contre `quality.schema.json` par le **validateur maison +Publiciste** (zéro `pip`, runner Gitea sans réseau · CLAUDE.md #2). + +## Résultat courant + +**Verdict `PASS`** — **17/17** modules à **100/100** (min 100), couverture +**100 %** des livrables gated. L'audit servira surtout de **gate anti-régression** : +retirer des tests, casser un `MANIFEST` ou supprimer un schéma fait chuter le +module sous 95 → `FAIL`. + +## Hors périmètre worker (VPS · #8) + +Publication du rapport dans le desk ERPNext + branchement du gate 4Big sur le +pipeline de release VPS → agent QA / DevOps. + +--- + +**Auto-score 4Big : 96/100.** diff --git a/05_deliverables_mvp/qa/audit_4big/audit_4big_gen.py b/05_deliverables_mvp/qa/audit_4big/audit_4big_gen.py new file mode 100644 index 0000000..3aef563 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/audit_4big_gen.py @@ -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()) diff --git a/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json b/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json new file mode 100644 index 0000000..98d706e --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json @@ -0,0 +1,16 @@ +{ + "generated_from": "quality_spec.json", + "generator": "audit_4big_gen.py", + "audit": "OTO QA · Audit 4Big Qualité", + "version": "1.0", + "verdict": "PASS", + "modules_audited": 17, + "min_score": 100, + "pass_score": 95, + "coverage_ok": true, + "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)." +} diff --git a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json new file mode 100644 index 0000000..2da74b3 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json @@ -0,0 +1,736 @@ +{ + "audit": "OTO QA · Audit 4Big Qualité", + "version": "1.0", + "reference_cadre": "CLAUDE.md #5 (score minimum 95/100 · niveau 4Big obligatoire) · Roadmap Sprint 7 · QA « Audit 4Big niveau 95+/100 sur 100% deliverables »", + "pass_score": 95, + "coverage": { + "ok": true, + "ci_modules_count": 17, + "registry_modules_count": 17, + "missing_in_registry": [], + "missing_in_ci": [], + "not_in_gate": [], + "self_module_excluded": "qa/audit_4big" + }, + "criteria": [ + { + "id": "DOC", + "label": "Documentation livrable (README / SPEC ≥ seuil)", + "weight": 20 + }, + { + "id": "CONTRAT", + "label": "Contrat de sortie explicite (*.schema.json)", + "weight": 20 + }, + { + "id": "TESTS", + "label": "Couverture de tests (≥ seuil de méthodes test_*)", + "weight": 25 + }, + { + "id": "CLI", + "label": "CLI reproductible (entrypoint __main__ + sous-commandes argparse)", + "weight": 15 + }, + { + "id": "HANDOFF", + "label": "Hand-off VPS présent et intègre (out/MANIFEST.json + artefact JSON valide)", + "weight": 20 + } + ], + "modules": [ + { + "id": "faisabilite-generator", + "path": "faisabilite/generator", + "sprint": "S2", + "archetype": "data_room", + "applicable_weight": 80, + "earned_weight": 80, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (4797 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "brief.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "16 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "faisabilite_gen.py (argparse + __main__)" + } + ] + }, + { + "id": "publiciste", + "path": "publiciste", + "sprint": "S2", + "archetype": "parser", + "applicable_weight": 60, + "earned_weight": 60, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (3901 octets)" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "22 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "publiciste.py (argparse + __main__)" + } + ] + }, + { + "id": "rbac", + "path": "rbac", + "sprint": "S2", + "archetype": "contract", + "applicable_weight": 65, + "earned_weight": 65, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "RBAC_50_ROLES_SPEC.md (10040 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "rbac.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "10 méthodes test_* dans 1 fichier(s) (seuil 8)" + } + ] + }, + { + "id": "rbac-applyplan", + "path": "rbac/apply_plan", + "sprint": "S2", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (5249 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "apply_plan.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "16 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "rbac_apply_plan.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "rbac-fixtures", + "path": "rbac/fixtures_gen", + "sprint": "S2", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (3858 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "fixtures.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "11 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "rbac_fixtures_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 2 artefact(s) JSON valides" + } + ] + }, + { + "id": "rbac-roleprofile", + "path": "rbac/roleprofile_gen", + "sprint": "S2", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (5358 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "roleprofile.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "11 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "roleprofile_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "rbac-userperm", + "path": "rbac/userperm_gen", + "sprint": "S2", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (5241 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "userperm.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "12 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "userperm_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "bancable", + "path": "faisabilite/bancable", + "sprint": "S3", + "archetype": "data_room", + "applicable_weight": 80, + "earned_weight": 80, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (6103 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "bancable.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "22 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "bancable_gen.py (argparse + __main__)" + } + ] + }, + { + "id": "crm-commissions", + "path": "crm/commissions", + "sprint": "S4", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (4838 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "bareme.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "25 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "commissions_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "crm-dossier-vente", + "path": "crm/dossier_vente", + "sprint": "S4", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (4383 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "doctype.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "31 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "doctype_dossier_vente_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "crm-workflow-vente", + "path": "crm/workflow_vente", + "sprint": "S4", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (6852 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "workflow.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "25 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "workflow_vente_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 3 artefact(s) JSON valides" + } + ] + }, + { + "id": "fiscal-ecf", + "path": "fiscal/ecf_dgii", + "sprint": "S4", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (6064 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "ecf.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "39 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "ecf_dgii_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "frontend-portails", + "path": "frontend/portails", + "sprint": "S4", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (5109 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "workspace.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "19 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "workspaces_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "legal-confotur", + "path": "legal/confotur", + "sprint": "S5", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (4402 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "confotur.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "44 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "confotur_application_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "qa-audit-5d", + "path": "qa/audit_5d", + "sprint": "S5", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (4374 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "audit.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "37 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "audit_5d_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] + }, + { + "id": "frontend-chat-otoia", + "path": "frontend/chat_otoia", + "sprint": "S6", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (3762 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "chat.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "31 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "chat_otoia_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 2 artefact(s) JSON valides" + } + ] + }, + { + "id": "seo", + "path": "seo", + "sprint": "S6", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (3900 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "seo.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "36 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "seo_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 3 artefact(s) JSON valides" + } + ] + } + ], + "totals": { + "modules": 17, + "pass": 17, + "fail": 0, + "min_score": 100, + "max_score": 100, + "pass_rate_pct": 100 + }, + "verdict": "PASS", + "notes": [ + "Audit de MÉTA-NIVEAU : chaque note de module est CALCULÉE depuis des faits du dépôt (présence README/schéma, nombre de méthodes test_*, entrypoint CLI, intégrité out/) — jamais saisie à la main (#6).", + "Un critère non applicable à l'archétype d'un module est EXCLU du dénominateur (pas de pénalité pour un hand-off qu'un module de contrat n'a jamais vocation à produire) ; les poids restants sont renormalisés à 100.", + "Le verdict global est PASS si et seulement si les 17 modules atteignent ≥ 95/100 ET la couverture == 100% des livrables gated (hors auditeur)." + ] +} diff --git a/05_deliverables_mvp/qa/audit_4big/q4lib/__init__.py b/05_deliverables_mvp/qa/audit_4big/q4lib/__init__.py new file mode 100644 index 0000000..f172857 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/q4lib/__init__.py @@ -0,0 +1,12 @@ +"""q4lib · briques de l'audit 4Big qualité (Sprint 7 · QA). + +Audit de MÉTA-NIVEAU : il note la qualité 4Big de 100% des livrables du mandat +à partir de FAITS du dépôt (documentation, contrat de sortie, tests, CLI, +intégrité du hand-off), et exige ≥ 95/100 partout (CLAUDE.md #5). + + registry → liste des modules audités + preuve de couverture 100% vs CI Gitea + criteria → 5 critères 4Big déterministes (lecture du système de fichiers) + scoring → note par module (renormalisation par archétype) + verdict + builder → assemblage du rapport quality_report.json + MANIFEST + deps → réutilisation du validateur maison Publiciste (zéro pip · #5) +""" diff --git a/05_deliverables_mvp/qa/audit_4big/q4lib/builder.py b/05_deliverables_mvp/qa/audit_4big/q4lib/builder.py new file mode 100644 index 0000000..877b838 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/q4lib/builder.py @@ -0,0 +1,52 @@ +"""Assemblage déterministe du rapport d'audit 4Big. + +Verdict global = PASS ssi (a) la couverture est bijective vs le CI (100% des +livrables gated, hors auditeur) ET (b) les 17 modules atteignent ≥ 95/100. +Sinon FAIL — l'audit est un GATE, pas un rapport indicatif. + +Aucune date/horodatage → build reproductible + diffable (gate CI stable). +""" + +from __future__ import annotations + +from typing import Any + +from . import registry, scoring + +REPORT_NAME = "OTO QA · Audit 4Big Qualité" + + +def build(spec: dict[str, Any]) -> dict[str, Any]: + coverage = registry.coverage_report(spec) + modules = scoring.score_all(spec) + + scores = [m["score"] for m in modules] + below = [m["id"] for m in modules if m["verdict"] != "PASS"] + totals = { + "modules": len(modules), + "pass": sum(1 for m in modules if m["verdict"] == "PASS"), + "fail": len(below), + "min_score": min(scores) if scores else 0, + "max_score": max(scores) if scores else 0, + "pass_rate_pct": scoring._round_half_up( + 100.0 * sum(1 for m in modules if m["verdict"] == "PASS") / len(modules) + ) if modules else 0, + } + + verdict = "PASS" if (coverage["ok"] and not below) else "FAIL" + + return { + "audit": REPORT_NAME, + "version": spec["version"], + "reference_cadre": spec["reference_cadre"], + "pass_score": spec["thresholds"]["pass_score"], + "coverage": coverage, + "criteria": [ + {"id": c["id"], "label": c["label"], "weight": c["weight"]} + for c in spec["criteria"] + ], + "modules": modules, + "totals": totals, + "verdict": verdict, + "notes": spec.get("notes", []), + } diff --git a/05_deliverables_mvp/qa/audit_4big/q4lib/criteria.py b/05_deliverables_mvp/qa/audit_4big/q4lib/criteria.py new file mode 100644 index 0000000..763ff3a --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/q4lib/criteria.py @@ -0,0 +1,122 @@ +"""Les 5 critères 4Big — purs, déterministes, calculés depuis le dépôt. + +Chaque critère lit UNIQUEMENT le système de fichiers (jamais une valeur saisie) +et renvoie {passed: bool, evidence: str}. C'est le cœur anti-invention (#6) de +l'audit : une note ne peut pas être « écrite pour faire 95 », elle est recomputée +à partir de faits vérifiables (présence d'un fichier, taille, comptage AST léger, +intégrité JSON). + +Aucune dépendance pip ; stdlib pure (le runner Gitea tourne sans réseau · #2). +""" + +from __future__ import annotations + +import json +import os +import re + +_TEST_DEF_RE = re.compile(r"^\s*def (test_[A-Za-z0-9_]+)\s*\(", re.MULTILINE) +_ADD_PARSER_RE = re.compile(r"add_parser\(|add_subparsers\(") + + +def _direct_files(mod_dir: str, suffix: str) -> list[str]: + """Fichiers se terminant par `suffix` directement dans mod_dir (non récursif).""" + if not os.path.isdir(mod_dir): + return [] + return sorted( + os.path.join(mod_dir, n) + for n in os.listdir(mod_dir) + if n.endswith(suffix) and os.path.isfile(os.path.join(mod_dir, n)) + ) + + +def crit_doc(mod_dir: str, min_bytes: int) -> dict: + """DOC — au moins un Markdown (README/SPEC) d'au moins `min_bytes` octets.""" + for md in _direct_files(mod_dir, ".md"): + size = os.path.getsize(md) + if size >= min_bytes: + return {"passed": True, + "evidence": f"{os.path.basename(md)} ({size} octets)"} + return {"passed": False, + "evidence": f"aucun *.md ≥ {min_bytes} octets à la racine du module"} + + +def crit_contrat(mod_dir: str) -> dict: + """CONTRAT — au moins un contrat de sortie *.schema.json.""" + schemas = _direct_files(mod_dir, ".schema.json") + if schemas: + return {"passed": True, + "evidence": ", ".join(os.path.basename(s) for s in schemas)} + return {"passed": False, "evidence": "aucun *.schema.json à la racine du module"} + + +def crit_tests(mod_dir: str, min_methods: int) -> dict: + """TESTS — ≥ min_methods méthodes `def test_*` dans tests/.""" + tests_dir = os.path.join(mod_dir, "tests") + count = 0 + files = 0 + if os.path.isdir(tests_dir): + for root, _dirs, names in os.walk(tests_dir): + if "__pycache__" in root: + continue + for n in names: + if n.startswith("test_") and n.endswith(".py"): + files += 1 + with open(os.path.join(root, n), encoding="utf-8") as fh: + count += len(_TEST_DEF_RE.findall(fh.read())) + passed = count >= min_methods + return {"passed": passed, + "evidence": f"{count} méthodes test_* dans {files} fichier(s) " + f"(seuil {min_methods})"} + + +def crit_cli(mod_dir: str) -> dict: + """CLI — un entrypoint __main__ avec sous-commandes argparse à la racine.""" + for py in _direct_files(mod_dir, ".py"): + with open(py, encoding="utf-8") as fh: + src = fh.read() + if '__name__ == "__main__"' in src and _ADD_PARSER_RE.search(src): + return {"passed": True, + "evidence": f"{os.path.basename(py)} (argparse + __main__)"} + return {"passed": False, + "evidence": "aucun entrypoint __main__ avec add_parser/add_subparsers"} + + +def crit_handoff(mod_dir: str) -> dict: + """HANDOFF — out/MANIFEST.json + ≥1 autre artefact, tous JSON non vides.""" + out_dir = os.path.join(mod_dir, "out") + manifest = os.path.join(out_dir, "MANIFEST.json") + if not os.path.isfile(manifest): + return {"passed": False, "evidence": "out/MANIFEST.json absent"} + others = [os.path.join(out_dir, n) for n in sorted(os.listdir(out_dir)) + if n.endswith(".json") and n != "MANIFEST.json"] + if not others: + return {"passed": False, + "evidence": "out/ ne contient que MANIFEST.json (aucun artefact)"} + for art in [manifest] + others: + try: + with open(art, encoding="utf-8") as fh: + data = json.load(fh) + except (json.JSONDecodeError, OSError) as exc: + return {"passed": False, + "evidence": f"{os.path.basename(art)} illisible : {exc}"} + if data in (None, {}, [], ""): + return {"passed": False, + "evidence": f"{os.path.basename(art)} JSON vide"} + return {"passed": True, + "evidence": f"MANIFEST + {len(others)} artefact(s) JSON valides"} + + +# id critère → (fonction, kwargs supplémentaires tirés des thresholds) +def evaluate(mod_dir: str, criterion_id: str, thresholds: dict) -> dict: + if criterion_id == "DOC": + return crit_doc(mod_dir, thresholds["min_doc_bytes"]) + if criterion_id == "CONTRAT": + return crit_contrat(mod_dir) + if criterion_id == "TESTS": + return crit_tests(mod_dir, thresholds["min_test_methods"]) + if criterion_id == "CLI": + return crit_cli(mod_dir) + if criterion_id == "HANDOFF": + return crit_handoff(mod_dir) + raise KeyError(f"Critère inconnu : {criterion_id}") diff --git a/05_deliverables_mvp/qa/audit_4big/q4lib/deps.py b/05_deliverables_mvp/qa/audit_4big/q4lib/deps.py new file mode 100644 index 0000000..d9d748d --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/q4lib/deps.py @@ -0,0 +1,31 @@ +"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication). + +L'audit 4Big ne redéfinit rien qui existe ailleurs. On importe — jamais on ne +duplique — le validateur JSON-Schema maison du Publiciste (draft-07, sous- +ensemble) pour valider le rapport de sortie SANS pip : le gate CI Gitea Actions +tourne sans réseau (CLAUDE.md #2). + +Import par `sys.path` (idiome des modules voisins) — une seule source de vérité. +""" + +from __future__ import annotations + +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +# qa/audit_4big/q4lib → 05_deliverables_mvp +_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", "..")) +_PUB = os.path.join(_DELIVERABLES, "publiciste") + +if _PUB not in sys.path: + sys.path.insert(0, _PUB) + +from lib import validator # type: ignore # noqa: E402 + +validate = validator.validate + +# Racine des livrables, exposée aux autres modules q4lib. +DELIVERABLES_ROOT = _DELIVERABLES + +__all__ = ["validate", "DELIVERABLES_ROOT"] diff --git a/05_deliverables_mvp/qa/audit_4big/q4lib/registry.py b/05_deliverables_mvp/qa/audit_4big/q4lib/registry.py new file mode 100644 index 0000000..8cd32c9 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/q4lib/registry.py @@ -0,0 +1,107 @@ +"""Registre des modules audités + PREUVE de couverture 100% vs le CI Gitea. + +Le périmètre revendiqué par cet audit — « 100% des livrables » — n'est crédible +que s'il est PROUVÉ, pas déclaré. On lit donc `.gitea/workflows/ci.yml` (la seule +autorité sur ce qui est réellement gated) et on vérifie : + + 1. l'ensemble des modules du registre == l'ensemble des `working-directory:` + sous `05_deliverables_mvp/` du CI, PRIVÉ du module auditeur lui-même + (séparation des pouvoirs · l'auditeur ne s'auto-note pas · ISA 315) ; + 2. chaque module gated alimente bien le job d'agrégat `gate` (via `needs`). + +Ainsi, tout futur livrable ajouté au CI sans mise à jour du registre casse la +couverture → le gate rougit. Aucune omission silencieuse possible. + +Parsing YAML volontairement minimal (stdlib pure · aucune dépendance pip · #2). +""" + +from __future__ import annotations + +import os +import re + +from .deps import DELIVERABLES_ROOT + +_REPO_ROOT = os.path.normpath(os.path.join(DELIVERABLES_ROOT, "..")) +_CI_PATH = os.path.join(_REPO_ROOT, ".gitea", "workflows", "ci.yml") + +_PREFIX = "05_deliverables_mvp/" + +_JOB_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") +_WD_RE = re.compile(r"^\s*working-directory:\s*(\S+)\s*$") +_NEEDS_RE = re.compile(r"^\s*needs:\s*\[(.*)\]\s*$") + + +def _read_ci() -> str: + with open(_CI_PATH, encoding="utf-8") as fh: + return fh.read() + + +def parse_ci() -> dict: + """Retourne {job_id -> chemin module} pour les jobs sous 05_deliverables_mvp + et la liste `needs` du job `gate`.""" + lines = _read_ci().splitlines() + current_job = None + job_to_path: dict[str, str] = {} + gate_needs: list[str] = [] + in_gate = False + + for line in lines: + m = _JOB_RE.match(line) + if m: + current_job = m.group(1) + in_gate = current_job == "gate" + continue + wd = _WD_RE.match(line) + if wd and current_job: + val = wd.group(1) + if val.startswith(_PREFIX): + job_to_path[current_job] = val[len(_PREFIX):] + continue + if in_gate: + nm = _NEEDS_RE.match(line) + if nm: + gate_needs = [j.strip() for j in nm.group(1).split(",") if j.strip()] + + return {"job_to_path": job_to_path, "gate_needs": gate_needs} + + +def ci_module_paths(exclude: str | None = None) -> set[str]: + """Chemins de modules gated par le CI (hors `exclude`, ex. l'auditeur).""" + paths = set(parse_ci()["job_to_path"].values()) + if exclude is not None: + paths.discard(exclude) + return paths + + +def coverage_report(spec: dict) -> dict: + """Compare le registre du spec à la réalité du CI. Retourne un diagnostic + sérialisable ; `ok=True` ssi couverture bijective + tous gated.""" + self_module = spec["self_module"] + registry_paths = {m["path"] for m in spec["modules"]} + ci = parse_ci() + job_to_path = ci["job_to_path"] + ci_paths = {p for p in job_to_path.values() if p != self_module} + + missing_in_registry = sorted(ci_paths - registry_paths) # gated mais non audité + missing_in_ci = sorted(registry_paths - ci_paths) # audité mais non gated + + # Chaque module audité doit alimenter le gate d'agrégat. + path_to_jobs: dict[str, list[str]] = {} + for job, path in job_to_path.items(): + path_to_jobs.setdefault(path, []).append(job) + not_in_gate = sorted( + p for p in registry_paths + if not any(j in ci["gate_needs"] for j in path_to_jobs.get(p, [])) + ) + + ok = not missing_in_registry and not missing_in_ci and not not_in_gate + return { + "ok": ok, + "ci_modules_count": len(ci_paths), + "registry_modules_count": len(registry_paths), + "missing_in_registry": missing_in_registry, + "missing_in_ci": missing_in_ci, + "not_in_gate": not_in_gate, + "self_module_excluded": self_module, + } diff --git a/05_deliverables_mvp/qa/audit_4big/q4lib/scoring.py b/05_deliverables_mvp/qa/audit_4big/q4lib/scoring.py new file mode 100644 index 0000000..d97baf6 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/q4lib/scoring.py @@ -0,0 +1,83 @@ +"""Notation d'un module : renormalisation par archétype + verdict. + +Principe : seuls les critères APPLICABLES à l'archétype du module comptent. Le +dénominateur est la somme de leurs poids (jamais 100 en dur) ; ainsi un module +de contrat (sans hand-off) n'est pas pénalisé pour un artefact out/ qu'il n'a +jamais vocation à produire. La note est renormalisée sur 100 puis arrondie. + + note = round(100 * Σ poids(critères applicables PASS) / Σ poids(applicables)) + +Un module PASS ssi note ≥ pass_score (95 · CLAUDE.md #5). + +Déterministe : arrondi « demi vers le pair » interdit (dépend de la plateforme) → +on utilise un arrondi explicite demi-supérieur, stable et reproductible. +""" + +from __future__ import annotations + +import os + +from . import criteria +from .deps import DELIVERABLES_ROOT + + +def _round_half_up(x: float) -> int: + return int(x + 0.5) + + +def _weight_of(spec: dict, cid: str) -> int: + for c in spec["criteria"]: + if c["id"] == cid: + return c["weight"] + raise KeyError(f"Poids introuvable pour le critère {cid}") + + +def score_module(spec: dict, module: dict) -> dict: + thresholds = spec["thresholds"] + archetype = module["archetype"] + applicable = spec["archetypes"][archetype]["applicable"] + mod_dir = os.path.join(DELIVERABLES_ROOT, module["path"]) + + if not os.path.isdir(mod_dir): + raise FileNotFoundError( + f"Module déclaré introuvable sur disque : {module['path']}") + + checks = [] + earned = 0 + total = 0 + for cid in applicable: + weight = _weight_of(spec, cid) + res = criteria.evaluate(mod_dir, cid, thresholds) + total += weight + if res["passed"]: + earned += weight + checks.append({ + "criterion": cid, + "weight": weight, + "passed": res["passed"], + "evidence": res["evidence"], + }) + + if total == 0: + raise ValueError( + f"Archétype {archetype} sans critère applicable — spec incohérent.") + + score = _round_half_up(100.0 * earned / total) + verdict = "PASS" if score >= thresholds["pass_score"] else "FAIL" + return { + "id": module["id"], + "path": module["path"], + "sprint": module["sprint"], + "archetype": archetype, + "applicable_weight": total, + "earned_weight": earned, + "score": score, + "verdict": verdict, + "checks": checks, + } + + +def score_all(spec: dict) -> list[dict]: + # Ordre stable : par sprint puis par chemin (déterminisme du hand-off). + mods = sorted(spec["modules"], key=lambda m: (m["sprint"], m["path"])) + return [score_module(spec, m) for m in mods] diff --git a/05_deliverables_mvp/qa/audit_4big/quality.schema.json b/05_deliverables_mvp/qa/audit_4big/quality.schema.json new file mode 100644 index 0000000..4d5d0bb --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/quality.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto-enterprise-os-dtp/qa/audit_4big.schema.json", + "title": "Rapport d'audit 4Big qualite genere depuis quality_spec.json + les faits du depot", + "description": "Contrat de sortie du generateur audit_4big_gen.py. Valide par le validateur maison Publiciste (zero pip) dans le job CI qa-audit-4big-tests.", + "type": "object", + "additionalProperties": false, + "required": ["audit", "version", "reference_cadre", "pass_score", "coverage", "criteria", "modules", "totals", "verdict", "notes"], + "properties": { + "audit": { "type": "string", "const": "OTO QA · Audit 4Big Qualité" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" }, + "reference_cadre": { "type": "string", "minLength": 1 }, + "pass_score": { "type": "integer", "minimum": 1, "maximum": 100 }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "ci_modules_count", "registry_modules_count", "missing_in_registry", "missing_in_ci", "not_in_gate", "self_module_excluded"], + "properties": { + "ok": { "type": "boolean" }, + "ci_modules_count": { "type": "integer", "minimum": 0 }, + "registry_modules_count": { "type": "integer", "minimum": 0 }, + "missing_in_registry": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "missing_in_ci": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "not_in_gate": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "self_module_excluded": { "type": "string", "minLength": 1 } + } + }, + "criteria": { + "type": "array", + "minItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "weight"], + "properties": { + "id": { "type": "string", "enum": ["DOC", "CONTRAT", "TESTS", "CLI", "HANDOFF"] }, + "label": { "type": "string", "minLength": 1 }, + "weight": { "type": "integer", "minimum": 1 } + } + } + }, + "modules": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "path", "sprint", "archetype", "applicable_weight", "earned_weight", "score", "verdict", "checks"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "sprint": { "type": "string", "pattern": "^S[0-9]+$" }, + "archetype": { "type": "string", "enum": ["generator", "data_room", "parser", "contract"] }, + "applicable_weight": { "type": "integer", "minimum": 1 }, + "earned_weight": { "type": "integer", "minimum": 0 }, + "score": { "type": "integer", "minimum": 0, "maximum": 100 }, + "verdict": { "type": "string", "enum": ["PASS", "FAIL"] }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["criterion", "weight", "passed", "evidence"], + "properties": { + "criterion": { "type": "string", "enum": ["DOC", "CONTRAT", "TESTS", "CLI", "HANDOFF"] }, + "weight": { "type": "integer", "minimum": 1 }, + "passed": { "type": "boolean" }, + "evidence": { "type": "string", "minLength": 1 } + } + } + } + } + } + }, + "totals": { + "type": "object", + "additionalProperties": false, + "required": ["modules", "pass", "fail", "min_score", "max_score", "pass_rate_pct"], + "properties": { + "modules": { "type": "integer", "minimum": 1 }, + "pass": { "type": "integer", "minimum": 0 }, + "fail": { "type": "integer", "minimum": 0 }, + "min_score": { "type": "integer", "minimum": 0, "maximum": 100 }, + "max_score": { "type": "integer", "minimum": 0, "maximum": 100 }, + "pass_rate_pct": { "type": "integer", "minimum": 0, "maximum": 100 } + } + }, + "verdict": { "type": "string", "enum": ["PASS", "FAIL"] }, + "notes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/05_deliverables_mvp/qa/audit_4big/quality_spec.json b/05_deliverables_mvp/qa/audit_4big/quality_spec.json new file mode 100644 index 0000000..a641973 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/quality_spec.json @@ -0,0 +1,87 @@ +{ + "audit": "OTO QA · Audit 4Big Qualité", + "version": "1.0", + "reference_cadre": "CLAUDE.md #5 (score minimum 95/100 · niveau 4Big obligatoire) · Roadmap Sprint 7 · QA « Audit 4Big niveau 95+/100 sur 100% deliverables »", + "thresholds": { + "pass_score": 95, + "min_doc_bytes": 400, + "min_test_methods": 8 + }, + "seuil_note": "pass_score = 95 est repris VERBATIM de CLAUDE.md #5. min_doc_bytes / min_test_methods sont des planchers structurels (pas des chiffres métier) — aucun chiffre de projet n'est fabriqué ici.", + "criteria": [ + { + "id": "DOC", + "label": "Documentation livrable (README / SPEC ≥ seuil)", + "weight": 20, + "reference": "4Big · un livrable non documenté n'est pas bancable (check_docs.sh)" + }, + { + "id": "CONTRAT", + "label": "Contrat de sortie explicite (*.schema.json)", + "weight": 20, + "reference": "4Big · toute sortie hand-off est validée contre un schéma draft-07" + }, + { + "id": "TESTS", + "label": "Couverture de tests (≥ seuil de méthodes test_*)", + "weight": 25, + "reference": "4Big · zéro régression silencieuse — le gate CI exige des tests" + }, + { + "id": "CLI", + "label": "CLI reproductible (entrypoint __main__ + sous-commandes argparse)", + "weight": 15, + "reference": "Workflow #2 · build/validate reproductible sans réseau (Gitea Actions)" + }, + { + "id": "HANDOFF", + "label": "Hand-off VPS présent et intègre (out/MANIFEST.json + artefact JSON valide)", + "weight": 20, + "reference": "#8 VPS · chaque générateur produit un hand-off importable côté desk" + } + ], + "archetypes": { + "generator": { + "description": "Produit un hand-off out/ importable côté VPS (fixtures/plan Frappe). Les 5 critères s'appliquent.", + "applicable": ["DOC", "CONTRAT", "TESTS", "CLI", "HANDOFF"] + }, + "data_room": { + "description": "Écrit dans data_room/PXX (template faisabilité / dossier bancable), pas de hand-off out/. HANDOFF non applicable.", + "applicable": ["DOC", "CONTRAT", "TESTS", "CLI"] + }, + "parser": { + "description": "Parse data_room/ → projets_master.json ; son contrat de sortie vit chez le consommateur (faisabilite/projets_master.schema.json). CONTRAT local et HANDOFF non applicables.", + "applicable": ["DOC", "TESTS", "CLI"] + }, + "contract": { + "description": "Contrat de données pur (JSON + schéma + tests), sans entrypoint exécutable. CLI et HANDOFF non applicables.", + "applicable": ["DOC", "CONTRAT", "TESTS"] + } + }, + "self_module": "qa/audit_4big", + "self_note": "L'auditeur ne s'auto-audite pas (séparation des pouvoirs · ISA 315). Le périmètre couvre 100% des AUTRES livrables gated par le CI. La couverture est prouvée par l'invariant : {working-directory CI sous 05_deliverables_mvp} \\ {self_module} == {modules registre}.", + "modules": [ + { "id": "publiciste", "path": "publiciste", "sprint": "S2", "archetype": "parser", "source": "roadmap Sprint 2 · Publiciste (parser faisabilité → projets_master.json) · ci.yml publiciste-tests" }, + { "id": "rbac", "path": "rbac", "sprint": "S2", "archetype": "contract", "source": "roadmap Sprint 2 · RBAC 50 rôles (contrat rbac_50_roles.json) · ci.yml rbac-tests" }, + { "id": "faisabilite-generator", "path": "faisabilite/generator", "sprint": "S2", "archetype": "data_room", "source": "roadmap Sprint 2 · Faisabilité générateur 4 volets → data_room/PXX · ci.yml faisabilite-gen-tests" }, + { "id": "rbac-fixtures", "path": "rbac/fixtures_gen", "sprint": "S2", "archetype": "generator", "source": "roadmap Sprint 2 · fixtures Role + Custom DocPerm · ci.yml rbac-fixtures-tests" }, + { "id": "rbac-userperm", "path": "rbac/userperm_gen", "sprint": "S2", "archetype": "generator", "source": "roadmap Sprint 2 · plan User Permission · ci.yml rbac-userperm-tests" }, + { "id": "rbac-roleprofile", "path": "rbac/roleprofile_gen", "sprint": "S2", "archetype": "generator", "source": "roadmap Sprint 2 · Role Profile par portail · ci.yml rbac-roleprofile-tests" }, + { "id": "rbac-applyplan", "path": "rbac/apply_plan", "sprint": "S2", "archetype": "generator", "source": "roadmap Sprint 2 · agrégateur run-book RBAC · ci.yml rbac-applyplan-tests" }, + { "id": "bancable", "path": "faisabilite/bancable", "sprint": "S3", "archetype": "data_room", "source": "roadmap Sprint 3 · dossier bancable trilingue → data_room/PXX/50_financier_bancable · ci.yml bancable-tests" }, + { "id": "crm-workflow-vente", "path": "crm/workflow_vente", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · workflow vente lead→CONFOTUR · ci.yml crm-workflow-vente-tests" }, + { "id": "crm-dossier-vente", "path": "crm/dossier_vente", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · DocType OTO Dossier Vente · ci.yml crm-dossier-vente-tests" }, + { "id": "crm-commissions", "path": "crm/commissions", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · barème commissions vendeurs · ci.yml crm-commissions-tests" }, + { "id": "fiscal-ecf", "path": "fiscal/ecf_dgii", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · e-CF DGII Compupar · ci.yml fiscal-ecf-tests" }, + { "id": "frontend-portails", "path": "frontend/portails", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · Workspaces 5 portails rôle · ci.yml frontend-portails-tests" }, + { "id": "legal-confotur", "path": "legal/confotur", "sprint": "S5", "archetype": "generator", "source": "roadmap Sprint 5 · DocType CONFOTUR Application · ci.yml legal-confotur-tests" }, + { "id": "qa-audit-5d", "path": "qa/audit_5d", "sprint": "S5", "archetype": "generator", "source": "roadmap Sprint 5 · Audit 5D conformité · ci.yml qa-audit-5d-tests" }, + { "id": "seo", "path": "seo", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · SEO trilingue mots-clés/schema.org/hreflang · ci.yml seo-tests" }, + { "id": "frontend-chat-otoia", "path": "frontend/chat_otoia", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · Chat OTOIA embarqué (Custom Block) · ci.yml chat-otoia-tests" } + ], + "notes": [ + "Audit de MÉTA-NIVEAU : chaque note de module est CALCULÉE depuis des faits du dépôt (présence README/schéma, nombre de méthodes test_*, entrypoint CLI, intégrité out/) — jamais saisie à la main (#6).", + "Un critère non applicable à l'archétype d'un module est EXCLU du dénominateur (pas de pénalité pour un hand-off qu'un module de contrat n'a jamais vocation à produire) ; les poids restants sont renormalisés à 100.", + "Le verdict global est PASS si et seulement si les 17 modules atteignent ≥ 95/100 ET la couverture == 100% des livrables gated (hors auditeur)." + ] +} diff --git a/05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py b/05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py new file mode 100644 index 0000000..dfa3657 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Tests du générateur d'audit 4Big qualité (QA · Sprint 7). + +Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea +(CLAUDE.md #2). Couvre : les 5 critères (positif + injection négative sur arbre +de module synthétique en tmpdir), la renormalisation par archétype, la preuve de +couverture 100% vs CI, les invariants du générateur, le déterminisme, le schéma. + +Axe central : une note ne peut pas être « fabriquée pour faire 95 » — elle est +RECALCULÉE depuis des faits du dépôt (#6). Un module dont un critère applicable +échoue tombe sous 95 → FAIL global (l'audit est un GATE). +""" + +from __future__ import annotations + +import copy +import json +import os +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_MOD = os.path.normpath(os.path.join(_HERE, "..")) +sys.path.insert(0, _MOD) + +from q4lib import builder, criteria, registry, scoring # noqa: E402 +import audit_4big_gen as gen # noqa: E402 + + +def _spec(): + with open(os.path.join(_MOD, "quality_spec.json"), encoding="utf-8") as fh: + return json.load(fh) + + +def _make_module(root: str, *, doc=True, doc_bytes=800, schema=True, + tests=12, cli=True, handoff=True) -> str: + """Construit un arbre de module synthétique et renvoie son chemin.""" + mod = os.path.join(root, "mod") + os.makedirs(mod, exist_ok=True) + if doc: + with open(os.path.join(mod, "README.md"), "w", encoding="utf-8") as fh: + fh.write("# Module\n" + ("x" * doc_bytes)) + if schema: + with open(os.path.join(mod, "out.schema.json"), "w", encoding="utf-8") as fh: + json.dump({"type": "object"}, fh) + tdir = os.path.join(mod, "tests") + os.makedirs(tdir, exist_ok=True) + body = "".join(f" def test_case_{i}(self):\n pass\n" + for i in range(tests)) + with open(os.path.join(tdir, "test_mod.py"), "w", encoding="utf-8") as fh: + fh.write("import unittest\nclass T(unittest.TestCase):\n" + + (body or " pass\n")) + if cli: + with open(os.path.join(mod, "mod_gen.py"), "w", encoding="utf-8") as fh: + fh.write('import argparse\n' + 'ap = argparse.ArgumentParser()\n' + 'sub = ap.add_subparsers()\n' + 'sub.add_parser("build")\n' + 'if __name__ == "__main__":\n pass\n') + if handoff: + odir = os.path.join(mod, "out") + os.makedirs(odir, exist_ok=True) + with open(os.path.join(odir, "MANIFEST.json"), "w", encoding="utf-8") as fh: + json.dump({"a": 1}, fh) + with open(os.path.join(odir, "artifact.json"), "w", encoding="utf-8") as fh: + json.dump({"b": 2}, fh) + return mod + + +# --------------------------------------------------------------------------- # +# 1. Critères — positifs et injections négatives (arbre synthétique). # +# --------------------------------------------------------------------------- # +class CriteriaTest(unittest.TestCase): + def test_all_criteria_pass_on_complete_module(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d) + self.assertTrue(criteria.crit_doc(mod, 400)["passed"]) + self.assertTrue(criteria.crit_contrat(mod)["passed"]) + self.assertTrue(criteria.crit_tests(mod, 8)["passed"]) + self.assertTrue(criteria.crit_cli(mod)["passed"]) + self.assertTrue(criteria.crit_handoff(mod)["passed"]) + + def test_doc_fails_when_too_small(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, doc_bytes=10) + self.assertFalse(criteria.crit_doc(mod, 400)["passed"]) + + def test_doc_fails_when_absent(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, doc=False) + self.assertFalse(criteria.crit_doc(mod, 400)["passed"]) + + def test_contrat_fails_without_schema(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, schema=False) + self.assertFalse(criteria.crit_contrat(mod)["passed"]) + + def test_tests_counts_methods_and_thresholds(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, tests=5) + res = criteria.crit_tests(mod, 8) + self.assertFalse(res["passed"]) + self.assertIn("5 méthodes", res["evidence"]) + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, tests=9) + self.assertTrue(criteria.crit_tests(mod, 8)["passed"]) + + def test_cli_fails_without_entrypoint(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, cli=False) + self.assertFalse(criteria.crit_cli(mod)["passed"]) + + def test_cli_ignores_tests_dir_scripts(self): + # Un script argparse dans tests/ ne compte pas comme entrypoint racine. + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, cli=False) + self.assertFalse(criteria.crit_cli(mod)["passed"]) + + def test_handoff_fails_without_manifest(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d, handoff=False) + self.assertFalse(criteria.crit_handoff(mod)["passed"]) + + def test_handoff_fails_when_manifest_only(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d) + os.remove(os.path.join(mod, "out", "artifact.json")) + self.assertFalse(criteria.crit_handoff(mod)["passed"]) + + def test_handoff_fails_on_broken_json(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d) + with open(os.path.join(mod, "out", "artifact.json"), "w") as fh: + fh.write("{ not json") + self.assertFalse(criteria.crit_handoff(mod)["passed"]) + + def test_handoff_fails_on_empty_artifact(self): + with tempfile.TemporaryDirectory() as d: + mod = _make_module(d) + with open(os.path.join(mod, "out", "artifact.json"), "w") as fh: + json.dump({}, fh) + self.assertFalse(criteria.crit_handoff(mod)["passed"]) + + +# --------------------------------------------------------------------------- # +# 2. Scoring — renormalisation par archétype + verdict. # +# --------------------------------------------------------------------------- # +class ScoringTest(unittest.TestCase): + def setUp(self): + self.spec = _spec() + self._orig_root = scoring.DELIVERABLES_ROOT + + def tearDown(self): + scoring.DELIVERABLES_ROOT = self._orig_root + + def _score(self, archetype, **kw): + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__("shutil").rmtree(d, ignore_errors=True)) + _make_module(d, **kw) + scoring.DELIVERABLES_ROOT = d + return scoring.score_module(self.spec, { + "id": "x", "path": "mod", "sprint": "S9", "archetype": archetype}) + + def test_generator_full_is_100(self): + r = self._score("generator") + self.assertEqual(r["score"], 100) + self.assertEqual(r["verdict"], "PASS") + self.assertEqual(r["applicable_weight"], 100) + + def test_generator_missing_handoff_fails(self): + r = self._score("generator", handoff=False) + self.assertEqual(r["score"], 80) # 80/100 + self.assertEqual(r["verdict"], "FAIL") + + def test_contract_excludes_cli_and_handoff(self): + r = self._score("contract", cli=False, handoff=False) + self.assertEqual(r["applicable_weight"], 65) # DOC20+CONTRAT20+TESTS25 + self.assertEqual(r["score"], 100) + self.assertEqual({c["criterion"] for c in r["checks"]}, + {"DOC", "CONTRAT", "TESTS"}) + + def test_parser_excludes_contrat(self): + r = self._score("parser", schema=False, handoff=False) + self.assertEqual({c["criterion"] for c in r["checks"]}, + {"DOC", "TESTS", "CLI"}) + self.assertEqual(r["score"], 100) + + def test_data_room_missing_cli_drops_below_threshold(self): + r = self._score("data_room", cli=False, handoff=False) + # applicable DOC20+CONTRAT20+TESTS25+CLI15 = 80 ; earned 65 → 81 + self.assertEqual(r["applicable_weight"], 80) + self.assertEqual(r["score"], 81) + self.assertEqual(r["verdict"], "FAIL") + + def test_missing_module_dir_raises(self): + scoring.DELIVERABLES_ROOT = "/does/not/exist" + with self.assertRaises(FileNotFoundError): + scoring.score_module(self.spec, { + "id": "x", "path": "nope", "sprint": "S9", + "archetype": "generator"}) + + def test_round_half_up_is_deterministic(self): + self.assertEqual(scoring._round_half_up(80.5), 81) + self.assertEqual(scoring._round_half_up(81.25), 81) + + +# --------------------------------------------------------------------------- # +# 3. Couverture 100% vs CI (preuve, pas déclaration). # +# --------------------------------------------------------------------------- # +class CoverageTest(unittest.TestCase): + def test_real_coverage_is_bijective(self): + cov = registry.coverage_report(_spec()) + self.assertTrue(cov["ok"], cov) + self.assertEqual(cov["missing_in_registry"], []) + self.assertEqual(cov["missing_in_ci"], []) + self.assertEqual(cov["not_in_gate"], []) + self.assertEqual(cov["ci_modules_count"], cov["registry_modules_count"]) + + def test_auditor_excluded_from_scope(self): + cov = registry.coverage_report(_spec()) + self.assertEqual(cov["self_module_excluded"], "qa/audit_4big") + self.assertNotIn("qa/audit_4big", + {m["path"] for m in _spec()["modules"]}) + + def test_extra_registry_module_flags_missing_in_ci(self): + spec = _spec() + spec["modules"].append({"id": "ghost", "path": "ghost/dir", + "sprint": "S9", "archetype": "generator", + "source": "test"}) + cov = registry.coverage_report(spec) + self.assertFalse(cov["ok"]) + self.assertIn("ghost/dir", cov["missing_in_ci"]) + + def test_dropped_registry_module_flags_missing_in_registry(self): + spec = _spec() + spec["modules"] = spec["modules"][:-1] # retire un module gated + cov = registry.coverage_report(spec) + self.assertFalse(cov["ok"]) + self.assertTrue(cov["missing_in_registry"]) + + def test_ci_parsing_finds_gate_needs(self): + ci = registry.parse_ci() + # le job d'agrégat `gate` liste ses dépendances (needs) — un job de + # test réel doit y figurer, preuve que le parsing du needs fonctionne. + self.assertIn("seo-tests", ci["gate_needs"]) + self.assertGreaterEqual(len(ci["gate_needs"]), 17) + self.assertGreaterEqual(len(ci["job_to_path"]), 17) + + +# --------------------------------------------------------------------------- # +# 4. Build réel + invariants + schéma + déterminisme. # +# --------------------------------------------------------------------------- # +class BuildTest(unittest.TestCase): + def setUp(self): + self.spec = _spec() + self.report = builder.build(self.spec) + + def test_real_build_passes_gate(self): + self.assertEqual(self.report["verdict"], "PASS") + self.assertEqual(self.report["totals"]["fail"], 0) + self.assertEqual(self.report["totals"]["modules"], 17) + self.assertGreaterEqual(self.report["totals"]["min_score"], 95) + + def test_invariants_clean_on_real_report(self): + errs = gen.check_invariants(self.report, self.spec) + self.assertEqual(errs, [], errs) + + def test_schema_validates(self): + with open(os.path.join(_MOD, "quality.schema.json"), encoding="utf-8") as fh: + schema = json.load(fh) + from q4lib.deps import validate + self.assertEqual(validate(self.report, schema), []) + + def test_determinism(self): + again = builder.build(_spec()) + self.assertEqual(json.dumps(self.report, sort_keys=True), + json.dumps(again, sort_keys=True)) + + def test_pass_score_is_95(self): + self.assertEqual(self.report["pass_score"], 95) + self.assertEqual(self.spec["thresholds"]["pass_score"], 95) + + def test_every_module_path_exists(self): + from q4lib.deps import DELIVERABLES_ROOT + for m in self.report["modules"]: + self.assertTrue( + os.path.isdir(os.path.join(DELIVERABLES_ROOT, m["path"])), + m["path"]) + + +# --------------------------------------------------------------------------- # +# 5. Invariants — détection d'une note fabriquée / d'un module en échec. # +# --------------------------------------------------------------------------- # +class InvariantGuardTest(unittest.TestCase): + def setUp(self): + self.spec = _spec() + self.report = builder.build(self.spec) + + def test_fabricated_score_is_caught(self): + bad = copy.deepcopy(self.report) + # On force une note sans toucher aux checks → note non recomputable. + bad["modules"][0]["score"] = 60 + errs = gen.check_invariants(bad, self.spec) + self.assertTrue(any("INV6" in e or "INV7" in e for e in errs), errs) + + def test_failing_module_forces_global_fail(self): + bad = copy.deepcopy(self.report) + m = bad["modules"][0] + # On retire un check passant → earned baisse, note recomputée < 95. + for c in m["checks"]: + if c["passed"]: + c["passed"] = False + break + m["earned_weight"] = sum(c["weight"] for c in m["checks"] if c["passed"]) + m["score"] = scoring._round_half_up( + 100.0 * m["earned_weight"] / m["applicable_weight"]) + m["verdict"] = "PASS" if m["score"] >= 95 else "FAIL" + bad["totals"]["fail"] = sum(1 for x in bad["modules"] + if x["verdict"] != "PASS") + bad["totals"]["pass"] = 17 - bad["totals"]["fail"] + bad["verdict"] = "FAIL" + errs = gen.check_invariants(bad, self.spec) + # Le rapport est cohérent en interne mais porte un FAIL → INV7 le signale. + self.assertTrue(any("< seuil 4Big" in e for e in errs), errs) + + def test_self_module_in_scope_is_caught(self): + bad = copy.deepcopy(self.report) + bad["modules"].append(copy.deepcopy(bad["modules"][0])) + bad["modules"][-1]["path"] = self.spec["self_module"] + errs = gen.check_invariants(bad, self.spec) + self.assertTrue(any("INV4" in e for e in errs), errs) + + def test_weights_sum_invariant(self): + bad_spec = copy.deepcopy(self.spec) + bad_spec["criteria"][0]["weight"] = 999 + errs = gen.check_invariants(self.report, bad_spec) + self.assertTrue(any("INV2" in e for e in errs), errs) + + def test_cli_build_and_validate_exit_zero(self): + self.assertEqual(gen.main(["validate"]), 0) + with tempfile.TemporaryDirectory() as d: + self.assertEqual(gen.main(["build", "-o", d]), 0) + self.assertTrue(os.path.isfile(os.path.join(d, "quality_report.json"))) + self.assertTrue(os.path.isfile(os.path.join(d, "MANIFEST.json"))) + + +if __name__ == "__main__": + unittest.main(verbosity=2)