# FINDINGS — runs réels contre de vraies apps Next.js

ELEVA-303 phase 2. Binaire : `headless-seo-check 0.1.0` (`cargo build --release`, branche
`feature/docs-contract`). Terrain : **Next.js 16.3.0 / React 19.2.8 / Node v26.6.0 / bun 1.3.14**,
macOS 25.5. Tous les runs sont rejouables par `./run-all.sh` ; les captures brutes (stdout+stderr
fusionnés + code de sortie) sont dans `outputs/`.

## Ce qui a été construit

| App | Contenu | Serveur |
| --- | --- | --- |
| `app-main` | 20 routes App Router couvrant rendu / indexabilité / hreflang / contenu, `app/sitemap.ts`, `next.config.ts` avec `headers()` + `redirects()` | `next start` (prod) |
| `app-main` **empoisonnée** | la même, rebuildée avec `await headers()` dans `app/not-found.tsx` | `next start` (prod) |
| `app-main` **basePath** | la même, rebuildée avec `basePath: "/app"` | `next start` (prod) |
| `app-export` | `output: "export"` + `trailingSlash: true` | `python3 -m http.server` |
| — | `next dev` (**piège uniquement**, jamais une référence) | `next dev` |

**23 scénarios × 3 modes = 69 captures**, plus 5 dumps d'en-têtes et le diff des arbres de build.
**38 cas de la matrice sur 38 sont exercés** (table de couverture en fin de document).

---

## 1. Le bug fondateur : ATTRAPÉ, et proprement

C'est le résultat le plus important du run, et il est sans ambiguïté.

`outputs/build-tree.diff.txt` — un seul `await headers()` dans `app/not-found.tsx` fait basculer
**21 routes sur 22** de `○` (prerendered) à `ƒ` (dynamic), sans une ligne d'avertissement au build :

```
-┌ ○ /                        +┌ ƒ /
-├ ○ /static                  +├ ƒ /static
-├ ○ /isr        1m      1y   +├ ƒ /isr
   … 18 autres routes …
```

`outputs/headers.poisoned.txt` — `next start` **self-hosted** répond alors, sur `/static` :

```
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
```

`outputs/poisoned-all.human.txt` :

```
  x /static              rendered per request, 325 words
      error rendered on every request, never cached at the edge
            cache-control: private, no-cache, no-store, max-age=0, must-revalidate
  …
  FAILED  30 blocking issues, 18 warnings
    21 × dynamic-render
```

contre `outputs/sane-all.human.txt` sur exactement le même code :

```
  FAILED  10 blocking issues, 18 warnings
     1 × dynamic-render
```

**1 → 21 `dynamic-render`, et rien d'autre ne bouge** (les 18 warnings, les 2 `hreflang-drift`,
les 2 `noindex-header`, le `sitemap-url-error` sont identiques dans les deux runs). Le signal est
isolé, non bruité, et le paragraphe « What to do » nomme précisément `not-found.tsx` comme premier
endroit où regarder. C'est exactement le comportement promis par le README.

**Correction d'une hypothèse de la phase 1** : je pensais que `next start` ne poserait aucun
en-tête exploitable hors Vercel. C'est faux sur Next 16 — voir la section 2.

---

## 2. Réponse à la question clé : une app Next.js self-hosted saine, sans CDN

**Verdict : ça marche, et c'est actionnable — à un défaut d'ergonomie près.**

`outputs/headers.sane.txt`, `next start` derrière rien du tout :

```
=== /static                          === /isr
Cache-Control: s-maxage=31536000     Cache-Control: s-maxage=60, stale-while-revalidate=31535940
x-nextjs-cache: HIT                  x-nextjs-cache: HIT
x-nextjs-prerender: 1                x-nextjs-prerender: 1
```

Next.js 16 émet donc en self-hosted **tout le vocabulaire que l'outil sait lire** :
`x-nextjs-cache`, `x-nextjs-prerender`, un `cache-control` complet. Les quatre checks fonctionnent
sans CDN. Sur 23 URLs de l'app saine, le binaire produit :

