# headless-seo-check — CLI contract

Tool version covered: `0.1.0`.

This is the **only normative document** in this repository. It states what the
tool promises to anyone who scripts against it: the command surface, the exit
codes, the stream discipline, the two machine formats, the finding kinds and
the behavioural guarantees. Every statement in it is held by a test in the
repository's suite; behaviour this document does not state is not promised.

The key words **MUST**, **MUST NOT**, **SHOULD** and **MAY** are to be
interpreted as described in RFC 2119. Where this document and the binary
disagree, one of them has a bug — file it.

Two companion documents are deliberately non-normative: `design.md` explains
why the rules below are what they are, and `decisions.md` records when each
one was decided or reversed.

---

## 1. Invocation

### 1.1 The positional argument

Exactly one positional argument, mandatory: the site to audit. A second
positional MUST be refused as a usage error. Only `http` and `https` origins
are accepted; any other scheme is a usage error naming the scheme it got.

A bare host is given a scheme: `http` on the loopback — `localhost` (any
case), `localhost.`, any host ending in `.localhost`, `::1`, and `127.x.y.z`
— and `https` everywhere else. An explicit scheme is never overridden. A host
with a port (`example.com:8080`) MUST NOT be mistaken for a scheme.

A path or query on the argument never scopes the run: the audit is always
origin-wide. The path is instead used as a **discovery hint** — where to look
for a sitemap, never a limit on what is checked (§8.13) — and what became of
it is announced on stderr, with a pointer to `--urls` for checking that one
page. The announcement MUST distinguish the two cases: a path that guided
discovery was not ignored, and a path that guided nothing (`--urls` was
given, or the path reduces to `/`) was.

### 1.2 Options

| Long | Short | Value | Default | Effect |
| --- | --- | --- | --- | --- |
| `--sample` | `-s` | `<N>` ≥ 1 | `10` | Pages to check, spread across the sitemap. |
| `--all` | `-a` | — | off | Check every URL in the sitemap. |
| `--urls` | `-u` | comma list, or `-` | — | Check these paths/URLs instead of the sitemap. |
| `--timeout` | — | `<MS>` ≥ 1 | `15000` | Per-request timeout, in milliseconds. |
| `--concurrency` | — | `<N>` ≥ 1 | `5` | Parallel requests, capped at 6. |
| `--allow-dynamic` | — | — | off | Demote `dynamic-render` from error to warning. |
| `--no-verify` | — | — | off | Skip every extra request: hreflang target verification and the second look at an unclear page. |
| `--json` | — | — | off | JSON report on stdout. |
| `--plain` | — | — | off | Tab-separated report, one typed record per line. |
| `--quiet` | `-q` | — | off | See §8.7. |
| `--verbose` | `-v` | — | off | Human mode only: full URLs, kind slugs, link counts and TTFB, unfolded redirect/non-HTML rows, and the cache headers behind a rendering verdict that produced no finding (§8.12). |
| `--no-color` | — | — | off | Force colour off. |
| `--help` | `-h` | — | — | Full help on stdout, exit 0. |
| `--version` | `-V` | — | — | `headless-seo-check <semver>` on stdout, exit 0. |

`-v` means `--verbose` — the POSIX/GNU/Cargo convention — and MUST never
print the version. `-V` and `--version` are unchanged. (Decided in
ELEVA-298; see `decisions.md`.)

Both `--flag value` and `--flag=value` are accepted; flags and the positional
are order-independent. An unknown option is refused with the offending option
named and, when edit distance permits, a suggestion.

`--verbose` MUST NOT change the exit code, the `--plain` records or the JSON
document: it is a human-mode rendering switch and nothing else.

### 1.3 Short forms

`-s -a -u -q -v -h -V` is the complete list of assigned short forms. The
letters `-n`, `-o`, `-f`, `-d`, `-p`, `-c`, `-j` and `-t` MUST stay
unassigned: each is reserved for the conventional meaning a future flag might
carry (dry-run, output file, force, debug, port), and granting one to
anything else would burn it forever.

### 1.4 Conflicting flags

Conflicts are refused as usage errors (exit 2), never silently ranked:

