Skip to content
AP.
04In development

PageScope

Audits one web page's technical and SEO correctness without a browser, and returns findings that carry the evidence they were derived from: a source snippet with line and column, a response header, a measurement, or an explicit statement of what was searched for and not found.

Scale / counted from the repository

rules across four categories
13
test files, none touching the network
19
tests, asserted against a fixed rule count
182
packages: a core library and a CLI
2
redirects followed, each re-validated
5
response cap that destroys the socket
5 MiB
default deadline for the whole request
10s
report formats: summary, JSON, Markdown, HTML
4
PageScope's report screen on a laptop: a URL field, a Run Audit button, findings split into issues and passed checks, and a score dial.
Role
Author
Period
2026
Type
Developer tooling · CLI and library
Stack
TypeScript, Node 22, parse5, undici, zod, pnpm workspaces

01

Overview

Most audit tools fetch a page with a browser and hand back a score. PageScope fetches with HTTP, parses what the server actually sent, runs 13 rules and returns a finding only where it has something to point at. The report also states what it did not look at: no JavaScript, no crawling, no WCAG conformance claim, no exploitability claim. Those omissions are the design, because an audit that guesses is worse than one that says which questions it could not answer.

02

The challenge

An automated audit has one failure mode that matters: asserting something it did not observe. A missing title reported alongside a title tag that was never there. A page that needs JavaScript scored as though its body were empty. A missing security header rewritten as an exploit. Every one of those reads as a result when it is not one, and a reader has no way to tell which findings came from a measurement and which came from a default.

03

My contribution

  • Designed the contracts: a snapshot in, a result out, so a rule cannot issue a second request or invent evidence.
  • Wrote all 13 rules across SEO, technical, accessibility and security, each a pure function of one snapshot.
  • Built the engine: statuses, coverage, and limitations derived from coverage rather than written by hand.
  • Wrote the CLI, the four reporters and the baseline mode that lets a CI gate ignore findings already known.
  • Wrote the SSRF guard: address classification inside the DNS lookup callback, IP literals checked separately, every redirect hop re-validated.

04

Product surfaces

  • CLI

    pagescope

    Who uses itA developer on a terminal, and a CI job

    Built withcommander, tsup build

    Runs onLocal process, 11.0 kB

  • Core library

    @pagescope/core

    Who uses itAnything that wants the engine without the CLI

    Built withparse5, undici, zod

    Runs onLocal process or another tool

  • CI gate

    pagescope --fail-on

    Who uses itA build that should stop on new findings

    Built withExit codes plus baseline files

    Runs onGitHub Actions, or any runner

05

Inside the product

  1. 01

    A status for every state, including the honest ones

    passed · failed · needs-review · not-applicable · unobservable · error

    A rule that did not run is never reported as passed, and a rule's return type cannot even express unobservable or error, so the engine owns those two. Unobservable stays separate from not-applicable because finding no images on a page you could read and failing to see the body at all are different claims, and reporting both the same way told the reader nothing.

  2. 02

    Evidence is a type, not a sentence

    html-snippet · http-header · http-response · measurement · absence

    Every finding carries at least one piece of evidence from a discriminated union. Absence exists so that a search which found nothing can be stated as itself: a missing title never arrives next to a title tag that was never served.

  3. 03

    The limitations are derived, not written

    engine/limits.ts, limitationsFor(coverage)

    Each report states what it deliberately did not inspect. Those sentences are generated from the coverage block, so removing an item when the feature ships removes the sentence from every report without anyone remembering to edit it.

  4. 04

    The rule list cannot change quietly

    packages/core/test/engine/audit.test.ts

    A test asserts the rule count, so registering a rule fails the suite until the number is updated. That friction is the point: a change to the public rule list becomes visible in review instead of appearing in a release note.

  5. 05

    A gate that only fails on what is new

    createBaseline · applyBaseline · --min-confidence

    A gate that fails on findings you already know about gets switched off within a week. A baseline records the current state, and identity is a hash of the rule id plus a semantic discriminator rather than a line number, so inserting a paragraph above a finding does not make the baseline forget it.

  6. 06

    Fetching a URL someone else chose is the risk

    packages/core/test/collection/ip-guard.test.ts

    Every resolved address is classified before a socket opens: loopback, private ranges, link-local including the cloud metadata address, CGNAT, multicast and reserved space. Classification runs inside the DNS lookup callback, so the address validated is the address connected to, and IP literals take the same check because Node skips DNS when the host is already an address.

06

Technical approach

Separate collection from evaluation

Fetching performs exactly one network operation and returns either a snapshot or an error. Rules receive the snapshot and nothing else. That is what makes every rule testable from an HTML string with no server, and it is why a rule cannot quietly issue an extra request.

No test touches the network: 182 tests and not one of them fetches.

Make the wrong claim impossible to express

Severity and confidence are orthogonal, so a missing header can be reported at high confidence without the report implying an attacker could use it. A needs-review finding says PageScope cannot tell which case applies, and a gate counts only failed findings, because ambiguity is not an assertion that something is broken.

The result contract in packages/core/src/contracts

Treat the tool as an SSRF proxy from the start

Anything that fetches a user-supplied URL is a potential proxy into a private network and a potential resource sink. Both got a control and a test each, and the boundaries are documented as boundaries rather than left as a backlog.

docs/security/threat-model.md: ten threats, ten controls

Built with

Language
TypeScriptNode 22+ESM only
Parsing and fetching
parse5undici
Contracts
zod 4JSON report schemaSCHEMA_VERSION 1.1.0
Build and test
tsupVitestpnpm workspaces
CLI
commander4 report formats

07

Where it stands

  • 01

    Not published to npm

    Both packages are version 0.1.0 and neither is on the registry, so npx pagescope does not work yet. Running it means cloning and building. The release checklist names two hygiene items rather than smoothing them over, one of them being that the v0.1.0 tag predates the version alignment.

  • 02

    No JavaScript execution

    Only the raw HTML response is inspected. A browser would make every run slower and unusable on a bare CI runner. The two rules that need the rendered DOM report unobservable on pages it detects as client-rendered, instead of a false pass.

  • 03

    One page per run

    Links are not crawled. Crawling changes the security model and the promise of the tool, so it stays a separate question rather than a flag.

  • 04

    Detection is deliberately narrow

    A page is called client-rendered only when there is a recognised mount element with no children, or an explicit enable-JavaScript message. A missed detection costs a rule its unobservable status; a guessed detection would cost the tool its credibility.

  • 05

    No score, and no hosted service

    One number hides which findings are guesses, so there is no score. There is also no public endpoint, by design. Anyone exposing an instance owns the authentication, rate limiting and egress isolation it does not provide.

08

Outcome

It runs as a CLI, as a library, and as an opt-in gate that fails a build only on findings it has not seen before. The two packages are still pre-release, so this is a working tool with an unpublishable install line rather than a shipped one. What it will not do is the part worth reading: it will not score a page, will not guess at a body it could not see, and will not turn an absent header into a claim about what an attacker can do with it.

Next project

ONEUX →