ax-audit 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/LICENSE +1 -1
  3. package/README.md +89 -231
  4. package/dist/baseline.d.ts +2 -0
  5. package/dist/baseline.d.ts.map +1 -1
  6. package/dist/baseline.js +42 -4
  7. package/dist/baseline.js.map +1 -1
  8. package/dist/check-ids.d.ts +19 -0
  9. package/dist/check-ids.d.ts.map +1 -0
  10. package/dist/check-ids.js +53 -0
  11. package/dist/check-ids.js.map +1 -0
  12. package/dist/checks/agent-access.d.ts +33 -0
  13. package/dist/checks/agent-access.d.ts.map +1 -0
  14. package/dist/checks/agent-access.js +256 -0
  15. package/dist/checks/agent-access.js.map +1 -0
  16. package/dist/checks/agent-card.d.ts +37 -0
  17. package/dist/checks/agent-card.d.ts.map +1 -0
  18. package/dist/checks/agent-card.js +352 -0
  19. package/dist/checks/agent-card.js.map +1 -0
  20. package/dist/checks/agent-operability.d.ts +66 -0
  21. package/dist/checks/agent-operability.d.ts.map +1 -0
  22. package/dist/checks/agent-operability.js +383 -0
  23. package/dist/checks/agent-operability.js.map +1 -0
  24. package/dist/checks/agent-skills.d.ts +24 -0
  25. package/dist/checks/agent-skills.d.ts.map +1 -0
  26. package/dist/checks/agent-skills.js +316 -0
  27. package/dist/checks/agent-skills.js.map +1 -0
  28. package/dist/checks/ai-catalog.d.ts +28 -0
  29. package/dist/checks/ai-catalog.d.ts.map +1 -0
  30. package/dist/checks/ai-catalog.js +254 -0
  31. package/dist/checks/ai-catalog.js.map +1 -0
  32. package/dist/checks/ai-directives.d.ts +57 -0
  33. package/dist/checks/ai-directives.d.ts.map +1 -0
  34. package/dist/checks/ai-directives.js +263 -0
  35. package/dist/checks/ai-directives.js.map +1 -0
  36. package/dist/checks/api-discovery.d.ts +26 -0
  37. package/dist/checks/api-discovery.d.ts.map +1 -0
  38. package/dist/checks/api-discovery.js +432 -0
  39. package/dist/checks/api-discovery.js.map +1 -0
  40. package/dist/checks/auth-discovery.d.ts +28 -0
  41. package/dist/checks/auth-discovery.d.ts.map +1 -0
  42. package/dist/checks/auth-discovery.js +213 -0
  43. package/dist/checks/auth-discovery.js.map +1 -0
  44. package/dist/checks/commerce-discovery.d.ts +40 -0
  45. package/dist/checks/commerce-discovery.d.ts.map +1 -0
  46. package/dist/checks/commerce-discovery.js +295 -0
  47. package/dist/checks/commerce-discovery.js.map +1 -0
  48. package/dist/checks/content-negotiation.d.ts.map +1 -1
  49. package/dist/checks/content-negotiation.js +135 -20
  50. package/dist/checks/content-negotiation.js.map +1 -1
  51. package/dist/checks/crawl-efficiency.d.ts +16 -0
  52. package/dist/checks/crawl-efficiency.d.ts.map +1 -0
  53. package/dist/checks/crawl-efficiency.js +186 -0
  54. package/dist/checks/crawl-efficiency.js.map +1 -0
  55. package/dist/checks/frontmatter.d.ts +34 -0
  56. package/dist/checks/frontmatter.d.ts.map +1 -0
  57. package/dist/checks/frontmatter.js +100 -0
  58. package/dist/checks/frontmatter.js.map +1 -0
  59. package/dist/checks/html-rendering.d.ts.map +1 -1
  60. package/dist/checks/html-rendering.js +0 -1
  61. package/dist/checks/html-rendering.js.map +1 -1
  62. package/dist/checks/html-utils.d.ts +10 -0
  63. package/dist/checks/html-utils.d.ts.map +1 -1
  64. package/dist/checks/html-utils.js +19 -0
  65. package/dist/checks/html-utils.js.map +1 -1
  66. package/dist/checks/http-headers.d.ts.map +1 -1
  67. package/dist/checks/http-headers.js +82 -10
  68. package/dist/checks/http-headers.js.map +1 -1
  69. package/dist/checks/http-hygiene.d.ts +26 -0
  70. package/dist/checks/http-hygiene.d.ts.map +1 -0
  71. package/dist/checks/http-hygiene.js +257 -0
  72. package/dist/checks/http-hygiene.js.map +1 -0
  73. package/dist/checks/index.d.ts.map +1 -1
  74. package/dist/checks/index.js +30 -8
  75. package/dist/checks/index.js.map +1 -1
  76. package/dist/checks/llms-txt.d.ts +15 -0
  77. package/dist/checks/llms-txt.d.ts.map +1 -1
  78. package/dist/checks/llms-txt.js +162 -2
  79. package/dist/checks/llms-txt.js.map +1 -1
  80. package/dist/checks/mcp-discovery.d.ts +30 -0
  81. package/dist/checks/mcp-discovery.d.ts.map +1 -0
  82. package/dist/checks/mcp-discovery.js +523 -0
  83. package/dist/checks/mcp-discovery.js.map +1 -0
  84. package/dist/checks/meta-tags.d.ts.map +1 -1
  85. package/dist/checks/meta-tags.js +6 -5
  86. package/dist/checks/meta-tags.js.map +1 -1
  87. package/dist/checks/robots-parser.d.ts +110 -0
  88. package/dist/checks/robots-parser.d.ts.map +1 -0
  89. package/dist/checks/robots-parser.js +277 -0
  90. package/dist/checks/robots-parser.js.map +1 -0
  91. package/dist/checks/robots-txt.d.ts +2 -0
  92. package/dist/checks/robots-txt.d.ts.map +1 -1
  93. package/dist/checks/robots-txt.js +252 -45
  94. package/dist/checks/robots-txt.js.map +1 -1
  95. package/dist/checks/{mcp.d.ts → rsl.d.ts} +1 -1
  96. package/dist/checks/rsl.d.ts.map +1 -0
  97. package/dist/checks/rsl.js +242 -0
  98. package/dist/checks/rsl.js.map +1 -0
  99. package/dist/checks/security-txt.d.ts.map +1 -1
  100. package/dist/checks/security-txt.js +0 -1
  101. package/dist/checks/security-txt.js.map +1 -1
  102. package/dist/checks/seo-basics.d.ts.map +1 -1
  103. package/dist/checks/seo-basics.js +0 -1
  104. package/dist/checks/seo-basics.js.map +1 -1
  105. package/dist/checks/sitemap.d.ts.map +1 -1
  106. package/dist/checks/sitemap.js +0 -1
  107. package/dist/checks/sitemap.js.map +1 -1
  108. package/dist/checks/structured-data.d.ts.map +1 -1
  109. package/dist/checks/structured-data.js +215 -4
  110. package/dist/checks/structured-data.js.map +1 -1
  111. package/dist/checks/structured-fields.d.ts +46 -0
  112. package/dist/checks/structured-fields.d.ts.map +1 -0
  113. package/dist/checks/structured-fields.js +112 -0
  114. package/dist/checks/structured-fields.js.map +1 -0
  115. package/dist/checks/surface.d.ts +59 -0
  116. package/dist/checks/surface.d.ts.map +1 -0
  117. package/dist/checks/surface.js +106 -0
  118. package/dist/checks/surface.js.map +1 -0
  119. package/dist/checks/tls-https.d.ts.map +1 -1
  120. package/dist/checks/tls-https.js +0 -1
  121. package/dist/checks/tls-https.js.map +1 -1
  122. package/dist/checks/usage-policy.d.ts +53 -0
  123. package/dist/checks/usage-policy.d.ts.map +1 -0
  124. package/dist/checks/usage-policy.js +339 -0
  125. package/dist/checks/usage-policy.js.map +1 -0
  126. package/dist/checks/utils.d.ts +25 -1
  127. package/dist/checks/utils.d.ts.map +1 -1
  128. package/dist/checks/utils.js +33 -1
  129. package/dist/checks/utils.js.map +1 -1
  130. package/dist/checks/waf.d.ts +75 -0
  131. package/dist/checks/waf.d.ts.map +1 -0
  132. package/dist/checks/waf.js +203 -0
  133. package/dist/checks/waf.js.map +1 -0
  134. package/dist/checks/webmcp.d.ts +55 -0
  135. package/dist/checks/webmcp.d.ts.map +1 -0
  136. package/dist/checks/webmcp.js +209 -0
  137. package/dist/checks/webmcp.js.map +1 -0
  138. package/dist/checks/well-known.d.ts +38 -0
  139. package/dist/checks/well-known.d.ts.map +1 -0
  140. package/dist/checks/well-known.js +202 -0
  141. package/dist/checks/well-known.js.map +1 -0
  142. package/dist/cli.d.ts +14 -0
  143. package/dist/cli.d.ts.map +1 -1
  144. package/dist/cli.js +144 -6
  145. package/dist/cli.js.map +1 -1
  146. package/dist/constants.d.ts +212 -14
  147. package/dist/constants.d.ts.map +1 -1
  148. package/dist/constants.js +625 -59
  149. package/dist/constants.js.map +1 -1
  150. package/dist/fetcher.d.ts +5 -1
  151. package/dist/fetcher.d.ts.map +1 -1
  152. package/dist/fetcher.js +62 -27
  153. package/dist/fetcher.js.map +1 -1
  154. package/dist/guide-urls.js +1 -1
  155. package/dist/guide-urls.js.map +1 -1
  156. package/dist/index.d.ts +3 -1
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +2 -0
  159. package/dist/index.js.map +1 -1
  160. package/dist/orchestrator.d.ts +2 -2
  161. package/dist/orchestrator.d.ts.map +1 -1
  162. package/dist/orchestrator.js +18 -7
  163. package/dist/orchestrator.js.map +1 -1
  164. package/dist/reporter/html.d.ts +9 -0
  165. package/dist/reporter/html.d.ts.map +1 -1
  166. package/dist/reporter/html.js +49 -11
  167. package/dist/reporter/html.js.map +1 -1
  168. package/dist/reporter/index.d.ts.map +1 -1
  169. package/dist/reporter/index.js +7 -0
  170. package/dist/reporter/index.js.map +1 -1
  171. package/dist/reporter/markdown.d.ts +8 -0
  172. package/dist/reporter/markdown.d.ts.map +1 -0
  173. package/dist/reporter/markdown.js +106 -0
  174. package/dist/reporter/markdown.js.map +1 -0
  175. package/dist/reporter/terminal.d.ts.map +1 -1
  176. package/dist/reporter/terminal.js +36 -1
  177. package/dist/reporter/terminal.js.map +1 -1
  178. package/dist/scorer.d.ts +10 -0
  179. package/dist/scorer.d.ts.map +1 -1
  180. package/dist/scorer.js +22 -5
  181. package/dist/scorer.js.map +1 -1
  182. package/dist/types.d.ts +96 -3
  183. package/dist/types.d.ts.map +1 -1
  184. package/docs/api.md +200 -0
  185. package/docs/architecture.md +104 -0
  186. package/docs/checks.md +555 -0
  187. package/docs/ci.md +89 -0
  188. package/docs/cli.md +103 -0
  189. package/docs/concepts.md +101 -0
  190. package/docs/faq.md +89 -0
  191. package/docs/getting-started.md +108 -0
  192. package/docs/roadmap.md +367 -0
  193. package/package.json +14 -5
  194. package/dist/checks/agent-json.d.ts +0 -14
  195. package/dist/checks/agent-json.d.ts.map +0 -1
  196. package/dist/checks/agent-json.js +0 -167
  197. package/dist/checks/agent-json.js.map +0 -1
  198. package/dist/checks/mcp.d.ts.map +0 -1
  199. package/dist/checks/mcp.js +0 -162
  200. package/dist/checks/mcp.js.map +0 -1
  201. package/dist/checks/openapi.d.ts +0 -4
  202. package/dist/checks/openapi.d.ts.map +0 -1
  203. package/dist/checks/openapi.js +0 -121
  204. package/dist/checks/openapi.js.map +0 -1
  205. package/dist/checks/well-known-ai.d.ts +0 -17
  206. package/dist/checks/well-known-ai.d.ts.map +0 -1
  207. package/dist/checks/well-known-ai.js +0 -123
  208. package/dist/checks/well-known-ai.js.map +0 -1