- `--json` with `--plain`
- `--all` with `--sample`
- `--urls` with `--sample`
- `--urls` with `--all`

### 1.5 Numeric validation

`--sample` and `--concurrency` accept positive whole numbers; `--timeout`
accepts a positive whole number of milliseconds. A rejected value MUST be
quoted back in the error message together with a correct example (for
instance `--sample 20`).

### 1.6 The concurrency cap

Concurrency is hard-capped at **6**. A value above the cap is clamped, not
refused, and the clamp is announced on stderr and recorded in the JSON
`notes` array: this tool audits a site, it does not load-test it. A value
within the cap produces no announcement.

### 1.7 `--urls` and stdin

`--urls` takes a comma-separated list of paths or URLs, or a single `-`:

- `-` reads one URL per line from stdin, skipping blank lines and lines
  starting with `#`;
- `-` mixed with literal values is a usage error;
- `-` while stdin is a terminal is a usage error — the tool MUST never block
  waiting for interactive input;
- an empty list read from stdin is a usage error.

An entry that cannot be parsed as a URL or path fails the whole run; in
`--json` the fatal carries `fatal.code = "bad-argument"` (§2).

### 1.8 Help, version, and no arguments

`--help`/`-h` print the full help on stdout and exit 0, with nothing on
stderr. The help text leads with examples, documents every flag including
`--version`, documents all four exit codes, and ends with the issues URL.
`--version`/`-V` print `headless-seo-check <semver>` on stdout and exit 0.

Invoked with no arguments at all, the tool prints a concise usage block to
stderr — the missing-site reason, a usage line, one example, and a `--help`
pointer — and exits 2. stdout stays empty.

---

## 2. Exit codes

| Code | Meaning | Triggers |
| --- | --- | --- |
| `0` | Run completed, nothing blocking | no blocking finding; also `--help` and `--version` |
| `1` | Run completed, at least one blocking finding | any error-level finding after `--allow-dynamic` has had its say |
| `2` | The run could not happen | usage error; no usable sitemap and no HTML homepage; every checked page failed at the transport layer; internal failure |
| `130` | Interrupted by SIGINT | the run was cut short — interruption overrides the verdict, so a partial clean scan is never exit 0 |

Warnings never affect the exit code. The code reported inside the document
(JSON `exit_code`, `--plain` `summary⇥exit_code`) MUST equal the process
exit code, in every output mode.

Exit `2` covers several causes. A machine consumer that needs to tell a
usage error from an infrastructure failure MUST run with `--json` and branch
on `fatal.code`, whose values are `"usage"`, `"unreachable"`, `"no-sitemap"`,
`"bad-argument"`, or a stable transport code — never on stderr text.

---

## 3. Streams

**stdout is the report, stderr is the running commentary.**
`headless-seo-check site > report.txt` captures a complete, self-contained
report and nothing else; the terminal still shows progress and problems.

| Output | Stream | Modes |
| --- | --- | --- |
| Final report (human, plain, JSON) | stdout | all |
| Fatal report ("nothing could be checked") | stdout | all — **except** a usage error outside `--json` |
| `--help`, `--version` | stdout | — |
| Start line (`checking {site} …`) and selection line | stderr | all unless `-q` |
| Notes (`note: …`) | stderr | all unless `-q` — **and** in the JSON `notes` array |
| Rebase announcement, concurrency clamp, dropped path | stderr | all unless `-q` |
| Progress line | stderr, TTY only | all unless `-q` |
| Errors (the reason for a fatal, usage errors) | stderr | all, `-q` included |
| Panics, with the issues URL | stderr | all |

Every stderr line the program writes itself is prefixed
`headless-seo-check: `, except the transient progress line (erased before the
report) and the continuation lines of the concise-usage block.

The line between the two fatal shapes is *whether a run happened at all*:

- a **usage error** produced no run, so stdout stays empty (exit 2, reason on
  stderr) — except under `--json`, where a fatal envelope is still written so
  a consumer that always parses stdout always finds a document;
- a **run that started and could not finish** is a result, and it goes to
  stdout in whichever mode was asked for, with the one-line reason echoed to
  stderr. The human fatal MUST end with the sentence stating that nothing was
  checked and exit 2 is not a passing result.

