File size: 3,378 Bytes
32c3118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
"""Garde-fou : tout lien interne dans ``docs/index.md`` doit pointer
vers un fichier réel.

Pourquoi ce test existe
-----------------------

``docs/index.md`` est l'**index canonique** de la documentation : il
est référencé depuis le README, depuis mkdocs.yml, et c'est la
première porte d'entrée pour un nouveau contributeur.

Avant Phase 1, ce fichier contenait 4 liens cassés (``first-benchmark``,
``writing-a-pipeline-module``, ``developer/narrative-engine``,
``user/...``) qui ont survécu pendant le rewrite parce qu'aucun test
ne validait ses propres liens.  Ce garde-fou élimine la classe
d'erreur : si l'index ment, la CI échoue.

Périmètre
---------

On parse les liens markdown ``[texte](cible)`` et on vérifie que la
``cible`` :

- soit pointe vers un fichier existant (résolution relative à
  ``docs/`` ou à la racine pour les ``../X``) ;
- soit est une URL externe (``http://...``, ``mailto:...``) — non
  vérifiée ici, c'est le rôle de tests externes ;
- soit est une ancre intra-document (``#section``) — non vérifiée.

Les liens vers des dossiers (``case-studies/``, ``audits/``) sont
vérifiés comme l'existence du dossier.
"""

from __future__ import annotations

import re
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
INDEX = REPO_ROOT / "docs" / "index.md"

#: Pattern markdown standard : ``[texte](cible)``.  On capture la
#: cible (groupe 2) qu'on évaluera comme chemin.
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")


def _resolve_link(target: str) -> Path | None:
    """Résout une cible de lien relativement à ``docs/index.md``.

    Retourne ``None`` si :
    - URL externe (``http``, ``mailto``, ``#``) ;
    - cible vide ;
    - chemin qui ne se résout pas.
    """
    target = target.strip()

    # URL externe — pas notre problème ici.
    if target.startswith(("http://", "https://", "mailto:", "#")):
        return None

    # Retirer l'ancre éventuelle (``foo.md#section``)
    target = target.split("#", 1)[0]
    if not target:
        return None

    # Les liens dans index.md sont relatifs à ``docs/``.
    # Les liens vers la racine (``../GOVERNANCE.md``) doivent
    # remonter au repo root.
    base = INDEX.parent
    resolved = (base / target).resolve()
    return resolved


def test_index_md_exists() -> None:
    assert INDEX.exists(), (
        f"{INDEX} absent — c'est l'index canonique de la doc, il "
        "ne peut pas manquer."
    )


def test_all_internal_links_in_index_resolve() -> None:
    """Tout lien interne dans ``docs/index.md`` doit pointer vers
    un fichier ou dossier existant."""
    text = INDEX.read_text(encoding="utf-8")
    offenders: list[str] = []
    for match in _LINK_RE.finditer(text):
        target = match.group(2)
        resolved = _resolve_link(target)
        if resolved is None:
            continue  # URL externe / ancre — pas notre périmètre
        if not resolved.exists():
            offenders.append(
                f"  « {match.group(1)} » → {target!r} "
                f"(résolu vers {resolved.relative_to(REPO_ROOT) if resolved.is_relative_to(REPO_ROOT) else resolved})"
            )

    assert not offenders, (
        f"{len(offenders)} lien(s) cassé(s) dans docs/index.md :\n"
        + "\n".join(offenders)
        + "\n\n→ Soit créer le fichier cible, soit corriger le lien."
    )