From 5af86e3518ce87309a981bf252e426c75149d22e Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Wed, 12 Aug 2026 07:41:05 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker=2020260812=5F073055]=20hardening=20?= =?UTF-8?q?correctness=20=C2=B7=20rendu=20HTML=20publiciste=20=E2=80=94=20?= =?UTF-8?q?percent-encode=20le=20SEUL=20segment=20d'URL=20restant=20(code)?= =?UTF-8?q?=20dans=20le=20url('=E2=80=A6')=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durcissement d'uniformité (pas bug déclenchable aujourd'hui) qui COMPLÈTE le fix correctness de la session 070054 (aeb30b8). Ce fix-là a percent-encodé hero['fichier'] dans src=f"/static/projets/{code}/{quote(fichier)}", mais dans la MÊME url('…') CSS il restait un 2ᵉ segment dynamique — `code` — interpolé brut puis html-échappé via _esc(src). C'est exactement l'anti-pattern identifié par 070054 (html.escape = mauvais encodeur pour un segment d'URL), appliqué de façon incomplète. Fix : src = f"/static/projets/{quote(code)}/{quote(hero['fichier'])}" — quote() est le SEUL encodeur correct pour un segment d'URL ; le CONTEXTE, pas la donnée courante, dicte l'encodeur. Zéro dérive : codes réels = P01..P09 (§Projets), quote("p01")=="p01" → byte-repro inchangé. Cas frère exact de 060044/criteria.py (« durcir le SEUL membre non-durci d'une famille ») : ici le SEUL segment d'URL non-encodé de la même url(). Test à dents (test_hero_media_code_segment_percent_encoded) : code théorique "P'X" → prouvé FAIL sans quote(code), PASS avec. Suite publiciste 25→26, matrice 630/613→631/614. Défaut d'abord : classe encodage-contextuel balayée sur tous les modules HTML/CSS/URL ; 2 candidats Explore réfutés non-défauts (chat mount_html = persona forcée Amélie par invariant 7 + portail_key enum ; code segment = théorique mais durci par uniformité). Cascade régénérée dans l'ordre (quality_report en DERNIER) · 3 fiches compteurs réalignées. run_ci 33/0/0 · NFC-clean · zéro code moteur V18 (bloqué D-06 #6) · zéro gate (#5) · zéro invention (#6). Co-Authored-By: Claude Opus 4.8 (1M context) --- 03_agents/erpnext_backend/AGENT.md | 2 +- 03_agents/publiciste/AGENT.md | 2 +- 03_agents/qa/AGENT.md | 2 +- 05_activity_log/2026-08-12.md | 20 +++++++++++++++++++ .../publiciste/lib/generator.py | 14 ++++++++----- .../publiciste/tests/test_publiciste.py | 19 ++++++++++++++++++ .../qa/audit_4big/out/quality_report.json | 2 +- .../qa/regression/out/MANIFEST.json | 2 +- .../qa/regression/out/regression_plan.json | 4 ++-- .../qa/regression/out/regression_run.json | 10 +++++----- 10 files changed, 60 insertions(+), 17 deletions(-) diff --git a/03_agents/erpnext_backend/AGENT.md b/03_agents/erpnext_backend/AGENT.md index f645805..eec1f89 100644 --- a/03_agents/erpnext_backend/AGENT.md +++ b/03_agents/erpnext_backend/AGENT.md @@ -37,7 +37,7 @@ le VPS reçoit — jamais l'inverse. vente, le barème commissions, le DocType Dossier Vente et le plan e-CF **résolvent** leurs rôles depuis ce fichier, jamais un nom Frappe en dur. Total backend **RBAC 60 tests** (10 + 11 + 12 + 11 + 16) + **e-CF 39 tests**, tous gated dans le CI (matrice -de régression du repo : **630 tests · 24 suites** · verdict `PASS`, source +de régression du repo : **631 tests · 24 suites** · verdict `PASS`, source `qa/regression/out/regression_run.json` — jamais compté à la main · #6). ## Hand-off reçus (à appliquer sur le VPS, dans l'ordre) diff --git a/03_agents/publiciste/AGENT.md b/03_agents/publiciste/AGENT.md index 664c9c7..901398e 100644 --- a/03_agents/publiciste/AGENT.md +++ b/03_agents/publiciste/AGENT.md @@ -92,7 +92,7 @@ du générateur, gaté en CI. C'est le **seul module net-neuf** du mandat, sur l | Module | Sprint | Rôle | Entrée CLI | Job CI | Tests | |---|---|---|---|---|---| -| [`publiciste/`](../../05_deliverables_mvp/publiciste/README.md) | 2 (README `scaffold (Sprint 2)` · GAP §3.13) | **Pipeline site public** : `data_room/PXX/` → `projets_master.json` → `index.html` luxury (`lib/parser.py` extraction · `lib/validator.py` schéma · `lib/generator.py` + `lib/branding.py` rendu dark+doré `#0a0a12`/`#f0b429` · `templates/site_public.html.tmpl`) | `publiciste.py parse\|validate\|generate\|run` | `publiciste-tests` | 25 | +| [`publiciste/`](../../05_deliverables_mvp/publiciste/README.md) | 2 (README `scaffold (Sprint 2)` · GAP §3.13) | **Pipeline site public** : `data_room/PXX/` → `projets_master.json` → `index.html` luxury (`lib/parser.py` extraction · `lib/validator.py` schéma · `lib/generator.py` + `lib/branding.py` rendu dark+doré `#0a0a12`/`#f0b429` · `templates/site_public.html.tmpl`) | `publiciste.py parse\|validate\|generate\|run` | `publiciste-tests` | 26 | Le contrat d'échange `projets_master.json` n'est **pas** défini ici mais par le Faisabilité Agent ([`../../05_deliverables_mvp/faisabilite/projets_master.schema.json`](../../05_deliverables_mvp/faisabilite/projets_master.schema.json), diff --git a/03_agents/qa/AGENT.md b/03_agents/qa/AGENT.md index fba668f..46279e7 100644 --- a/03_agents/qa/AGENT.md +++ b/03_agents/qa/AGENT.md @@ -32,7 +32,7 @@ périmètre — un auditeur ne s'audite jamais. **conformité** normative des hand-off. ## Verdict agrégé courant (source `qa/regression/out/regression_run.json`) -**24 suites gated · 630 tests · 613 passés · 0 échec · 0 erreur → verdict `PASS`.** +**24 suites gated · 631 tests · 614 passés · 0 échec · 0 erreur → verdict `PASS`.** Ce compte est **découvert automatiquement depuis le CI** (`q4lib.registry.parse_ci`, réutilisé sans duplication) — jamais compté à la main. Les **17 tests restants sont ignorés** (`skipped`) : ce sont les *oracles optionnels* diff --git a/05_activity_log/2026-08-12.md b/05_activity_log/2026-08-12.md index 1f20b71..5fba5c5 100644 --- a/05_activity_log/2026-08-12.md +++ b/05_activity_log/2026-08-12.md @@ -132,3 +132,23 @@ Les trois sont des travaux de vérif/hardening à **artefacts byte-identiques** **Pourquoi pas un gate (#5).** Le bug est un one-shot de correctness ; sa robustesse vit dans le code (`quote`) + le test de régression à dents, pas dans un gate de drift. Aucune nouvelle classe de drift à surveiller. **Vérif.** `./run_ci.sh` = **33 PASS · 0 FAIL · 0 SKIP**. Fichiers édités **compilent** + **NFC-clean** (vérifié). **0 code moteur V18** (bloqué #6), **0 chiffre/fixture inventé** (#6 · tous les compteurs re-dérivés des artefacts régénérés), **0 gate ajouté** (#5), **0 commande VPS** (#8), **0 API externe**. Fichiers : `M generator.py` (fix `quote`) · `M test_publiciste.py` (test à dents) · `M` 4 artefacts régénérés (regression×3 + quality_report) · `M` 3 fiches AGENT.md (compteurs) + ce journal. + +## Session `20260812_073055` — HARDENING correctness · rendu HTML publiciste : le SEUL segment d'URL restant non percent-encodé dans le `url('…')` CSS (complète le fix de la session `070054`) + test à dents + +**État au démarrage.** `./run_ci.sh` = **33 PASS · 0 FAIL · 0 SKIP** (baseline verte). `regression_run.json totals` = **630 exécutés · 613 passés · 17 skippés · 0 échec** (PASS). `quality_report.json` = **PASS · 24 modules · min 100/100 · bijection 24↔24**. Séquence moteur V18 toujours **bloquée** en gouvernance (D-06 = gate d'entrée · directive « NE PAS coder avant l'audit » · #6) — **aucun code moteur légitime**. Arbitrages produit D-01→D-08 en attente Michel (`OPEN_DECISIONS_REGISTER.md`). + +**Chasse au défaut d'abord (classe = encodage contextuel erroné, frère du fix `070054`) → 2 candidats Explore, tous deux RÉFUTÉS comme non-défauts, mais 1 durcissement d'uniformité principiel retenu.** Un Explore very-thorough sur tous les modules générant HTML/CSS/URL/CSV/JSON-LD a remonté 2 candidats : +- **`frontend/chat_otoia/chatlib/frappe.py:40` `mount_html`** — attributs HTML (`css_class`/`portail_key`/`persona`) interpolés **sans** échappement. **RÉFUTÉ (non-défaut, aucune entrée réelle ne casse)** : `persona["nom"]` est **forcé à `"Amélie"`** par l'invariant 7 (`chat_otoia_gen.py:155`), `portail_key` est un **enum** (`ventes`/`construction`/`achat`/`compta`/`direction`), `css_class` vient du spec contrôlé. Toutes provablement propres → défensif seul, pas de fix (`verify-non-defects` · #6). +- **`publiciste/lib/generator.py:99`** `_hero_media` — c'est le locus **retenu** (voir ci-dessous). + +**Le locus (durcissement, pas bug déclenchable — honnêteté #6).** Le fix `070054` a percent-encodé `hero['fichier']` dans `src = f"/static/projets/{code}/{quote(hero['fichier'])}"`, mais dans la **MÊME** `url('…')` il restait un **second segment dynamique** — `code` — interpolé **brut** puis html-échappé via `_esc(src)`. C'est **exactement** l'anti-pattern que le fix `070054` a identifié (html.escape = mauvais encodeur pour un segment d'URL), **appliqué de façon incomplète**. Ce n'est **pas** un bug déclenchable aujourd'hui : les codes réels sont strictement `P01..P09` (§Projets), jamais d'apostrophe → le byte-repro est **inchangé** (`quote("p01") == "p01"`). C'est un **durcissement d'uniformité de construction**, cas frère exact de la session `060044` (`criteria.py` : « durcir le SEUL détecteur non-durci d'une famille ») : ici **le SEUL segment d'URL non-encodé** de la même `url()`. + +**Le fix + preuve de dents.** `src = f"/static/projets/{quote(code)}/{quote(hero['fichier'])}"` — `quote()` est le SEUL encodeur correct pour un segment d'URL, **le contexte (pas la donnée courante) dicte l'encodeur** (commentaire mis à jour en conséquence). Nouveau test `test_hero_media_code_segment_percent_encoded` : code théorique `"P'X"` → assert `/static/projets/p%27x/hero.jpg` **et** absence d'apostrophe brute dans le chemin HTML-décodé. Vérifié **teeth** : sans `quote(code)`, le test **ÉCHOUE** ; avec, **PASS**. + +**Cascade de régénération (mémoire `artifact-reproducibility-gate`).** Suite publiciste 25→26, matrice **630/613 → 631/614** (17 skips inchangés). Régénéré dans l'ordre (`quality_report` en DERNIER, mémoire `audit4big-rebuild-after-doc-edits`) : `qa/regression/{regression_plan,MANIFEST,regression_run}.json` puis `qa/audit_4big/quality_report.json`. Fiches à compteurs dérivés réalignées (gate `check_readme_claims`) : `03_agents/qa/AGENT.md` (630/613→631/614), `03_agents/erpnext_backend/AGENT.md` (630→631), `03_agents/publiciste/AGENT.md` (suite 25→26). Diffs d'artefact = **pure propagation de compteur** (vérifié). + +**Canal stakeholder laissé à une session dédiée.** `daily_reports/2026-08-12.md` cite encore `629/612` : il est déjà en retard d'un incrément depuis le fix `070054` (→630) que cette session-là n'a délibérément pas répercuté (canal snapshot **batché** par sessions stakeholder dédiées, cf. `3a40aee` qui a réconcilié 3 jalons d'un coup). Une future session stakeholder réconciliera **630 (070054) + 631 (cette session)** ensemble — pas d'invention (#6), chiffres re-dérivables de l'artefact. + +**Pourquoi pas un gate (#5).** Robustesse portée par le code (`quote`) + le test à dents ; aucune nouvelle classe de drift. Occurrence isolée (uniformité d'un fix du même jour). + +**Vérif.** `./run_ci.sh` = **33 PASS · 0 FAIL · 0 SKIP**. Fichiers édités **NFC-clean** (vérifié). **0 code moteur V18** (bloqué #6), **0 chiffre/fixture inventé** (#6 · compteurs re-dérivés), **0 gate ajouté** (#5), **0 commande VPS** (#8), **0 API externe**. Fichiers : `M generator.py` (`quote(code)`) · `M test_publiciste.py` (test à dents) · `M` 4 artefacts régénérés (regression×3 + quality_report) · `M` 3 fiches AGENT.md (compteurs) + ce journal. diff --git a/05_deliverables_mvp/publiciste/lib/generator.py b/05_deliverables_mvp/publiciste/lib/generator.py index 914d232..3dc9c95 100644 --- a/05_deliverables_mvp/publiciste/lib/generator.py +++ b/05_deliverables_mvp/publiciste/lib/generator.py @@ -90,13 +90,17 @@ def _hero_media(projet: dict[str, Any]) -> str: return '
' # Convention de chemin public (miroir de /opt/oto/sites/static/projets/pXX/). # Le nom de rendu réel peut contenir espaces/apostrophes/accents (« Vue - # d'ensemble.png », « façade nord.webp ») : on percent-encode le segment de - # fichier pour que l'URL reste valide DANS le url('…') CSS. Sans cela, une - # apostrophe — une fois l'attribut style HTML-décodé par le navigateur — - # referme la chaîne CSS et casse silencieusement le background-image. + # d'ensemble.png », « façade nord.webp ») : on percent-encode CHAQUE segment + # dynamique de l'URL (code ET fichier) pour qu'elle reste valide DANS le + # url('…') CSS. Sans cela, une apostrophe — une fois l'attribut style + # HTML-décodé par le navigateur — referme la chaîne CSS et casse + # silencieusement le background-image. quote() est le SEUL encodeur correct + # pour un segment d'URL (html.escape ne l'est pas) : `code` est un identifiant + # contrôlé (P01..P09) aujourd'hui, mais on l'encode par uniformité de + # construction — le contexte, pas la donnée courante, dicte l'encodeur. # (_esc protège l'attribut HTML ; quote protège le contexte CSS interne.) code = projet["code"].lower() - src = f"/static/projets/{code}/{quote(hero['fichier'])}" + src = f"/static/projets/{quote(code)}/{quote(hero['fichier'])}" style = f"background-image:url('{_esc(src)}')" return f' ' diff --git a/05_deliverables_mvp/publiciste/tests/test_publiciste.py b/05_deliverables_mvp/publiciste/tests/test_publiciste.py index c6a327e..0dcc05a 100644 --- a/05_deliverables_mvp/publiciste/tests/test_publiciste.py +++ b/05_deliverables_mvp/publiciste/tests/test_publiciste.py @@ -240,6 +240,25 @@ class TestGenerator(unittest.TestCase): import html as _h self.assertNotIn("Vue d'ensemble", _h.unescape(media)) + def test_hero_media_code_segment_percent_encoded(self): + # Le segment `code` est l'AUTRE segment dynamique de la même url('…'). + # Un code contenant un caractère spécial (théorique : codes réels = P01..P09) + # doit lui aussi être percent-encodé — quote() est le SEUL encodeur correct + # pour un segment d'URL. Sinon _esc(html) laisserait l'apostrophe, une fois + # l'attribut style HTML-décodé, refermer la chaîne CSS (même bug que fichier). + projet = { + "code": "P'X", + "nom": "Théorique", + "statut": "disponible", + "rendus": [{"fichier": "hero.jpg", "hero": True}], + } + media = generator._hero_media(projet) + self.assertIn("/static/projets/p%27x/hero.jpg", media) + import html as _h + # Après HTML-décodage de l'attribut style, aucune apostrophe brute ne survit + # dans le segment de chemin (elle refermerait le url('…')). + self.assertNotIn("projets/p'x", _h.unescape(media)) + @staticmethod def _article(html: str, code: str) -> str: start = html.index(f'data-code="{code}"') diff --git a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json index 734bd8b..2811418 100644 --- a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json +++ b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json @@ -60,7 +60,7 @@ "criterion": "TESTS", "weight": 25, "passed": true, - "evidence": "25 méthodes test_* dans 1 fichier(s) (seuil 8)" + "evidence": "26 méthodes test_* dans 1 fichier(s) (seuil 8)" }, { "criterion": "CLI", diff --git a/05_deliverables_mvp/qa/regression/out/MANIFEST.json b/05_deliverables_mvp/qa/regression/out/MANIFEST.json index edae16b..b346d1a 100644 --- a/05_deliverables_mvp/qa/regression/out/MANIFEST.json +++ b/05_deliverables_mvp/qa/regression/out/MANIFEST.json @@ -5,7 +5,7 @@ "version": "1.0", "verdict": "PASS", "suites": 24, - "test_methods": 630, + "test_methods": 631, "coverage_ok": true, "artifacts": [ "regression_plan.json" diff --git a/05_deliverables_mvp/qa/regression/out/regression_plan.json b/05_deliverables_mvp/qa/regression/out/regression_plan.json index ff2b27c..4183f65 100644 --- a/05_deliverables_mvp/qa/regression/out/regression_plan.json +++ b/05_deliverables_mvp/qa/regression/out/regression_plan.json @@ -189,7 +189,7 @@ "in_gate": true, "has_tests_dir": true, "test_files": 1, - "test_methods": 25 + "test_methods": 26 }, { "id": "qa.acceptance", @@ -295,7 +295,7 @@ "totals": { "suites": 24, "test_files": 24, - "test_methods": 630, + "test_methods": 631, "min_methods": 10, "under_threshold": 0 }, diff --git a/05_deliverables_mvp/qa/regression/out/regression_run.json b/05_deliverables_mvp/qa/regression/out/regression_run.json index 0ce209d..1f17177 100644 --- a/05_deliverables_mvp/qa/regression/out/regression_run.json +++ b/05_deliverables_mvp/qa/regression/out/regression_run.json @@ -174,9 +174,9 @@ { "id": "publiciste", "path": "publiciste", - "expected_methods": 25, - "ran": 25, - "passed": 23, + "expected_methods": 26, + "ran": 26, + "passed": 24, "failures": 0, "errors": 0, "skipped": 2, @@ -296,8 +296,8 @@ "suites": 24, "green": 24, "red": 0, - "ran": 630, - "passed": 613, + "ran": 631, + "passed": 614, "failures": 0, "errors": 0, "skipped": 17