package/docs/checks.md ADDED
@@ -0,0 +1,555 @@
1
+ # Checks Reference
2
+
3
+ ax-audit runs 26 checks. Twenty-three are **weighted**, summing to 100. Three rest on draft specifications and stay at weight 0: scoring a site against a specification that may be renamed next quarter would make the number less trustworthy, not more.
4
+
5
+ Checks are grouped into five areas, and reports are ordered by them: **content** (is there substance an agent can read?), **discovery** (can an agent find your machine-readable files?), **access** (can it actually retrieve them?), **policy** (what usage rights do you declare?), and **protocols** (what can an agent call?).
6
+
7
+ Some checks are **conditional**. A blog has no API to describe, no MCP server to advertise and nothing to authorize, so those checks report **n/a** and are excluded from the score rather than counted as failures. Everything counted against a site is something the site could have done. `--profile api|mcp|agent|docs|commerce|all` forces them applicable, for auditing against what a site intends to become.
8
+
9
+ ## Weights
10
+
11
+ | Area | Total | Checks |
12
+ | --- | --- | --- |
13
+ | Content | 33 | html-rendering 11 · agent-operability 7 · structured-data 6 · seo-basics 5 · content-negotiation 4 |
14
+ | Access | 24 | agent-access 9 · ai-directives 6 · http-hygiene 4 · tls-https 3 · crawl-efficiency 2 |
15
+ | Discovery | 21 | robots-txt 9 · llms-txt 5 · http-headers 4 · sitemap 2 · meta-tags 1 |
16
+ | Protocols | 13 | api-discovery 4 · agent-card 3 · mcp-discovery 3 · agent-skills 2 · auth-discovery 1 |
17
+ | Policy | 9 | usage-policy 4 · security-txt 3 · rsl 2 |
18
+ | Draft specifications | 0 | ai-catalog · webmcp · commerce-discovery |
19
+
20
+ Content leads because the failure that breaks the most agents is a page with nothing in its HTML: most crawlers do not run JavaScript, and a site whose content appears only after hydration is invisible no matter how many discovery files it publishes. Access is second because a firewall rule or a `nosnippet` directive silently undoes everything else, and those are the failures operators are least likely to know about. llms.txt sits at 5 rather than the 11 it carried in 3.x, because most published files are never fetched by an AI search crawler.
21
+
22
+ Weights live in `CHECK_WEIGHTS` in `src/constants.ts`, and only there. Checks used to declare their own alongside it, and the two drifted.
23
+
24
+ Every probed path is labelled by standing — **IANA-registered**, **vendor convention**, **draft**, or **legacy** — because the agent web mixes registered URIs with drafts that get renamed. A missing draft file is not the same kind of finding as a missing registered one, and reports say which is which.
25
+
26
+ This page documents the **exact scoring** of every check: each deduction, bonus, and formula, extracted from the source. Every finding links to a step-by-step remediation guide at `axrush.com/guides/<check-id>`.
27
+
28
+ **Reading the tables:** each check starts at 100 unless noted. Deductions stack additively; `buildResult` clamps the final score to [0, 100]. "Hard fail" rows short-circuit the check.
29
+
30
+ ---
31
+
32
+ ## Weighted checks
33
+
34
+ ### `llms-txt` — 11%
35
+
36
+ `/llms.txt` presence and [llmstxt.org](https://llmstxt.org) spec compliance.
37
+
38
+ | Condition | Points |
39
+ | --- | --- |
40
+ | `/llms.txt` not found | **hard fail → 0** |
41
+ | Wrong Content-Type (expected `text/plain` or `text/markdown`) | −5 |
42
+ | First line is not an H1 (`# `) | −15 |
43
+ | No blockquote description (`> `) | −10 |
44
+ | No `##` section headings | −10 |
45
+ | No Markdown links | −10 |
46
+ | Content under 100 characters | −10 |
47
+ | `/llms-full.txt` also available | **+10** (capped at 100) |
48
+ | Broken links among a sample of 15 | informational, 0 in 3.x |
49
+ | Redirecting or duplicated links | informational, 0 |
50
+ | No `rel="describedby"` pointing at the file (llms.txt v2) | informational, 0 |
51
+ | No per-page Markdown mirror (`/index.md`, `/index.html.md`) | informational, 0 |
52
+ | File over 50 KB | informational, 0 |
53
+
54
+ Every report states plainly that Google says Search ignores llms.txt and that most published files are never fetched by an AI search crawler, while Claude Code, Cursor and OpenCode do read it. It is a developer-tooling signal, not a search-visibility one.
55
+
56
+ ### `robots-txt` — 11%
57
+
58
+ AI-crawler configuration. Scoring runs against the frozen 3.x core set (GPTBot, ClaudeBot, ChatGPT-User, Claude-SearchBot, Google-Extended, PerplexityBot, OAI-SearchBot, CCBot); the wider September-2026 core set adds Meta-ExternalAgent, Applebot-Extended, Amazonbot and Bytespider, reported but not scored until 4.0.
59
+
60
+ Findings are tiered by what a client does with a page, because that determines the cost of blocking it. Blocking a **training** crawler is a policy choice and is reported as such. Blocking a **search** crawler removes the site from that assistant's answers. Blocking a **user-triggered fetcher** often does nothing, because most vendors document that robots.txt may not apply to them.
61
+
62
+ | Condition | Points |
63
+ | --- | --- |
64
+ | `/robots.txt` not found | **hard fail → 0** |
65
+ | No core AI crawler explicitly configured | −40 |
66
+ | Some core crawlers missing | −`round(missing/8 × 30)` |
67
+ | Core crawler(s) blocked only via `User-agent: *` + `Disallow: /` | −5 per crawler |
68
+ | Known AI crawler(s) explicitly blocked (`Disallow: /`) | −3 per crawler |
69
+ | No `Sitemap:` directive | −5 |
70
+ | Partial path restrictions on AI crawlers | warn only, 0 |
71
+ | Blocking a crawler token added in 3.7 (`meta-webindexer`, `Amzn-SearchBot`, …) | informational, 0 in 3.x |
72
+ | Rules targeting a retired or fictional token (`GeminiBot`, `Claude-Web`, `NeevaBot`, …) | informational, 0 |
73
+ | [Content Signals](https://contentsignals.org) findings, including the `use=immediate\|reference\|full` field | informational, 0 in 3.x |
74
+ | [IETF AIPREF](https://datatracker.ietf.org/wg/aipref/documents/) `Content-Usage:` findings, including vocabulary mix-ups | informational, 0 in 3.x |
75
+
76
+ ### `html-rendering` — 9%
77
+
78
+ Whether the static HTML contains content — most AI crawlers do not execute JavaScript. Thresholds: 500 chars / 80 words of visible text, 5% text-to-markup ratio.
79
+
80
+ | Condition | Points |
81
+ | --- | --- |
82
+ | No HTML body returned | **hard fail → 0** |
83
+ | Zero visible text in static HTML | −50 |
84
+ | Sparse content (< 500 chars or < 80 words) | −25 |
85
+ | Text-to-markup ratio < 5% | −10 |
86
+ | Empty SPA mount point (`#root`, `#__next`, `#__nuxt`, `#app`, `#svelte`, `#gatsby`) | −20 |
87
+ | 0 semantic landmarks (`<main>`, `<article>`, `<header>`, `<footer>`, `<nav>`) | −15 |
88
+ | 1–2 semantic landmarks | −10 |
89
+ | No `<h1>` | −10 |
90
+ | Multiple or empty `<h1>` | −5 |
91
+ | > 15 executable scripts without `<noscript>` fallback | −5 |
92
+ | `<img alt>` coverage < 90% | −5 |
93
+
94
+ ### `structured-data` — 9%
95
+
96
+ JSON-LD on the homepage. Key entity types: Person, Organization, WebSite, WebPage, ProfilePage.
97
+
98
+ | Condition | Points |
99
+ | --- | --- |
100
+ | No JSON-LD blocks | **hard fail → 0** |
101
+ | Every JSON-LD block has invalid JSON | **→ 10** |
102
+ | Invalid JSON in a block | −10 per block |
103
+ | No schema.org `@context` | −15 |
104
+ | No key entity types found | −15 |
105
+ | Only one key entity type | −10 |
106
+ | No `@graph` array | −5 |
107
+ | No `BreadcrumbList` | −5 |
108
+ | No `author`, no `sameAs`, or an author with no `publisher` | informational, 0 in 3.x |
109
+ | No `dateModified` / `datePublished`, a future date, or content over 730 days old | informational, 0 |
110
+ | `headline` or `name` not present in the visible text | informational, 0 |
111
+
112
+ The visible-text comparison is Google's one explicit requirement for structured data and AI features. It reads static HTML, so text rendered by script reads as missing here too, and the finding says so.
113
+
114
+ ### `http-headers` — 9%
115
+
116
+ Security headers, AI discovery `Link` headers (RFC 5988-parsed), CORS on `.well-known`. Either Agent Card path satisfies the discovery-link requirement.
117
+
118
+ | Condition | Points |
119
+ | --- | --- |
120
+ | No headers retrievable | **hard fail → 0** |
121
+ | Missing critical security header (HSTS, X-Content-Type-Options) | −10 each |
122
+ | Only 1–3 of the 7 tracked security headers present | −5 |
123
+ | `Link` header missing both llms.txt and the Agent Card | −15 |
124
+ | `Link` header missing one of the two | −5 |
125
+ | No CORS on the Agent Card | −10 |
126
+ | Additional discovery relations (`describedby`, `api-catalog`, `service-desc`, `service-doc`, `ai-catalog`, `c2pa-manifest`, `license`, markdown `alternate`) and the `X-Llms-Txt` header | informational, 0 in 3.x |
127
+
128
+ ### `agent-card` — 7%
129
+
130
+ The [A2A Agent Card](https://a2a-protocol.org), probed at `/.well-known/agent-card.json` (IANA-registered since A2A v0.3.0, 2025-07-30) and then at the pre-0.3 path `/.well-known/agent.json`. *Former id: `agent-json`, still accepted in `--checks` and in saved baselines.*
131
+
132
+ Two spec generations are in the wild, and the check detects which one a card follows from its own structure rather than from a version field:
133
+
134
+ - **A2A 1.0** (2026-03-12) declares every endpoint inside `supportedInterfaces[]`. Required: `name`, `description`, `version`, `capabilities`, `supportedInterfaces`, `defaultInputModes`, `defaultOutputModes`, `skills`.
135
+ - **A2A 0.3** declares a top-level `url` and `protocolVersion`. Required: those two plus `name`, `description`, `version`, `capabilities`, `defaultInputModes`, `defaultOutputModes`, `skills`.
136
+
137
+ | Condition | Points |
138
+ | --- | --- |
139
+ | Not found at either path | **hard fail → 0** |
140
+ | Invalid JSON | **→ 10** |
141
+ | Served only from the pre-0.3 `agent.json` path | warn only, 0 |
142
+ | Wrong Content-Type (expected `application/json` or `application/a2a+json`) | −5 |
143
+ | Card shape matches neither generation | −30 |
144
+ | Missing required field for the detected generation | −15 per field |
145
+ | `supportedInterfaces[]` empty (1.0) | −15 |
146
+ | Interface missing `url`, `protocolBinding` or `protocolVersion` (1.0) | −10 |
147
+ | Unrecognised `protocolBinding` (not JSONRPC / GRPC / HTTP+JSON) | −5 |
148
+ | Interface or `url` on a different origin | −5 |
149
+ | `url` not an absolute URL (0.3) | −5 |
150
+ | `skills` empty | −10 |
151
+ | `skills` entries missing `id` or `description` | −5 |
152
+ | Uses `authentication`, removed from the spec in 0.2.x | −5 |
153
+ | No optional descriptive fields (`provider`, `documentationUrl`, `iconUrl`) | −5 |
154
+
155
+ ### `mcp-discovery` — 7%
156
+
157
+ How an agent finds this site's [Model Context Protocol](https://modelcontextprotocol.io) server. *Former id: `mcp`.*
158
+
159
+ `/.well-known/mcp.json` was never part of the MCP specification. What emerged instead is the **server card**, which deliberately carries no `tools[]` — tool lists come from a live `tools/list` call, and a static copy drifts the day it is written. Discovery is probed in this order:
160
+
161
+ 1. `/.well-known/ai-catalog.json` entries of type `application/mcp-server-card+json` *(draft)*
162
+ 2. `/.well-known/mcp/server-card.json`, `/.well-known/mcp/server-cards.json` *(vendor convention: Cloudflare, Mintlify)*
163
+ 3. `<endpoint>/server-card` for `/mcp`, `/api/mcp`, `/sse` *(the MCP extension's own recommendation)*
164
+ 4. `/.well-known/mcp.json` *(legacy)*
165
+
166
+ An HTML response counts as absence, not a malformed card: SPA catch-alls answer every unknown path with the index shell.
167
+
168
+ **When a server card is found:**
169
+
170
+ | Condition | Points |
171
+ | --- | --- |
172
+ | Wrong Content-Type (expected `application/json` or `application/mcp-server-card+json`) | −5 |
173
+ | Invalid JSON | **→ 10** |
174
+ | Missing `$schema`, `name`, `version` or `description` | −15 each |
175
+ | `name` not in reverse-DNS form | −5 |
176
+ | No `remotes[]` | −15 |
177
+ | Remote with an unrecognised transport (not `streamable-http` / `sse`) | −5 |
178
+ | Remote missing a `url` | −10 |
179
+ | No `supportedProtocolVersions` | −10 |
180
+ | Only pre-2025-06 protocol revisions | −10 |
181
+ | Unrecognised protocol version | −5 |
182
+ | No CORS headers | −10 |
183
+ | Declares `tools[]`, which the schema omits by design | warn only, 0 |
184
+
185
+ **When only `/.well-known/mcp.json` is found**, the pre-3.7 rules are applied unchanged so the score is exactly what 3.6 produced (missing `name` −10, missing `description` −5, no `tools` −15, no tool descriptions −10 / −5, no `resources` −5, no version −5, no CORS −10, wrong Content-Type −5). The path itself is reported, not penalised.
186
+
187
+ ### `seo-basics` — 7%
188
+
189
+ Head-tag fundamentals. Bounds: title 20–70 chars, description 70–160.
190
+
191
+ | Condition | Points |
192
+ | --- | --- |
193
+ | Homepage HTML unavailable | **hard fail → 0** |
194
+ | `<title>` missing or empty | −25 |
195
+ | Title too short / too long | −10 / −5 |
196
+ | Meta description missing | −20 |
197
+ | Description too short / too long | −8 / −5 |
198
+ | Description duplicates the title | −5 |
199
+ | No canonical link | −10 |
200
+ | Multiple canonicals / missing href / relative href | −5 each |
201
+ | `<html lang>` missing / invalid BCP 47 | −10 / −5 |
202
+ | No UTF-8 charset | −5 |
203
+ | Missing viewport | −5 |
204
+ | hreflang present without `x-default` | −3 |
205
+
206
+ ### `security-txt` — 6%
207
+
208
+ `/.well-known/security.txt` per [RFC 9116](https://www.rfc-editor.org/rfc/rfc9116).
209
+
210
+ | Condition | Points |
211
+ | --- | --- |
212
+ | Not found | **hard fail → 0** |
213
+ | Missing `Contact` or `Expires` | −25 per field |
214
+ | `Expires` in the past | −20 |
215
+ | No optional fields (Canonical, Preferred-Languages, Policy, Encryption, Hiring) | −5 |
216
+
217
+ ### `meta-tags` — 6%
218
+
219
+ AI meta tags (`ai:summary`, `ai:content_type`, `ai:author`, `ai:api`, `ai:agent_card`), discovery links, Open Graph, Twitter Card.
220
+
221
+ | Condition | Points |
222
+ | --- | --- |
223
+ | Homepage HTML unavailable | **hard fail → 0** |
224
+ | 0 AI meta tags | −18 |
225
+ | Only 1–2 AI meta tags | −12 |
226
+ | No `rel="alternate"` → llms.txt | −12 |
227
+ | No `rel="alternate"` → the Agent Card | −8 |
228
+ | No `rel="me"` identity links | −8 |
229
+ | No Open Graph tags at all | −12 |
230
+ | OG required incomplete (`og:title`, `og:description`, `og:url`, `og:type`) | −8 |
231
+ | OG recommended incomplete (`og:image`, `og:site_name`) | −3 |
232
+ | No Twitter Card tags at all | −6 |
233
+ | Twitter required incomplete (`twitter:card`, `twitter:title`, `twitter:description`) | −5 |
234
+ | Twitter recommended incomplete (`twitter:image`) | −2 |
235
+
236
+ ### `api-discovery` — 6%
237
+
238
+ Whether an agent can find, and read, a machine-readable API description. *Former id: `openapi`.*
239
+
240
+ `/.well-known/openapi.json` is a folk convention — unregistered, and not prescribed by the OpenAPI specification, which recommends the file name `openapi.json` without a location. Discovery is probed in order of authority:
241
+
242
+ 1. `/.well-known/api-catalog` *(RFC 9727, IANA-registered)* → its `service-desc` links
243
+ 2. `Link: rel="service-desc"` on the homepage *(RFC 8631)*
244
+ 3. `<link rel="service-desc">` in the HTML head
245
+ 4. Conventional paths: `/.well-known/openapi.json`, `/openapi.json`, `/openapi.yaml`, `/.well-known/openapi.yaml`, `/api/openapi.json`, `/v1/openapi.json`, `/swagger.json`, `/api-docs`, `/asyncapi.json`, `/arazzo.json`
246
+
247
+ | Condition | Points |
248
+ | --- | --- |
249
+ | No description found by any mechanism | **hard fail → 0** |
250
+ | Invalid JSON | **→ 10** |
251
+ | Wrong Content-Type on a JSON document | −5 |
252
+ | No `openapi`/`swagger` version field | −20 |
253
+ | Swagger 2.x instead of OpenAPI 3.x | −10 |
254
+ | Missing `info.title` | −10 |
255
+ | Missing `info.description` | −5 |
256
+ | No `paths` documented | −15 |
257
+ | No `servers` | −5 |
258
+ | Found only by guessing a path (nothing links to it) | warn only, 0 |
259
+ | `operationId` coverage below 100% | informational, 0 in 3.x |
260
+ | API catalog present but empty, or entries missing `anchor` / `service-doc` | warn only, 0 |
261
+
262
+ YAML descriptions are recognised and reported, but only surface-validated: ax-audit ships no YAML parser, and the finding says so rather than pretending otherwise.
263
+
264
+ ### `tls-https` — 5%
265
+
266
+ HTTPS, redirect, HSTS. Thresholds: max-age ≥ 15,768,000s (~6 months), preload ≥ 31,536,000s (1 year).
267
+
268
+ | Condition | Points |
269
+ | --- | --- |
270
+ | Invalid URL | **hard fail → 0** |
271
+ | Served over plain HTTP | −50 |
272
+ | HTTP does not redirect to HTTPS | −15 |
273
+ | Redirect unverifiable | −5 |
274
+ | No HSTS header | −15 |
275
+ | HSTS without `max-age` | −10 |
276
+ | `max-age` < 6 months | −5 |
277
+ | No `includeSubDomains` | −5 |
278
+ | `preload` present but ineligible | −5 |
279
+ | No `preload` directive | −3 |
280
+
281
+ ### `sitemap` — 4%
282
+
283
+ Located via robots.txt `Sitemap:` or `/sitemap.xml`. Limits: 50,000 URLs / 50 MB / 365-day freshness.
284
+
285
+ | Condition | Points |
286
+ | --- | --- |
287
+ | No sitemap found | **hard fail → 0** |
288
+ | Response is not XML | **→ 20** |
289
+ | Over 50 MB | −10 |
290
+ | Unexpected Content-Type | −5 |
291
+ | Sitemap index with no `<sitemap>` entries | −20, stop |
292
+ | Some sampled child sitemaps unreachable | −10 |
293
+ | `<urlset>` with no `<url>` entries | −30 |
294
+ | Over 50,000 URLs declared | −10 |
295
+ | `<lastmod>` coverage < 50% | −5 |
296
+ | Newest `<lastmod>` older than 365 days | −5 |
297
+
298
+ ---
299
+
300
+ ## Checks on draft specifications (weight 0)
301
+
302
+ `ai-catalog`, `webmcp` and `commerce-discovery` run on every audit and report full findings, but never affect the score. Each rests on a specification that is still a draft and may be renamed; scoring a site against one would make the number less trustworthy, not more.
303
+
304
+ `well-known-ai` was removed in 4.0. Re-verification found three of its five scored files had no consumer: `/.well-known/nlweb.json` appears in no NLWeb release, `genai.txt` has no specification, and `/ai-plugin.json` described a product shut down in 2024. Its live probes moved into the checks that own them — TDMRep into `usage-policy`, the Web Bot Auth key directory and `AGENTS.md` into the reporting they belong to.
305
+
306
+ ### `content-negotiation` — Markdown for Agents
307
+
308
+ Probes the homepage with `Accept: text/markdown` — the pattern served by Cloudflare and Vercel and requested by Claude Code, Cursor, and OpenCode (~80% token reduction vs HTML).
309
+
310
+ | Condition | Points |
311
+ | --- | --- |
312
+ | Probe request fails (network) | **hard fail → 0** |
313
+ | No Markdown served, no fallback | **→ 0** |
314
+ | No Markdown served, but `<link rel="alternate" type="text/markdown">` present | **→ 40** |
315
+ | Markdown served (correct Content-Type, 2xx) | base 100 |
316
+ | Body is empty | −30 |
317
+ | Body is a relabeled HTML document | −25 |
318
+ | `Vary` does not include `Accept` | −15 |
319
+ | Markdown not smaller than HTML | warn only, 0 |
320
+ | Origin-reported token counts (`x-markdown-tokens` / `x-original-tokens`) | informational, 0 |
321
+ | No frontmatter, or frontmatter with no title / canonical URL / date | informational, 0 |
322
+ | User-agent negotiation or a `.md` suffix URL, when Accept negotiation fails | informational, 0 |
323
+
324
+ The probe sends the Accept header a real agent sends (`text/markdown, text/html;q=0.9, */*;q=0.1`). A bare `text/markdown` would pass against an implementation that fails every real request.
325
+
326
+ ### `rsl` — Really Simple Licensing
327
+
328
+ [RSL 1.0](https://rslstandard.org/rsl) discovery (robots.txt `License:`, `Link: rel="license"` header, `<link rel="license" type="application/rsl+xml">`) and document validation. Plain CC-style license links without the RSL media type are ignored.
329
+
330
+ | Condition | Points |
331
+ | --- | --- |
332
+ | No discovery mechanism found | **hard fail → 0** |
333
+ | License document unreachable | **→ 25** (cap) |
334
+ | Root `<rsl>` element missing | −40, stop |
335
+ | No `<content>` elements | −20, stop |
336
+ | Wrong or missing `https://rslstandard.org/rsl` namespace | −15 |
337
+ | `<license>` elements missing | −15 |
338
+ | robots.txt `License:` not an absolute URI | −10 |
339
+ | `<content>` missing required `url` attribute | −10 |
340
+ | Wrong Content-Type (expected `application/rsl+xml`) | −5 |
341
+ | `permits`/`prohibits` with invalid `type` | −5 |
342
+ | Tokens outside the RSL 1.0 vocabulary (incl. pre-1.0 draft tokens) | −5 |
343
+ | Invalid `payment` type | −5 |
344
+
345
+ ### `agent-access` — blocking and cloaking detection
346
+
347
+ Probes the homepage with realistic user agents for the 10 core AI crawlers that actually issue requests (`Google-Extended` and `Applebot-Extended` are robots.txt control tokens, so probing with them tests nothing) and compares each response against the default-UA baseline. **Credit-ratio formula:**
348
+
349
+ ```
350
+ score = round(credit / 10 × 100)
351
+ ```
352
+
353
+ Responses are classified by *how* a request was turned away, because the remedies differ completely:
354
+
355
+ | Outcome per crawler | Credit |
356
+ | --- | --- |
357
+ | Same page as a regular client | 1 |
358
+ | Refused, consistent with an explicit robots.txt `Disallow` | 1 |
359
+ | Priced access (`402` + `crawler-price`) or an RSL licence challenge | 1 |
360
+ | JavaScript challenge (`cf-mitigated: challenge`, `x-vercel-mitigated`, AWS WAF's `202`), Web Bot Auth demand, rate limit, or a refusal from a bot-verifying CDN | 0.75, **inconclusive** |
361
+ | Different page than the baseline: less text, or a changed title / h1 / JSON-LD block count | 0.5 |
362
+ | Refused by a plain origin while robots.txt permits it | 0 |
363
+ | Baseline request itself fails | **hard fail → 0** |
364
+
365
+ The probe is unsigned and comes from the auditor's own network, so an edge that verifies crawlers by IP range or Web Bot Auth signature will reject it while admitting the genuine crawler. Those outcomes are reported as inconclusive with the exact header observed, never as "blocks AI crawlers". Confirm against WAF logs before changing a rule.
366
+
367
+ ### `crawl-efficiency`
368
+
369
+ | Condition | Points |
370
+ | --- | --- |
371
+ | Homepage request fails | **hard fail → 0** |
372
+ | Uncompressed response | −30 |
373
+ | gzip/deflate/zstd instead of Brotli | pass with suggestion, 0 |
374
+ | No `ETag` / `Last-Modified` validator | −30 |
375
+ | Validator present but conditional request not answered with `304` | −15 |
376
+ | Page > 2 MB decompressed | −10 |
377
+ | Page > 500 KB decompressed | −5 |
378
+ | Content tokens, wire tokens and markup share (estimated at 4 chars per token) | informational, 0 |
379
+ | Response over 2s | informational, 0 |
380
+
381
+ ---
382
+
383
+ ### `ai-directives` — page-level AI controls
384
+
385
+ The controls Google and Microsoft document that they honor, read from robots meta tags and `X-Robots-Tag` (including the user-agent-scoped header form).
386
+
387
+ | Condition | Points |
388
+ | --- | --- |
389
+ | Homepage HTML unavailable | **hard fail → 0** |
390
+ | `noindex` or `none` | **hard fail → 0** — invisible to every search-grounded assistant |
391
+ | `nosnippet`, or `max-snippet:0` | −30 — excluded as a direct input to Google AI Overviews and AI Mode |
392
+ | `noarchive` | −30 — excluded from Microsoft Copilot grounding |
393
+ | `nocache` | −10 — Copilot may use only the URL, title and snippet |
394
+ | `data-nosnippet` wrapping `<main>`, `<article>` or `<body>` | −20 |
395
+ | `noimageindex` | −5 |
396
+ | `max-snippet:[n]` under 160 | warn only, 0 |
397
+ | `noai` / `noimageai` | reported, 0 — no major operator documents honoring them |
398
+ | robots.txt disallows `Google-Extended` with no snippet directive set | warn only, 0 |
399
+
400
+ That last row is the finding this check exists for. `Google-Extended` governs Gemini training and grounding in Gemini Apps and Vertex AI, **not** AI Overviews, which follow Googlebot and the snippet directives. A site that disallows it expecting to leave AI Overviews has opted out of the thing it probably did not mind.
401
+
402
+ ### `usage-policy` — do your usage signals agree?
403
+
404
+ Normalises every machine-readable usage declaration onto three questions — may you train on it, ground an answer in it, index it — and reports where they disagree.
405
+
406
+ | Mechanism | Training | Grounding | Search |
407
+ | --- | --- | --- | --- |
408
+ | Content Signals (robots.txt or header) | `ai-train=yes\|no` | `ai-input=yes\|no` | `search=yes\|no` |
409
+ | IETF AIPREF (robots.txt or header) | `train-ai=y\|n` | *(no category yet)* | `search=y\|n` |
410
+ | RSL licence | `ai-train` | `ai-input` | `ai-index`, `search` |
411
+ | TDMRep (meta > header > well-known) | `tdm-reservation: 0\|1` | — | — |
412
+ | robots meta | `noai` | — | — |
413
+
414
+ | Condition | Points |
415
+ | --- | --- |
416
+ | No declaration of any kind | **→ 40** |
417
+ | Two mechanisms give opposite answers on one dimension | −25 per dimension |
418
+ | `Content-Usage` header outside the AIPREF vocabulary | warn only, 0 |
419
+ | A dimension no declaration covers | warn only, 0 |
420
+
421
+ Every report states that only robots.txt access rules are documented as honored by major AI operators. The rest are declarations whose weight is legal rather than technical.
422
+
423
+ ### `http-hygiene` — status-code honesty
424
+
425
+ | Condition | Points |
426
+ | --- | --- |
427
+ | A nonexistent path returns 200 | −30 |
428
+ | A nonexistent path redirects | −20 |
429
+ | A 429 with no `Retry-After` | −20 |
430
+ | A 429 with `Retry-After` on the second request | −10 |
431
+ | `HEAD` refused (405/501) | −10 |
432
+ | Over one redirect hop to the homepage | −10 |
433
+ | No `Content-Type` header | −10 |
434
+ | No charset in the header or the document | −10 |
435
+ | `<html lang>` disagrees with `Content-Language` | −5 |
436
+ | A nonexistent path returns 403/401 | −5 |
437
+ | Empty 404 body | −5 |
438
+ | The 404 probe was challenged by bot management | warn only, 0 |
439
+
440
+ ### `ai-catalog` — the index of everything callable
441
+
442
+ Discovery, in the order Lighthouse's `ard-schema` audit uses: robots.txt `Agentmap:`, `Link: rel="ai-catalog"`, `<link rel="ai-catalog">`, then `/.well-known/ai-catalog.json` and `/.well-known/ard.json`. Both specifications are drafts, so absence warns and scores nothing.
443
+
444
+ | Condition | Points |
445
+ | --- | --- |
446
+ | Catalog is not valid JSON | **→ 10** |
447
+ | An entry points at a document that cannot be fetched | −15 each |
448
+ | No entries | −20 |
449
+ | Entry missing identifier, type, or url/data | −10 |
450
+ | No `specVersion` / no `host` | −5 each |
451
+ | Entry served with a different media type than declared | −5 |
452
+
453
+ ### `agent-skills` — installable procedures
454
+
455
+ Conditional: **n/a** unless the site has a developer-facing surface (documentation links, llms.txt, or an API description). Probes `/.well-known/agent-skills/index.json`, `/.well-known/skills/index.json`, then `/skill.md`.
456
+
457
+ | Condition | Points |
458
+ | --- | --- |
459
+ | Index is not valid JSON | **→ 10** |
460
+ | A sampled skill is unreachable, or its frontmatter name disagrees with the index | −10 per problem |
461
+ | Index lists no skills | −30 |
462
+ | A skill has no description | −15 |
463
+ | No entry carries a url | −15 |
464
+ | A skill name is outside `[a-z0-9-]{1,64}` | −10 |
465
+ | Malformed digest, unknown type, over-long description, or no `$schema` | −5 each |
466
+ | No skill declares a digest | −5 |
467
+ | A single `/skill.md` with no index | −20 |
468
+
469
+ ### `webmcp` — forms as callable tools
470
+
471
+ Conditional: **n/a** on a page with no forms and no WebMCP code. Never asks for WebMCP — it is a Community Group draft in a Chrome origin trial.
472
+
473
+ | Condition | Points |
474
+ | --- | --- |
475
+ | `toolname` with no `tooldescription`, or the reverse | −30 |
476
+ | Parameters with no `toolparamdescription` | −15 |
477
+ | Tool name is not a usable identifier | −10 |
478
+ | Deprecated `navigator.modelContext` namespace | −10 |
479
+ | Forms present, none annotated | warn only, 0 |
480
+
481
+ ### `agent-operability` — can a browser agent work this page?
482
+
483
+ Browser agents read the accessibility tree, not the pixels. A `<div onclick>` styled as a button has no role and no name, so it does not appear in the tree at all: the agent does not see a button it cannot press, it sees nothing.
484
+
485
+ | Condition | Points |
486
+ | --- | --- |
487
+ | Homepage HTML unavailable | **hard fail → 0** |
488
+ | Under 90% of buttons and links have an accessible name | −20 |
489
+ | Under 90% of form controls are labelled | −20 |
490
+ | Clickable elements that are not buttons or links (no `role` + `tabindex`) | −15 |
491
+ | A CAPTCHA or `<meta http-equiv="refresh">` on the entry page | −15 |
492
+ | Links with no `href` or a `javascript:` one | −10 |
493
+ | Tables with no `<th>` | −10 |
494
+ | Untitled iframes, `<time>` without `datetime`, heading-level skips, unsized media, no `<html lang>` | −5 each |
495
+
496
+ Names are read from visible text, `aria-label`, `aria-labelledby`, `title`, image `alt`, and SVG `<title>`. Labels from `<label for>`, a wrapping label, or ARIA.
497
+
498
+ Every run ends with a method note: this reads markup, not a rendered accessibility tree, so labels attached by script and roles computed at runtime are invisible to it. A low proportion is a prompt to check the real tree, not a count to act on blindly. Every finding is also a plain accessibility defect.
499
+
500
+ ### `commerce-discovery` — Universal Commerce Protocol
501
+
502
+ Conditional: **n/a** unless the page shows storefront signals. A lone `Offer` is a price statement, not a catalog, so it counts only alongside a cart link.
503
+
504
+ | Condition | Points |
505
+ | --- | --- |
506
+ | Profile requires authentication | **hard fail → 0** |
507
+ | Profile is not valid JSON | **→ 10** |
508
+ | No `ucp` object | **→ 20** |
509
+ | No services declared | −25 |
510
+ | A declared schema URL cannot be fetched | −20 |
511
+ | No version | −20 |
512
+ | No payment handlers | −15 |
513
+ | Version is not a specification date | −10 |
514
+ | No signing keys | −10 |
515
+ | No schema URL declared | −10 |
516
+ | Service name not in reverse-DNS form, unnamed handler | −5 each |
517
+
518
+ The OpenAI and Stripe Agentic Commerce Protocol defines no manifest, and AP2 advertises through an A2A card extension, so neither is probed.
519
+
520
+ ### `auth-discovery` — can an agent get credentials?
521
+
522
+ Conditional: **n/a** unless the site exposes an API description, API catalog, MCP server card or commerce profile. Follows the RFC 9728 chain from `WWW-Authenticate` or `/.well-known/oauth-protected-resource` to the authorization server's RFC 8414 or OpenID metadata.
523
+
524
+ | Condition | Points |
525
+ | --- | --- |
526
+ | Metadata names no authorization server | **→ 40** |
527
+ | Authorization server publishes no discovery metadata | −30 |
528
+ | Invalid issuer URL | −25 |
529
+ | Missing `issuer`, `authorization_endpoint` or `token_endpoint` | −15 each |
530
+ | No PKCE with `S256` | −15 |
531
+ | No dynamic registration and no Client ID Metadata Documents | −10 |
532
+ | No `resource` identifier | −10 |
533
+
534
+ ---
535
+
536
+ ## Overall scoring model
537
+
538
+ Each check returns 0–100. The overall score is the weighted average across the checks that ran:
539
+
540
+ ```
541
+ overall = round( Σ (score_i / 100 × weight_i) / Σ weight_i × 100 )
542
+ ```
543
+
544
+ When every selected check has weight 0 (e.g. `--checks rsl`), the overall falls back to a plain average of check scores.
545
+
546
+ Checks reporting `applicable: false` are excluded from both the numerator and the denominator. A check whose meta exists but produced no result — because it crashed — still counts at full weight, so a broken check cannot inflate a score by shrinking the denominator.
547
+
548
+ | Grade | Score | Exit code |
549
+ | --- | --- | --- |
550
+ | Excellent | 90–100 | 0 |
551
+ | Good | 70–89 | 0 |
552
+ | Fair | 50–69 | 1 |
553
+ | Poor | 0–49 | 1 |
554
+
555
+ Weights live in `src/constants.ts` (`CHECK_WEIGHTS`); a check's own `meta.weight` takes precedence. The scoring policy for 3.x — why new checks ship at weight 0 — is documented in [architecture.md](./architecture.md).
package/docs/ci.md ADDED
@@ -0,0 +1,89 @@
1
+ # CI Integration
2
+
3
+ ax-audit's exit codes (see [cli.md](./cli.md)) make it a drop-in quality gate: `0` for Good/Excellent, `1` for Fair/Poor or regressions.
4
+
5
+ ## GitHub Actions
6
+
7
+ ### Basic gate
8
+
9
+ ```yaml
10
+ - name: AX Audit
11
+ run: npx ax-audit https://your-site.com
12
+ # Fails the step if the score < 70
13
+ ```
14
+
15
+ ### Regression gate with a committed baseline
16
+
17
+ Commit `.ax-baseline.json` to the repo and fail the build only when a check drops:
18
+
19
+ ```yaml
20
+ - name: AX Audit (regression gate)
21
+ run: npx ax-audit https://your-site.com --baseline .ax-baseline.json --fail-on-regression 5
22
+ ```
23
+
24
+ Refresh the baseline deliberately (e.g., after intentional changes):
25
+
26
+ ```bash
27
+ npx ax-audit https://your-site.com --save-baseline .ax-baseline.json
28
+ git add .ax-baseline.json && git commit -m "chore: refresh AX baseline"
29
+ ```
30
+
31
+ ### Markdown report as a PR comment
32
+
33
+ ```yaml
34
+ - name: AX Audit (markdown)
35
+ run: npx ax-audit ${{ env.PREVIEW_URL }} --output markdown > ax-report.md
36
+ continue-on-error: true
37
+
38
+ - name: Comment PR
39
+ uses: marocchino/sticky-pull-request-comment@v2
40
+ with:
41
+ path: ax-report.md
42
+ ```
43
+
44
+ This pairs naturally with Vercel/Netlify preview deployments: audit the preview URL on every PR and the reviewer sees the AX impact inline.
45
+
46
+ ### Artifacts
47
+
48
+ ```yaml
49
+ - name: AX Audit (JSON)
50
+ run: npx ax-audit https://your-site.com --json > ax-report.json
51
+
52
+ - uses: actions/upload-artifact@v4
53
+ with:
54
+ name: ax-audit-report
55
+ path: ax-report.json
56
+ ```
57
+
58
+ ## Auditing multiple environments
59
+
60
+ ```yaml
61
+ - name: AX Audit (all properties)
62
+ run: npx ax-audit https://www.your-site.com https://docs.your-site.com https://api.your-site.com --concurrency 3
63
+ # Exit 1 if any property scores < 70
64
+ ```
65
+
66
+ ## Tuning for CI stability
67
+
68
+ - `--retries 3` absorbs transient 5xx/timeouts from cold preview deployments (default is 2).
69
+ - `--timeout 15000` for slow staging environments.
70
+ - `--checks ...` to gate only on the surface you are iterating on — but remember the overall score then averages only the selected checks.
71
+
72
+ ## Scheduled audits
73
+
74
+ A weekly audit catches drift from infrastructure changes (CDN settings, WAF rules, header changes deployed by other teams):
75
+
76
+ ```yaml
77
+ on:
78
+ schedule:
79
+ - cron: '0 6 * * 1'
80
+
81
+ jobs:
82
+ ax-audit:
83
+ runs-on: ubuntu-latest
84
+ steps:
85
+ - uses: actions/checkout@v4
86
+ - run: npx ax-audit https://your-site.com --baseline .ax-baseline.json --fail-on-regression 0
87
+ ```
88
+
89
+ `--fail-on-regression 0` makes any per-check drop fail the workflow — appropriate for scheduled runs where every change is unexpected.