stdout is written exactly once, at the end. An interrupted or killed run can
therefore produce an empty stdout but never a truncated document: a redirected
`--json > report.json` either parses or is empty. A closed stdout (`| head`)
exits without a panic or an error message.

---

## 4. The JSON document

One document, pretty-printed, followed by a newline. stdout carries nothing
else, and never an ANSI escape sequence.

### 4.1 Run envelope

| Key | Type | Notes |
| --- | --- | --- |
| `tool` | string | always `"headless-seo-check"` |
| `version` | string | the tool's semver |
| `checked_at_unix` | integer | seconds since the epoch |
| `interrupted` | boolean | |
| `notes` | array of strings | the same notes stderr carries |
| `fatal` | object or `null` | **always present**; `null` on a run that happened |
| `site` | string | the audited origin, ASCII-serialised |
| `discovery` | object | §4.2 |
| `pages` | array | §4.3 |
| `summary` | object | §4.4 |
| `warnings` | integer | counted by the same rule as `summary.blocking` |
| `exit_code` | integer | equals the process exit code |

### 4.2 `discovery`

| Key | Type | Notes |
| --- | --- | --- |
| `source` | string | the sitemap URL used (prefix included, §8.13), `--urls`, `homepage (no sitemap found)`, or `{url} (no sitemap found)` when the homepage checked was not the origin's root |
| `total` | integer | URLs discovered |
| `checked` | integer | URLs actually audited |
| `rebased` | string | **omitted** when no URL was moved |

### 4.3 `pages[]` and `findings[]`

| Key | Type | Omission |
| --- | --- | --- |
| `url` | string | never |
| `outcome` | `"checked" \| "redirect" \| "http-error" \| "not-html" \| "unreachable"` | never |
| `status` | integer | omitted when absent |
| `ttfb_ms` | integer | omitted when absent |
| `render_mode` | `"static" \| "isr" \| "cached" \| "dynamic" \| "unclear"` | omitted when absent |
| `words` | integer | omitted when the body was never read |
| `internal_links` | integer | omitted when absent |
| `redirects_to` | string | omitted when absent |
| `findings` | array | never (may be empty) |

An absent key means "never measured" and MUST be omitted rather than `null`
— a missing `words` and a zero `words` are different facts.

Each finding is `{ kind, severity, message, detail? }`:

| Key | Type | Notes |
| --- | --- | --- |
| `kind` | string | a stable slug from §6 — machine consumers MUST match on this, never on the message |
| `severity` | `"info" \| "warn" \| "error"` | the **effective** severity, after `--allow-dynamic` |
| `message` | string | one line, no trailing period; free to improve between versions |
| `detail` | array of strings | omitted when empty |

### 4.4 `summary`

Ten counters, all integers, all always present: `checked`, `dynamic`,
`unclear`, `noindex_header`, `hreflang_drift`, `hreflang_dead_target`,
`empty_html`, `sitemap_errors`, `unreachable`, `blocking`. Pages with a
`redirect` or `not-html` outcome count in none of them. `warnings`,
`interrupted` and `exit_code` live at the document root, not in `summary`.

### 4.5 Fatal envelope

A fatal document carries a **different** key set: `tool`, `version`,
`checked_at_unix`, `interrupted` (always `false`), `notes`, `site`,
`fatal { code, message, detail? }`, `exit_code` (always `2`) — and no
`discovery`, `pages`, `summary` or `warnings`. A consumer branches on
`fatal`, which is why the run envelope never omits it.

---

## 5. The `--plain` grammar

Tab-separated, one record per line, the record type in the first field. No
colour ever, no decorative framing, no truncation, no padding. Tabs,
newlines and carriage returns inside a value are replaced by single spaces,
so a record can never split across lines.

### 5.1 The five record types

Each type has a **fixed and distinct** field count — the check a parser runs.

| Type | Fields | Layout |
| --- | --- | --- |
| `run` | **5** | `run`, site, source, total, checked |
| `page` | **10** | `page`, url, outcome, status, render_mode, words, internal_links, ttfb_ms, blocking, redirects_to |
| `finding` | **5** | `finding`, url, severity, kind, message |
| `detail` | **4** | `detail`, url, kind, text |
| `summary` | **3** | `summary`, key, value |

