[DTP-Worker] Sprint 8 · buffer L75 · Traçabilité non vérifiée : les n° de ligne roadmap_line de la matrice d'acceptation pouvaient pointer à côté en silence (roadmap éditée) → INV11 ancrage roadmap (dérive 8+7 du fichier · #6) + parse_roadmap_anchors + 6 tests ; régénéré 558→564

INV10 ne garantissait que « roadmap_line est un entier positif ». Ajout de
parse_roadmap_anchors (deps) qui DÉRIVE la structure réelle de la roadmap, et
d'INV11 qui exige que chaque roadmap_line pointe RÉELLEMENT son bullet
(DELIVERABLE du sprint SX · k-ième bullet métrique) et que le « 8 + 7 » soit
dérivé du fichier, pas figé. Morsure prouvée sur le spec réel (S1=999, M3=200).
Régénéré consommateurs : regression 558→564 (run/plan/MANIFEST), quality_report
(acceptance 31→37 méthodes, 100/100 inchangé), fiches QA + Backend, README
acceptance (10→11 invariants). 7 gates verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-31 06:12:15 +00:00
parent 11f1250b29
commit 25541c365d
11 changed files with 273 additions and 22 deletions
+6 -2
View File
@@ -43,7 +43,7 @@ qu'aucune promesse n'est silencieusement oubliée.
```bash
python3 acceptance_gen.py build # écrit out/acceptance_matrix.json + out/MANIFEST.json
python3 acceptance_gen.py validate # (re)génère en mémoire + valide (schéma + 10 invariants)
python3 acceptance_gen.py validate # (re)génère en mémoire + valide (schéma + 11 invariants)
python3 -m unittest discover -s tests -p 'test_*.py' -v
```
@@ -56,7 +56,7 @@ python3 -m unittest discover -s tests -p 'test_*.py' -v
Sortie **déterministe** (ordre du spec, listes triées, aucun horodatage).
## Invariants (10 familles)
## Invariants (11 familles)
1. ids uniques ; livrables S1..S8 + métriques M1..M7 ordonnés.
2. toute preuve module est **gated** (drapeau recalculé depuis le CI).
@@ -68,6 +68,10 @@ Sortie **déterministe** (ordre du spec, listes triées, aucun horodatage).
8. `mvp_metric` ⇒ sprint nul ; `sprint_deliverable` ⇒ sprint renseigné.
9. **SoD** : la matrice ne se cite jamais elle-même comme preuve.
10. `roadmap_line` entier positif.
11. **ancrage roadmap** : chaque `roadmap_line` pointe RÉELLEMENT son bullet dans
`04_roadmap/…` (n° DELIVERABLE du sprint SX · k-ième bullet métrique), et le
compte « 8 + 7 » est **dérivé du fichier roadmap**, pas figé — une promesse
ajoutée/retirée ou des lignes décalées rougissent au lieu de dériver en silence.
## Hors périmètre worker (#8)
@@ -21,7 +21,9 @@ module tracé non gated → génération refusée.
Anti-invention (#6) : les nombres des énoncés (<1h, 95/100, 7 dashboards, 2
stores) sont des CITATIONS verbatim de la roadmap ; la matrice ne PRÉTEND aucune
mesure de performance ; tout hors-périmètre porte une source.
mesure de performance ; tout hors-périmètre porte une source. Le n° de ligne
(`roadmap_line`) de chaque promesse est VÉRIFIÉ (INV11) contre la structure réelle
de la roadmap — il ne peut pas pointer à côté en silence si la roadmap est éditée.
Produit :
- acceptance_matrix.json → la matrice (une ligne par promesse : preuves
@@ -218,6 +220,51 @@ def validate_bundle(bundle: dict, spec: dict, ci: dict, audit: dict) -> list[str
if any(not isinstance(x, int) or x < 1 for x in lines):
errors.append(f"roadmap_line: valeur non positive {lines}.")
# INV11 · ANCRAGE ROADMAP (traçabilité VÉRIFIÉE, pas déclarée · #6). INV10 ne
# garantit que « roadmap_line est un entier » : un n° de ligne qui pointe à
# côté (roadmap éditée, lignes décalées) reste « vert » alors qu'il cite un
# bullet qui n'existe plus. On DÉRIVE la structure réelle de la roadmap et on
# exige que chaque roadmap_line pointe RÉELLEMENT le bullet qu'il prétend :
# · nombre de bullets DELIVERABLE == nombre de livrables de sprint du spec,
# et nombre de bullets métrique == nombre de métriques (le « 8 + 7 » n'est
# plus un compte figé mais dérivé du fichier roadmap) ;
# · chaque livrable SX pointe le bullet **DELIVERABLE** du sprint SX ;
# · chaque métrique (dans l'ordre) pointe le k-ième bullet « - ✅ ».
try:
anchors = deps.parse_roadmap_anchors(spec["roadmap_ref"])
except OSError as e:
errors.append(f"roadmap: {spec.get('roadmap_ref')!r} illisible ({e}).")
else:
dmap = anchors["deliverables"]
specd = spec.get("sprint_deliverables", [])
specm = spec.get("mvp_metrics", [])
if len(dmap) != len(specd):
errors.append(
f"roadmap: {len(dmap)} bullet(s) DELIVERABLE dans la roadmap ≠ "
f"{len(specd)} livrable(s) de sprint du spec (promesse ajoutée/retirée non tracée).")
if len(anchors["metrics"]) != len(specm):
errors.append(
f"roadmap: {len(anchors['metrics'])} bullet(s) métrique dans la roadmap ≠ "
f"{len(specm)} métrique(s) du spec (promesse ajoutée/retirée non tracée).")
for d in specd:
want = dmap.get(d["sprint"])
if want is None:
errors.append(
f"roadmap: {d['id']}: aucun bullet DELIVERABLE pour le sprint {d['sprint']}.")
elif d["roadmap_line"] != want:
errors.append(
f"roadmap: {d['id']}: roadmap_line={d['roadmap_line']} ne pointe pas "
f"le DELIVERABLE de {d['sprint']} (ligne {want}).")
for k, d in enumerate(specm):
want = anchors["metrics"][k] if k < len(anchors["metrics"]) else None
if want is None:
errors.append(
f"roadmap: {d['id']}: aucun {k + 1}e bullet métrique dans la roadmap.")
elif d["roadmap_line"] != want:
errors.append(
f"roadmap: {d['id']}: roadmap_line={d['roadmap_line']}{k + 1}e bullet "
f"métrique (ligne {want}).")
return errors
@@ -22,11 +22,14 @@ from __future__ import annotations
import json
import os
import re
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
# qa/acceptance/acclib → 05_deliverables_mvp
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
# 05_deliverables_mvp → racine du dépôt (où vit 04_roadmap/).
_REPO_ROOT = os.path.normpath(os.path.join(_DELIVERABLES, ".."))
_PUB = os.path.join(_DELIVERABLES, "publiciste")
_AUDIT = os.path.join(_DELIVERABLES, "qa", "audit_4big")
@@ -55,6 +58,54 @@ def audit_sprints() -> dict:
return {m["path"]: m["sprint"] for m in spec["modules"]}
# Ancres de traçabilité de la roadmap (structure, jamais des n° saisis à la main).
_SPRINT_HEAD = re.compile(r"##\s+SPRINT\s+\d+.*?·\s*(S\d+)\s*\(")
_METRIC_HEAD = re.compile(r"##\s+M[ée]triques\s+succ")
_DELIVERABLE = re.compile(r"\*\*DELIVERABLE")
_METRIC_BULLET = re.compile(r"^-\s*✅")
def parse_roadmap_anchors(roadmap_ref: str) -> dict:
"""DÉRIVE les ancres de traçabilité depuis la roadmap réelle (#6 · jamais une
liste de n° de ligne saisie à la main).
Renvoie :
- ``deliverables`` : {sprint SX -> n° de ligne du bullet ``**DELIVERABLE**``
appartenant au sprint SX} (le SX est LU dans le titre ``## SPRINT n … · SX (…``) ;
- ``metrics`` : [n° de ligne] des bullets ``- ✅`` sous la section
``## Métriques succès MVP``, en ordre de fichier ;
- ``path`` : le chemin résolu (pour messages).
``roadmap_ref`` absolu est utilisé tel quel ; relatif est résolu depuis la
racine du dépôt. Lève ``OSError`` si la roadmap est absente/illisible — une
référence de traçabilité vers un fichier fantôme est un mensonge (red honnête)."""
path = roadmap_ref if os.path.isabs(roadmap_ref) \
else os.path.join(_REPO_ROOT, roadmap_ref)
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
deliverables: dict[str, int] = {}
metrics: list[int] = []
current_sprint: str | None = None
in_metrics = False
for i, ln in enumerate(lines, 1):
mh = _SPRINT_HEAD.match(ln)
if mh:
current_sprint, in_metrics = mh.group(1), False
continue
if _METRIC_HEAD.match(ln):
current_sprint, in_metrics = None, True
continue
if ln.startswith("## "): # toute autre section clôt le contexte
current_sprint, in_metrics = None, False
continue
if current_sprint and _DELIVERABLE.search(ln):
deliverables[current_sprint] = i
if in_metrics and _METRIC_BULLET.match(ln):
metrics.append(i)
return {"deliverables": deliverables, "metrics": metrics, "path": path}
def artifact_exists(rel_path: str) -> bool:
"""Vrai ssi l'artefact cité (chemin relatif à 05_deliverables_mvp) existe.
@@ -67,6 +118,7 @@ __all__ = [
"validate",
"parse_ci",
"audit_sprints",
"parse_roadmap_anchors",
"artifact_exists",
"DELIVERABLES_ROOT",
]
@@ -1,19 +1,21 @@
"""Tests de la Matrice d'acceptation / traçabilité MVP (stdlib pur · zéro pip).
Couvre : le build réel (couverture bijective vs CI Gitea + registre 4Big), la
conformité au schéma, les 10 familles d'invariants, le déterminisme, et une
conformité au schéma, les 11 familles d'invariants, le déterminisme, et une
batterie d'injections négatives (chaque invariant DOIT rougir quand on le viole).
Les négatifs s'appuient sur une fixture SYNTHÉTIQUE minimale (CI + registre +
spec factices) pour contrôler exactement l'écart injecté, sans dépendre de la
forme du dépôt réel.
spec + roadmap factices) pour contrôler exactement l'écart injecté, sans dépendre
de la forme du dépôt réel.
"""
from __future__ import annotations
import atexit
import copy
import os
import sys
import tempfile
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
@@ -40,6 +42,29 @@ def _mini_ci(paths, self_module=_SELF) -> dict:
return {"job_to_path": j2p, "gate_needs": list(j2p)}
# --------------------------------------------------------------------------- #
# Roadmap SYNTHÉTIQUE : la même structure que la vraie (## SPRINT n · SX · bullet
# **DELIVERABLE**, puis section Métriques avec bullets « - ✅ »), en miniature.
# INV11 lit ce fichier ; on contrôle donc exactement les n° de ligne attendus :
# L3 → DELIVERABLE S2 · L5 → DELIVERABLE S3 · L7 → 1er bullet métrique (M1).
# --------------------------------------------------------------------------- #
_MINI_ROADMAP = (
"# Roadmap fixture\n" # L1
"## SPRINT 2 · Fixture · S2 (obj S1)\n" # L2
"- **DELIVERABLE** : livrable S2\n" # L3
"## SPRINT 3 · Fixture · S3 (obj S2)\n" # L4
"- **DELIVERABLE** : livrable S3\n" # L5
"## Métriques succès MVP (fixture)\n" # L6
"- ✅ métrique M1\n" # L7
)
_ROADMAP_S2, _ROADMAP_S3, _ROADMAP_M1 = 3, 5, 7
_fd, _ROADMAP_PATH = tempfile.mkstemp(suffix="_roadmap.md", prefix="acc_fixture_")
with os.fdopen(_fd, "w", encoding="utf-8") as _fh:
_fh.write(_MINI_ROADMAP)
atexit.register(lambda: os.path.exists(_ROADMAP_PATH) and os.remove(_ROADMAP_PATH))
def _mini_audit() -> dict:
return {"a/one": "S2", "b/two": "S3"}
@@ -48,23 +73,23 @@ def _mini_spec() -> dict:
return {
"version": "test",
"reference_cadre": "fixture",
"roadmap_ref": "ROADMAP.md",
"roadmap_ref": _ROADMAP_PATH,
"self_module": _SELF,
"cible_portage": "recette",
"extra_module_sprint": {},
"sprint_deliverables": [
{"id": "S2", "kind": "sprint_deliverable", "sprint": "S2",
"roadmap_line": 39, "statement": "livrable S2", "status": "in_repo",
"roadmap_line": _ROADMAP_S2, "statement": "livrable S2", "status": "in_repo",
"evidence_modules": ["a/one"], "evidence_artifacts": [],
"out_of_scope": []},
{"id": "S3", "kind": "sprint_deliverable", "sprint": "S3",
"roadmap_line": 46, "statement": "livrable S3", "status": "in_repo",
"roadmap_line": _ROADMAP_S3, "statement": "livrable S3", "status": "in_repo",
"evidence_modules": ["b/two"], "evidence_artifacts": [],
"out_of_scope": []},
],
"mvp_metrics": [
{"id": "M1", "kind": "mvp_metric", "sprint": None,
"roadmap_line": 81, "statement": "métrique M1", "status": "in_repo",
"roadmap_line": _ROADMAP_M1, "statement": "métrique M1", "status": "in_repo",
"evidence_modules": ["a/one"], "evidence_artifacts": [],
"out_of_scope": []},
],
@@ -279,6 +304,63 @@ class TestNegativeInjections(unittest.TestCase):
self.assertFalse(b["verdict"])
# --------------------------------------------------------------------------- #
class TestRoadmapAnchor(unittest.TestCase):
"""INV11 · les n° de ligne `roadmap_line` sont ANCRÉS à la structure réelle
de la roadmap (traçabilité vérifiée, pas déclarée)."""
def test_parse_real_roadmap_structure(self):
# Le parseur dérive 8 livrables (S1..S8) + 7 métriques de la vraie roadmap.
spec = _spec()
a = deps.parse_roadmap_anchors(spec["roadmap_ref"])
self.assertEqual(sorted(a["deliverables"]),
[f"S{i}" for i in range(1, 9)])
self.assertEqual(len(a["metrics"]), 7)
# Chaque roadmap_line du spec réel pointe bien son bullet.
for d in spec["sprint_deliverables"]:
self.assertEqual(d["roadmap_line"], a["deliverables"][d["sprint"]],
f"{d['id']} désancré")
for k, d in enumerate(spec["mvp_metrics"]):
self.assertEqual(d["roadmap_line"], a["metrics"][k], f"{d['id']} désancré")
def test_deliverable_line_offby_fails(self):
spec = _mini_spec()
spec["sprint_deliverables"][0]["roadmap_line"] = 99 # ne pointe plus S2
self.assertTrue(any("ne pointe pas" in e for e in _mini_errors(spec)),
_mini_errors(spec))
def test_metric_line_offby_fails(self):
spec = _mini_spec()
spec["mvp_metrics"][0]["roadmap_line"] = 1 # pas le bullet métrique
self.assertTrue(any("bullet métrique" in e for e in _mini_errors(spec)),
_mini_errors(spec))
def test_wrong_sprint_owner_fails(self):
# Le livrable prétend S3 mais garde la ligne du DELIVERABLE de S2.
spec = _mini_spec()
spec["sprint_deliverables"][0]["sprint"] = "S3"
spec["sprint_deliverables"][0]["roadmap_line"] = _ROADMAP_S2
self.assertTrue(any("ne pointe pas" in e for e in _mini_errors(spec)),
_mini_errors(spec))
def test_extra_deliverable_count_mismatch_fails(self):
# Un livrable de plus que ce que contient la roadmap fixture.
spec = _mini_spec()
spec["sprint_deliverables"].append({
"id": "S4", "kind": "sprint_deliverable", "sprint": "S4",
"roadmap_line": 3, "statement": "x", "status": "in_repo",
"evidence_modules": ["a/one"], "evidence_artifacts": [], "out_of_scope": []})
errs = _mini_errors(spec)
self.assertTrue(any("bullet(s) DELIVERABLE" in e for e in errs)
or any("aucun bullet DELIVERABLE" in e for e in errs), errs)
def test_missing_roadmap_file_fails(self):
spec = _mini_spec()
spec["roadmap_ref"] = "/nonexistent/path/ROADMAP.md"
self.assertTrue(any("illisible" in e for e in _mini_errors(spec)),
_mini_errors(spec))
# --------------------------------------------------------------------------- #
class TestCLI(unittest.TestCase):
"""Le CLI build/validate produit les artefacts et sort proprement."""