Architecture
siteready/
├── src/
│ ├── cli.js # entry point: scan / enhance / rescan / diff-report / scan-local / rescan-local / loop
│ ├── scan.js # runs configured scanner adapters -> normalized report (shared by scan & rescan)
│ ├── report.js # normalized report -> report.md / report.json
│ ├── diff-report.js # baseline vs re-scan -> diff-report.md / diff-report.json
│ ├── detect-stack.js # framework/host fingerprinting from the LOCAL repo (package.json, config files)
│ ├── enhance.js # detects stack, applies the matching fixer + platform module
│ ├── pr.js # opt-in enhance --pr flow (branch, commit, push, gh pr create)
│ ├── loop.js # local scan orchestration (scan-local / rescan-local / loop)
│ ├── skill-install.js # install-skill orchestration: resolves package root, dispatches to installers/
│ ├── scanners/ # pluggable scanner adapters — export run*Scan(url, options) -> { normalized, raw }
│ ├── fixers/ # pluggable, framework-scoped remediation
│ ├── platforms/ # deployment-target adapters (negotiation/headers)
│ ├── installers/ # pluggable per-agent SKILL.md installers (claude, agents-skill for codex/opencode)
│ └── lib/
│ ├── npx-runner.js # cross-platform npx invocation (see Cross-platform notes below)
│ └── local-server.js # build + serve a repo locally for `scan-local`/`rescan-local` (no live deployment)
└── fixtures/
├── astro-starlight-cf-pages/ # reference fixture the Astro+Starlight fixer is verified against
├── astro-cf-pages/ # reference fixture the plain-Astro fixer is verified against
└── nextjs-vercel/ # reference fixture the Next.js+Vercel fixer is verified against
Every scanner adapter and every fixer is independently pluggable — a scanner going offline or a framework having no fixer yet degrades to “unsupported,” never breaks the pipeline.
Design notes
- Agent skill installers (
install-skill,src/skill-install.js+src/installers/). The package shipsSKILL.mdat its root either way;install-skilljust copies it to wherever a given agent tool discovers skills from — no separate content to maintain per agent. Claude Code gets a byte-for-byte copy at.claude/skills/siteready/SKILL.md, since it tells the agent its own skill’s base directory at load time andSKILL.md’s<skill-dir>placeholder is written for the agent to resolve that way. Codex CLI and OpenCode both discover skills at the same.agents/skills/siteready/SKILL.mdpath but don’t document an equivalent runtime signal, so their shared installer (installers/agents-skill.js) bakes<skill-dir>into a real absolute path at install time instead — installing one ofcodex/opencodeinstalls the other for free. Cursor, Windsurf, and Aider have no comparable skill-discovery directory (flat single rules files, no per-tool namespace) and aren’t wired up yet — seeCONTRIBUTING.mdfor the installer contract to add one. - Site-type filtering (
--site-type content|api|application|auto, defaultauto/unfiltered).is-agentic/Ora score a site against ~184 checks spanning discovery, access, usability, and payments — a chunk of the usability/payments checks (openapi-spec,oauth-support, the whole Payments layer, etc.) only make sense if the site exposes a public API, and drag down a pure content/docs site’s grade for something it was never going to have.--site-type contentexcludes those from scoring;api/applicationcurrently score everything, same asauto— the ask this solved (seeISSUES.md’sis-agenticvolatility entry) was keeping API checks off a content site’s grade, not the reverse.src/site-types.jsholds the id→applicability map, sourced from Ora’s live/api/checkscatalog — deliberately a conservative subset (Payments layer + the API-transport checks the volatility report named), not a full classification of every check, since most of the rest (MCP, GraphQL, accessibility, discovery) are either broadly applicable or need product judgment this tool has no authority to guess. Score is only recomputed net of the excluded checks for scanners that report per-check point weights (afdocs, Ora);is-agenticnever exposes those (itschecks[]only lists non-passing issues with no per-check weight), so its own score is left as reported — excluded checks are still listed under “Not applicable for this site type” for visibility, with a note that the score above isn’t adjusted.report.jsonrecords whichsiteTypewas used;rescandefaults to the baseline’s own site type, anddiff-report/rescanwarn if baseline and re-scan end up on different types instead of silently misreading the delta. - The normalized schema (
{ target, generatedAt, scanners: { <name>: {...} } }) holds multiple scanners side by side without a rewrite — adding a new scanner is a new entry underscanners, no changes to the ones already there. scanners/afdocs.js,scanners/is-agentic.js, andscanners/ora.jsare all instances of the adapter contract every future scanner adapter should follow: export arun*Scan(url, options)that returns{ normalized, raw }.is-agentic’sissues[]only lists non-passing checks (afdocs and Ora list every check they ran) — each adapter reconciles that into the samechecks[]/summaryshape soreport.jsneeds no scanner-specific branching.- Both scanner CLIs are invoked at a pinned version (
afdocs@0.20.0,is-agentic@1.0.1), not a bare package name — an unpinnednpxcall always fetches whatever’s newest, and either CLI is young enough that a breaking JSON-schema change upstream could silently break every scan. Bump deliberately, re-verify the adapter’snormalize()against the new output. - Staying current without floating the pin. Agent-readiness scanning is a new-enough category
that these engines update often, so two things soften the pin above without weakening it:
npm run check-scanner-versionsdiffs the pinned versions against npm’s latest and tells you when a bump is due (doesn’t change anything itself);AFDOCS_VERSION=x.y.z/IS_AGENTIC_VERSION=x.y.zenv vars override the version for one run, so you can try a newer release ahead of a deliberate bump without editing source..github/workflows/scanner-version-check.ymlruns the check weekly and files/updates ascanner-version-drift-labeled issue when a pin falls behind — it never bumps the pin itself, same “re-verifynormalize()first” rule applies.ora.jsneeds neither — it’s a direct API call with no version to pin, so it’s always on Ora’s latest engine automatically. - Every scanner adapter must invoke its CLI through
src/lib/npx-runner.js, not its ownchild_processcall — it resolves npm’snpx-cli.jsrelative to the running Node binary instead of trusting a PATH-resolvednpx(which can point at an entirely different Node/npm install), and it treats a scanned site’s own failing checks (which make the scanner CLI exit non-zero) as the expected case, not a tool failure. scanners/ora.jsis the one exception to the npx-runner rule above: Ora (https://ora.ai/, the engine Vercel’s Is Agentic wraps with a simplifiedinclude=essentialssubset) has no CLI, only a public, keyless-for-reads API, so the adapter callsfetch()directly againstPOST https://ora.ai/api/scan?format=audit. Rate-limited per IP — 10 scans/minute burst, 30 per rolling 24h, of which 6 may be force (cache-bypassing) scans (ora.ai/docs). Cache hits don’t consume quota, so the adapter defaultsforce: falseand repeated scans of the same URL lean on Ora’s own 6-hour freshness window instead of burning quota; a burst of distinct URLs is what actually exhausts it. Over the limit, Ora returns HTTP 429 with aRetry-Afterheader — the adapter turns that into an error naming the quotas and the wait, so it reads as “come back later,” not “the scan is broken.”orais opt-in, not inDEFAULT_SCANNERS.is-agentic’s score is computed from the same Ora API withinclude=essentials— it’s a strict subset ofora’s full ranker, not an independent measurement. Until a fixer targets someora-specific check (ARD catalog, A2A agent card, etc. — none of which the current fixers touch), running both by default would just double the hosted-API cost for overlapping data. Pass--scanners oraexplicitly to use it. If a fixer forora-specific checks ever ships, revisit retiringis-agentic.jsin favor ofora.jsrequestinginclude=essentialsin the same call.detect-stack.js+enhance.js+fixers/*.js+platforms/*.jssplit cleanly along a framework/platform axis: a fixer is framework-only (llms.txt,.mdmirrors, a body-level directive), a platform module is platform-only (content-negotiation headers/Functions), andenhance.jsjust detects the pair and calls both. Adding a second framework or platform later is additive — new files, no changes to the pair already shipped.scan.jsis the one place bothscanandrescancall into —rescanisn’t a separate scanning implementation, just the samescanTarget()plus a diff against a baseline.diff-report.jsdiffs two normalized reports check-by-check (fixed/regressed/stillFailing/newChecks/removedChecks), not just score-by-score, sodiff-report.mdreads as a real before/after — which checks got fixed, which regressed, what’s still backlog.lib/local-server.jsbuilds a repo (npm install+npm run build) and serves the output locally —wrangler pages devfor Cloudflare Pages (via a long-running process spawned throughlib/npx-runner.js), ornpm run start(next start) for Vercel/Next.js, which is also what runs Proxy/Middleware locally (a static export doesn’t) — soscan-local/rescan-localcan scan a fixer’s target with no live deployment. Windows needstaskkill /tto kill the whole process tree (child.kill()alone leaves the child process running); POSIX uses a detached process group +process.kill(-pid). Every other platform (Netlify, GitLab Pages, and any future static host) falls back to a generic in-processnode:httpstatic file server over the build output — no bespoke CLI, no npx download, no account, since serving a static directory needs no platform-specific tooling.runScanLocal’s only hard exclusion is Jekyll, by framework rather than platform: its build is Ruby/bundler, notnpm run build, soensureInstalled/buildSitedon’t apply regardless of which Pages host it targets — every other platform valuedetect-stack.jscan return rides one of the two paths above automatically, with no allowlist to keep extending. The generic static-file fallback has a real ceiling: it serves file content only, so a platform’s edge runtime (Netlify’s Edge Functions, for its markdown-negotiation fix) never actually runs — a local scan reads that check as failing even afterenhance, and only a public scan against the real deployed URL shows it fixed.loop.js’slocalScanCaveatFornames this whenever the detected platform isn’t inlocal-server.js’sPLATFORMS_WITH_EDGE_RUNTIME(Cloudflare Pages, Vercel), surfaced in CLI output and asreport.localScanNote— see ISSUES.md.lib/tunnel.jsextends that to the hosted scanners.afdocsfetches the scanned URL from this machine, solocalhostis fine for it;is-agenticandorarun their own crawler on someone else’s infrastructure and can never reachlocalhost. Requesting either fromscan-local/rescan-local/loopopens an ephemeral Cloudflare Quick Tunnel (cloudflared tunnel --url, no account or signup) to the local preview server, so those commands work against every scanner with no deployment.loopopens two, back-to-back in one process (baseline scan, then re-scan) — reliable since the precheck fix below, which is what letloopcome back after being removed over a (disproven) theory that two tunnels that close together were unsafe. The site is briefly reachable by anyone holding the random URL and is torn down right after the scan — same risk class as a preview deployment, shorter-lived. Quick Tunnels are anonymous and best-effort; any failure (cloudflaredexiting, or the hostname not becoming reachable in time) is retried as a whole fresh tunnel (3 attempts; override withSITEREADY_TUNNEL_ATTEMPTS). Before probing a freshly-minted hostname at all,startTunnelwaits forcloudflared’s own “precheck complete hard_fail=false” log line — probing before that line appears was the actual cause of early DNS-reachability failures (WORKLOG.md 2026-09-12 “#19 resolved”), not propagation speed or spacing between tunnel creations, both of which were tried and reverted first.startTunnelalso warns once usage nears the empirically-observed (also undocumented) ~20/hour creation rate limit, but no longer refuses or delays a new tunnel itself.pr.jsis the opt-in--prflow forenhance— it degrades to “left as an unstaged diff” (never throws) if there’s no git remote orghisn’t authenticated, so a user without those configured still gets the default behavior.is-agenticrescans can lag a real production change with no way to force a fresh crawl. It’s a hosted third-party scanner (npx is-agentic@1.0.1) that caches results per domain server-side;rescancalling it twice — once right after a fix ships, once after the deploy is confirmed live viacurl— can return the identical cached result both times (samescanned_at).afdocs, by contrast, re-crawls live on every call. Ifrescan/rescan-localshows zero movement onis-agenticfor a check you know you fixed, verify the live site directly (curlthe page, grep for the expected content) before concluding the fix didn’t work — then, if it’s confirmed live, either wait out the cache or manually trigger a rescan on is-agentic.com’s own page. Its score has also shown double-digit swings scan-to-scan on an unchanged site — treat it as noisier/less deterministic than afdocs, especially near category boundaries.ora.jshits the same underlying engine but its API does expose aforceparam to bypass the cache —runOraScanjust doesn’t default to it, to conserve the 6/day force-scan quota.- A hand-rolled markdown-mirror route (
.md.tsservingentry.body/doc.bodyverbatim) will fail afdocs’markdown-content-paritycheck on any Astro site using the default markdown pipeline, even when the served markdown is byte-for-byte the page’s real source. Astro runsremark-smartypantsby default, curling straight quotes/apostrophes ("/'→“”/’) and collapsing...→…on the rendered HTML only — the raw collection body a.md.tsroute echoes back keeps the straight characters, so a parity checker diffing rendered text against raw markdown sees every quote-bearing paragraph as “missing” (seen at 43–49% missing on real quote-heavy blog posts). Root cause, not the route’s content actually being stale — fix is typographic normalization on the served body (seefixers/astro.js’ssmartQuotes()), not regenerating content. A residual, much smaller gap can remain on posts using Markdown footnotes ([^1]): the parity checker extracts rendered footnote references as a bare visible number (no brackets), which will never text-match the raw[^1]:source syntax — this looks like a structural limit of that specific check rather than something a fixer can close further.
Cross-platform notes
Runs on Windows, macOS, and Linux — this is CLI-driven (npx-based scanners, file-editing fixers), so “does it run on the user’s machine” is a real correctness bar, not a nice-to-have. Two platform-layout bugs already caught building this, both fixed in shared helpers so no individual scanner/fixer needs its own OS branching:
npxresolution: Windows (<root>/node.exe,<root>/node_modules/npm/...), POSIX tarball/nvm/fnm (<root>/bin/node,<root>/lib/node_modules/npm/...), and Homebrew (/usr/local/bin/nodesymlinked into a versioned Cellar path) all lay out npm differently.lib/npx-runner.jstries all three known layouts (existence-checked, first match wins,fs.realpathSyncfirst to see through Homebrew’s symlink) and only falls back to a PATH-resolvednpx/npx.cmdif none match..cmdshims:child_process.spawn('npm.cmd', args)fails withEINVALon Windows unlessshell: trueis set — plain executables don’t need this, but anything invoked through npm’s Windows batch-file shims does.lib/local-server.jssets it conditionally onwin32.
Node version note: siteready itself only needs Node ≥18, but fixtures/astro-starlight-cf-pages
pins a floating Astro range that currently requires Node ≥22.12 to build, and next@16 (used
by fixtures/nextjs-vercel) requires Node ≥20.9 — CI runs on Node 22 to satisfy both. If
scan-local/verify-loop.js fails with “Node.js vX is not supported by Astro” (or an equivalent
Next.js engine error), that’s a fixture’s own dependency, not siteready — upgrade Node, don’t
downgrade the fixture’s declared range.