# @geonosis/walk

Through the front door: `geonosis walk` — the metapackage pins this and every other kit tool at ONE
version, and passes the exit code through unchanged.

The customer walk as a gate. Thirteen computed-DOM probes and an `axe` probe over a list of URLs,
one JSON report, and an exit code that tells a run that could not happen from a page that was clean.

It exists because a screenshot lies. A cream chip on a cream page photographs exactly like a chip
that is there; thirteen copies of one placeholder SVG photograph as thirteen icons; a full product
page for a handle no backend has photographs as a product. The DOM does not lie, so every probe here
reads computed styles, box geometry, asset signatures, rendered text and HTTP statuses — never
pixels.

```bash
npm i -D @geonosis/walk playwright
npx geonosis-walk https://example.test/ https://example.test/products/x --report walk.json
```

```
0  the walk ran and found nothing
1  the walk ran and found something
2  the walk did not run — no browser, a page that could not be opened, a probe with no list to look for
```

Two is why there are three. A tool that answered "no findings" for a browser it could not launch
would pass hardest exactly when it was broken.

**A page that answers is walked, whatever its status.** A 404 is a page a customer sees: every probe
runs on it with the status passed through, so `error-page-status`'s `honestFrom` takes effect (a
status at or above it is honest and the probe skips the page). What the status ought to be is
declared per URL, and a page that answers anything else is a finding, not a crash:

| the walk list says | the page answers | finding |
|---|---|---|
| nothing | 200 | none |
| nothing | 400 or worse | `link-integrity`, blocking: a broken link in the walk list |
| `{ "url": …, "status": 404 }` | 404 | none: the healthy answer of a deliberate not-found page |
| `{ "url": …, "status": 404 }` | 200 | `error-page-status`, blocking: a soft 404, which every health check reads as healthy |
| `{ "url": …, "status": 200 }` | 404 | `link-integrity`, blocking |

Exit 2 stays for a page that answered nothing or could not be opened. It stops the walk, and the
pages walked before it are not thrown away: they are written beside the report as
`<report>.partial.json` (`stoppedAt` names the URL), and the report itself, the one a ratchet
counter reads, is left absent.

## The probes

| id | What it reads | Needs |
|---|---|---|
| `light-on-light` | An element with an opaque background of its own, no background image, no border, no shadow, no outline, whose box does not simply repaint the one behind it — and whose WCAG contrast against the painted ancestor background is under the floor. | nothing (`minContrast` defaults to 1.05) |
| `placeholder-asset` | Asset signatures: an image's resolved `src`, an SVG's own shape geometry. Fires on a signature used in more slots than allowed, and on any signature or `alt`/`aria-label`/`title` matching the placeholder list. | nothing (`maxRepeats` defaults to 2) |
| `link-integrity` | `href` absent, empty, `#` or `javascript:` — and, for same-origin links, a HEAD (GET on 405/501) that answers 400 or worse. | nothing |
| `fabricated-claim` | The page's rendered text, against a list of claim patterns. Not the markup: a claim in a comment is a claim nobody was shown. | **`claims`** |
| `stub-only-entity` | An element naming an entity, offering a way to buy it, whose id is in no authoritative set. | **`known`** |
| `fake-session` | Anything that means "signed in", on a page loaded with no cookies and no storage. | **`selectors` or `text`** |
| `ops-leakage` | The rendered text of a scope, against patterns that name internal choices. Scope is what separates a leak from a footnote. | **`patterns`** |
| `buy-box-above-fold` | `getBoundingClientRect()` at the walk's viewport: the buy box below the element it must precede, or below the fold. | **`selector`** |
| `error-page-status` | A page whose rendered text matches an error-surface pattern, served with a healthy status. Every curl-style health check, uptime monitor and load balancer reads such a page as fine. | **`patterns`** |
| `thin-page` | The words a reader is actually shown in a scope, counted after layout, against a floor. A page with a heading, a layout and a 200 that answers nothing reads as fine to every other gate. | **`minWords`** |
| `empty-section` | A container carrying a heading and no body text and no media — a promise with nothing under it. | **`selector`** |
| `money-format` | Money-shaped strings in the page's text nodes, against the shapes this repo accepts. `£24` beside `£24.00` is two answers to what a price looks like here. | **`accept`** |
| `orphan-url` | Which walked URLs nothing in the walk links to. The one probe with a report-level half: `run` records each page's links, `finish` names the pages nothing pointed at. | nothing (`entry` lists the deliberately unlinked) |
| `axe` | Deque's axe rules, run through `@axe-core/playwright` on the page as rendered. One finding per violated rule, at the rule's impact. | **`@axe-core/playwright`**, an optional peer |

The ten in bold **refuse the run** when the option or the peer is missing, naming it (D-031). None
of the nine options can have a default that is true of a repo which has not configured it — what a
company can stand behind, which entities exist, what its signed-in header looks like, which words
mean "operations", where its buy box is, what its error surface says, how much a page owes a reader,
which containers are sections, what a price looks like — and a probe that ran with an empty list
would report a clean page for every repo that never set one up.

`axe` files `critical` as a `blocking` and `serious` as a `major` finding of class
`accessibility-error`, and `moderate` and `minor` as `minor` findings of class
`accessibility-warning`, so a ratchet counter can count the errors without the warnings. With the
peer missing, the walk exits 2 naming the install command, `npm i -D @axe-core/playwright`, and does
not skip the probe in silence. A walk that names no probes does not run it.

Four of the others are the `content-integrity` class: a page that renders correctly and says nothing.
Four defects of one audit were filed under `fabricated-claim`'s forbidden-string engine for want of
it, which is one class borrowing another's mechanism.