`outcome`, `render_mode`, `severity` and `kind` use the same slugs as the
JSON document. A field with no value is a literal `-`, never empty. `blocking`
is that page's count of blocking findings; `severity` is the effective
severity, so `--allow-dynamic` shows `warn` here exactly as it does in JSON.

### 5.2 Order and cardinality

1. Exactly one `run` record, first.
2. For each page in report order: one `page`, then per finding one `finding`
   followed by one `detail` per supporting line. A page with no findings
   emits only its `page` record — there is no `ok` record type.
   Findings keep **production order** — the order the checks ran — in both
   machine formats; the human report sorts them by severity under each page,
   which it may do because it is not a stable interface (§10).
3. The `summary` records, always in this order: `checked`, `dynamic`,
   `unclear`, `noindex_header`, `hreflang_drift`, `hreflang_dead_target`,
   `empty_html`, `sitemap_errors`, `unreachable`, `blocking`, `warnings`,
   `interrupted` (`0` or `1`), `exit_code`.

### 5.3 A fatal in `--plain`

A fatal stays inside the same grammar: a `run` record that discovered and
checked nothing (`-` source, `0`, `0`), a `summary⇥fatal⇥<code>` record, the
reason and its detail as `detail` records, then `summary⇥blocking⇥0` and
`summary⇥exit_code⇥2`. `grep '^summary'` works whether the run happened or
not.

### 5.4 Guarantees

- `awk -F'\t' '$1=="finding" && $3=="error"'` counts exactly the blocking
  findings, and that count equals `summary⇥blocking`, equals the JSON
  `summary.blocking`, equals the number in the human verdict.
- `-q` and `--verbose` change nothing in `--plain`, byte for byte.
- The format evolves additively only: new record types, new `summary` keys,
  new kinds. Existing fields are never reordered, renamed or removed, and a
  record type's field count is fixed for good.

---

## 6. Finding kinds

The `kind` field takes exactly **18** values. Slugs are frozen: they are the
identifiers machine consumers match on, and they MUST never be renamed or
reused. Messages, by contrast, are prose and free to improve.

"Blocking" means the finding drives the exit code to `1`. The only demotion
is `--allow-dynamic`, which turns `dynamic-render` — and nothing else — from
error to warning, consistently in every output mode at once.

| Kind | Severity | Blocking | Fires when |
| --- | --- | --- | --- |
| `dynamic-render` | error | yes, unless `--allow-dynamic` | the response says `Cache-Control: no-store` or `private` — rendered on every request |
| `render-mode-unclear` | info | no | the headers say nothing about caching either way; our uncertainty, never the site's fault |
| `noindex-header` | error | yes | `X-Robots-Tag` carries `noindex` — a blocker invisible in the page source |
| `noindex-meta` | warn | no | `<meta robots>` says `noindex` on a URL offered for crawling |
| `indexability-conflict` | error | yes | the header says `noindex` while the HTML says otherwise; the header wins |
| `canonical-missing` | warn | no | no canonical link in the served HTML |
| `canonical-elsewhere` | info | no | the canonical points at another page or another host (the staging shape) |
| `hreflang-drift` | error | yes | the `Link:` header and the HTML advertise different URLs for a language |
| `hreflang-dead-target` | error | yes | a verified hreflang target does not exist (4xx/5xx), whether it drifted or both sources advertise it identically |
| `hreflang-one-sided` | warn | no | languages present in only one of the two sources, when both sources exist |
| `empty-server-html` | error | yes | a JavaScript-heavy page serves fewer than 60 words |
| `thin-server-html` | warn | no | a JavaScript-heavy page serves fewer than 200 words |
| `no-server-text` | info | no | a page with no bundle serves exactly zero words — a soft 404, mentioned but never blocking |
| `no-internal-links` | warn | no | a JavaScript-heavy page links to no other page on its host |
| `sitemap-url-error` | error | yes | a sitemap URL answers 4xx/5xx — in the sitemap, but not on the site |
| `sitemap-url-redirect` | info | no | a sitemap URL answers 3xx; untidy, not broken |
| `not-html` | info | no | the URL serves a non-HTML media type; skipped without reading the body |
| `unreachable` | error | yes | transport failure — we cannot say the page is fine |

