# The JSON report

> The shape of the document every other view is derived from: findings, the locale axis, diagnostics, and the fields worth reading first.

Everything goflag prints is a pure function of one object. The terminal view is a
rendering of it; `--summary` is a rollup of it; the baseline diff is two of them
compared. If something other than a person consumes the result, read this and
ignore the terminal.

```bash
goflag https://example.com --json > report.json
goflag https://example.com --report report.json   # also prints the terminal view
```

## Top level

{/* prettier-ignore */}
```jsonc
{
  "url": "https://example.com/",
  "finishedAt": "2026-08-04T11:22:33.041Z",
  // Which rule profile judged this run. It changes what "clean" means, so it
  // travels with the report rather than being remembered by whoever ran it.
  "profile": "default",
  "summary": {
    "brokenLinks": 3,
    "missingTranslations": 2,
    "seoIssues": 14,
    "siteIssues": 1,
    "unreachablePages": 0,
    "verdict": "red"
  },
  "localeAxis": {
    "locales": ["en", "fr", "de"],
    "source": "sitemap",
    "multilingual": true
  },
  // The collections are elided here for space; `pages` has one entry per
  // crawled document. Every `summary` count is the length of the collection it
  // names — except `brokenLinks`, which counts the *unique targets* whose
  // verdict is `broken`, while the `brokenLinks` array has one entry per
  // (page, target) pair and also carries `blocked` and `warning` targets.
  "pages": [
    {
      "url": "https://example.com/",
      "status": 200,
      "locale": null
    }
    /* … ×128 */
  ],
  "unreachablePages": [],
  "brokenLinks": [/* 3 entries */],
  "missingTranslations": {
    "holes": [/* 2 entries */],
    "reciprocity": []
  },
  "seoIssues": [/* 14 entries */],
  "siteIssues": [/* 1 entry */],
  "diagnostics": {
    "pagesCrawled": 128,
    "pagesScanned": 128,
    "pagesFailed": 0,
    "truncated": false,
    "warnings": [],
    // Always present: which pages the run chose to audit.
    "coverage": {
      "mode": "structural",
      "considered": 412,
      "selected": 128,
      "families": [{ "pattern": "/{locale}/blog/{2}", "size": 117, "sampled": 3 }]
    },
    // Present whenever sitemap discovery ran, so unless `--no-sitemap`.
    "sitemap": {
      "found": true,
      "sitemapUrl": "https://example.com/sitemap.xml",
      "urlCount": 412,
      "uncrawled": 0
    }
  }
}
```

`verdict` is `green`, `yellow` or `red`. `red` means at least one of: a finding
of `error` severity, a broken link, an unreachable page, a crawl that reached no
page at all, or a link scan that scanned none. The last four carry no severity —
a single timed-out page is enough to turn a run red, because a page the audit
meant to judge and could not is a hole in the report rather than a clean result.
`yellow` means findings exist but none of the above. `green` means nothing at
all.

## Every finding has an `id`

```json
{
  "id": "seo-1a2b3c4d5e",
  "pageUrl": "https://example.com/pricing",
  "ruleId": "og.image.missing",
  "severity": "warning",
  "message": "Page has no `og:image`; a shared link renders as a bare text row.",
  "why": "…",
  "fix": "export const metadata = { openGraph: { images: [\"/og.png\"] } }"
}
```

`id` is the **fingerprint**: `<category>-<10 hex characters>`, stable across runs
and across message rewordings. It is what [baseline gating](/docs/ci/baseline)
matches on, and what you should key on if you store findings anywhere yourself.
Do not parse it; treat it as opaque.

It survives an environment change for every page-attributed finding, whose page
URL is reduced to an origin-independent route. `brokenLinks` are the deliberate
exception: a link's identity includes _where it points_, so its id keeps the
target's origin, and a broken internal link fingerprints differently on
`http://localhost:3000` and on `https://example.com`.

`why` and `fix` are present when the rule offers them. `fix` assumes the Next.js
App Router and says so; the finding is still correct on any stack.

## The finding collections

