# MATRIX — cas réels à couvrir avec de vraies apps Next.js

ELEVA-303 phase 1. Écrit **avant** de construire quoi que ce soit, à partir de la lecture du code
(`src/checks/render.rs`, `content.rs`, `indexability.rs`, `hreflang.rs`, `html.rs`) et du README.

## Rappel des seuils réels du binaire (lus dans le code, pas devinés)

**Rendu** (`checks/render.rs::classify`, headers uniquement) :

| Condition sur les en-têtes | Verdict |
| --- | --- |
| `cache-control` contient `no-store` **ou** `private` | `Dynamic` — **bloquant** |
| `x-nextjs-prerender: 1`, ou `x-vercel-cache/x-nextjs-cache: PRERENDER`, ou ttl ≥ 31 536 000 s | `Static` |
| `s-maxage` (sinon `max-age`) fini et > 0, sans `no-cache` | `ISR` |
| `x-vercel-cache`/`x-nextjs-cache`/`cf-cache-status` ∈ {HIT, STALE, REVALIDATED, UPDATING}, ou `x-cache`/`cache-status`/`netlify-cache-status` contient HIT, ou `Age > 0` | `Cached` |
| sinon | `Unclear` — **Info, jamais bloquant** (2e requête tentée) |

Piège majeur déjà visible à la lecture : `no-cache` **annule** le ttl mais n'est pas `Dynamic`.
`cache-control: no-cache` seul ⇒ `Unclear`, pas `Dynamic`.

**Contenu** (`checks/content.rs`) : `js_heavy` = `script_src ≥ 3` **ou** `inline JS ≥ 8 KiB` **ou**
`empty_root` (id ∈ `root|app|__next|__nuxt|q-app` et vide).
- `js_heavy && words < 60` ⇒ `empty-server-html` **Error**
- `js_heavy && words < 200` ⇒ `thin-server-html` **Warn**
- `!js_heavy && words == 0` ⇒ `no-server-text` **Info**
- `js_heavy && internal_links == 0` ⇒ `no-internal-links` **Warn**
- CJK : chaque idéogramme/hangul compte pour 1 mot (`html.rs::CJK_WORD_RANGES`) — fix ELEVA-299.

**Indexabilité** : `X-Robots-Tag: noindex` ⇒ Error ; header noindex + meta index ⇒ `indexability-conflict`
Error en plus ; meta noindex seul ⇒ Warn ; **canonical absent ⇒ `canonical-missing` Warn** ;
canonical vers une autre page ⇒ Info.

**hreflang** : `Link:` header vs HTML, dérive par langue ⇒ Error ; cible 404 ⇒ Error ;
présent d'un seul côté ⇒ Warn.

---

## A. Le bug fondateur — `await headers()` dans `not-found.tsx` racine

| # | Scénario | Comportement Next.js attendu | Le binaire DEVRAIT dire | Piège |
| --- | --- | --- | --- | --- |
| A1 | App App Router saine, `next build` + `next start`, routes statiques | Toutes les routes prerendered (`○`) | Rien de bloquant sur le rendu | **`next start` ne pose PAS les en-têtes CDN de Vercel.** Sur une route statique il pose typiquement `cache-control: s-maxage=31536000, stale-while-revalidate` ou rien du tout. À vérifier empiriquement : si c'est « rien », verdict `Unclear` — l'outil ne peut pas confirmer que la page est statique en self-hosted. |
| A2 | **Même app** + `await headers()` dans `app/not-found.tsx` | Le build bascule **toutes** les routes en `ƒ` (Dynamic) | Toutes les pages ⇒ `dynamic-render` Error, exit 1 | C'est LE cas d'usage. Il ne marche que si `next start` pose bien `cache-control: private, no-cache, no-store, max-age=0, must-revalidate` sur une route dynamique. Si Next.js ne pose rien du tout sur du dynamique en self-hosted, **l'outil rate le bug fondateur hors Vercel** — découverte critique. |
| A3 | Diff A1 vs A2 sur la même app | — | Le seul changement dans la sortie doit être le mode de rendu | Vérifier qu'aucun autre check ne bouge (bruit). |

## B. Modes de rendu explicites (une app multi-routes)

| # | Route | Next.js | Attendu binaire | Piège |
| --- | --- | --- | --- | --- |
| B1 | `/static` (défaut) | prerendered | pas de finding de rendu (ou `Unclear` Info) | cf. A1 |
| B2 | `/force-dynamic` (`export const dynamic = "force-dynamic"`) | ƒ, `no-store` | `dynamic-render` **Error** | — |
| B3 | `/isr` (`export const revalidate = 60`) | ISR | `ISR` si `s-maxage=60` présent ; sinon `Unclear` | `next start` sert-il un `s-maxage=60` ? À vérifier — c'est la question qui décide si l'outil est utile en self-hosted. |
| B4 | `output: "export"` servi par `python3 -m http.server` | HTML pur sur disque | `Unclear` Info (http.server ne pose ni cache-control ni Age) | Piège : le serveur statique ne pose **aucun** en-tête de cache ⇒ 2e requête ⇒ toujours `Unclear` ⇒ « checked twice, the second response said no more ». C'est correct mais faut-il que ça apparaisse ? |
| B5 | `--allow-dynamic` sur B2 | — | `dynamic-render` dégradé en warn, exit 0 | Vérifier que le JSON garde bien `Severity::Error` (le README le promet) et que seul le verdict change. |