| render_mode | pages |
| --- | --- |
| `static` | 20 |
| `dynamic` | 1 (la seule route `force-dynamic`) |
| `unclear` | **0** |

Zéro `unclear`. C'est le meilleur résultat possible : la crainte « l'outil est inutile hors Vercel »
est infondée sur Next 16.

**Le point frustrant, lui, est réel** : sur 23 pages, **14 portent `canonical-missing`** (warn), soit
toutes celles qui n'ont pas de `alternates.canonical` explicite. Or **une app Next.js par défaut
n'émet aucun canonical** — le développeur n'a rien fait de mal, il n'a juste pas écrit
`metadataBase` + `alternates`. Résultat : un rapport dont l'écrasante majorité des lignes de bruit
disent la même chose, ce qui écrase visuellement les 10 vrais problèmes bloquants. Le finding est
juste sur le fond ; c'est sa répétition par page qui coûte. Cf. bug candidat #4.

L'autre coût est la découverte : le sitemap `app/sitemap.ts` liste forcément des URLs absolues de
production, donc le rebasage se déclenche systématiquement en local et l'annonce clairement —
`23 URLs pointed at another host, checked on 127.0.0.1:4311 instead`. Bien conçu.

---

## 3. Verdicts corrects (avec preuve)

| Cas | Verdict | Preuve |
| --- | --- | --- |
| A1/A2/A3 bug fondateur | OK | § 1 |
| B2 `force-dynamic` | OK `dynamic-render` error | `sane-render-modes.human.txt` |
| B1 statique | OK `static`, aucun finding de rendu | `sane-render-modes.human.txt` |
| B4 `output: export` sur serveur bête | OK `unclear` info, non bloquant | `export-all.human.txt` |
| B5 `--allow-dynamic` | OK dégradé en warn, `blocking` 10 → 9 | `sane-allow-dynamic.*` |
| C1 meta noindex | OK warn `noindex-meta`, non bloquant | `sane-indexability.human.txt` |
| C2 `X-Robots-Tag` seul | OK error `noindex-header` | idem |
| C3 **conflit header/meta** | OK **deux** findings : `noindex-header` + `indexability-conflict` | idem |
| C4 canonical auto-référent **relatif** | OK résolu, aucun finding (Next émet `href="/canonical-self"`) | idem |
| C5 canonical croisé | OK info `canonical-elsewhere` | idem |
| D1 hreflang cohérent | OK silence | `sane-hreflang.human.txt` |
| D2 dérive | OK error, avec les deux URLs et le statut de la cible | idem |
| D4 hreflang HTML seul (cas normal Next) | OK **silence** — `comparable()` exige les deux côtés | idem : bon design, zéro bruit sur une app i18n normale |
| E1/E2 coquille cliente | OK `empty-server-html` + `no-internal-links` | `sane-content.human.txt` |
| **E3 CJK ≥ 200** | OK **268 mots, aucun finding** — régression ELEVA-299 verte | idem |
| E4 CJK maigre | OK 39 mots ⇒ `empty-server-html` | idem |
| E6 bande 60–200 | OK 117 mots ⇒ `thin-server-html` warn, non bloquant | idem |
| F1 sitemap généré + rebasage | OK 23 URLs rebasées et annoncées | `sane-all.human.txt` |
| F2 URL 404 au sitemap | OK error `sitemap-url-error` | idem |
| F3 URL qui redirige (308) | OK `. /redirected-old HTTP 308 — redirects to …`, non bloquant | idem |
| F4 pas de robots.txt | OK fallback silencieux sur `/sitemap.xml` | idem |
| F6 aucun sitemap | OK « only the homepage was checked », exit 0 | run ad hoc, § 5 |
| G3 compression | OK `accept-encoding: gzip`, UA Googlebot exact, 325 mots comptés | § 5 |
| G6 `--urls` relatif / absolu / `-` stdin / host nu | OK les quatre formes | `sane-urls-absolute.*` + § 5 |
| G7 exit codes | OK 0 / 1 / 2 (port fermé **et** flags contradictoires) | `trap-closed-port.*`, `trap-bad-flags.*` |
| G8 cohérence des 3 modes | OK **20 scénarios sur 20** : exit code et compte de bloquants identiques en humain / plain / json | vérifié programmatiquement |