| Field                             | What it holds                                                                                                                                                                                                                                                                  |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `brokenLinks`                     | One entry per (page, target) pair, with `status`, `verdict` and a human `reason`. A footer link on 500 pages is probed once but reported per referring page.                                                                                                                   |
| `unreachablePages`                | Pages the audit meant to judge and could not: crawled pages that answered non-2xx, plus pages that never answered at all (timeout, reset, DNS failure), which carry `status: 0` and have **no** entry in `pages` or in `pagesCrawled`. Any entry here makes the verdict `red`. |
| `missingTranslations.holes`       | `{ route, presentLocales, missingLocales }`: a route served in some locales and not others.                                                                                                                                                                                    |
| `missingTranslations.reciprocity` | hreflang findings: `missing-back-link`, `x-default-missing`, `locale.invalid`. Read out of the markup, so they are reported even when no locale axis was found.                                                                                                                |
| `seoIssues`                       | Per-page findings from the [page rule registry](/docs/rules).                                                                                                                                                                                                                  |
| `siteIssues`                      | Cross-page findings: same shape as `seoIssues`, but a statement about the whole site, so fixing one usually fixes a whole column.                                                                                                                                              |

## `localeAxis`

```json
{
  "locales": [],
  "source": "none",
  "multilingual": false,
  "candidates": [
    {
      "tag": "cv",
      "pages": 1,
      "isKnownLanguage": true,
      "htmlLangAgrees": false,
      "observedLangs": ["en"]
    }
  ]
}
```

