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.
goflag https://example.com --json > report.json
goflag https://example.com --report report.json # also prints the terminal viewTop level
{
"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
{
"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
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. |
siteIssues | Cross-page findings: same shape as seoIssues, but a statement about the whole site, so fixing one usually fixes a whole column. |
localeAxis
{
"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.
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. |
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.
"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:
{
"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.
"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.
"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 draws, and it is the one section with no
CLI flag: the command sets the option, and a programmatic caller passes it to
runAudit.
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.
{
"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:
assetsabsent means no probe pass ran; an empty object means one ran and found nothing to fetch. Inside an entry,sizesabsent means the format was not decoded — goflag reads PNG and ICO headers — never that the file has no dimensions.links.manifest.parsedabsent means the manifest was never looked at,falsethat it was fetched and could not be read,truethat it was read.hydrationabsent means goflag has no two readings to compare, which is the case on any--staticrun — 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. Two guarantees
worth relying on in the meantime: the document is always fully
JSON-serialisable, and id stays stable for an unchanged finding.