diff --git a/.github/workflows/paragraph-length-advisory.yml b/.github/workflows/paragraph-length-advisory.yml new file mode 100644 index 0000000000..2185f8d4b4 --- /dev/null +++ b/.github/workflows/paragraph-length-advisory.yml @@ -0,0 +1,139 @@ +name: Paragraph Length Advisory + +# The organ for "wall-of-text markdown paragraphs" (incident 2026-09-10 : +# the README Probas shipped a 3336-char single-line paragraph in PR #15405 +# commit 76d7a5bc, no guard caught it). The signal: any markdown prose +# paragraph > 2000 chars on a *single* run of non-empty lines, after +# stripping code fences / tables / headings / HTML comments (CATALOG-STATUS). +# +# ADVISORY, never blocking at this stage (decision user 2026-09-10). The +# job ALWAYS exits 0. The actionable payload is the `paragraph-length` LABEL, +# NEVER the green conclusion of the job. Promote to blocking once the +# residual corpus is at 0 and stable (post-sweep issue de suivi). +# +# Per-PR scope: scans ONLY the *.md files modified by the PR, not a +# repo-wide scan on every push. Self-cover (#8822) lists the workflow +# itself in `paths:`. +# +# Calibration 2026-09-10 (rglob, 791 *.md, 23049 paragraphs) : +# p50=79 p75=274 p90=536 p95=760 p99=1437 max=13409 +# > 2000 c : 42 fichiers / 84 paragraphes (hors _archives, vendored) +# These 42 pre-existing files are NOT corrected here -- sweep is its own +# follow-up issue, tracked separately. This PR only adds the organ that +# SHOULD HAVE CAUGHT the Probas paragraph in #15405. + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + paths: + - '**/*.md' + # Self-cover (#8822): a paths-filtered label-poser must list its own file, + # else it cannot re-run (so cannot remove its own label) once the matching + # paths leave the PR diff. + - '.github/workflows/paragraph-length-advisory.yml' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: paragraph-length-advisory-${{ github.ref }} + cancel-in-progress: true + +jobs: + paragraph-length-advisory: + name: "Paragraph length > 2000 chars advisory (label, non-blocking)" + runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + if: github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout PR + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Advisory check on modified markdown files + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + LABEL_OVERSIZED: paragraph-length + LABEL_UNMEASURED: paragraph-length-unmeasured + run: | + set -uo pipefail + + # 3-point diff (#10403): merge-base...HEAD -- strictly this PR's apport. + git diff --name-only --diff-filter=d "$BASE...$HEAD" -- '*.md' \ + | grep -v $'\r$' > changed_md.txt || true + COUNT=$(wc -l < changed_md.txt | tr -d ' ') + echo "Modified *.md in this PR: $COUNT" + + ensure_label() { + gh label create "$1" --description "$2" --color "$3" --force 2>/dev/null || true + } + set_label() { gh pr edit "$PR_NUMBER" --add-label "$1" || true; } + unset_label() { gh pr edit "$PR_NUMBER" --remove-label "$1" 2>/dev/null || true; } + + if [ "$COUNT" -eq 0 ]; then + echo "No modified markdown to check." + unset_label "$LABEL_OVERSIZED"; unset_label "$LABEL_UNMEASURED" + exit 0 + fi + + # Advisory: ALWAYS exit 0. Job never fails; label is the signal. + # Detector returns rc 0/1/2 per file, but we don't propagate exit + # code here -- the JSON payload is what matters. + python scripts/notebook_tools/detect_paragraph_length.py \ + --json $(cat changed_md.txt) > payload.json 2>&1 || true + cat payload.json + + # Payload shape: {files: [{file, findings, counts, error?}], summary} + OVERSZ_COUNT=$(python -c " + import json + try: + d = json.load(open('payload.json')) + print(d['summary']['total_findings']) + except Exception: + print('UNREADABLE') + " 2>/dev/null || echo "UNREADABLE") + FLAG_COUNT=$(python -c " + import json + try: + d = json.load(open('payload.json')) + print(d['summary']['flagged_count']) + except Exception: + print('?') + " 2>/dev/null || echo "?") + echo "Oversized paragraphs across modified md: $OVERSZ_COUNT" + echo "Flagged files: $FLAG_COUNT" + + ensure_label "$LABEL_OVERSIZED" \ + "A modified markdown file carries a paragraph > 2000 chars (wall-of-text, see #15405 incident). Resorb before merge." "FBCA04" + ensure_label "$LABEL_UNMEASURED" \ + "A modified markdown file could not be measured by detect_paragraph_length.py -- NOT verified (#8819)." "BFD4F2" + + # #8819 guard: if the payload is unreadable, never claim green. + if [ "${OVERSZ_COUNT:-0}" = "UNREADABLE" ] 2>/dev/null; then + echo "::error::paragraph-length payload illisible -- gate measured NOTHING; conformity neither claimed nor denied." + set_label "$LABEL_UNMEASURED" + exit 0 + fi + + if [ "${OVERSZ_COUNT:-0}" -gt 0 ] 2>/dev/null; then + echo "::notice::$OVERSZ_COUNT oversized paragraph(s) across $FLAG_COUNT modified markdown file(s). See the '$LABEL_OVERSIZED' label." + set_label "$LABEL_OVERSIZED" + else + unset_label "$LABEL_OVERSIZED" + fi + + echo "RESULT: $OVERSZ_COUNT oversized paragraph(s) across $FLAG_COUNT modified markdown file(s)." + exit 0 \ No newline at end of file diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index aec6caf398..40b0ed2f34 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -326,6 +326,19 @@ # advisory.yml). Rollback = revert de la PR (l'entree disparait de # l'allowlist). "translation-hot-drift-advisory.yml", + # #15405 (decision ai-01 2026-09-11, owner myia-ai-01:CoursIA) : garde + # advisory pur-Python sur les paragraphes markdown depassant 2000 + # caracteres (detect_paragraph_length.py, stdlib-only). Declencheur + # pull_request filtrant `**/*.md` + auto-couverture du workflow (#8822, + # sans quoi le poseur de label ne peut plus retirer son propre label une + # fois les chemins sortis du diff). Pose un label signe, jamais exit != 0. + # `pull-requests: write` + GITHUB_TOKEN limites a l'API labels -- meme + # profil que markdown-deaccent-advisory.yml / repeated-prose-advisory.yml, + # deja admis a ce titre. Garde same-repo au niveau job (#13874) : les PRs + # de fork se font skipper proprement par pr_gate. Runner = jambe Linux + # containerisee (LINUX_RUNNER_LABELS). Rollback = revert de la PR + # (l'entree disparait de l'allowlist). + "paragraph-length-advisory.yml", } GITHUB_HOSTED_LABELS = { "ubuntu-latest", diff --git a/scripts/notebook_tools/README.md b/scripts/notebook_tools/README.md index 72ae03936c..6be9c1d0c9 100644 --- a/scripts/notebook_tools/README.md +++ b/scripts/notebook_tools/README.md @@ -15,7 +15,7 @@ complement. L'inventaire ci-dessous remplace la lecture en aveugle de | Categorie | Scripts | Role | |-----------|---------|------| -| **Detecteurs anti-regression** | `detect_blank_figures.py`, `detect_fabricated_outputs.py`, `detect_svg_decimal_commas.py`, `detect_svg_empty_display.py`, `detect_ascii_workaround.py`, `detect_accent_stripping.py`, `detect_link_target_regression.py`, `detect_solution_leaks.py`, `detect_cjk_residue.py` | Flags deterministes par regle C.1 / H.1 / SOTA / #2876 (axe-1 texte + **axe-3 link-targets** triade) / #3801 / #4970 / **#6927** (SVG inline rollout) / **#6891 axe-2 fabrication textuelle** (sibling detector) / **#8428** (CJK LLM-translation residue, regression-guard post-fleet-sweep) | +| **Detecteurs anti-regression** | `detect_blank_figures.py`, `detect_fabricated_outputs.py`, `detect_svg_decimal_commas.py`, `detect_svg_empty_display.py`, `detect_ascii_workaround.py`, `detect_accent_stripping.py`, `detect_link_target_regression.py`, `detect_solution_leaks.py`, `detect_cjk_residue.py`, `detect_paragraph_length.py` | Flags deterministes par regle C.1 / H.1 / SOTA / #2876 (axe-1 texte + **axe-3 link-targets** triade) / #3801 / #4970 / **#6927** (SVG inline rollout) / **#6891 axe-2 fabrication textuelle** (sibling detector) / **#8428** (CJK LLM-translation residue, regression-guard post-fleet-sweep) / **#15405** (paragraphes markdown > 2000 c, wall-of-text guard) | | **Validateurs CI** | `validate_pr_notebooks.py`, `check_c2_compliance.py`, `check_notebook_navlinks.py`, `check_plotly_static_risk.py` | Gates pre-merge, `--check` exit-code CI-ready | | **Scanners structurels** | `scan_cell_ordering.py`, `scan_md_hierarchy.py`, `scan_figure_visual_signature.py` | Audit hierarchie markdown + ordre cellules pedagogiques + **signature visuelle des figures PNG (consolidation L777-L1/L778-L1/L2/L779-L1/L2/L780-L1/L2/L3/L781-L1/L2/L3 du rollout MANIFEST c.754-c.781, EPIC #5780)** | | **Execution kernels** | `dotnet_executor.py`, `exec_dotnet_persist.py`, `exec_single_cell.py`, `batch_reexecute.py`, `wsl_papermill.py` | .NET Interactive + Python Papermill via WSL | @@ -253,6 +253,40 @@ Baseline c.884 : 937 notebooks, **0 residu inattendu**, 2 allowed (fleet clean post-sweep). Le guard n'empeche que la recidive ; la correction d'un nouveau residu reste byte-surgical par notebook (cf #8428 fix pattern). +### `detect_paragraph_length.py` (#15405, organe anti « wall-of-text ») + +Detecteur de paragraphes markdown trop longs : un seul bloc contigu de +lignes non-vides > **2000 caracteres** est signale avec sa position et +son extrait. Ignore les fences code (``` / ~~~), les lignes de tableau +(`|`), les titres (`#` ... `######`), les commentaires HTML (`` dont le marqueur CATALOG-STATUS) et les directives Sphinx. +Listes et blockquotes comptent (un item de liste de 10k c est un mur). + +Incident fondateur : le README Probas (PR #15405, commit `76d7a5bc`, +remarque user 2026-09-10) livrait un paragraphe unique de **3336 c / 24 +phrases sur une seule ligne physique**. Aucun garde CI ne le detectait. + +Calibration 2026-09-10 (rglob sur 791 `*.md` / 23049 paragraphes) : +p50=79, p75=274, p90=536, p95=760, p99=1437, max=13409. Le seuil 2000 +capture l'incident avec marge et signale 42 fichiers / 84 paragraphes +(les autres sont de la prose technique legitime, sweeps a venir). + +```bash +python scripts/notebook_tools/detect_paragraph_length.py README.md # human-readable +python scripts/notebook_tools/detect_paragraph_length.py --json README.md # dict agrege CI-ready +python scripts/notebook_tools/detect_paragraph_length.py --self-test # temoin fondateur tire +python scripts/notebook_tools/detect_paragraph_length.py README.md --fail-on-findings +# exit 2 si au moins un paragraphe > 2000 c +``` + +Câblé dans `.github/workflows/paragraph-length-advisory.yml` (advisory, +label `paragraph-length`, jamais bloquant a ce stade). Bascule +bloquante = PR dediee apres que le sweep de resorption sur les 42 +fichiers pre-existants soit a zero. + +**Owner** : partition-mienne pour les PRs docs (relecture fichier-entier +README series), cluster-manager pour la bascule bloquante. + --- ## Triade accent #2876 — défense outillée (3 axes complémentaires) diff --git a/scripts/notebook_tools/detect_paragraph_length.py b/scripts/notebook_tools/detect_paragraph_length.py new file mode 100644 index 0000000000..fc892c9630 --- /dev/null +++ b/scripts/notebook_tools/detect_paragraph_length.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +"""Detecteur de paragraphes markdown trop longs (« wall of text »). + +Incident fondateur (remarque user 2026-09-10 sur le commit 76d7a5bc, PR +#15405, audit README Probas) : le README de la serie Probas portait un +paragraphe unique de **3336 caracteres / 24 phrases sur une seule ligne +physique** (« Cette serie couvre trois stacks... »), issu du diff +d'audit README de #15405. Illisible, impossible a scanner, et **passe au +CI** : aucun garde n'intercepte un paragraphe markdown trop long. + +Calibration 2026-09-10 (rglob des `*.md` du repo, 791 fichiers / 23049 +paragraphes) : + p50=79 p75=274 p90=536 p95=760 p99=1437 max=13409 caracteres + +Le seuil 2000 est choisi pour : + (a) capturer le paragraphe-mur incident (3336 c) avec marge (~1.6x) ; + (b) signaler seulement les vrais outliers : 42 fichiers / 84 paragraphes + > 2000 c sur le corpus complet (avant ce garde, la majorite sont + des sous-sections techniques longues et legitimes -- ils ne sont + PAS corriges ici, voir issue de suivi). + +Signal : tout bloc contigu de lignes non-vides, en dehors de fences de +code (``` ou ~~~), de lignes de tableau markdown (commencant par `|`) et +de titres (`#`, `##`, ...). Listes, blockquotes et paragraphes ordinaires +comptent comme bloc -- un item de liste de 10k caracteres est un mur +aussi. + +Codes de retour : 0 = clean ; 1 = fichier illisible/introuvable ou scan +vacuue (aucun bloc eligible) ; 2 = findings (avec --fail-on-findings). + +Detection : stdlib uniquement (regex). O(n_lignes) par fichier, ~1 ms +pour 1000 lignes. Voir scripts/notebook_tools/tests/fixtures/ +paragraph_wall_md.md pour les cas fondateur. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Seuil unique verrouille par la calibration 2026-09-10 (voir docstring). +# Deliberement NON exposable en CLI -- un seuil arbitraire detruit le +# signal ; cf. pedagogy_density.DENSITY_THRESHOLD pour le precedent. +MAX_PARAGRAPH_LEN = 2000 + +# Catastrophes qui degradent la lisibilite et qu'on ne veut PAS confondre +# avec un mur de prose : fences code ```/~~~, lignes de tableau markdown, +# titres (# ... ######), commentaires HTML (utilises par le +# marqueur CATALOG-STATUS), directives Sphinx/RST (^:[a-z]+:). +FENCE_RE = re.compile(r"^\s*(```|~~~)") +TABLE_ROW_RE = re.compile(r"^\s*\|") +HEADING_RE = re.compile(r"^\s*#{1,6}\s") +HTML_COMMENT_RE = re.compile(r"^\s*\n\nParagraphe normal.\n" + if detect(catalog_text): + failures.append("commentaire HTML (CATALOG-STATUS) non ignore") + table_text = "| col1 | col2 |\n|------|------|\n| " + "x" * 3000 + " | y |\n" + if detect(table_text): + failures.append("ligne de tableau longue non ignoree") + + # 5. SCAN VACUITE -- fichier avec uniquement des titres/listes vides. + vacuous = "## Titre\n## Autre\n- item\n" + if detect(vacuous): + failures.append("scan non vacu : structure seule signalee comme prose") + + if failures: + print("SELF-TEST FAILED") + for f in failures: + print(f" - {f}") + return 1 + print(f"self-test OK : temoin fondateur tire ({wall[0]['chars']} c), " + f"post-fix muet, seuil exact, fences/structure ignorees.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("paths", nargs="*", + help="fichier(s) .md a scanner (defaut: stdin si -)") + parser.add_argument("--json", action="store_true", + help="sortie machine : un dict agrege {files, " + "summary} sur stdout (CI-friendly)") + parser.add_argument("--fail-on-findings", action="store_true", + help="exit 2 si au moins un finding") + parser.add_argument("--self-test", action="store_true", + help="controles positif/negatif sur le temoin " + "fondateur (refuse de passer a vide)") + args = parser.parse_args(argv) + + if args.self_test: + return self_test() + + if not args.paths: + parser.error("fournir un chemin .md ou --self-test") + paths = [Path(p) for p in args.paths] + rc = scan_paths(paths, args.json, args.fail_on_findings) + return rc if args.fail_on_findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/scripts/notebook_tools/tests/fixtures/paragraph_wall_md.md b/scripts/notebook_tools/tests/fixtures/paragraph_wall_md.md new file mode 100644 index 0000000000..82ebe3f03e --- /dev/null +++ b/scripts/notebook_tools/tests/fixtures/paragraph_wall_md.md @@ -0,0 +1,61 @@ +# Fixture — paragraphes markdown pour `detect_paragraph_length` + +Ce fichier sert de **fixture externe** au détecteur +[scripts/notebook_tools/detect_paragraph_length.py](../../../../scripts/notebook_tools/detect_paragraph_length.py), +en complément du `--self-test` embarqué. Les cas vivent dans des sections +distinctes pour qu'un test puisse viser une section par `path` + `start_line`. + +## Section 1 — paragraphe-mur fondateur (DOIT tirer) + +Cette serie couvre trois stacks complementaires : Infer.NET, PyMC, et des applications standalone comme RSA, identification causale avec DoWhy, ou encore la percolation de liens sur tore fini. Elle totalise 69 notebooks -- 28 en C#/.NET Interactive, 38 en Python, 3 en Lean 4 -- repartis entre le corpus bayesien Infer.NET (21 notebooks, socle 1-20 sans le numero 6 qui vit en accretion Infer-2b, plus les accretions Infer-1b premier modele et Infer-20 quotients et fibres en kernel Python), l'arc theorie de la decision (8 notebooks C# DecInfer plus 2 notebooks Lean pour la certification formelle des lemmes d'escompte geometrique et de l'indice de Gittins, le tout certifie par le lake companion decision_theory_lean), le versant PyMC en parite 1:1 (19 notebooks corpus plus 12 miroirs de l'arc decision, dont la jambe actuarielle 8-12 specialisee en frequence x severite hierarchique et le notebook DecPyMC-11 sur la valeur de l'information en souscription), la percolation (1 notebook Python trois regimes mesures sur un tore fini par Diskin-Easo-Radhakrishnan-Sudakov-Tassion, plus 1 notebook Lean compagnon du lake percolation_lean qui demontre sans sorry le noyau fini), et le pont causal DecisionTheory/Causal-Bridges qui federe les quatre traitements de la causalite dissemines dans le depot (Tweety logique formelle, Infer.NET message passing via le notebook Infer-5-Causal-Inference, PyMC echantillonnage MCMC via PyMC-05-Causal-Inference, et emergence causale PyPhi via ICT-5 et ICT-6 du dossier IIT) autour de l'echelle de Pearl et des trois regles du do-calculus -- identifie l'estimande par backdoor/front-door/variable instrumentale, l'estime par regression ou modele causal, puis le refute par placebo et randomisation. L'outil de reference dowhy (pywhy.org) est execute reel sur chaque notebook, jamais une reimplementation jouet, et la documentation detaillee de chaque segment pedagogique vit dans les README specialises (Infer/, PyMC/, DecisionTheory/DecInfer/, DecisionTheory/PyMC/, DecisionTheory/Causal-Bridges/, Applications/Percolation/). + +## Section 2 — paragraphe normal (NE DOIT PAS tirer) + +Six paragraphes distincts, courts. Chaque bloc est separe par une ligne vide. Aucun ne depasse les 500 caracteres. La calibration confirme : p50 = 79 c, p75 = 274 c. + +Le corpus bayesien compte 21 notebooks, socle numéroté 1 à 20. + +L'arc décision en extrait 8 notebooks C# via DecInfer. + +Le versant PyMC porte 19 corpus + 12 miroirs de l'arc décision. + +La percolation complète ce trio Lean avec son duo Python/Lean. + +Le pont causal fédère les quatre séries autour de l'échelle de Pearl. + +## Section 3 — paragraphe avec une fence code longue (NE DOIT PAS tirer) + +Texte court avant la fence. + +```python +# 5000 caractères de code dans une fence -- ne doit pas être compté +def wall_of_code(): + s = "a" * 5000 + return s +``` + +Texte court après la fence. + +## Section 4 — paragraphe avec CATALOG-STATUS (NE DOIT PAS tirer) + + + +Paragraphe normal apres le marqueur CATALOG-STATUS. Court. + +## Section 5 — titre + ligne de tableau longue (NE DOIT PAS tirer) + +# Titre H1 + +## Titre H2 avec une table dont une ligne fait 5000 chars + +| Col1 | Col2 | +|------|------| +| aaaa (5000 chars) | y | + +Paragraphe normal apres le tableau. \ No newline at end of file diff --git a/scripts/notebook_tools/tests/test_detect_paragraph_length.py b/scripts/notebook_tools/tests/test_detect_paragraph_length.py new file mode 100644 index 0000000000..f5dda318fe --- /dev/null +++ b/scripts/notebook_tools/tests/test_detect_paragraph_length.py @@ -0,0 +1,135 @@ +"""Tests for detect_paragraph_length.py -- paragraphes markdown trop longs. + +Le contrat epingle : (1) un paragraphe > 2000 c est flagge avec son +emplacement ; (2) un paragraphe de longueur frontiere (pile 2000 ou +pile 2001) tranche exactement ; (3) fences code, titres, lignes de +tableau, commentaires HTML (CATALOG-STATUS) sont ignores ; (4) un +paragraphe post-fix du meme contenu segmente en 6 est silencieux ; +(5) la fixture externe demontre chaque cas sur un fichier reel. + +Pas de reseau, pas de kernel. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from detect_paragraph_length import ( + FOUNDING_PARAGRAPH, + MAX_PARAGRAPH_LEN, + detect, + iter_paragraphs, +) + +FIXTURE = Path(__file__).parent / "fixtures" / "paragraph_wall_md.md" + + +def _read_section(start_marker: str) -> str: + """Lit le contenu entre `## {start_marker}` et le prochain `## `.""" + text = FIXTURE.read_text(encoding="utf-8") + s = text.split(f"## {start_marker}", 1)[1] + return s.split("\n## ", 1)[0] + + +def test_founding_paragraph_fires(): + """Le paragraphe fondateur (PR #15405, 3336 c reellement sur main) tire.""" + got = detect(FOUNDING_PARAGRAPH + "\n") + walls = [f for f in got if f["type"] == "oversized_paragraph"] + assert walls, "temoin fondateur muet -- detecteur debranche" + assert walls[0]["chars"] >= 3000, f"longueur sous-estimee : {walls[0]['chars']}" + assert walls[0]["chars"] <= 3500, f"longueur surestimee : {walls[0]['chars']}" + + +def test_threshold_exact(): + """2000 c pile = OK ; 2001 c = finding (frontiere > stricte).""" + assert not detect("a" * 2000 + "\n"), "2000 c devrait etre muet" + over = detect("a" * 2001 + "\n") + assert over, "2001 c devrait declencher" + assert over[0]["chars"] == 2001 + + +def test_fixture_section_1_fires(): + """La section 1 de la fixture (paragraphe-mur fondateur) tire.""" + section = _read_section("Section 1") + got = detect(section) + walls = [f for f in got if f["type"] == "oversized_paragraph"] + assert walls, f"section 1 muette : devrait tirer (mur fondateur). got={got}" + + +def test_fixture_section_2_silent(): + """6 paragraphes aeres (post-fix PR A) -- muets.""" + section = _read_section("Section 2") + got = detect(section) + assert not got, f"section 2 (6 paragraphes aeres) devrait etre muette : {got}" + + +def test_fixture_section_3_silent(): + """Fence code de 5000 chars ne declenche pas.""" + section = _read_section("Section 3") + got = detect(section) + assert not got, f"section 3 (fence code longue) ne devrait pas tirer : {got}" + + +def test_fixture_section_4_silent(): + """Bloc CATALOG-STATUS (commentaire HTML) ignore.""" + section = _read_section("Section 4") + got = detect(section) + assert not got, f"section 4 (CATALOG-STATUS + paragraphe court) muette : {got}" + + +def test_fixture_section_5_silent(): + """Titre H1 + ligne de tableau de 5000 chars ignores.""" + section = _read_section("Section 5") + got = detect(section) + assert not got, f"section 5 (titre + tableau long) ne devrait pas tirer : {got}" + + +def test_iter_paragraphs_ignores_fences(): + """Le decoder ne voit PAS les 5000 chars du code dans une fence.""" + text = ( + "Avant la fence.\n\n" + "```python\n" + "# code de 3000 chars dans la fence\n" + + ("x" * 3000) + "\n" + "```\n\n" + "Apres la fence.\n" + ) + paras = iter_paragraphs(text) + bodies = [body for _, _, body in paras] + joined = " ".join(bodies) + assert "x" * 3000 not in joined, "le code de la fence a fuit dans un paragraphe" + + +def test_iter_paragraphs_keeps_list_with_long_item(): + """Une liste avec un item de 2500 chars doit etre signalee (meme si + c'est une liste -- c'est de la prose continue, pas de la structure).""" + text = "- item tres long : " + ("bla " * 500) + "\n- item court.\n" + got = detect(text) + walls = [f for f in got if f["type"] == "oversized_paragraph"] + assert walls, "item de liste > 2000 c devrait etre signale" + + +def test_detect_returns_sorted_findings(): + """Les findings sont tries par longueur decroissante (les murs + d'abord), puis par start_line croissant.""" + text = ( + "court.\n\n" + + ("a" * 2500) + "\n\n" + + ("b" * 2100) + "\n\n" + "encore court.\n" + ) + got = detect(text) + assert len(got) == 2 + assert got[0]["chars"] == 2500 + assert got[1]["chars"] == 2100 + + +def test_max_paragraph_len_locked(): + """Le seuil est un module constant, PAS un argument CLI.""" + assert MAX_PARAGRAPH_LEN == 2000 + + +def test_self_test_runs_clean(): + """Le --self-test ne fait pas d'I/O, son resultat est l'assertion.""" + from detect_paragraph_length import self_test + assert self_test() == 0 \ No newline at end of file