"JavaScript-heavy" is observable from the served HTML alone: three or more
`<script src>` tags, or at least 8192 bytes of inline script, or a known
framework root container (`root`, `app`, `__next`, `__nuxt`, `q-app`) with
no element children. A thin or empty page that is **not** JavaScript-heavy
is never flagged (above zero words): this check runs in CI, where a false
alarm costs more than an omission.

`empty-server-html` and `thin-server-html` are mutually exclusive on one
page, and `no-server-text` is only reachable on a page that is not
JavaScript-heavy.

---

## 7. Contractual thresholds and constants

These values are part of the contract: changing any of them is a behaviour
change, not a tuning detail.

| Value | Meaning |
| --- | --- |
| `60` words | below this, a JavaScript-heavy page is `empty-server-html` |
| `200` words | below this, a JavaScript-heavy page is `thin-server-html` |
| `3` `<script src>` tags | JavaScript-heavy threshold |
| `8192` bytes inline script | JavaScript-heavy threshold |
| `12` verifications / run | the hreflang target budget, for the whole run, never per page; `0` under `--no-verify` |
| `4 MiB` | body cap for an audited page |
| `8 MiB` | body cap for a sitemap |
| `512 KiB` | body cap for `robots.txt` |
| `20` | child sitemaps read from a sitemap index |
| `5` | redirect hops followed where following is allowed |
| `6` | the concurrency hard cap (§1.6) |
| `10` | default `--sample` |
| `5` | default `--concurrency` |
| `15000` ms | default `--timeout` |

Requests are sent with Googlebot's User-Agent —
`Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)` —
because Googlebot's verdict is the one that matters, plus an HTML-preferring
`Accept` header and `Accept-Language: en;q=0.9,*;q=0.5`. Only `GET` is ever
sent; no cookies, no authentication, no telemetry — the only host contacted
is the one named on the command line, with the single exception in §8.2.

Exceeding a body cap truncates: the tool stops reading rather than draining
the connection, and a truncated sitemap is announced in a note.

---

## 8. Behavioural promises

### 8.1 Rebasing

Every discovered URL — from a sitemap, a sitemap index child, `robots.txt`,
or `--urls` — is rebased onto the origin the user named: path and query
kept, origin always replaced. Pointing the tool at `localhost` MUST never
send audit traffic to production, whatever the sitemap says; a URL that
cannot be kept on the origin (including protocol-relative escapes) is
dropped rather than trusted. Rebasing is never silent: when URLs are moved,
a stderr line and the `discovery.rebased` field say how many.

### 8.2 hreflang targets: what is verified, and the one exception

Verification covers both a target the two sources disagree about (the header
side of a drift) and a target they advertise **identically** — one generator
emits both, so a wrongly derived URL is emitted into both at once, and
agreement is not existence. Each page spends the shared budget on its drift
first and on its agreed targets with whatever is left; the run-wide cap of 12
covers the two together. A target equal to the page's own URL is never
requested — the page has just answered — and one URL advertised under
several language tags costs exactly one request.

Verification probes each target **where it lives**, off the
audited origin if that is where the header points — a rebased target would
answer for a URL the site never advertised. This is the only traffic ever
sent to another host. It is bounded (headers only, 12 targets per run, 5
hops per chain) and announced: targets actually probed off the origin
produce a note, in the origin sense of scheme + host + port. `--no-verify`
is the opt-out and sends nothing anywhere.

### 8.3 Redirects on audited pages are findings, not detours

An audited page's redirect is never followed: following it would audit a
different page than the one asked about. The redirect becomes a
`sitemap-url-redirect` finding with its target reported. Redirects **are**
followed (up to 5 hops) where following is the point: `robots.txt`, sitemap
fetches, the homepage fallback probe, and hreflang target chains.

### 8.4 Notes the run must produce

Whenever the condition holds, the run says so — on stderr and in the JSON
`notes` array:

- a checked page answered with a Next.js **development server**'s headers —
  `x-powered-by` naming Next.js, `cache-control` carrying `no-cache`, and
  neither `x-nextjs-cache` nor `x-nextjs-prerender`, all three together — since
  a dev server's headers describe a per-request rebuild and say nothing about
  production, which would otherwise pass silently as `unclear`; the note names
  the production build to audit instead and changes no verdict, no finding and
  no exit code;
- URLs rebased from another host (§8.1);
- hreflang targets probed off the audited origin (§8.2);
- the verification budget ran out with hreflang targets unchecked, drifted
  or agreed — a dead target among them would go unreported, so silence would
  dress a truncated report as a clean one; nothing is counted under
  `--no-verify`;
- concurrency clamped (§1.6);
- gzipped sitemaps skipped (the tool does not decompress `.gz`), rather
  than a fully-gzipped site looking like a site with no sitemap;
- a sitemap truncated at its body cap;
- a child sitemap that failed;
- the homepage fallback in use (no sitemap found; the homepage alone was
  checked).

### 8.5 Word counting speaks CJK

A word is a whitespace-separated token containing at least one
non-CJK alphanumeric character — plus **one word per CJK character**,
because Chinese and Japanese write no spaces and a healthy CJK page would
otherwise count zero words behind a bundle. The character must be
alphanumeric (CJK punctuation does not count) and fall in one of seven
Unicode ranges: Hiragana (U+3040–309F), Katakana (U+30A0–30FF), halfwidth
Katakana (U+FF66–FF9D), CJK ideographs (U+4E00–9FFF), extension A
(U+3400–4DBF), compatibility ideographs (U+F900–FAFF), Hangul syllables
(U+AC00–D7AF). A mixed token counts its CJK characters plus one for the
remainder. Latin text counts exactly as before. One character = one word is
right for Chinese and deliberately overcounts Japanese: this tool would
rather miss a thin page than invent a shell.

### 8.6 Interruption

- The **first** SIGINT stops scheduling new requests, waits for the requests
  already in flight (bounded by `--timeout`), announces itself on stderr,
  then writes the partial report and exits `130`.
- A **second** SIGINT abandons the scan: exit `130`, stdout empty.
- Every mode states partiality: the human report says
  `interrupted — {n} of {m} selected pages checked`, `--plain` carries
  `summary⇥interrupted⇥1`, JSON carries `"interrupted": true`.
- The human verdict of an interrupted run is **INTERRUPTED**, never `PASSED`
  and never a bare `FAILED`: `so far` is the load-bearing clause — nothing
  is concluded about pages never reached. The badge survives `-q`.
- An interrupted run whose checked pages were all unreachable stays `130`,
  not a fatal `2`: "unreachable" is a verdict this run did not earn.

### 8.7 What `--quiet` means

`-q` silences commentary, never results or errors:

- suppressed: the start and selection lines, notes, warnings on stderr, the
  progress line; in human mode the provenance block, the guidance block,
  info-level findings and pages carrying nothing worse than info;
- untouched: errors on stderr, the exit code, the verdict badge and its
  breakdown, pages and findings at warn or error — and **`--plain` and
  `--json`, byte for byte**. A machine stream that shrinks because a human
  asked for less is not machine-readable.

On a clean site, `-q` human output is exactly two lines: the title and
`PASSED  no blocking issue`.

### 8.8 Colour

Colour MUST be disabled when any of the following holds:

1. the target stream is not a TTY;
2. `NO_COLOR` is set to a non-empty value;
3. `TERM=dumb`;
4. `--no-color` was passed;
5. the mode is `--plain` or `--json`.

ASCII symbols only (`x ! . +`), no emoji. The progress line renders only
when stderr is a terminal — no repeated progress lines in CI logs.

### 8.9 Second look at an unclear page

A page whose rendering mode is undetermined is requested a second time,
headers only, at most once — never a third request, and never when the first
response already said something. `--no-verify` disables it. A second
response that settles the verdict is credited to itself, not to the first.

### 8.10 Errors reach the user translated