## C. Indexabilité

| # | Route | Next.js | Attendu | Piège |
| --- | --- | --- | --- | --- |
| C1 | `/noindex-meta` (`metadata.robots = { index: false }`) | `<meta name="robots" content="noindex">` | `noindex-meta` **Warn**, non bloquant | Next.js émet `noindex, nofollow` ; vérifier le parsing. |
| C2 | `/noindex-header` (`next.config.js headers()` ⇒ `X-Robots-Tag: noindex`) | header seul | `noindex-header` **Error**, bloquant | — |
| C3 | `/noindex-conflict` : header `X-Robots-Tag: noindex` **+** `metadata.robots = { index: true }` | header noindex, meta `index, follow` | `noindex-header` Error **+** `indexability-conflict` Error | Le cas le plus vicieux du monde réel. Vérifier qu'on a bien **deux** findings et pas un. |
| C4 | `/canonical-self` (`alternates.canonical: "/canonical-self"`) | `<link rel=canonical href="…/canonical-self">` | **aucun** finding canonical | Piège : Next.js émet le canonical **relatif** si `metadataBase` n'est pas défini ⇒ le parseur doit résoudre. Vérifier. |
| C5 | `/canonical-other` (canonical ⇒ `/static`) | canonical croisé | `canonical-elsewhere` **Info** | — |
| C6 | Toute page **sans** `alternates.canonical` | rien | `canonical-missing` **Warn** sur chaque page | **Piège majeur d'ergonomie** : une app Next.js par défaut n'émet aucun canonical ⇒ un warn sur 100 % des pages. Bruit ou signal ? À juger. |

## D. hreflang

| # | Route | Setup | Attendu | Piège |
| --- | --- | --- | --- | --- |
| D1 | `/hreflang-good` | `alternates.languages` en HTML **et** `Link:` header identique | rien | — |
| D2 | `/hreflang-drift` | HTML `fr → /fr/slug-traduit`, header `fr → /fr/hreflang-drift` | `hreflang-drift` **Error** | C'est le cas réel des frameworks qui dérivent l'alternate en préfixant la locale. |
| D3 | `/hreflang-dead` | header `fr → /fr/nexiste-pas` (404) | `hreflang-dead-target` **Error** (+ drift si l'HTML diverge) | Nécessite `--no-verify` absent ; vérifier que `--no-verify` supprime bien ces deux findings. |
| D4 | `/hreflang-html-only` | `alternates.languages` en HTML, **pas** de header | `hreflang-one-sided` **Warn** | Piège : c'est le comportement **normal** de Next.js self-hosted (Next n'émet jamais de `Link:` hreflang tout seul) ⇒ un warn systématique sur toute app i18n. Ergonomie à juger. |

## E. Contenu servi

| # | Route | Setup | Attendu | Piège |
| --- | --- | --- | --- | --- |
| E1 | `/shell` : `"use client"`, ~5 mots de texte, gros bundle | Next.js émet ≥ 3 `<script src>` | `empty-server-html` **Error** | Piège : un composant client est quand même **SSR** par Next.js ⇒ son texte est dans l'HTML. Pour faire une vraie coquille il faut du contenu rendu seulement après `useEffect`/mount (`typeof window`), sinon le test ne teste rien. |
| E2 | `/shell` sans `<a>` interne | — | `no-internal-links` **Warn** en plus | — |
| E3 | `/cjk` : ~250 caractères japonais rendus **serveur** derrière le bundle Next | Next compte 3 `<script src>` ⇒ `js_heavy` | **aucun** finding contenu (fix ELEVA-299) | Régression à garder verte. Sans le fix, 250 chars japonais ≈ 3 « mots » ⇒ faux `empty-server-html`. |
| E4 | `/cjk-thin` : ~40 caractères japonais + bundle | js_heavy, 40 mots | `empty-server-html` Error | Contrôle négatif : le fix ne doit pas rendre le check aveugle au japonais. |
| E5 | Page normale ~300 mots latins | js_heavy mais ≥ 200 mots | rien | Contrôle. |
| E6 | Page latine de 100 mots avec bundle Next | js_heavy, 60 ≤ w < 200 | `thin-server-html` **Warn** | La bande intermédiaire — vérifier qu'elle ne bloque pas. |

## F. Sitemap / découverte