Le message d'erreur du port fermé mérite d'être cité, il est excellent :

```
  CANNOT RUN  cannot reach 127.0.0.1:4399: connection refused (nothing listening on that port)
  Nothing was checked, so this is not a passing result. Exit code 2.
```

---

## 4. Écarts observés

### 4.1 `next dev` produit un rapport VERT (faux négatif total) — grave

`outputs/headers.dev.txt` :

```
Cache-Control: no-cache, must-revalidate
```

`no-cache` annule le ttl mais n'est ni `no-store` ni `private` ⇒ `Unclear` ⇒ **info, jamais
bloquant**. `outputs/trap-next-dev.human.txt` :

```
  ! /static    rendering undetermined, 325 words
      info  rendering mode undetermined — the response headers say nothing about caching
  PASSED  no blocking issue, 3 warnings
--- exit code: 0 ---
```

L'app servie ici est **la même que celle qui donne 10 issues bloquantes en prod**. Un utilisateur
qui pointe l'outil sur `localhost:3000` (le réflexe naturel) obtient `PASSED`, exit 0, et croit son
site sain. Rien dans le rapport ne dit que la cible était un serveur de développement, alors que
l'empreinte est parfaitement reconnaissable : `X-Powered-By: Next.js` + `cache-control: no-cache,
must-revalidate` + **absence** de `x-nextjs-cache`/`x-nextjs-prerender`.
Qualification : **bug** — un outil qui existe pour éviter un faux sentiment de sécurité en produit
un ici.

### 4.2 Une cible hreflang morte n'est signalée que s'il y a dérive — grave

`src/checks/hreflang.rs` : le filtre des cibles mortes itère sur `report.drift`, pas sur les
alternates. Conséquence, `outputs/sane-hreflang.human.txt` :

```
  + /hreflang-dead       static, 327 words          <-- RIEN
  x /hreflang-drift-dead static, 327 words
      error 1 hreflang target in the Link: header do not exist
```

`/hreflang-dead` publie `Link: </fr/nexiste-pas>; rel="alternate"; hreflang="fr"` **et** le même
`/fr/nexiste-pas` en HTML. La cible répond 404. Les deux sources sont d'accord — donc pas de
dérive — donc l'outil **passe la page en vert**. C'est pourtant le cas le plus courant dans la
vraie vie : une page supprimée dont les alternates n'ont pas été nettoyés, des deux côtés à la fois.
Le README annonce « un hreflang target qui n'existe pas » comme bloquant sans mentionner cette
condition. Qualification : **bug** (ou au minimum limite non documentée).

### 4.3 ISR est rapporté `static` en self-hosted — moyen

`outputs/sane-render-modes.json.txt` :

```json
{ "url": ".../isr", "render_mode": "static", ... }
```

alors que la réponse porte `cache-control: s-maxage=60, stale-while-revalidate=31535940`. La
branche `prerendered` (`x-nextjs-prerender: 1`) est évaluée **avant** le ttl fini. Or `render.rs`
justifie lui-même l'ordre inverse deux lignes plus bas :

> « Checked before `Cached`: a page that is both served from cache and carries a finite lifetime is
> better described by the lifetime, which says when it will be rebuilt. »

Le même raisonnement s'applique à `Static`. Comme Next 16 pose `x-nextjs-prerender: 1` sur **toute**
page servie depuis le cache de prerender, ISR compris, le mode `isr` est de fait **inatteignable
sur une app Next self-hosted** : les 20 pages non dynamiques du run sont toutes étiquetées `static`.
Non bloquant, mais c'est une information fausse affichée sur chaque ligne du rapport.
Qualification : **bug d'exactitude, faible sévérité**.

### 4.4 `canonical-missing` répété sur chaque page noie le rapport — moyen (ergonomie)

Détaillé en § 2. Le finding est défendable, sa granularité par page ne l'est pas : sur un site réel
de 355 routes auditées à `--sample 20`, ce sera 20 lignes identiques.
Qualification : **attendu-mais-mal-dit**.

### 4.5 `--verbose` ne montre pas l'évidence des verdicts qui passent — moyen

Le README : « `--verbose` show the evidence behind **every** classification ». Observé
(`outputs/sane-verbose.human.txt`) :

```
  ! http://127.0.0.1:4311/static  static, 325 words, 17 links, 2 ms
      warn  no canonical link in the served HTML [canonical-missing]
