> **Archived document.** This is the original clig.dev audit of the JavaScript
> prototype and the pre-implementation specification of the Rust port, kept
> verbatim as history. **It is no longer normative and is not maintained**:
> the binding contract is [`docs/contract.md`](../contract.md), the reasoning
> lives in [`docs/design.md`](../design.md), and the decisions recorded below
> (D1–D7 and the implementation reversals) are carried forward as dated
> entries in [`docs/decisions.md`](../decisions.md). Where this text and the
> shipped binary disagree, the binary and its contract win. The `index.mjs`
> prototype it audits has since been removed from the repository; git history
> has it.

# CLI Conformance — clig.dev audit and Rust port specification

Audit target: `index.mjs` (JavaScript implementation, v1.0.0, 1517 lines).
Reference: [Command Line Interface Guidelines](https://clig.dev), read in full.

This document has two parts:

- **Part 1** audits the current JavaScript implementation against every applicable
  clig.dev rule, with line references.
- **Part 2** is the normative specification for the Rust port. When Part 1 and
  Part 2 disagree, Part 2 wins. Implementers should not need to read clig.dev.

Line numbers refer to `index.mjs` at the commit this document was written against.

### Status of Part 2

Part 2 was written **before** the Rust implementation. It has since been
reconciled with the binary that is actually built from `src/`, so it now doubles
as a record of what `0.1.0` ships. Where implementation reversed a decision the
specification had taken, the reversal is stated in place rather than edited away:
a conformance audit whose revisions are invisible stops being an audit. Those
notes are marked **Revised during implementation**.

Two of the reversals are substantive — §2.2 (the fatal report goes to stdout,
not stderr) and §2.5 (`--plain` records are typed, not four bare columns). Four
are smaller: the assumed scheme on the loopback and the scope of `--verbose`
(both §2.1), the `--json` key names (§2.12), and the issues URL in the panic
hook (§2.10). One section, §2.8 (signals and interruption), specifies behaviour
that is **not implemented at all** and is labelled as such throughout.

A note on versions: `package.json` at the repository root declares the
JavaScript prototype as `1.0.0`, but **that package was never published** — no
npm release, no GitHub release, no crates.io release. "v1.0.0" throughout this
document means "the JavaScript prototype as it stands in this repository", and
the compatibility arguments built on it (D2 in particular) are arguments about
not breaking anyone running that file directly. `Cargo.toml` is at `0.1.0`.

---

## Part 1 — Audit of the JavaScript implementation

Status legend: **PASS** (rule followed), **FAIL** (rule violated), **PARTIAL**
(followed with gaps), **N/A** (rule does not apply to this tool).

### 1.1 The Basics

| Rule | Status | Evidence |
|---|---|---|
| Use an argument parsing library | FAIL (deliberate) | Hand-rolled parser (`parseArgs`, lines 1110–1213). Deliberate for the zero-dependency npx story; the Rust port uses clap, which resolves this. |
| Zero exit code on success, non-zero on failure | PASS | `summarise` (line 1070): `fatal ? 2 : blocking ? 1 : 0`. Contract documented in help (lines 1094–1097). |
| Primary output to stdout | PASS | Report goes to stdout (`emit`, lines 1363–1370). |
| Messaging (logs, errors, notices) to stderr | **FAIL** | Almost everything goes to stdout: fatal "cannot run" blocks (`renderText`, lines 1224–1230), run notes (line 1247), the provenance line (lines 1233–1236). Only argument-parse errors (line 1396) and the crash handler (line 1515) reach stderr. `tool > report.txt` on an unreachable host produces a report file containing the error and an empty terminal. This is the single most impactful deviation. |

### 1.2 Help

| Rule | Status | Evidence |
|---|---|---|
| `-h` / `--help` show help | PASS | Line 1136. |
| Concise help when run with no arguments | FAIL | No args yields only `missing the site to check` + "Run with --help for usage." on stderr (lines 1195, 1106). clig wants a brief usage + example. |
| Lead with examples | **FAIL** | `HELP` (lines 1078–1104) is usage line + flag reference. Zero examples. |
| Support path (website / issues link) in help | FAIL | No URL anywhere in the help text. |
| Show all available flags | FAIL | `--version` / `-v` are implemented (line 1137) but absent from the help text — an undocumented flag. |
| Suggest corrections for typos | FAIL | `unknown option "--sampel"` (line 1191) with no "did you mean". clap provides this for free. |
| Display help if input expected on stdin but stdin is a TTY | N/A | Tool never reads stdin today (changes in Part 2 with `--urls -`). |
| Web docs / man page | PARTIAL | README exists; no man page. Not blocking for v1 of the port. |

### 1.3 Output

| Rule | Status | Evidence |
|---|---|---|
| Human-readable output first | PASS | Text renderer is the default; compact, aligned, colored. |
| Machine-readable output preserved | PARTIAL | `--json` exists (lines 1317–1361) and is good (single `summarise` source of truth, line 1060, so text and JSON cannot disagree). But the human text format is not greppable: padded columns (line 1266), Unicode ellipsis truncation, `·` separators, findings on continuation lines. |
| `--plain` flag for tabular greppable output | **FAIL** | Does not exist. |
| `--json` flag | PASS | Exists; embeds `exitCode`, `notes`, full per-page data. |
| Brief output on success, `-q` to suppress | FAIL | No `-q` / `--quiet`. |
| Inform user of state changes | N/A | Read-only tool. |
| Cross-boundary actions explicit | PASS | Exemplary: the rebase-to-origin behavior (line 592) exists precisely so a localhost run never hits production, and the help text says so (lines 1099–1100). Foreign-host rebases are reported (lines 1239–1246). |
| Color used intentionally | PASS | Red/yellow/green/dim for error/warn/ok/context only (lines 65–72). |
| Disable color: non-TTY | PASS | Line 65 checks `process.stdout.isTTY`. |
| Disable color: `NO_COLOR` | PASS | Line 65 (any non-empty value, matching the no-color.org spec). |
| Disable color: `TERM=dumb` | FAIL | Not checked. |
| Disable color: `--no-color` flag | FAIL | No such flag. |
| No animations when non-interactive | PASS (vacuous) | There is no progress display at all — see Robustness. |
| No debug noise by default / no log-level labels on stderr | PASS | Output is clean. |
| Pager for long output | FAIL (accepted) | Not used. Part 2 deliberately keeps it that way — see decision D6. |

### 1.4 Errors

| Rule | Status | Evidence |
|---|---|---|
| Catch errors, rewrite for humans | **PASS — model behavior** | `CODE_MESSAGES` (lines 125–146) turns `TypeError: fetch failed` into "domain does not resolve", "not a TLS port (try http:// instead of https://)", etc., including unwrapping `AggregateError` from Happy Eyeballs (lines 148–183). The Rust port must preserve this table. |
| Signal-to-noise, group similar errors | PASS | Blocking issues are grouped by code with counts in the summary (lines 1304–1311). |
| Important info at the end | PASS | Summary is the last block printed. |
| Debug info + bug-report path on unexpected errors | PARTIAL | Crash handler prints the stack to stderr (line 1515) but gives no issues URL. |

### 1.5 Arguments and flags

| Rule | Status | Evidence |
|---|---|---|
| Prefer flags to args | PARTIAL | One positional (`<site>`), everything else is flags. Kept in Part 2 — see decision D1. |
| Every flag has a long form | PASS | All flags are long-form. |
| Short forms for the most-used flags | FAIL | Only `-h` and `-v` exist. `--sample`, `--all`, `--urls` — the flags a human types repeatedly — have no short form. (Note: clig does *not* require a short form for every flag; long-only is correct for rare flags.) |
| Standard names where conventions exist | PARTIAL | `--json`, `--help`, `--version`, `--all` match conventions. `-v` bound to *version* is exactly the ambiguity clig warns about ("`-v` may mean verbose or version — choose to avoid confusion"). |
| Sensible defaults | PASS | sample 10, timeout 15 s, concurrency 5 capped at 6 (with an explanatory note when clamped, lines 1405–1407). |
| `--flag value` and `--flag=value` both accepted | PASS | `takeValue` (lines 1127–1134). |
| Order-independent | PASS | Positional and flags may interleave. |
| Validate input early, understandable errors | PASS | Every numeric flag validated with an example in the message (e.g. line 1156: `--sample needs a value, for example --sample 20`). Non-URL site rejected with the offending text quoted (line 1206). |
| Conflicting flags detected | FAIL | `--all --sample 50` silently ignores `--sample` (line 1482). |
| `-` for stdin | FAIL | `--urls -` not supported. |
| Secrets never via flags | N/A | No secrets. |

### 1.6 Interactivity

| Rule | Status | Evidence |
|---|---|---|
| Prompt only when stdin is a TTY / `--no-input` | N/A | The tool never prompts. This is correct for a CI-first tool and must stay true in the port; `--no-input` is unnecessary because there is no input. |

### 1.7 Subcommands

N/A throughout — the tool has no subcommands, no catch-all, no abbreviations.
The port must keep it that way: one tool, one job.

### 1.8 Robustness

| Rule | Status | Evidence |
|---|---|---|
| Respond within 100 ms | **FAIL** | First byte of output is written only after sitemap discovery *and* the entire scan complete (line 1511). Pointed at a slow site with `--all`, the tool is silent for minutes. |
| Progress for long operations | **FAIL** | None. A 350-URL scan gives no sign of life. |
| Configurable timeouts | PASS | `--timeout`, default 15 000 ms, applied per request via AbortController (lines 244–247). |
| Crash-only design | PASS | No state, no temp files, no cleanup. Re-invocation is recovery. |
| Anticipate misuse | PASS | Concurrency hard-capped (line 29 rationale), body size caps (lines 34–35), redirect hop cap, sitemap child cap, non-HTML bodies never downloaded (line 801), `process.exitCode` instead of `process.exit()` so piped stdout is never truncated (comment lines 1368–1369 — keep this property in Rust: flush stdout before exiting). |
| Parallelism without garbled output | PASS | Results are pooled and rendered once, in order (line 102). |

### 1.9 Signals

| Rule | Status | Evidence |
|---|---|---|
| Handle SIGINT, exit fast | **FAIL** | No handler. Ctrl-C at page 200 of 350 kills the process with the work of 200 requests discarded — no partial report, nothing on stderr. |
| Second Ctrl-C skips cleanup | FAIL | Follows from the above. |

### 1.10 Configuration and environment

| Rule | Status | Evidence |
|---|---|---|
| Precedence flags > env > project > user config | N/A today | No config, no env-var settings. Part 2 decision D5: stays that way, deliberately. |
| XDG for config files | N/A | No config files (deliberate, D5). |
| Honor `NO_COLOR` | PASS | Line 65. |
| Honor `TERM=dumb` | FAIL | Not checked. |
| Honor `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | FAIL | Node's `fetch` (undici) ignores proxy env vars by default, so a networking tool silently bypasses corporate proxies. reqwest honors them by default — the port fixes this for free. |
| `DEBUG` env var | FAIL (accepted) | Ignored. Part 2 keeps ignoring it, deliberately — see decision D4. |
| No secrets from env | PASS | None used. |

### 1.11 Future-proofing, naming, distribution, analytics

| Rule | Status | Evidence |
|---|---|---|
| Additive changes only | PASS so far | v1.0.0 is the baseline; Part 2 §11 makes the compatibility guarantee explicit. |
| Name: lowercase, memorable, no generic verb | PASS | `headless-seo-check`. |
| Single-binary distribution | PASS (both) | One dependency-free .mjs today; a static binary tomorrow. |
| No analytics without consent | PASS | The tool phones nowhere except the audited site. |

### 1.12 Audit summary — ordered by impact

1. **stdout/stderr split is wrong** (fatal errors, notes, provenance all on
   stdout). Read this one alongside §2.2: the port moved the *notes* to stderr
   and kept the *provenance* and the *fatal report* on stdout, on the grounds
   that both are results rather than commentary. What the JavaScript version got
   wrong was having no split at all, not the placement of every single line.
2. **No progress and no output before the scan ends** — violates both the 100 ms
   rule and progress-for-long-operations; worst with `--all`.
3. **No SIGINT handling** — a long interrupted scan loses everything.
4. **No `--plain`**, and the human format is not a substitute.
5. **No `-q/--quiet`, no `--verbose`.**
6. **Color disabling incomplete** — no `TERM=dumb`, no `--no-color`.
7. **Help text**: no examples, no support URL, undocumented `--version`.
8. **`-v` means version** — the exact ambiguity clig warns against.
9. **Proxy env vars ignored** (runtime accident, fixed by reqwest).
10. **No typo suggestions**; `--all`+`--sample` conflict silent.

---

## Part 2 — Specification for the Rust port

Normative. "MUST" is a hard requirement; "SHOULD" allows deviation with a
justified comment in the code.

### 2.0 Decisions taken (and why)

- **D1 — the site stays a positional argument.** clig prefers named flags, but
  its own carve-out applies: a single argument that is the primary object of a
  simple action is idiomatic (`curl <url>`, `ping <host>`). `headless-seo-check
  example.com` reads as a sentence; `--site example.com` adds typing and zero
  clarity, and there is no second positional it could ever be confused with
  (two positionals are already rejected, and stay rejected). Exactly one
  positional, mandatory, forever.
- **D2 — `-V` is version, `-v` is a frozen compatibility alias for version,
  verbose gets no short form.** clig says `-v` is ambiguous and to pick a side.
  v1.0.0 shipped `-v` = version (undocumented, but shipped), so reassigning it
  to verbose would silently change behavior for existing users — the worst kind
  of break. `-V/--version` is the clap/cargo/rustc convention. `-v` remains a
  hidden alias of `--version` and MUST never be reused for verbose.
- **D3 — `--verbose` exists, long-form only.** A diagnostic tool needs a way to
  show its evidence (cache headers seen, second-request resolution, per-page
  timing) without the user reading the source. It is rare enough not to earn a
  letter. *As shipped, most of that evidence turned out to belong in the
  findings themselves, in every mode, so `--verbose` reduced to a human-mode
  rendering switch — see the note under §2.1.*
- **D4 — the generic `DEBUG` env var is deliberately ignored.** clig suggests
  honoring it, but `DEBUG` is a wildcard commonly exported globally in CI for
  *other* tools (the npm `debug` package ecosystem). A diagnostic tool whose
  output contract changes because of an ambient variable is not reproducible.
  Verbosity is opt-in per invocation: `--verbose` only. Deviation is deliberate
  and documented here.
- **D5 — no configuration file, no `.env`, no XDG.** clig pushes toward XDG
  config, but this tool's entire configuration fits on one command line, and its
  primary home is CI, where the workflow file *is* the versioned project config.
  A user-level config file would make the same command line produce different
  results on different machines — fatal for a diagnostic whose output people
  paste into bug reports. If a config file is ever added, it must be
  project-level, explicitly opted into via `--config <path>`, and this document
  must be revised first.
- **D6 — no pager.** Output is bounded by `--sample` (default 10 pages, a
  screenful). Users who run `--all` on 350 URLs are exactly the users piping to
  a file or `less` themselves; auto-invoking `$PAGER` would complicate the CI
  path for no interactive gain.
- **D7 — interruption exits 130 and still prints the partial report.** Work
  already done (up to 350 HTTP requests) must not be discarded; but a partial
  clean scan must never be mistaken for a full pass, so the exit code cannot
  be 0. 130 (128+SIGINT) is the shell convention and is distinguishable from
  all three normal codes.
  **Not implemented in 0.1.0** — see §2.8. The plumbing exists
  (`report::exit_code` returns 130, `Emit.interrupted` is threaded through all
  three renderers) but nothing observes SIGINT, so the decision is a design
  intent, not a shipped behaviour.

### 2.1 Option surface

All v1.0.0 long flags MUST keep their exact names and semantics (compatibility
guarantee, §2.11). New surface is additive.

| Long | Short | Value | Default | Meaning |
|---|---|---|---|---|
| *(positional)* `<site>` | — | host or URL | required | Origin to audit. Only http/https, exactly one. A bare host gets a scheme assumed for it: `http` on the loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), `https` everywhere else — see the note below. A path or query on the argument is dropped, out loud, on stderr. |
| `--sample` | `-s` | `<n>` ≥ 1 | `10` | Pages to check, spread evenly across the sitemap. |
| `--all` | `-a` | — | off | Check every sitemap URL. Conflicts with `--sample` and `--urls` (usage error, exit 2 — no silent precedence). |
| `--urls` | `-u` | comma list, or `-` | — | Check these paths/URLs instead of the sitemap. `-` reads one URL per line from stdin, skipping blank lines and `#` comments so a checked-in list can explain itself; it cannot be mixed with literal values (`--urls -,/about` is a usage error), and if stdin is a TTY it is a usage error rather than a hang. An empty stdin list is a usage error too. Conflicts with `--all` and `--sample`. |
| `--timeout` | — | `<ms>` ≥ 1 | `15000` | Per-request timeout in milliseconds. |
| `--concurrency` | — | `<n>` ≥ 1 | `5` | Parallel requests. Values above 6 are clamped to 6 with a note on stderr (unchanged rationale: auditing, not load testing). |
| `--allow-dynamic` | — | — | off | Downgrade `dynamic-render` from error to warning. |
| `--no-verify` | — | — | off | Skip the extra requests that verify hreflang targets. |
| `--json` | — | — | off | JSON report on stdout. Conflicts with `--plain`. |
| `--plain` | — | — | off | Tab-separated report, one record per line (§2.5). Conflicts with `--json`. |
| `--quiet` | `-q` | — | off | Suppress stderr messaging (progress, notes, the scan-start line) — errors excepted, which `-q` never silences — and, in human mode, the provenance block, the guidance block, info-level findings and per-page lines that carry no warning or error. The verdict line always prints. `--plain` and `--json` are byte-for-byte unaffected: a machine-readable stream that shrinks because a human asked for less is not machine-readable. |
| `--verbose` | — | — | off | Human mode only: full URLs instead of paths, internal-link counts and TTFB added to the metrics column, the stable kind slug printed after each finding, and redirect / non-HTML rows unfolded instead of summarised on one line. It adds **nothing** to `--plain`, `--json` or stderr — see the note below. |
| `--no-color` | — | — | off | Force color off (one of five conditions, §2.6). |
| `--help` | `-h` | — | — | Full help (§2.7), stdout, exit 0. |
| `--version` | `-V` (+ hidden `-v`) | — | — | `headless-seo-check <semver>` on stdout, exit 0. |

Short-form rationale (letters are a scarce resource — every grant justified):

- `-s`, `-a`, `-u`: the three flags a human iterates on interactively
  ("check more pages", "check all", "check just this page"). `-a` = `--all` is
  clig's own standard table. `-u` conventionally means `--user`; this tool has
  no user concept, so no confusion is possible.
- `-q`: clig standard.
- `-h`, `-V`: universal; `-h` reserved exclusively for help.
- Everything else stays long-only: `--timeout`/`--concurrency` are set-once
  tuning, `--allow-dynamic`/`--no-verify` are policy flags that live in CI
  configs where long names self-document, `--json`/`--plain` are typed by
  scripts, not fingers.
- **Reserved, MUST stay unassigned**: `-n` (dry-run convention), `-o` (output
  file), `-f` (force), `-d` (debug), `-p` (port). Future flags with those
  conventional meanings must get those letters.

Parsing rules (clap 4 provides all of these; do not disable them):

- `--flag value` and `--flag=value` both accepted.
- Flags and the positional are order-independent.
- Unknown flags MUST produce a "did you mean `--sample`?" suggestion when edit
  distance permits, then exit 2.
- Numeric validation errors MUST include a correct example, preserving the v1
  style: `--sample must be a positive whole number, got "abc"`.

Two entries in the table above changed during implementation and are worth
spelling out.

**The assumed scheme is not always `https`.** The specification said "`https://`
assumed when no scheme", and the very first example printed by `--help` is
`headless-seo-check localhost:3000 -u /,/pricing` — which that rule made fail,
because no dev server speaks TLS on 3000. `default_scheme()` in `src/cli.rs`
therefore assumes `http` on the loopback and `https` everywhere else. The
exception stops at the loopback and at `.localhost` (RFC 6761) on purpose:
guessing `http` for a public host would silently downgrade a real site, which is
a worse mistake than the papercut it fixes. An explicit scheme always wins.

**`--verbose` is a human-mode flag.** The specification promised classification
evidence "on stderr in machine modes". It ships as a human-mode rendering switch
only, because the evidence it was meant to surface — the cache headers a verdict
rests on, the outcome of the second request, the hreflang maps — is already
carried by the findings themselves, in every mode, as `detail` lines. Printing it
a second time on stderr would have duplicated the report rather than added to it.
`--verbose` does not change the exit code, the JSON document or the `--plain`
records.

### 2.2 stdout / stderr contract

Rule of thumb: **stdout is the report, stderr is the running commentary.**
`headless-seo-check site > report.txt` must capture a complete, self-contained
report and nothing else; the terminal must still show progress and problems.

| Output | Stream | Modes |
|---|---|---|
| Final report (human, plain, or JSON) | stdout | all |
| Provenance line ("10 of 350 URLs checked, from …") and foreign-host rebase line | stdout — it is result metadata, so it is part of the report | human (two lines under the title, hidden by `-q`); plain (the `run` record, §2.5, always); JSON (the `discovery` object: `source`, `total`, `checked`, and `rebased` when set) |
| Help, version | stdout, exit 0 | — |
| Progress ("checking 37/350  12 issues so far") | stderr, TTY only (§2.9) | all unless `-q` |
| Scan-start line, then the selection line ("checking https://site …", "10 of 350 URLs selected, from …/sitemap.xml") | stderr | all unless `-q` |
| Run notes (gzipped sitemaps skipped, robots.txt unreadable, child sitemap failures) | stderr, prefixed `headless-seo-check: note: `, unless `-q` | **all three modes, including `--json`** — and the JSON document carries them in `notes` as well, for whoever only ever reads the file |
| Concurrency clamped, and the foreign-host rebase | stderr, unprefixed by `note: ` because neither is incidental — the user asked for something and got something else, or we audited a host other than the one the sitemap named | all unless `-q` |
| **Fatal — a run that started and could not finish** (no usable sitemap, host unreachable, every request failed at the transport layer) | **stdout**: the full report, in whichever mode was asked for. **stderr**: the one-line reason plus its detail lines. Exit 2. | all |
| **Usage error** (bad scheme, missing site, two positionals, unknown flag, conflicting flags, `--urls -` with nothing to read) | stderr only, exit 2. stdout gets **nothing** — except in `--json`, where the fatal envelope is still written so a consumer that always parses stdout always finds JSON there. | all |
| No arguments at all | stderr: concise help (usage line, one example, `--help` pointer), exit 2 | — |
| Interrupt notice | stderr — **not implemented**, see §2.8 | all |
| Internal errors / panics | stderr, with the issues URL (§2.10) | all |

**Revised during implementation — the fatal report is on stdout, not stderr.**
The specification originally routed the whole fatal block to stderr and listed
that as an intended break from v1 (§2.12). The binary does the opposite, and the
line it draws is not "fatal vs. non-fatal" but **"did a run happen at all"**:

- A **usage error** produced no run, so there is nothing to report and stdout
  stays empty. There is no site we were ever pointed at; a report would be a
  document about nothing.
- A **run that started and could not finish** did produce a result, and "nothing
  could be checked, and here is why" is the answer to the question the user
  asked. A result belongs on stdout with every other result — which is also the
  only way `--json` can keep its promise that stdout is always a parseable
  document, and the only way `--plain` can state `summary<TAB>exit_code<TAB>2`.

The reason the specification wanted stderr — that `tool > report.txt` on a dead
host must not leave the terminal blank — is met by writing the one-line reason
(and its detail lines) to stderr *as well*. The user sees the failure on screen;
the file still contains a complete report. `tests/network.rs` holds both halves.

All stderr lines emitted by this program are prefixed `headless-seo-check: `,
except the TTY progress line (erased before the report is printed) and the
continuation lines of the concise-usage block. One category is not prefixed at
all because this program does not write it: clap's own parse errors
(`error: unexpected argument '--nope' found`, its `tip:` suggestion and its
usage line) keep clap's wording — see §2.7.

### 2.3 Exit codes

| Code | Meaning | Triggers |
|---|---|---|
| `0` | Ran to completion, nothing blocking | No error-level findings. Warnings do not affect the exit code. `--allow-dynamic` therefore turns a dynamic-only failure into exit 0. |
| `1` | Ran to completion, at least one blocking issue | Any error-level finding. The complete list of error-level kinds is `dynamic-render` (unless `--allow-dynamic`), `noindex-header`, `indexability-conflict`, `hreflang-drift`, `hreflang-dead-target`, `empty-server-html`, `sitemap-url-error`, `unreachable` (a single page). Every other kind is `warn` or `info` and never affects the exit code. |
| `2` | The run could not happen or could not finish | Usage errors; no usable sitemap *and* homepage not HTML; every checked page failed at the transport layer; internal error. |
| `130` | Interrupted by SIGINT | §2.8. **Not implemented**: `report::exit_code` returns it when told the run was interrupted, but nothing ever tells it. |

These are part of the public contract and MUST be documented in `--help`
exactly as in §2.7. Never `std::process::exit()` while stdout may be unflushed:
write and flush the report, then set the code (the JS comment at lines
1368–1369 explains the truncation bug this prevents; Rust: flush explicitly,
`exit()` after flush is fine).

### 2.4 Output modes — human (default)

Keep the v1 layout: header line, provenance, one line per page
(`x` red / `!` yellow / `.` dim skipped / `+` green), indented findings, final
summary with per-code counts. Changes from v1:

- Notes move to stderr per §2.2. Fatals go to stdout *and* stderr — see the
  revision note in §2.2.
- Each finding line is tagged with its effective severity (`error` / `warn ` /
  `info `), so the level is readable without relying on colour.
- The verdict is a badge line: `PASSED  no blocking issue, 1 warning` or
  `FAILED  8 blocking issues, 1 warning`, followed by the per-code breakdown.
  It is the one line CI reads, and it survives `-q`.
- Below the verdict, a **What to do** block prints one short paragraph per
  blocking code present, keyed on the stable kind rather than on the message.
  It is grouped rather than repeated under each occurrence: on a 350-URL scan
  the same paragraph forty times is noise, once is advice. `-q` hides it.
- `--verbose` switches paths to full URLs, adds link counts and TTFB, prints the
  kind slug after each finding and unfolds the one-line redirect / non-HTML
  rows. It is human-mode only — see the note under §2.1.
- `-q` hides the provenance block, the guidance block, info-level findings and
  pages whose findings are info-level or none; the verdict still prints, so `-q`
  output for a clean site is exactly two lines (title + `PASSED  no blocking
  issue`).
- Human output is explicitly **not** a stable interface (clig: humans first,
  iterate freely). Scripts must use `--plain` or `--json`. Say so in the README,
  not in --help.

### 2.5 Output modes — `--plain`

Purpose: greppable, awk-able, diff-able. One record per line, tab-separated,
ASCII-only in structure, no header, no alignment padding, no truncation, no
colour ever — not even under `CLICOLOR_FORCE=1`. `-q` changes nothing.

**Revised during implementation — records are typed, not four bare columns.**
The specification called for exactly four untyped fields
(`url  level  code  message`). Two things were impossible under that shape:

1. `awk '$1=="finding"'` — with no type column, a consumer has to infer what a
   line is from how many fields it has and what they look like.
2. Stating the run's own verdict. There was nowhere for `--plain` to say how
   many blocking findings it counted, or what exit code the process would carry.
   The invariant this whole module exists to hold — *the three modes agree on
   the number of blocking findings* — had no expression in `--plain` at all, so
   nothing could test it there.

The shipped grammar puts the record type in the first column. Each type has a
**fixed and different** field count, which is itself the check a parser runs.

```text
run      SITE  SOURCE    TOTAL   CHECKED                                        5 fields
page     URL   OUTCOME   STATUS  RENDER  WORDS  LINKS  TTFB_MS  BLOCKING  TO   10 fields
finding  URL   SEVERITY  KIND    MESSAGE                                        5 fields
detail   URL   KIND      TEXT                                                   4 fields
summary  KEY   VALUE                                                            3 fields
```

Field meanings:

- **`run`** — emitted once, first. `SITE` is the origin actually audited;
  `SOURCE` is where the URL list came from (a sitemap URL, `--urls`, or
  `homepage (no sitemap found)`); `TOTAL` is how many URLs were discovered and
  `CHECKED` how many were selected.
- **`page`** — one per checked URL, in order. `OUTCOME` is one of `checked`,
  `redirect`, `http-error`, `not-html`, `unreachable`. `RENDER` is one of
  `static`, `isr`, `cached`, `dynamic`, `unclear`. `BLOCKING` is that page's
  count of error-level findings *after* `--allow-dynamic` has had its say. Any
  field with no value is a literal `-`, never an empty field.
- **`finding`** — one per finding, following its `page` record. `SEVERITY` is
  `info` | `warn` | `error`, and it is the **effective** severity, so
  `--allow-dynamic` shows `warn` here exactly as it does in `--json`. `KIND` is
  the same stable slug `--json` uses, never a translated message.
- **`detail`** — one per supporting line of the preceding finding. A detail line
  is its own record rather than a wrapped field, because a wrapped field is
  precisely what breaks `grep`.
- **`summary`** — the tail block, one key per line, 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`.

Tabs, newlines and carriage returns inside any value are replaced by single
spaces before the record is written, so a record can never split across lines.

Example (a fixture run, tabs shown as gaps):

```text
run	http://127.0.0.1:50018	http://127.0.0.1:50018/sitemap.xml	13	13
page	http://127.0.0.1:50018/ok	checked	200	isr	65	0	0	0	-
page	http://127.0.0.1:50018/shell	checked	200	isr	0	0	0	1	-
finding	http://127.0.0.1:50018/shell	error	empty-server-html	the served HTML carries 0 words — Googlebot has to run JavaScript to see this page
detail	http://127.0.0.1:50018/shell	empty-server-html	4 <script src> + an empty root container
finding	http://127.0.0.1:50018/shell	warn	no-internal-links	no links to any other page on this host — the navigation is not in the served HTML
page	http://127.0.0.1:50018/moved	redirect	301	-	-	-	0	0	http://127.0.0.1:50018/ok
summary	blocking	8
summary	warnings	1
summary	interrupted	0
summary	exit_code	1
```

A **fatal** stays inside the same grammar rather than inventing a sixth type: a
`run` record that discovered and checked nothing, a `summary<TAB>fatal<TAB>CODE`
naming the failure, the reason and its detail as `detail` records, then
`summary<TAB>blocking<TAB>0` and `summary<TAB>exit_code<TAB>2`. So
`grep '^summary'` works the same whether the run happened or not:

```text
run	https://dead.example	-	0	0
summary	fatal	unreachable
detail	https://dead.example	unreachable	cannot reach dead.example: domain does not resolve (ENOTFOUND)
summary	blocking	0
summary	exit_code	2
```

Guarantees:

- `awk -F'\t' '$1=="finding" && $3=="error"'` counts blocking findings, and that
  count equals `summary blocking`, equals `--json`'s `summary.blocking`, equals
  the number in the human verdict. `tests/output.rs` asserts all four at once.
- `awk -F'\t' '$1=="page" {print $2}'` lists checked URLs.
- The format changes additively only: new record types, new `summary` keys, new
  kinds, new severities. Existing fields are never reordered, renamed or
  removed, and a record type's field count is fixed for good.

**Revised during implementation.** The `--help` example was `--plain | grep -w
error`. It over-matched: `-` is not a word character, so `-w` also caught the
`http-error` outcome of a `page` record and the `sitemap-url-error` kind of an
`info` finding — measured at 3 lines where the true count was 1. The example now
selects on the type column instead, and the exact blocking count is
`awk -F'\t' '$1=="finding" && $3=="error"'`, which equals `summary⇥blocking`.

### 2.6 Color and formatting rules

Color applies only to the human report on stdout and to stderr messaging.
Color MUST be disabled when **any** of the following holds (clig's full list):

1. The target stream is not a TTY (evaluate stdout and stderr independently).
2. `NO_COLOR` is set to any non-empty value.
3. `TERM=dumb`.
4. `--no-color` was passed.
5. Mode is `--plain` or `--json` (these never contain escape sequences,
   TTY or not).

`anstream` (already in Cargo.toml) implements 1–3; wire 4 and 5 on top. No
program-specific `HEADLESS_SEO_CHECK_NO_COLOR` variable: the surface is small
enough that `NO_COLOR` + `--no-color` cover every case; adding one would be
surface without users. Palette stays v1: red errors, yellow warnings, green ok,
dim context, bold title. No emoji, ASCII symbols only (`x ! . +`).

### 2.7 Help text

`--help` MUST print exactly this to stdout (version number substituted), exit 0:

```
headless-seo-check <version>
What your headless site actually serves, checked from the outside.

Usage
  headless-seo-check <site> [options]

Examples
  headless-seo-check example.com                     sample 10 pages from the sitemap
  headless-seo-check https://staging.example.com -a  check every sitemap URL
  headless-seo-check localhost:3000 -u /,/pricing    check specific pages only
  headless-seo-check example.com --json > report.json
  headless-seo-check example.com --plain | awk -F'\t' '$1=="finding"'

Checks
  rendering mode   pages meant to be prerendered but served no-store/private
  indexability     X-Robots-Tag vs <meta robots> vs the canonical
  hreflang drift   Link: header alternates contradicting the HTML ones
  served content   real server HTML vs an empty client-side shell

Options
  -s, --sample <n>       pages to check, spread across the sitemap (default 10)
  -a, --all              check every URL in the sitemap
  -u, --urls <list>      check these paths or URLs instead of the sitemap
                         (comma-separated; "-" reads one URL per line from stdin)
      --timeout <ms>     per-request timeout in milliseconds (default 15000)
      --concurrency <n>  parallel requests, hard-capped at 6 (default 5)
      --allow-dynamic    per-request rendering is intended here: warn, do not fail
      --no-verify        skip the requests that verify hreflang targets
      --json             machine-readable JSON report on stdout
      --plain            tab-separated report, one finding per line
  -q, --quiet            suppress progress and pages without problems
      --verbose          show the evidence behind every classification
      --no-color         disable color (also: NO_COLOR, TERM=dumb, non-TTY)
  -h, --help             this text
  -V, --version          print the version and exit

Exit codes
  0    nothing blocking
  1    at least one blocking issue was found
  2    the run could not happen (bad arguments, no sitemap, host unreachable)
  130  interrupted; the report covers only the pages already checked

Every URL discovered in a sitemap is rebased onto the origin you asked for, so
pointing this at localhost never sends traffic to production.

Sitemaps are looked up in robots.txt, then /sitemap.xml, then /sitemap_index.xml.
A site with no sitemap at all is checked on its homepage only, and says so.

Bugs and feedback: https://github.com/bastienallain/headless-seo-check/issues
```

Notes for the implementer: examples lead (clig), common flags first, support
URL last, `--version` documented this time. If clap's generated help cannot
reproduce this layout, override with a hand-written template — the text above
is the contract, not clap's default rendering.

As shipped, `src/cli.rs` passes exactly this string to clap's `override_help`,
with the version substituted at compile time from `CARGO_PKG_VERSION`. `--help`
and `-h` print it to stdout and exit 0 with nothing on stderr; `--version`,
`-V` and the hidden `-v` print `headless-seo-check <semver>` the same way.

This contract covers the *help text only*. clap's own refusals keep clap's
wording and layout — `error: unexpected argument '--sampel' found`, the
`tip: a similar argument exists: '--sample'` line, and a usage line — printed to
stderr, unprefixed, exit 2. That is deliberate: the suggestion machinery and the
conflict messages are worth more than uniform phrasing. The refusals this
program raises itself (bad scheme, `--urls -` misuse) do carry the
`headless-seo-check: ` prefix and are followed by
`headless-seo-check: run 'headless-seo-check --help' for usage`.

Running with **no arguments** prints this concise form to stderr and exits 2:

```
headless-seo-check: missing the site to check

Usage:   headless-seo-check <site> [options]
Example: headless-seo-check example.com

Run 'headless-seo-check --help' for all options.
```

### 2.8 Signals and interruption

> **Not implemented in 0.1.0.** Everything below is design intent, not shipped
> behaviour, and it is the one section of Part 2 the binary does not satisfy.
>
> What exists: `report::exit_code(summary, interrupted)` returns `130` when
> `interrupted` is true, `Emit.interrupted` is threaded through all three
> renderers (the human report gains an `interrupted — N of M selected pages
> checked` line, `--json` gains `"interrupted": true`, `--plain` gains
> `summary<TAB>interrupted<TAB>1`), and `main.rs` writes stdout exactly once,
> from a buffer, at the end — so an interrupted run can only ever produce an
> empty stdout, never half a document.
>
> What does not exist: anything that observes a signal. `Cargo.toml` does not
> enable tokio's `signal` feature, so `main.rs` hard-codes `interrupted = false`
> with a comment saying why. Ctrl-C therefore takes the default disposition:
> the process dies on SIGINT, stdout is empty, stderr carries nothing extra, and
> the shell reports the usual 128+2 for a signal death rather than the program
> exiting 130 of its own accord. Observed, not inferred.
>
> `EXIT_INTERRUPTED = 130` is defined; the SIGTERM counterpart 143 is
> deliberately **not** defined, on the grounds that a constant no code path can
> reach is a promise the program does not keep. §2.12 is corrected accordingly.
>
> Implementing this means enabling `tokio` `signal`, racing the checks against
> `tokio::signal::ctrl_c()`, and setting `interrupted` from the winner. Nothing
> in the report layer needs to change.

Scenario: Ctrl-C in the middle of a 350-URL `--all` scan.

1. **First SIGINT**: immediately print to stderr
   `headless-seo-check: interrupted — writing report for the 212 pages already checked (Ctrl-C again to quit without one)`.
   Stop scheduling new requests; abort all in-flight requests (drop the reqwest
   futures / cancel the tokio tasks); render the report over completed pages
   only. The report MUST state its partiality: human mode gets
   `interrupted — 212 of 350 selected pages checked` in the header area; JSON
   gets `"interrupted": true` and a `discovery.checked` reflecting reality;
   plain mode gets `summary<TAB>interrupted<TAB>1` and is naturally partial
   (fewer records). Flush stdout. Exit **130**.
2. **Second SIGINT**: exit 130 immediately, no report, no cleanup. There is
   nothing to clean: the tool is crash-only (no temp files, no state, no
   lock files) and MUST stay crash-only.
3. The whole first-SIGINT sequence MUST complete in under ~1 s regardless of
   in-flight request timeouts — aborting must not wait for them.
4. SIGTERM: same behavior as first SIGINT (CI systems send SIGTERM on
   cancellation), but exit 143.
5. SIGPIPE / closed stdout (e.g. `| head`): exit quietly without a panic and
   without an error message. Rust ignores SIGPIPE by default and turns it into
   an `ErrorKind::BrokenPipe` write error — handle it, don't unwrap it.
   **This point, alone in §2.8, is implemented**: `write_stdout` discards the
   result of both `write_all` and `flush`, and `… --plain | head -3` exits
   without a panic or a stray message.

### 2.9 Progress and responsiveness

clig: something on screen within 100 ms, progress for anything long.

- Before any network I/O, print the scan-start line to stderr (unless `-q`):
  `headless-seo-check: checking https://example.com …` — then, once the
  sitemap is resolved, `headless-seo-check: 10 of 350 URLs selected, from https://example.com/sitemap.xml`.
- While pages are being checked and **stderr is a TTY**: a single in-place
  progress line (`checking 37/350  12 issues so far`), updated at most ~10
  times/second, erased (`\r`, clear-to-EOL) before the report is printed.
  Never more than one line; no spinners stacked per worker.
- When stderr is **not** a TTY (CI): no repeated progress lines — the
  scan-start line is the only pre-report output. This is the
  no-Christmas-tree-logs rule.
- `-q` suppresses all of it.

### 2.10 Errors and internal failures

- The transport-error humanization table (`CODE_MESSAGES`, lines 125–146) is a
  feature, not an implementation detail. Port it: map reqwest/hyper/rustls
  error chains to the same phrasings ("domain does not resolve", "TLS
  certificate has expired", "not a TLS port (try http:// instead of https://)",
  "timed out after 15000ms", …). Walk `Error::source()` chains the way
  `flattenCauses` walks `.cause`; never surface `error sending request` bare.
- Expected failures (unreachable page, HTTP 4xx/5xx, bad sitemap) are findings
  or fatals — never backtraces.
- Unexpected failures: install a panic hook that prints, to stderr:
  the panic message, `this is a bug in headless-seo-check, not in your site`,
  and `please report it: <issues URL>`. As shipped that URL is
  `https://github.com/bastienallain/headless-seo-check/issues` — the single
  `ISSUES_URL` constant the help text also uses, rather than the `/issues/new`
  variant this section first asked for, so the two can never drift apart.
  Note: the release profile sets `panic = "abort"`; the hook still runs before
  abort, but the exit code will be the SIGABRT one, not 2 — acceptable for a
  genuine bug. All *anticipated* error paths must be `Result`-based and exit 2.

### 2.11 Environment variables

| Variable | Behavior |
|---|---|
| `NO_COLOR` | Honored (any non-empty value disables color). |
| `TERM` | `dumb` disables color. |
| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | Honored — reqwest's default proxy resolution. Do not disable it. (The JS version silently ignored these; the Rust port must not.) |
| `DEBUG` | **Deliberately ignored** — decision D4. |
| `PAGER` | Deliberately ignored — decision D6 (no pager). |
| `EDITOR`, `TMPDIR`, `LINES`/`COLUMNS`, `.env` files | Ignored: the tool opens no editor, writes no files, and uses a fixed layout so identical runs diff identically. |
| Anything `HEADLESS_SEO_CHECK_*` | Reserved namespace. **The binary reads none of them**, and that is the property this row protects. Three are read by the npm gateway package's installer (`npm/`), never by the tool: `HEADLESS_SEO_CHECK_BINARY`, `HEADLESS_SEO_CHECK_SKIP_DOWNLOAD`, `HEADLESS_SEO_CHECK_DOWNLOAD_BASE` — they decide where the binary comes from, not what it does. Any future variable that reaches the tool itself must be additive and documented here first. |

No secrets are read from anywhere, and none may ever be accepted via flags or
environment.

### 2.12 Compatibility guarantees (future-proofing)

- Every v1.0.0 long flag keeps its name and meaning: `--sample`, `--all`,
  `--urls`, `--timeout`, `--concurrency`, `--allow-dynamic`, `--no-verify`,
  `--json`, `--help`, `--version`. Changes to the CLI surface are additive
  only; removing or repurposing a flag requires a deprecation cycle (warn on
  stderr for at least one minor version, with the migration spelled out).
- The `--json` schema is **not** v1's schema, and the specification was wrong to
  promise that it would be. The port renamed every key to `snake_case` and moved
  provenance into a nested object, which is a break that could be taken cleanly
  because the JavaScript package was never published — nobody was parsing the
  old document. The shipped envelope for a completed run is:

  `tool`, `version`, `checked_at_unix`, `interrupted`, `notes[]`, `fatal`
  (always present, `null` when the run happened), `site`,
  `discovery { source, total, checked, rebased? }`, `pages[]`, `summary { … }`,
  `warnings`, `exit_code`.

  A fatal envelope carries `tool`, `version`, `checked_at_unix`, `interrupted`,
  `notes[]`, `site`, `fatal { code, message, detail[]? }`, `exit_code` — and no
  `discovery`, `pages`, `summary` or `warnings`, because there are none. A
  consumer branches on `fatal`, which is why it is never omitted from the
  success envelope.

  Per-page keys are `url`, `outcome`, `status?`, `ttfb_ms?`, `render_mode?`,
  `words?`, `internal_links?`, `redirects_to?`, `findings[]`, each finding being
  `{ kind, severity, message, detail[]? }` with the **effective** severity.
  Optional keys are omitted rather than null, so "the body was never read" reads
  as a missing key, not as a zero.

  **This** schema is the one frozen from 0.1.0 onward: additive evolution only.
  `--json` and `--plain` are the stable interfaces; human output is explicitly
  mutable.
- Exit codes 0/1/2 are frozen and implemented. 130 is reserved for interruption
  and currently unreachable (§2.8). **143 is not defined at all** — the
  specification listed it, the implementation deliberately did not add it, on
  the grounds that a constant no code path can reach is a promise the program
  does not keep. It is reserved for SIGTERM if §2.8 is ever implemented.
- Behavioural changes to v1, for the changelog: run notes move to stderr (in
  every mode, `--json` included, while still appearing in the JSON `notes`
  array); the fatal report stays on stdout with its one-line reason echoed to
  stderr (§2.2 — this reverses what the specification originally asked for);
  `--version` prints `headless-seo-check <semver>` instead of the bare version;
  `--all` + `--sample` becomes a usage error instead of silently preferring
  `--all`; `-V`, `--plain`, `-q/--quiet`, `--verbose`, `--no-color`, `-s`, `-a`,
  `-u` and `--urls -` added; colour additionally disabled by `TERM=dumb`,
  `--no-color` and the machine modes; a bare host on the loopback assumes
  `http` rather than `https`; the JSON schema is rekeyed as described above.