## Configuration

Everything lives in the `walk` block of `geonosis.json`; the command line wins where they overlap.

```json
{
  "walk": {
    "urls": [
      "http://localhost:3000/",
      "http://localhost:3000/products/kraft-mailer-box",
      { "url": "http://localhost:3000/does-not-exist/", "status": 404 },
      {
        "url": "http://localhost:3000/designer/",
        "scrollTo": "#demo",
        "waitFor": "#demo .designer__layout"
      }
    ],
    "versionKey": "release-check",
    "viewport": { "width": 1440, "height": 900 },
    "report": ".geonosis/walk-report.json",
    "probes": ["light-on-light", "placeholder-asset", "link-integrity", "fabricated-claim"],
    "options": {
      "fabricated-claim": { "claims": ["\\d\\.\\d/5", "\\d[\\d,]*\\+ brands", "\\d+M\\+"] },
      "ops-leakage": { "scope": "[data-testid=delivery]", "patterns": ["Consolidated Sea"] },
      "buy-box-above-fold": { "selector": "[data-testid=buy-box]", "above": "[data-testid=preview]" }
    }
  }
}
```

```
geonosis-walk [url…] [--report <path>] [--config <path>] [--viewport WIDTHxHEIGHT] [--probe <id>]… [--version-key <key>] [--no-gpu]
```

A URL entry takes `scrollTo` and `waitFor`, two CSS selectors, for a page that mounts its content
when a section scrolls into view. The walk scrolls `scrollTo` into view, then waits for `waitFor` to
be in the page, before any probe reads it; a page that waits for an IntersectionObserver is
otherwise read unmounted and reported clean. A selector that never appears within ten seconds stops
the walk at that page and exits 2, naming the selector and the URL: a walk that cannot measure has
not passed.

`versionKey` (or `--version-key`) sends `Cloudflare-Workers-Version-Key: <key>` with every request
to the origin of each walked page, including the HEAD requests `link-integrity` makes, and to no
other origin. During a gradual deployment on Workers, Cloudflare routes each request to a version by
chance unless the key pins the client, so a walk without one can read HTML from one version and its
assets from the other
([Version affinity](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/version-affinity/)).

The walk runs Playwright's full Chromium (`channel: 'chromium'`, installed by `npx playwright install
chromium`), not its headless shell: the shell draws WebGL in SwiftShader and grants a context that
asks `failIfMajorPerformanceCaveat` even with no graphics processor, which Chrome refuses. `"gpu":
false` (or `--no-gpu`) walks as a visitor with no graphics processor, so a page that offers a
fallback for that refusal is walked on its fallback.

With no `probes` the walk runs the four that need no list. A mobile pass is the same command with
`--viewport 390x844`.

## The report

```json
{
  "startedAt": "…", "finishedAt": "…",
  "viewport": { "width": 390, "height": 844 },
  "gpu": true,
  "webgl": { "available": true, "caveat": "granted", "renderer": "ANGLE (…)", "vendor": "Google Inc. (…)" },
  "probes": ["buy-box-above-fold"],
  "counts": { "buy-box-above-fold": 1 },
  "pages": [{ "url": "…", "status": 200, "findings": [
    { "class": "buy-box-above-fold", "severity": "major", "url": "…",
      "selector": "[data-testid=\"buy-box\"]",
      "evidence": "the buy box starts at 900px, below the fold at 844px" }
  ]}]
}
```

`gpu` is whether the walk launched with a graphics processor, and `webgl` is what the browser
answered for WebGL: `caveat` is `granted` or `refused` for a context asking
`failIfMajorPerformanceCaveat`, `renderer` and `vendor` come from the `WEBGL_debug_renderer_info`
extension (`null` when the browser does not expose it), and `{ "available": false }` means no WebGL
context at all. Two reports of one page are comparable only when these agree.

`counts` is keyed by the classes that RAN, so a zero means "probed for, found none" and an absent
class was never looked for. The summary line the walk prints says the same: `<ran> of <shipped> probe(s) ran:`,
each probe that ran by its id, with its classes beside it where they are not the id
(`axe (accessibility-error, accessibility-warning)`), then the probes it never looked for. `@geonosis/ratchet`'s `walkFindings` counter reads this file and refuses
when it is missing or unparsable, which is the same rule as the exit code: a gate that cannot
measure has not passed.

## Driving your own page

`walk()` gives every URL a fresh, storage-free context — that is what makes `fake-session` mean
anything. A surface behind a flow (a checkout with a cart in it) is not that shape, so drive your
own page and call the probe:

```ts
import { probeById } from '@geonosis/walk'

const response = await page.goto(url)

const findings = await probeById('ops-leakage').run(page, {
  memo: new Map(),
  options: { patterns: ['Maersk Ocean'], scope: '[data-testid=delivery]' },
  status: response?.status() ?? 0,
  url: page.url(),
  viewport: { width: 1440, height: 900 },
})
```

`status` is the page's own response status, not a second request: `error-page-status` asks whether
the status agrees with the body, and asking the server again asks a different question.

## What this is not

A green report is not a clean surface. Twelve of the thirteen computed-DOM probes came from hand
audits of one storefront, the largest holding 36 defects; six are reproduced by a probe alone,
sixteen partially, and fourteen — five product tables that disagree, a cart that re-prices what the
product page quoted, copy promising a control nobody built, four levels of taxonomy for fourteen
products — are invisible to any browser probe there could be. Those stay the walker's judgement. The
thirteenth came from a production incident instead, which is how the list is meant to grow. The
reasoning, defect by defect, is in
[`docs/walk-review-inventory-2026-08-30.md`](../../docs/walk-review-inventory-2026-08-30.md).