Transport failures are rendered as stable, human phrases with stable codes
(`ENOTFOUND` → `domain does not resolve`, `ECONNREFUSED` →
`connection refused (nothing listening on that port)`, timeouts as
`timed out after {n}ms`, and so on). Raw library error text MUST never reach
the user, in any mode, on any stream.

### 8.11 How a rendering mode is decided

Response headers only, in this order, first match wins:

1. `Cache-Control` carrying `no-store` or `private` → `dynamic`. Nothing
   overrides it: the site is telling every shared cache that each hit costs a
   render.
2. a **finite** shared lifetime — `s-maxage`, else `max-age`, strictly between
   zero and a year, and only when `no-cache` is absent → `isr`. A lifetime
   outranks a prerender marker: the marker says how the page was born, the
   lifetime says when it will be born again.
3. a prerender marker (`x-nextjs-prerender: 1`, `x-vercel-cache: PRERENDER`,
   `x-nextjs-cache: PRERENDER`), or a lifetime of a year or more → `static`.
4. positive proof of a cache right now — a non-zero `Age`, or a CDN `HIT` /
   `STALE` / `REVALIDATED` / `UPDATING` — → `cached`.
5. otherwise `unclear`, which is never blocking (§6).

`static`, `isr` and `cached` are all healthy and produce no finding; the mode
is a label, not a verdict.

### 8.12 `--verbose` shows the evidence behind a healthy verdict

The headers a rendering verdict rests on always reach the user. A `dynamic` or
`unclear` page carries them in its finding's `detail`, in **every** mode; a
page that passed has no finding to carry them, so `--verbose` prints them under
the page row instead — and only there. They are never printed twice, never
invented when the response carried no cache header, and never added to
`--plain`, to the JSON document or to any stream but the human report on
stdout (§1.2). Under `-q` a page that passed is not printed at all, so neither
is its evidence.

### 8.13 Where a sitemap is looked for

Candidates are tried in this order, first usable one wins:

1. every `Sitemap:` line of `robots.txt`, **always fetched at the origin's
   root** — that is where the web contract puts it, base path or not, and its
   lines already name the site's real layout;
2. `{path}/sitemap.xml`, then `{path}/sitemap_index.xml`, when the positional
   argument carried a path;
3. `/sitemap.xml`, then `/sitemap_index.xml`.

The path from step 2 is normalised first: query and fragment dropped, empty
segments collapsed, no trailing slash — `/app`, `/app/`, `//app//` and
`/app?x=1` all name the prefix `/app`, and a candidate is never
`/app//sitemap.xml`. A path that reduces to `/` adds no candidate, and an
argument with no path MUST produce exactly the requests it produced before:
no prefixed candidate is ever requested, and `robots.txt` is never looked for
under a prefix.

Discovered URLs are rebased as always (§8.1), which keeps the prefix that is
part of their path. `discovery.source` names the sitemap actually read,
prefix included.

The homepage fallback follows the same rule: the prefixed page is probed
first when the argument carried a path, the origin's root otherwise, and
exactly one of the two becomes the list.

---

## 9. Environment

| Variable | Behaviour |
| --- | --- |
| `NO_COLOR`, `TERM` | honoured for colour (§8.8) |
| `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` (both cases) | honoured; `NO_PROXY` keeps covered hosts direct; a CGI environment (`REQUEST_METHOD` set) disables proxy resolution entirely |
| `DEBUG`, `PAGER` | deliberately ignored |
| `HEADLESS_SEO_CHECK_*` | reserved namespace; **the binary reads none of them** (three are read by the npm installer, never by the tool) |

The tool reads no configuration file, no `.env`, writes nothing to disk, and
never prompts. No secrets are accepted from anywhere.

---

## 10. Compatibility

- Every long flag keeps its name and meaning; CLI changes are additive only.
  Removing or repurposing a flag requires a deprecation cycle.
- The JSON schema (§4) and the `--plain` grammar (§5) are the stable
  interfaces, frozen from `0.1.0` onward, additive evolution only. New keys,
  new record types, new kinds and new severities may appear; nothing
  existing is renamed, reordered or removed.
- Finding-kind slugs (§6) and exit codes (§2) are frozen.
- The human report is explicitly **not** a stable interface. Scripts MUST
  use `--plain` or `--json`.