```

Aucune trace de `cache-control: s-maxage=31536000` ni de `x-nextjs-prerender: 1`. Cause
structurelle : `render::finding()` retourne `None` pour `Static`/`Isr`/`Cached`, et l'`evidence`
collectée est jetée. C'est précisément l'information dont on a besoin pour croire un verdict
« static » — et pour comprendre pourquoi la même page dit `dynamic` après un déploiement.
Qualification : **écart au contrat documenté**.

### 4.6 `basePath` rend le site inauditable, avec un conseil trompeur — moyen

Avec `basePath: "/app"`, `sitemap.xml`, `robots.txt` **et la homepage** vivent sous `/app`.
`outputs/trap-basepath-path.human.txt` :

```
$ headless-seo-check http://127.0.0.1:4315/app -a
headless-seo-check: checking the whole of http://127.0.0.1:4315; the path you gave (/app) is
                    ignored — use --urls /app to check just that page
headless-seo-check: no sitemap found for http://127.0.0.1:4315
  CANNOT RUN … Exit code 2.
```

L'utilisateur a donné la bonne information (`/app`) et l'outil la jette en le renvoyant vers
`--urls /app`, qui n'auditerait qu'**une** page. Le fallback homepage ne rattrape rien puisque `/`
répond 404. `--urls /app/static,/app/cjk` fonctionne (`trap-basepath-urls`), mais il faut le
deviner. Qualification : **limite connue mal outillée**.

### 4.7 `--allow-dynamic` change la sévérité en JSON, contre le commentaire du code — faible

Observé : `sane-all.json.txt` → `"severity": "error"` ; `sane-allow-dynamic.json.txt` →
`"severity": "warn"` pour le même `dynamic-render`. Le comportement est cohérent entre les trois
modes (`report::effective_severity`, source unique). Mais `src/checks/render.rs:148-150` affirme
l'inverse noir sur blanc :

> « demoting it under `--allow-dynamic` is `PageReport::blocking`'s job, so the severity recorded in
> the JSON never depends on a flag. »

Un consommateur du JSON qui a lu ce commentaire filtrera sur `severity == "error"` et manquera les
pages dynamiques. Qualification : **commentaire périmé ou contrat non tenu — à trancher.**

### 4.8 Ordre des findings : l'erreur est enterrée entre deux warnings — faible

`outputs/sane-content.human.txt` :

```
  x /shell     static, 0 words
      warn  no canonical link in the served HTML
      error the served HTML carries 0 words — Googlebot has to run JavaScript to see this page
      warn  no links to any other page on this host …