`source` is `explicit` (you passed `--locales`), `sitemap`, or `none`. When it is
`none`, goflag did not guess an axis: `locales` is empty and no translation holes
are reported. hreflang reciprocity is read out of the markup itself and still
runs, so `missingTranslations.reciprocity` can be non-empty on such a report.
`candidates` lists the locale-looking prefixes it saw with the evidence for and
against each, as a suggestion for `--locales`. See
[Translations](/docs/i18n#where-the-locale-axis-comes-from).

## `diagnostics`

The field to read before you trust a count.

| Field                           | Why it matters                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pagesCrawled` / `pagesScanned` | Scanned is the subset whose links were extracted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `pagesFailed`                   | Pages whose HTML could not be re-fetched during the link scan, so their links were never extracted. Not the crawl's failures: a page the crawl could not fetch is in `unreachablePages` with `status: 0`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `truncated`                     | `true` when a page or link cap cut the crawl short. **A report with `truncated: true` is a report about part of your site** — but it is not the only such report: a sampled run leaves it `false`, so read `coverage` beside it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `warnings`                      | Non-fatal problems, in prose — and where a run says a check ran degraded. A page that failed once and answered on retry; pages judged on their static HTML because the browser could not start; a Node that cannot tell a real language tag from an invented one, so `locale.invalid` fell back to a shape check; URLs the sitemap declared in two different clusters; a crawl or a link scan that saw nothing; a sitemap that would not parse; no locale axis to work from. **Read this before trusting a count** — several of those say the check behind the count was not running at full strength.                                                                                                                                                                                    |
| `ignoredHoles`                  | Translation gaps suppressed by `--ignore-holes`. A suppression cannot hide how much it is hiding.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `duplicatePages`                | Pages excluded from the rule layer because they declare a canonical pointing at another crawled page (the site's own statement that they are duplicates). Reported so a shrinking finding count is explicable.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `unverifiedAlternates`          | Translations counted as present on the word of an `hreflang` alone: the target was not crawled, and your sitemap does not list it either. Each one may be hiding the gap it appears to fill, so **a `0 missing translations` next to a non-zero number here is worth a look**. goflag does not act on it — refusing to believe an unlisted alternate would invent holes on every site that uses `sitemap: false` on purpose. See [Translations](/docs/i18n#the-matrix).                                                                                                                                                                                                                                                                                                                   |
| `declaredClusters`              | `{ count, conflicts?, fromHead?, refused? }` — translation clusters the site declared, when it declared any: with `xhtml:link` in the sitemap, or with reciprocal `hreflang` between two crawled pages' `<head>`. A declared cluster decides which **rows** the translation matrix has, so a site that translates its slugs (`/en/pricing` ⇄ `/fr/tarifs`) stops reporting a hole per locale on a pair that is fully translated. It never fills a cell and never asserts a page exists. `fromHead` counts the clusters that exist only because the pages said so; `conflicts` counts URLs claimed for two different clusters, where the sitemap's declaration was kept; `refused` counts clusters goflag saw and declined to use because no `x-default` inside the cluster named the row. |
| `coverage`                      | `{ mode, considered?, selected?, families? }` — which pages the run chose to audit. `mode` is `structural` (the default whenever a sitemap is found) or `all`. Under `structural`, routes that stand alone are all audited and families built from one template are **sampled**: `considered` is how many URLs the sitemap listed, `selected` how many were audited, `families` names each sampled pattern with its `size` and how many were `sampled`. **`selected` far below `considered` is a report about a sample of your site.**                                                                                                                                                                                                                                                    |
| `sitemap`                       | `{ found, sitemapUrl, urlCount, uncrawled, unreachable? }`. `urlCount` is every URL the sitemap listed; `uncrawled` counts only the URLs this run _selected_ and still never reached, so read `coverage` for what it skipped on purpose. `unreachable` is present when the sitemap itself could not be fetched: the crawl lost its seeds and followed links instead, so the run is not comparable to a baseline and the CLI exits `2` rather than reporting.                                                                                                                                                                                                                                                                                                                              |

## `conformance` — asked for with `--conformance`

The finding collections answer "what is wrong here". They cannot answer "where
do we stand against the catalogue": a rule that passes on every page and a rule
that never applied to a single one both look like silence. `--conformance` adds
the missing view — every rule's status on every judged page.

{/* prettier-ignore */}
```jsonc
"conformance": {
  // Rule metadata is carried once, in a legend, rather than repeated in every
  // cell: a 200-page grid with it inline is 2,200 copies of the same fields.
  "rules": [
    {
      "ruleId": "canonical.absolute",
      "kind": "boolean",
      "title": "A canonical URL must be absolute",
      "rigor": "vendor-spec",
      "sources": ["ietf-rfc6596", "google-canonicalization", "whatwg-url"],
      "expected": "an absolute `http(s)://` canonical URL",
      // pass + fail + warn + na + crashed always equals pages.length.
      "totals": { "pass": 10, "fail": 1, "warn": 0, "na": 1, "crashed": 0 }
    }
    /* … one per rule the active profile left enabled */
  ],
  "pages": [
    // A cell is `pass`, `fail`, `warn` or `na`. `crashed` is a totals bucket and
    // never a cell: a rule whose evaluator threw on a page is *absent* from that
    // page's `statuses`, and the crash is reported as an `engine.rule-crashed`
    // issue instead.
    { "url": "https://example.com/", "statuses": { "canonical.absolute": "pass" } }
    /* … one per judged page */
  ]
}
```

`pages` covers the **judged** pages, which is fewer than `pages` at the top
level: unreachable pages, non-HTML resources, and pages the site itself declared
duplicate never reach the rule layer. Use `conformance.pages.length` as the
denominator — every rule's totals sum to it, `crashed` included, so a matrix
whose arithmetic does not close is a bug rather than a rounding.

A rule the active profile switched off is **absent** from the view rather than
present with five zeroes: "not run" and "never applied" are different claims,
and only the second one is `na`.

## `advisories` — asked for with `--advisories`

The judgement calls goflag refuses to fake. Whether a title _describes_ the page
is not a thing a linter can decide, so goflag states the question, cites what
makes it a real requirement, attaches the observed facts, and stops.

Two kinds arrive here, and they differ in one field. **Page questions** read a
single page, and their `evidence` keys are paths into the observation model — the
example below. **Site questions** read the whole site, so their evidence is a
comparison rather than a lookup, and the keys are named for what was compared:

{/* prettier-ignore */}
```jsonc
{
  "ruleId": "hreflang.sitemap-mismatch",
  "kind": "prose",
  "prose": "Your `<head>` advertises a translation your sitemap does not list. Is the sitemap missing an entry, or is the alternate pointing at a page you did not mean to publish?",
  // `null` where no document supports the question. Weight it on the evidence
  // alone — that is the honest answer, and the reason this stopped being a rule.
  "rigor": null,
  "sources": [],
  "evidence": {
    "route": "/solo",
    "headAdvertises": ["en", "es", "fr", "pt-br"],
    "sitemapLists": ["en"],
    "advertisedButUnlisted": ["es", "fr", "pt-br"]
  },
  "verdict": "needs-judgment"
}
```

Either kind holds **observations, never conclusions**. You are meant to be able
to disagree with goflag's reading, which you cannot do if it only ships its
reading.

{/* prettier-ignore */}
```jsonc
"advisories": [
  {
    "id": "advisory-a3f37dba70",       // fingerprinted like any other finding
    "pageUrl": "https://example.com/",
    "ruleId": "description.accurate",
    "kind": "prose",
    "prose": "Does the description accurately summarize this page's content, and is it written for this page rather than copied across the site?",
    "rigor": "guideline",
    "sources": ["google-snippet", "moz-meta-description"],
    // An absent observation is `null`, never a missing key: "this page has no
    // og:image" is itself evidence.
    "evidence": {
      "meta.description": { "value": "…", "origin": { "kind": "meta", "name": "description" } },
      "document.title": { "value": "…", "origin": { "kind": "title" } },
      "http.finalUrl": "https://example.com/"
    },
    "verdict": "needs-judgment"
  }
]
```

## `diff` — present when `--baseline` was given

Attached to the report as soon as a baseline file is read, so `--json` and
`--report` carry the comparison alongside the run itself. It is also inside the
file `--update-baseline` writes, which is worth knowing if you validate baselines
against a schema.

{/* prettier-ignore */}
```jsonc
"diff": {
  "baseline": { "url": "https://example.com/", "finishedAt": "…", "profile": "default" },
  // Present only when this run was judged under a different profile than the
  // baseline was captured under: reported loudly, gates nothing.
  "profileMismatch": { "baseline": "default", "current": "strict" },
  "added": [/* findings absent from the baseline — what fails CI */],
  "resolved": [/* baseline findings now gone */],
  "unchanged": 41
}
```

A `DiffEntry` is `{ id, kind, severity, summary, pageUrl? }`, where `kind` is
`brokenLink`, `unreachablePage`, `translationHole`, `reciprocity`, `seo` or
`site`. `severity` is the finding's own for rule findings and mapped for the
rest, to match the verdict: a broken link or an unreachable page is an `error`,
a translation gap a `warning`. `summary` is built for the diff view — for a rule
finding it is `"<ruleId> on <pageUrl>"`, not the finding's `message`.

`verdict` is `needs-judgment` and nothing else — there is no code path in goflag
that sets it otherwise. Advisories never touch `summary`, the verdict or the
exit code, because nobody has judged them yet. They are asked only where the
subject exists: no question about a description on a page that has none, since
`description.missing` already says that.

## `extractions` — asked for with `AuditOptions.extractions`

Every other section of this document is a _judgement_: a rule fired, or it did
not. This one is the observation the judgements were made from — what each page
declared in its `<head>`, as the rules read it.

It exists because a violations list cannot describe a page. A page that passes
every `og.*` rule still has a title, an image and a description that somebody
wants to look at, and none of them appears anywhere else in this file. That is
what [`goflag preview`](/docs/preview) draws, and it is the one section with no
CLI flag: the command sets the option, and a programmatic caller passes it to
`runAudit`.

```ts
const report = await runAudit("https://example.com", { extractions: true });
```

Each entry carries its own `http.finalUrl` — the same string `pages[].url` and
`seoIssues[].pageUrl` use — so nothing keys it.

{/* prettier-ignore */}
```jsonc
{
  "extractionVersion": 1,
  "fetchedAt": "2026-08-16T09:14:03.221Z",
  "http": { "requestedUrl": "…/fr", "finalUrl": "…/fr", "status": 200, "redirects": 0 },
  "rendering": { "mode": "static", "escalated": false },
  "document": { "title": { "value": "…", "origin": { "kind": "title" } } },
  "meta": { "description": { "value": "…", "origin": { "kind": "meta", "name": "description" } } },
  "openGraph": { "title": …, "images": [ { "url": …, "width": …, "alt": … } ], "localeAlternates": [] },
  "twitter": { "card": … },
  "links": { "hreflang": [], "icons": [], "feeds": [] },
  "jsonLd": [],
  "assets": { "https://cdn.example.com/og.png": { "status": 200, "ok": true, "sizes": [ … ] } },
  "hydration": { "titleChanged": false, "injectedMetas": [], "jsonLdBlocksAdded": 0 }
}
```

Three fields repay a careful read, because each is three-valued rather than two:

- **`assets`** absent means no probe pass ran; an empty object means one ran and
  found nothing to fetch. Inside an entry, `sizes` absent means the format was
  not decoded — goflag reads PNG and ICO headers — never that the file has no
  dimensions.
- **`links.manifest.parsed`** absent means the manifest was never looked at,
  `false` that it was fetched and could not be read, `true` that it was read.
- **`hydration`** absent means goflag has no two readings to compare, which is
  the case on any `--static` run — never that hydration changed nothing. When it
  is present, it lists the tags client JavaScript added to or took from the
  `<head>`: the values a browser shows and a crawler that runs no JavaScript
  never receives.

Scalar values are `{ value, origin }` — and `raw` too, when normalising changed
the string. `origin` names the tag the value came from, never the render pass.

The section covers fewer pages than `pages`, for the same reason `conformance`
does: only healthy HTML documents reach the rule layer.

All three optional sections are **omitted entirely** when not asked for, rather
than emitted empty. An empty `advisories: []` would read as "goflag looked and
had nothing to ask", which is a claim it has not made.

## Stability

goflag is `0.x`, and this shape can change in a minor version. Additions are
common; removals are called out in the [changelog](/changelog). Two guarantees
worth relying on in the meantime: the document is always fully
JSON-serialisable, and `id` stays stable for an unchanged finding.