| # | Scénario | Attendu | Piège |
| --- | --- | --- | --- |
| F1 | `app/sitemap.ts` généré, servi sur `/sitemap.xml` | découverte OK, « N of M URLs selected, from …/sitemap.xml » | `app/sitemap.ts` exige une URL **absolue** ⇒ elle pointera sur `https://example.com` ⇒ le rebasage doit se déclencher et le dire (« N URLs pointed at another host »). C'est un excellent test du rebasage. |
| F2 | URL 404 dans le sitemap | `sitemap-url-error` **Error** | — |
| F3 | URL du sitemap qui redirige (`next.config` `redirects()`) | ligne `HTTP 308 — redirects to …`, **non bloquant** | Next.js redirige en 308 par défaut, pas 301. |
| F4 | Pas de `robots.txt` (défaut Next) | fallback `/sitemap.xml` | Vérifier l'ordre robots.txt → sitemap.xml → sitemap_index.xml. |
| F5 | `-a` vs `-s 3` | échantillonnage réparti, pas les 3 premiers | Vérifier que ce n'est pas la tête du sitemap. |
| F6 | App **sans** sitemap du tout | « only the homepage is checked and the report says so » | — |

## G. Pièges d'environnement (à documenter, pas seulement à tester)

| # | Piège | Pourquoi ça compte |
| --- | --- | --- |
| G1 | **Ne jamais tester `next dev`** | En dev, Next pose `cache-control: no-store, must-revalidate` sur tout ⇒ **100 % des pages seraient `dynamic-render` Error**. Un utilisateur qui lance l'outil sur `localhost:3000` en dev obtient un rapport entièrement faux. **Tranché (ELEVA-304 / ADR-019)** : les runs réels montrent `cache-control: no-cache, must-revalidate` sans marqueur de prerender ⇒ `unclear`, donc jamais bloquant, donc `PASSED` menteur. L'outil ne refuse pas le mode dev — il l'**annonce** via une note de run (empreinte : `x-powered-by: Next.js` + `no-cache` + ni `x-nextjs-cache` ni `x-nextjs-prerender`). |
| G2 | `next start` ne pose pas les en-têtes CDN | Tout le vocabulaire `x-vercel-cache`/`x-nextjs-cache` est mort en self-hosted. Reste `cache-control` seul. Question centrale de ce run. |
| G3 | Compression | L'outil envoie-t-il `accept-encoding` ? `reqwest` + `async-compression` sont dans les deps ⇒ gzip/br décompressés. Vérifier que le comptage de mots n'est pas fait sur du binaire. |
| G4 | Trailing slash (`trailingSlash: true`) | `/static` ⇒ 308 vers `/static/`. Le sitemap Next émet-il l'un ou l'autre ? Une incohérence donnerait un rapport plein de redirections. |
| G5 | `basePath: "/app"` | `robots.txt`/`sitemap.xml` restent-ils à la racine ? Découverte cassée ? |
| G6 | `--urls` avec chemins relatifs vs absolus | Vérifier les deux formes. |
| G7 | Exit codes | 0 sain, 1 problème, 2 injoignable. Tester un port fermé. |
| G8 | Cohérence des 3 modes | humain / `--plain` / `--json` doivent donner le même `exit_code` et le même compte de bloquants. |

## Priorisation pour la phase 2

Deux applications suffisent :

- **`app-main`** : App Router, TypeScript, multi-routes, couvre B1-B3, B5, C1-C6, D1-D4, E1-E6, F1-F5, G3-G4, G7-G8.
  Buildée **deux fois** : `sane` puis `notfound-dynamic` (mutation de `app/not-found.tsx`) ⇒ A1/A2/A3.
- **`app-export`** : `output: "export"`, servie par `python3 -m http.server` ⇒ B4, F6.

Tout le reste (G1, G2) se déduit des en-têtes observés et se consigne dans FINDINGS.md.

---

## Post-scriptum — prédictions falsifiées par les runs (phase 2)

Trois hypothèses de la phase 1 se sont révélées fausses ; elles sont conservées ci-dessus
telles qu'écrites, et corrigées ici.

- **G2 / A1 / B3 — « `next start` ne pose pas les en-têtes CDN, donc tout sera `unclear` ».
  FAUX sur Next.js 16.3.** `next start` pose `x-nextjs-cache: HIT`, `x-nextjs-prerender: 1`,
  `x-nextjs-stale-time` et un `cache-control` complet. Le vocabulaire que l'outil sait lire est
  donc bien présent en self-hosted, et le bug fondateur y est parfaitement détectable.
- **D3 — « cible morte ⇒ `hreflang-dead-target` ». FAUX quand il n'y a pas de dérive.**
  `findings()` ne cherche les cibles mortes que *parmi les dérives*. Une cible 404 sur laquelle
  l'HTML et le header sont d'accord ne produit rien du tout.
- **G1 — « `next dev` donnerait 100 % de `dynamic-render` ». FAUX, et c'est pire :**
  Next 16 en dev répond `cache-control: no-cache, must-revalidate`, ce qui tombe dans `Unclear`.
  Le rapport est donc **vert (exit 0)** sur un serveur de dev.