```

Les findings sortent dans l'ordre des checks (rendu → indexabilité → hreflang → contenu), pas par
sévérité. L'exemple du README montre `error` puis `warn` ; la réalité est un sandwich.
Qualification : **cosmétique**.

### 4.9 Nits

- **`« 1 words »`** : sur une page d'un seul mot, le rapport écrit `rendering undetermined, 1 words`.
  Le reste de l'outil pluralise soigneusement (`1 URL`, `1 blocking issue`).
- **`-q` supprime la ligne blanche avant le verdict** : `error HTTP 404 …` touche `FAILED` sans
  respiration (`outputs/sane-quiet.human.txt`).
- **`-q` supprime aussi la ligne de provenance** (`23 of 23 URLs checked, from …`) : un rapport
  quiet archivé ne dit plus d'où venaient les URLs ni combien ont été rebasées.
- **`-v` est accepté comme alias de `--verbose`** mais le bloc d'usage du README ne montre que
  `--verbose`. (`-V` = version, correct.)
- **`ttfb_ms: 0`** s'affiche souvent en local (`0 ms`), ce qui a l'air d'une mesure ratée.
- **`--sample 3` rend `PASSED` exit 0** sur un site à 10 problèmes bloquants
  (`outputs/sane-sample3.human.txt`). Comportement documenté de l'échantillonnage, pas un bug — mais
  la ligne `3 of 23 URLs checked` est le seul garde-fou.

---

## 5. Runs ad hoc (hors `run-all.sh`, reproduits à la main)

- **Aucun sitemap** (serveur minimal) : `note: no sitemap at robots.txt, /sitemap.xml or
  /sitemap_index.xml — only the homepage was checked` puis `1 of 1 URL selected, from homepage
  (no sitemap found)`. Conforme.
- **En-têtes envoyés** (serveur d'écho) : `user-agent: Mozilla/5.0 (compatible; Googlebot/2.1;
  +http://www.google.com/bot.html)`, `accept-encoding: gzip`, `accept-language: en;q=0.9,*;q=0.5`.
  Ordre de découverte observé : `/robots.txt` → `/sitemap.xml` → `/sitemap_index.xml` → `/`.
- **`--urls -`** (stdin, une URL par ligne) : fonctionne.
- **Host nu `127.0.0.1:4311`** : schéma `http` deviné sur loopback, conforme.

---

## BUGS CANDIDATS

Classés par sévérité. Chacun est reproductible via `./run-all.sh` puis le fichier cité.

### 1. `next dev` produit un rapport vert — l'outil doit refuser ou avertir
- **Titre proposé** : `Un serveur next dev passe PASSED : détecter le mode développement et refuser de conclure`
- **Sévérité** : haute (faux négatif total, sur le geste le plus naturel de l'utilisateur)
- **Repro** : `cd tmp/real-runs/app-main && bun run dev -p 4314` puis
  `headless-seo-check http://127.0.0.1:4314 --urls /static,/isr,/cjk`
- **Observé** : `PASSED  no blocking issue, 3 warnings`, exit 0 — sur une app qui donne 10 issues
  bloquantes en `next start`. Captures : `outputs/trap-next-dev.human.txt`, `outputs/headers.dev.txt`
- **Attendu** : un avertissement explicite (« ces en-têtes ressemblent à un serveur de
  développement — le verdict de rendu n'a pas de valeur »), voire exit 2. Empreinte disponible :
  `X-Powered-By: Next.js` + `cache-control: no-cache, must-revalidate` + absence de
  `x-nextjs-cache`/`x-nextjs-prerender`.

### 2. Une cible hreflang morte sur laquelle HTML et header sont d'accord n'est jamais signalée
- **Titre proposé** : `hreflang-dead-target ne vérifie que les cibles en dérive : une cible 404 non contestée passe en vert`
- **Sévérité** : haute (le cas le plus fréquent en production)
- **Repro** : `headless-seo-check http://127.0.0.1:4311 --urls /hreflang-dead,/hreflang-drift-dead`
- **Observé** : `+ /hreflang-dead` — aucun finding, alors que `/fr/nexiste-pas` répond 404 des deux
  côtés. Captures : `outputs/sane-hreflang.human.txt`, `outputs/sane-hreflang.plain.txt`
- **Attendu** : `hreflang-dead-target` error, ou à défaut une mention explicite dans le README que
  seules les cibles en dérive sont vérifiées.
- **Note d'implémentation** : `src/checks/hreflang.rs`, le `let dead: Vec<&Drift> = report.drift
  .iter()…` doit partir des alternates, pas des dérives.

### 3. `--verbose` ne montre pas l'évidence des verdicts qui passent
- **Titre proposé** : `--verbose n'affiche pas les en-têtes derrière un verdict static/ISR/cached`
- **Sévérité** : moyenne (écart au contrat écrit dans le README)
- **Repro** : `headless-seo-check http://127.0.0.1:4311 --urls /static --verbose`
- **Observé** : aucune trace de `cache-control: s-maxage=31536000` / `x-nextjs-prerender: 1`.
  Captures : `outputs/sane-verbose.human.txt` vs `outputs/headers.sane.txt`
- **Attendu** : sous `--verbose`, l'`evidence` déjà collectée par `render::classify` s'affiche pour
  tous les modes, y compris ceux qui ne produisent aucun finding.

### 4. `canonical-missing` répété sur chaque page noie le rapport
- **Titre proposé** : `Regrouper canonical-missing en une ligne de synthèse au lieu d'un warn par page`
- **Sévérité** : moyenne (ergonomie, mais elle décide si l'outil est adopté ou éteint)
- **Repro** : `headless-seo-check http://127.0.0.1:4311 -a` sur une app Next.js sans
  `alternates.canonical` — c'est-à-dire la configuration par défaut.
- **Observé** : 14 warns identiques sur 23 pages ; capture `outputs/sane-all.human.txt`
- **Attendu** : une ligne de synthèse (« 14 des 23 pages ne déclarent pas de canonical »), ou une
  info plutôt qu'un warn quand *aucune* page du site n'en a (c'est un choix de template, pas un
  accident par page).

### 5. Un site à `basePath` est inauditable et le message oriente à côté
- **Titre proposé** : `basePath : retenter <chemin>/sitemap.xml quand la racine ne donne rien`
- **Sévérité** : moyenne
- **Repro** : app Next avec `basePath: "/app"`, puis `headless-seo-check http://127.0.0.1:4315/app -a`
- **Observé** : le chemin est jeté, `CANNOT RUN … no sitemap found`, exit 2, et le conseil donné
  (`--urls /app`) n'auditerait qu'une page. Capture : `outputs/trap-basepath-path.human.txt`
- **Attendu** : quand un chemin est fourni **et** que la racine ne donne aucun sitemap, tenter
  `<chemin>/robots.txt` et `<chemin>/sitemap.xml` avant d'abandonner ; mentionner `basePath` dans le
  message d'échec.

### 6. ISR étiqueté `static` : le marqueur de prerender l'emporte sur le ttl fini
- **Titre proposé** : `Un s-maxage fini doit primer sur x-nextjs-prerender (ISR affiché comme static)`
- **Sévérité** : basse (exactitude d'affichage, non bloquant)
- **Repro** : `headless-seo-check http://127.0.0.1:4311 --urls /isr --json`
- **Observé** : `"render_mode": "static"` pour `cache-control: s-maxage=60, stale-while-revalidate=…`
  + `x-nextjs-prerender: 1`. Capture : `outputs/sane-render-modes.json.txt`
- **Attendu** : `isr`. Le commentaire de `render.rs` sur la priorité `Isr` > `Cached` défend
  exactement ce raisonnement ; il n'est pas appliqué face à `Static`.

### 7. `--allow-dynamic` modifie la sévérité JSON, contre le commentaire du code
- **Titre proposé** : `Trancher : --allow-dynamic doit-il changer la severity du JSON ? (code et commentaire se contredisent)`
- **Sévérité** : basse, mais c'est un contrat machine
- **Repro** : `headless-seo-check http://127.0.0.1:4311 -a --json` avec et sans `--allow-dynamic`
- **Observé** : `"severity": "error"` → `"severity": "warn"`. Le commentaire
  `src/checks/render.rs:148-150` promet l'inverse. Captures : `outputs/sane-all.json.txt`,
  `outputs/sane-allow-dynamic.json.txt`
- **Attendu** : soit la sévérité brute est conservée et seul `blocking` bouge, soit le commentaire
  est corrigé et le README documente la démotion.

### 8. Polish du rapport humain (lot unique)
- **Titre proposé** : `Rapport humain : trier les findings par sévérité, corriger « 1 words », rétablir la ligne blanche en -q`
- **Sévérité** : basse
- **Repro / observé** :
  - `outputs/sane-content.human.txt` — sur `/shell`, l'`error` est prise en sandwich entre deux `warn`.
  - page d'un seul mot → `rendering undetermined, 1 words`.
  - `outputs/sane-quiet.human.txt` — pas de ligne blanche entre le dernier finding et `FAILED`.
  - `-q` supprime aussi `N of M URLs checked, from …` (provenance perdue dans un rapport archivé).
  - `-v` fonctionne comme alias de `--verbose` mais n'est pas dans le bloc d'usage du README.

---

## CE QUI MÉRITE D'ÊTRE COMMITTÉ

Par ordre de valeur :

**1. `tests/fixtures/next/` — les fixtures d'app, oui. Le `node_modules` et les builds, non.**
Les fichiers de `tmp/real-runs/fixtures/` (≈ 12 Ko de TSX) sont le vrai actif : ils encodent
**comment fabriquer chaque symptôme en Next.js**, ce qui est le savoir difficile à reconstruire
(une coquille cliente n'est une coquille que si le contenu arrive après `useEffect` ; le sitemap
doit être `force-static` sous `output: "export"` ; `trailingSlash: true` est obligatoire pour servir
un export par un serveur bête). À committer sous `tests/fixtures/next/` avec un README court disant
quel symptôme chaque route produit.

**2. `run-all.sh` — oui, en tant que script manuel, pas en CI.**
Autonome, idempotent, teardown qui tue par PID **et** par port (validé : `lsof` rend la main vide
après chaque exécution, y compris interrompue). Mais il télécharge `create-next-app` et compile
5 builds Next : pas sa place dans `cargo test`. Emplacement suggéré : `scripts/real-runs.sh`,
invoqué à la main avant une release, avec une phrase dans `CONTRIBUTING.md`.

**3. Un document court — oui, mais pas ce fichier-ci.**
`FINDINGS.md` est un rapport d'enquête daté. Ce qui mérite de vivre dans `docs/` est plus étroit :
**une page « ce que l'outil voit sur les hébergeurs réels »**, avec la table d'en-têtes observés
(Next 16 `next start` : `x-nextjs-prerender` + `s-maxage` ; `output: export` sur serveur statique :
rien du tout ; `next dev` : `no-cache, must-revalidate`). C'est la connaissance qui périme le moins
vite et celle qui manque le plus au README — d'autant qu'elle **corrige une croyance répandue**
(« ça ne marche que sur Vercel »). Attention : `docs/` est en cours de restructuration par un autre
agent, donc à proposer, pas à écrire de force.

**4. Deux tests d'intégration à ajouter au serveur fixture Rust existant** (`tests/common/mod.rs`),
qui n'ont besoin d'aucun Next.js et verrouillent les deux bugs graves :
- une route dont l'alternate `Link:` **et** l'HTML pointent la même cible 404 → doit produire
  `hreflang-dead-target` (bug candidat #2) ;
- une route répondant `cache-control: no-cache, must-revalidate` sans marqueur de prerender →
  ancre le comportement `next dev` et documente la décision prise sur le bug candidat #1.

**À ne pas committer** : `app-main/`, `app-export/`, `outputs/`, les snapshots `.next-*`. Tout est
régénérable par `run-all.sh` et pèse plusieurs centaines de Mo.

---

## Couverture de la matrice

38 cas prévus, 38 exercés.

| Groupe | Cas | État |
| --- | --- | --- |
| A — bug fondateur | A1 A2 A3 | OK OK OK |
| B — modes de rendu | B1 B2 B3 B4 B5 | OK OK / B3 mal étiqueté (§ 4.3) / OK OK |
| C — indexabilité | C1…C6 | OK ×5, C6 bruyant (§ 4.4) |
| D — hreflang | D1 D2 D3 D4 | OK OK / **D3 ÉCHEC (§ 4.2)** / OK |
| E — contenu | E1…E6 | OK ×6 |
| F — sitemap | F1…F6 | OK ×6 |
| G — pièges | G1…G8 | **G1 ÉCHEC (§ 4.1)** · G2 hypothèse falsifiée (bonne nouvelle) · G3 OK · G4 OK · G5 limite (§ 4.6) · G6 OK · G7 OK · G8 OK |

Un seul cas non atteignable en Next.js et donc non testé : `no-server-text` (info), qui exige une
page à 0 mot **sans** aucun script — Next en émet toujours au moins six.
