@forkpoint/agent-lighthouse-core 2.0.0 → 3.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.
package/dist/index.mjs CHANGED
@@ -8,6 +8,7 @@ var MAX_CONCURRENT_REQUESTS = 10;
8
8
  var SCANNER_USER_AGENT = "AgentLighthouse/1.0 (+https://github.com/ForkPoint/agent-lighthouse)";
9
9
  var TAG_SKIPPED_PAGE_TYPE = "skipped:page-type";
10
10
  var TAG_SCAN_ERROR = "scan-error";
11
+ var TAG_SKIPPED_NO_EVIDENCE = "skipped:no-evidence";
11
12
  var CATEGORY_NAMES = {
12
13
  "access-crawl-control": "Access & Crawl Control",
13
14
  "content-extraction": "Content Extraction",
@@ -226,6 +227,7 @@ function createFetcher() {
226
227
  });
227
228
  let gateArmed;
228
229
  let hops = 0;
230
+ const redirectChain = [];
229
231
  while (followRedirects && REDIRECT_STATUS.has(response.statusCode) && response.headers["location"] !== void 0 && hops < MAX_REDIRECTS) {
230
232
  const rawLocation = response.headers["location"];
231
233
  const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
@@ -261,6 +263,7 @@ function createFetcher() {
261
263
  currentMethod = "GET";
262
264
  currentBody = void 0;
263
265
  }
266
+ redirectChain.push({ status: response.statusCode, from: currentUrl, to: next });
264
267
  currentUrl = next;
265
268
  hops += 1;
266
269
  response = await request(currentUrl, {
@@ -307,7 +310,8 @@ function createFetcher() {
307
310
  totalMs: Math.round(totalMs),
308
311
  contentType: headers["content-type"] ?? "",
309
312
  contentLength: bytes ? bytes.byteLength : truncatedBody.length,
310
- ...bytes ? { bytes } : {}
313
+ ...bytes ? { bytes } : {},
314
+ ...redirectChain.length > 0 ? { redirectChain } : {}
311
315
  };
312
316
  } catch (err) {
313
317
  const totalMs = performance.now() - start;
@@ -556,12 +560,23 @@ function extractHeadings($) {
556
560
  });
557
561
  return headings;
558
562
  }
559
- function getMainContentText($) {
560
- const root = $("main").first().length ? $("main").first() : $("body");
563
+ function readableText(root) {
561
564
  const clone = root.clone();
562
565
  clone.find("script, style, noscript, template").remove();
563
566
  return clone.text().replace(/\s+/g, " ").trim();
564
567
  }
568
+ function getMainContentText($) {
569
+ let best = "";
570
+ $("body").find("main").each((_, el) => {
571
+ const text3 = readableText($(el));
572
+ if (text3.length > best.length) best = text3;
573
+ });
574
+ if (best) return best;
575
+ return readableText($("body"));
576
+ }
577
+ function getRenderedText($) {
578
+ return readableText($("body"));
579
+ }
565
580
  function getWordCount($) {
566
581
  const text3 = getMainContentText($);
567
582
  return text3.split(/\s+/).filter(Boolean).length;
@@ -795,6 +810,12 @@ var DeprecationNoticeSchema = z.object({
795
810
  var EvidenceGradeSchema = z.enum(["A", "B", "C", "D"]);
796
811
  var AuditTierSchema = z.enum(["scored", "informative", "experimental"]);
797
812
  var AUDIT_ID_PATTERN = /^[a-z-]+\/[a-z0-9-]+$/;
813
+ var EvidenceKeySchema = z.enum([
814
+ "origin-reachable",
815
+ "unblocked-fetches",
816
+ "rendered-body",
817
+ "sample-adequate"
818
+ ]);
798
819
  var AuditMetaSchema = z.object({
799
820
  id: z.string().regex(AUDIT_ID_PATTERN, "audit id must be a `category/slug` path"),
800
821
  category: z.string(),
@@ -813,7 +834,10 @@ var AuditMetaSchema = z.object({
813
834
  // its weight comes from (grade + tier) and which dossier proves it.
814
835
  evidenceGrade: EvidenceGradeSchema,
815
836
  tier: AuditTierSchema,
816
- dossier: z.string().min(1).max(500)
837
+ dossier: z.string().min(1).max(500),
838
+ // What the audit needs the scan to have obtained. Checked against the
839
+ // source by `scripts/check-requires.mjs`, not enforced here beyond shape.
840
+ requires: z.array(EvidenceKeySchema).optional()
817
841
  });
818
842
  var CheckResultSchema = z.object({
819
843
  // v2 ids are `category/slug` paths, which outgrew the old 20-char cap.
@@ -1038,6 +1062,19 @@ function calculateOverallScore(categories) {
1038
1062
  if (totalMass === 0) return 0;
1039
1063
  return Math.round(weighted / totalMass);
1040
1064
  }
1065
+ var GATED_MASS_UNSCORED_THRESHOLD = 0.35;
1066
+ function gatedMassShare(checks2) {
1067
+ let gated = 0;
1068
+ let total = 0;
1069
+ for (const check of checks2) {
1070
+ if (isInformative(check)) continue;
1071
+ const mass = check.weight ?? 0;
1072
+ if (mass <= 0) continue;
1073
+ total += mass;
1074
+ if (check.tags?.includes(TAG_SKIPPED_NO_EVIDENCE)) gated += mass;
1075
+ }
1076
+ return total === 0 ? 0 : gated / total;
1077
+ }
1041
1078
 
1042
1079
  // src/audits/access-crawl-control/no-nofollow.ts
1043
1080
  var NoNofollowAudit = class _NoNofollowAudit extends Audit {
@@ -1052,6 +1089,8 @@ var NoNofollowAudit = class _NoNofollowAudit extends Audit {
1052
1089
  evidenceGrade: "A",
1053
1090
  tier: "scored",
1054
1091
  dossier: "docs/evidence/audits/access-crawl-control/no-nofollow.md",
1092
+ // Gate exemption: being refused is what this category reports.
1093
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
1055
1094
  defaultPriority: "high",
1056
1095
  guidance: {
1057
1096
  impact: "A nofollow directive prevents AI crawlers from following links on your pages, effectively hiding all linked content from AI indexing. Your deeper pages become invisible to AI search engines, drastically reducing discoverability.",
@@ -1133,6 +1172,8 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
1133
1172
  evidenceGrade: "A",
1134
1173
  tier: "scored",
1135
1174
  dossier: "docs/evidence/audits/access-crawl-control/no-redirect-chains.md",
1175
+ // Gate exemption: being refused is what this category reports.
1176
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
1136
1177
  defaultPriority: "medium",
1137
1178
  guidance: {
1138
1179
  impact: "Redirect chains slow down AI crawlers and waste their limited crawl budget. Each extra redirect adds latency and increases the chance a crawler gives up before reaching the final page, leaving content unindexed.",
@@ -1278,6 +1319,8 @@ var CanonicalLinksAudit = class extends Audit {
1278
1319
  evidenceGrade: "A",
1279
1320
  tier: "scored",
1280
1321
  dossier: "docs/evidence/audits/access-crawl-control/canonical.md",
1322
+ // Gate exemption: being refused is what this category reports.
1323
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
1281
1324
  defaultPriority: "medium",
1282
1325
  guidance: {
1283
1326
  impact: "A canonical pointing at the wrong URL is worse than no canonical at all: when every page canonicalizes onto the homepage \u2014 a common CMS and SPA template bug \u2014 the pages consolidate onto one URL and drop out of the index that AI Overviews and AI Mode draw on. A canonical pointing at another domain hands the attribution there.",
@@ -1726,6 +1769,8 @@ var GptbotAudit = class extends CrawlerBotAudit {
1726
1769
  evidenceGrade: "A",
1727
1770
  tier: "scored",
1728
1771
  dossier: "docs/evidence/audits/access-crawl-control/gptbot.md",
1772
+ // Gate exemption: being refused is what this category reports.
1773
+ requires: ["origin-reachable"],
1729
1774
  defaultPriority: "medium",
1730
1775
  guidance: {
1731
1776
  impact: "Blocking GPTBot prevents your content from being used by OpenAI's models and appearing in ChatGPT responses. Explicitly allowing it signals that your site welcomes AI indexing for the largest AI platform by user base.",
@@ -1756,6 +1801,8 @@ var GoogleExtendedAudit = class extends CrawlerBotAudit {
1756
1801
  evidenceGrade: "A",
1757
1802
  tier: "scored",
1758
1803
  dossier: "docs/evidence/audits/access-crawl-control/google-extended.md",
1804
+ // Gate exemption: being refused is what this category reports.
1805
+ requires: ["origin-reachable"],
1759
1806
  defaultPriority: "medium",
1760
1807
  guidance: {
1761
1808
  impact: "Blocking Google-Extended prevents your content from being used in Google's AI features like Gemini and AI Overviews. Allowing it ensures your site appears in Google's AI-powered search experiences alongside traditional results.",
@@ -1790,6 +1837,8 @@ var AnthropicAudit = class extends CrawlerBotAudit {
1790
1837
  evidenceGrade: "A",
1791
1838
  tier: "scored",
1792
1839
  dossier: "docs/evidence/audits/access-crawl-control/anthropic-ai.md",
1840
+ // Gate exemption: being refused is what this category reports.
1841
+ requires: ["origin-reachable"],
1793
1842
  defaultPriority: "medium",
1794
1843
  guidance: {
1795
1844
  impact: "Disallowing ClaudeBot keeps the site out of the web content Anthropic collects for potential model training. It is an effective, documented control, so it is only a problem where the block was not intended. It buys back very little traffic either way: Cloudflare Radar measures Anthropic's crawl-to-refer ratio at roughly 50,000:1, so the allow-side case is about corpus inclusion rather than referral visibility.",
@@ -1899,6 +1948,8 @@ var PerplexitybotAudit = class extends CrawlerBotAudit {
1899
1948
  evidenceGrade: "A",
1900
1949
  tier: "scored",
1901
1950
  dossier: "docs/evidence/audits/access-crawl-control/perplexitybot.md",
1951
+ // Gate exemption: being refused is what this category reports.
1952
+ requires: ["origin-reachable"],
1902
1953
  defaultPriority: "medium",
1903
1954
  guidance: {
1904
1955
  impact: "Blocking PerplexityBot prevents your content from appearing in Perplexity AI search results, one of the fastest-growing AI answer engines. Allowing it gives your content visibility in AI-native search.",
@@ -1929,6 +1980,8 @@ var ApplebotExtendedAudit = class extends CrawlerBotAudit {
1929
1980
  evidenceGrade: "A",
1930
1981
  tier: "scored",
1931
1982
  dossier: "docs/evidence/audits/access-crawl-control/applebot-extended.md",
1983
+ // Gate exemption: being refused is what this category reports.
1984
+ requires: ["origin-reachable"],
1932
1985
  defaultPriority: "medium",
1933
1986
  guidance: {
1934
1987
  impact: "Blocking Applebot-Extended prevents your content from being used in Apple Intelligence features, Siri AI answers, and Safari Highlights. Allowing it ensures visibility across Apple's AI ecosystem.",
@@ -1959,6 +2012,8 @@ var CcbotAudit = class extends CrawlerBotAudit {
1959
2012
  evidenceGrade: "A",
1960
2013
  tier: "scored",
1961
2014
  dossier: "docs/evidence/audits/access-crawl-control/ccbot.md",
2015
+ // Gate exemption: being refused is what this category reports.
2016
+ requires: ["origin-reachable"],
1962
2017
  defaultPriority: "medium",
1963
2018
  guidance: {
1964
2019
  impact: "Blocking CCBot prevents your content from being included in the Common Crawl dataset, which is a foundational training data source for many AI models. Allowing it broadens your content's reach across multiple AI systems.",
@@ -1990,6 +2045,8 @@ var MetaExternalAgentAudit = class extends CrawlerBotAudit {
1990
2045
  evidenceGrade: "A",
1991
2046
  tier: "scored",
1992
2047
  dossier: "docs/evidence/audits/access-crawl-control/meta-external-agent.md",
2048
+ // Gate exemption: being refused is what this category reports.
2049
+ requires: ["origin-reachable"],
1993
2050
  defaultPriority: "medium",
1994
2051
  guidance: {
1995
2052
  impact: "Disallowing Meta-ExternalAgent keeps the site out of Meta's foundation-model training corpus and out of the direct content indexing that improves Meta products. It is an effective, documented control, so it is only a problem where the block was not intended. It does not by itself govern Meta AI search citations \u2014 Meta documents Meta-WebIndexer as the token behind those.",
@@ -2082,6 +2139,8 @@ var AmazonbotAudit = class extends CrawlerBotAudit {
2082
2139
  evidenceGrade: "A",
2083
2140
  tier: "scored",
2084
2141
  dossier: "docs/evidence/audits/access-crawl-control/amazonbot.md",
2142
+ // Gate exemption: being refused is what this category reports.
2143
+ requires: ["origin-reachable"],
2085
2144
  defaultPriority: "medium",
2086
2145
  guidance: {
2087
2146
  impact: "Blocking Amazonbot prevents your content from appearing in Alexa AI answers and Amazon's AI-powered search features. Allowing it gives your content visibility in Amazon's voice and commerce AI ecosystem.",
@@ -2156,6 +2215,8 @@ var AiBotDirectivesAudit = class extends Audit {
2156
2215
  evidenceGrade: "B",
2157
2216
  tier: "scored",
2158
2217
  dossier: "docs/evidence/audits/access-crawl-control/ai-bot-directives.md",
2218
+ // Gate exemption: being refused is what this category reports.
2219
+ requires: ["origin-reachable"],
2159
2220
  defaultPriority: "medium",
2160
2221
  guidance: {
2161
2222
  impact: "Blocking YouBot removes the site from You.com's live search index; blocking AI2Bot removes it from the Allen Institute's open training corpora while leaving closed commercial crawlers untouched. Leaving either to the wildcard rule means the policy silently flips the day a blanket block is added. The other three tokens carry no comparable consumer, so this audit never penalises blocking them.",
@@ -2222,6 +2283,8 @@ var ChatgptUserAudit = class extends CrawlerBotAudit {
2222
2283
  evidenceGrade: "C",
2223
2284
  tier: "informative",
2224
2285
  dossier: "docs/evidence/audits/access-crawl-control/chatgpt-user.md",
2286
+ // Gate exemption: being refused is what this category reports.
2287
+ requires: ["origin-reachable"],
2225
2288
  defaultPriority: "medium",
2226
2289
  guidance: {
2227
2290
  impact: "Blocking ChatGPT-User prevents ChatGPT from browsing your site in real-time when users ask it to visit your pages. This blocks your content from being cited in ChatGPT Browse conversations, losing a significant source of AI-driven traffic.",
@@ -2252,6 +2315,8 @@ var ClaudeUserAudit = class extends CrawlerBotAudit {
2252
2315
  evidenceGrade: "A",
2253
2316
  tier: "scored",
2254
2317
  dossier: "docs/evidence/audits/access-crawl-control/claude-user.md",
2318
+ // Gate exemption: being refused is what this category reports.
2319
+ requires: ["origin-reachable"],
2255
2320
  defaultPriority: "medium",
2256
2321
  guidance: {
2257
2322
  impact: "Blocking Claude-User prevents Claude from browsing your site in real-time when users ask it to visit your pages. This blocks your content from being cited in Claude conversations with web access enabled.",
@@ -2281,6 +2346,8 @@ var OaiSearchbotAudit = class extends CrawlerBotAudit {
2281
2346
  evidenceGrade: "A",
2282
2347
  tier: "scored",
2283
2348
  dossier: "docs/evidence/audits/access-crawl-control/oai-searchbot.md",
2349
+ // Gate exemption: being refused is what this category reports.
2350
+ requires: ["origin-reachable"],
2284
2351
  defaultPriority: "medium",
2285
2352
  guidance: {
2286
2353
  impact: "Blocking OAI-SearchBot prevents your content from appearing in OpenAI's SearchGPT and ChatGPT web search results. Allowing it ensures your site is discoverable through OpenAI's real-time search features.",
@@ -2311,6 +2378,8 @@ var MetaExternalFetcherAudit = class extends CrawlerBotAudit {
2311
2378
  evidenceGrade: "A",
2312
2379
  tier: "scored",
2313
2380
  dossier: "docs/evidence/audits/access-crawl-control/meta-external-fetcher.md",
2381
+ // Gate exemption: being refused is what this category reports.
2382
+ requires: ["origin-reachable"],
2314
2383
  defaultPriority: "medium",
2315
2384
  guidance: {
2316
2385
  impact: "Blocking Meta-ExternalFetcher prevents Meta's AI from fetching your content in real-time for AI-powered features across Facebook, Instagram, and WhatsApp. Allowing it ensures your content can be surfaced in Meta's real-time AI experiences.",
@@ -2340,6 +2409,8 @@ var BravebotAudit = class extends CrawlerBotAudit {
2340
2409
  evidenceGrade: "C",
2341
2410
  tier: "informative",
2342
2411
  dossier: "docs/evidence/audits/access-crawl-control/bravebot.md",
2412
+ // Gate exemption: being refused is what this category reports.
2413
+ requires: ["origin-reachable"],
2343
2414
  defaultPriority: "medium",
2344
2415
  guidance: {
2345
2416
  impact: "Blocking Bravebot prevents your content from appearing in Brave Search AI answers and Brave Leo AI assistant responses. Allowing it gives your content visibility in the privacy-focused Brave browser ecosystem.",
@@ -2369,6 +2440,8 @@ var DuckassistbotAudit = class extends CrawlerBotAudit {
2369
2440
  evidenceGrade: "A",
2370
2441
  tier: "scored",
2371
2442
  dossier: "docs/evidence/audits/access-crawl-control/duckassistbot.md",
2443
+ // Gate exemption: being refused is what this category reports.
2444
+ requires: ["origin-reachable"],
2372
2445
  defaultPriority: "medium",
2373
2446
  guidance: {
2374
2447
  impact: "Blocking DuckAssistBot prevents your content from appearing in DuckDuckGo's AI-powered DuckAssist feature, which generates instant answers from crawled web pages. Allowing it ensures visibility in this privacy-first AI search experience.",
@@ -2398,6 +2471,8 @@ var MistralaiUserAudit = class extends CrawlerBotAudit {
2398
2471
  evidenceGrade: "A",
2399
2472
  tier: "scored",
2400
2473
  dossier: "docs/evidence/audits/access-crawl-control/mistralai-user.md",
2474
+ // Gate exemption: being refused is what this category reports.
2475
+ requires: ["origin-reachable"],
2401
2476
  defaultPriority: "medium",
2402
2477
  guidance: {
2403
2478
  impact: "Blocking MistralAI-User prevents Mistral AI's Le Chat from browsing your site in real-time when users ask it to visit your pages. Allowing it ensures your content can be cited in Mistral-powered AI conversations.",
@@ -2427,6 +2502,8 @@ var ClaudeSearchbotAudit = class extends CrawlerBotAudit {
2427
2502
  evidenceGrade: "A",
2428
2503
  tier: "scored",
2429
2504
  dossier: "docs/evidence/audits/access-crawl-control/claude-searchbot.md",
2505
+ // Gate exemption: being refused is what this category reports.
2506
+ requires: ["origin-reachable"],
2430
2507
  defaultPriority: "medium",
2431
2508
  guidance: {
2432
2509
  impact: "Blocking Claude-SearchBot prevents your content from appearing in Claude's web search results. Allowing it ensures your site is included when Claude searches the web to answer user questions.",
@@ -2456,6 +2533,8 @@ var NoBlanketBlockAudit = class extends Audit {
2456
2533
  evidenceGrade: "B",
2457
2534
  tier: "scored",
2458
2535
  dossier: "docs/evidence/audits/access-crawl-control/no-blanket-block.md",
2536
+ // Gate exemption: being refused is what this category reports.
2537
+ requires: ["origin-reachable"],
2459
2538
  defaultPriority: "critical",
2460
2539
  guidance: {
2461
2540
  impact: "A blanket Disallow: / under User-agent: * blocks every crawler, including all AI agents. Your site becomes completely invisible to AI search engines, ChatGPT Browse, Perplexity, Claude, and all other AI-powered discovery tools.",
@@ -2609,6 +2688,8 @@ var SensitivePathsAudit = class extends Audit {
2609
2688
  evidenceGrade: "A",
2610
2689
  tier: "scored",
2611
2690
  dossier: "docs/evidence/audits/access-crawl-control/sensitive-paths.md",
2691
+ // Gate exemption: being refused is what this category reports.
2692
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
2612
2693
  defaultPriority: "low",
2613
2694
  guidance: {
2614
2695
  impact: 'Cart, checkout, site-search, login and account URLs carry nothing an answer engine can cite, but they are crawled and can surface in AI answers as dead, session-bearing links. Apple documents Applebot and Applebot-Extended honouring "Disallow: /private/", and Meta documents the same for meta-externalagent, so a path-level rule keeps that noise out of AI crawls. Two limits matter: RFC 9309 states the protocol "is not a substitute for valid content security measures" and that listed paths become publicly discoverable, so never use robots.txt to protect anything; and user-initiated fetchers are documented not to obey it \u2014 OpenAI says of ChatGPT-User "Because these actions are initiated by a user, robots.txt rules may not apply", and Perplexity says Perplexity-User "generally ignores robots.txt rules".',
@@ -2704,6 +2785,8 @@ var CrawlDelayAudit = class extends Audit {
2704
2785
  evidenceGrade: "C",
2705
2786
  tier: "informative",
2706
2787
  dossier: "docs/evidence/audits/access-crawl-control/crawl-delay.md",
2788
+ // Gate exemption: being refused is what this category reports.
2789
+ requires: ["origin-reachable"],
2707
2790
  defaultPriority: "high",
2708
2791
  guidance: {
2709
2792
  impact: "Excessive Crawl-delay values (over 10 seconds) dramatically slow AI indexing, meaning your latest content may take days or weeks to appear in AI search results while competitors with lower delays get indexed faster.",
@@ -2844,6 +2927,8 @@ var MetaRobotsNotBlockingAudit = class extends Audit {
2844
2927
  evidenceGrade: "A",
2845
2928
  tier: "scored",
2846
2929
  dossier: "docs/evidence/audits/access-crawl-control/robots-directives.md",
2930
+ // Gate exemption: being refused is what this category reports.
2931
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
2847
2932
  defaultPriority: "high",
2848
2933
  guidance: {
2849
2934
  impact: 'A content page carrying "noindex" (in a robots meta tag, a per-bot meta tag, or the X-Robots-Tag response header) is dropped from the search index, and Google documents that a page must be indexed to appear in AI Overviews or AI Mode. "nosnippet", "noarchive" and "max-snippet:0" keep the page indexed but stop its text being used as a direct input for AI answers.',
@@ -2932,6 +3017,8 @@ var NoBotDetectionAudit = class extends Audit {
2932
3017
  evidenceGrade: "A",
2933
3018
  tier: "scored",
2934
3019
  dossier: "docs/evidence/audits/access-crawl-control/no-bot-detection.md",
3020
+ // Gate exemption: being refused is what this category reports.
3021
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
2935
3022
  defaultPriority: "high",
2936
3023
  guidance: {
2937
3024
  impact: "Bot-detection services like Cloudflare Turnstile, DataDome, and reCAPTCHA can block legitimate AI agents from accessing your content. When agents are challenged, they cannot complete page fetches, making your content inaccessible to AI-powered search and assistants.",
@@ -3093,6 +3180,8 @@ var TdmRepAudit = class extends Audit {
3093
3180
  evidenceGrade: "C",
3094
3181
  tier: "experimental",
3095
3182
  dossier: "docs/evidence/audits/access-crawl-control/tdm-rep.md",
3183
+ // Gate exemption: being refused is what this category reports.
3184
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3096
3185
  // Nothing consumes the signal, so nothing here should outrank an item that
3097
3186
  // changes what an agent can do.
3098
3187
  defaultPriority: "low",
@@ -3241,6 +3330,8 @@ var AgentGovernanceAudit = class extends Audit {
3241
3330
  evidenceGrade: "A",
3242
3331
  tier: "scored",
3243
3332
  dossier: "docs/evidence/audits/access-crawl-control/agent-governance.md",
3333
+ // Gate exemption: being refused is what this category reports.
3334
+ requires: ["origin-reachable"],
3244
3335
  defaultPriority: "medium",
3245
3336
  guidance: {
3246
3337
  impact: "Without separate rules for training crawlers and live conversational agents, you cannot block dataset scraping while still appearing in ChatGPT, Claude, and Perplexity answers. A blanket policy either locks you out of AI-powered discovery entirely or leaves your content open to bulk training crawls you never agreed to.",
@@ -3376,6 +3467,8 @@ var AiContentDeclarationAudit = class extends Audit {
3376
3467
  evidenceGrade: "D",
3377
3468
  tier: "experimental",
3378
3469
  dossier: "docs/evidence/audits/access-crawl-control/ai-content-declaration.md",
3470
+ // Gate exemption: being refused is what this category reports.
3471
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3379
3472
  // Was `medium` on an invented directive; the whole class of signals is
3380
3473
  // pre-consumer, so nothing here should outrank an actionable item.
3381
3474
  defaultPriority: "low",
@@ -3439,6 +3532,8 @@ var HttpsEnabledAudit = class extends Audit {
3439
3532
  evidenceGrade: "A",
3440
3533
  tier: "scored",
3441
3534
  dossier: "docs/evidence/audits/access-crawl-control/https-enabled.md",
3535
+ // Gate exemption: being refused is what this category reports.
3536
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3442
3537
  defaultPriority: "critical",
3443
3538
  guidance: {
3444
3539
  impact: "HTTP-only sites are completely excluded from all major AI systems. GPTBot, ClaudeBot, Perplexity, and enterprise RAG pipelines refuse to connect to non-HTTPS origins due to security policies. Your entire site is invisible to AI-generated answers, product recommendations, and agentic workflows.",
@@ -3587,6 +3682,8 @@ var RobotsAiGroupShadowingAudit = class _RobotsAiGroupShadowingAudit extends Aud
3587
3682
  evidenceGrade: "A",
3588
3683
  tier: "scored",
3589
3684
  dossier: "docs/evidence/audits/access-crawl-control/robots-ai-group-shadowing.md",
3685
+ // Gate exemption: being refused is what this category reports.
3686
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3590
3687
  defaultPriority: "high",
3591
3688
  guidance: {
3592
3689
  impact: "RFC 9309 \xA72.2.1 states the wildcard group is consulted only 'if no matching group exists'. Therefore, for any site with a named AI-bot group, the wildcard group's Disallow rules provably do not apply to that bot, and the operator's stated intent (expressed once in `*`) diverges from the enforced policy by exactly the symmetric difference of the two rule sets. Falsifiable by construction: given robots.txt R and token T, the set of paths where R_T and R_star disagree is computable and either empty or not.",
@@ -4045,6 +4142,8 @@ var AiCrawlerEdgeParityAudit = class extends Audit {
4045
4142
  evidenceGrade: "A",
4046
4143
  tier: "scored",
4047
4144
  dossier: "docs/evidence/audits/access-crawl-control/ai-crawler-edge-parity.md",
4145
+ // Gate exemption: being refused is what this category reports.
4146
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
4048
4147
  defaultPriority: "critical",
4049
4148
  guidance: {
4050
4149
  impact: 'robots.txt (RFC 9309) is advisory metadata parsed by the crawler; the edge access decision is enforced independently by the WAF. A site can therefore publish "User-agent: PerplexityBot / Allow: /" and return a non-200 to every request carrying that user agent, and the operator \u2014 who reads their own robots.txt \u2014 believes they are open while the crawler never sees a byte. Falsifiable: fetch URL U with a browser UA and with crawler UA C; if robots.txt permits C for U and the C request is not 2xx while the browser request is 200, the two policy layers contradict each other. Cloudflare makes one branch deterministic \u2014 a challenge always carries cf-mitigated: challenge \u2014 and a 200 whose main-content text is under 40% of the baseline is a block wearing a 200.',
@@ -4260,6 +4359,8 @@ var BotContentDeltaDeclaredAudit = class extends Audit {
4260
4359
  evidenceGrade: "A",
4261
4360
  tier: "scored",
4262
4361
  dossier: "docs/evidence/audits/access-crawl-control/bot-content-delta-declared.md",
4362
+ // Gate exemption: being refused is what this category reports.
4363
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
4263
4364
  defaultPriority: "high",
4264
4365
  guidance: {
4265
4366
  impact: "Google states that isAccessibleForFree: false with hasPart/cssSelector markup 'helps Google differentiate paywalled content from the practice of cloaking, which violates spam policies' \u2014 serving a crawler less than a user is sanctioned only when it is declared. The measurement is falsifiable both ways: extract the main text of URL U under a browser UA and under crawler UA C, and if the length ratio falls below 0.6 or the 5-gram shingle similarity below 0.7, the site conditions content on the User-Agent. The declaration is equally checkable, and the declared cssSelector must match a real element in the served HTML \u2014 which is where most implementations silently fail, leaving markup that validates and points at nothing. The second-order cost is not the spam risk: an answer engine that only ever sees the stub cites the stub.",
@@ -4560,6 +4661,8 @@ var AiUsageSignalCoherenceAcrossChannelsAudit = class extends Audit {
4560
4661
  weight: weightForGrade("B", "scored"),
4561
4662
  defaultPriority: "high",
4562
4663
  dossier: "docs/evidence/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.md",
4664
+ // Gate exemption: being refused is what this category reports.
4665
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
4563
4666
  guidance: {
4564
4667
  impact: "No standard defines precedence between these channels; each specifies only its own parsing. A crawler that reads TDM-Rep and a crawler that reads AIPREF therefore read disjoint inputs, and when those inputs disagree the two reach opposite conclusions about the same page. Whichever one you did not mean to publish is the one some operator will act on. The documented worst case is not even yours to make: Cloudflare\u2019s managed robots.txt prepends its own Content-Signal block above your file, so your stated policy can be contradicted at the edge without you knowing.",
4565
4668
  fix: "Decide the policy once, then say the same thing in every channel you publish. If you do not intend to maintain a channel, remove it rather than leaving a stale value \u2014 a contradicted signal is worse than a missing one. Where your CDN prepends its own robots.txt block, either turn that feature off or make your own declarations match it.",
@@ -4772,6 +4875,8 @@ var AiprefContentUsageDeclarationValidityAudit = class extends Audit {
4772
4875
  weight: weightForGrade("B", "scored"),
4773
4876
  defaultPriority: "medium",
4774
4877
  dossier: "docs/evidence/audits/access-crawl-control/aipref-content-usage-declaration-validity.md",
4878
+ // Gate exemption: being refused is what this category reports.
4879
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
4775
4880
  guidance: {
4776
4881
  impact: "AIPREF is the one AI-usage vocabulary on the IETF standards track, so a declaration written in it is the one a future crawler is most likely to read. A crawler that cannot parse the line ignores it, and the site is then treated as having no preference at all \u2014 the same outcome as publishing nothing, after the work of publishing something. The costliest version is invisible: a preference attached to a path robots.txt disallows is discarded by the spec itself, so the line looks right and does nothing.",
4777
4882
  fix: "Write `Content-Usage: train-ai=n` \u2014 an RFC 8941 dictionary of `y`/`n` values against the `train-ai` and `search` categories. Use `yes`/`no` only in a Cloudflare `Content-Signal:` line, which is a different directive. Attach preferences to paths a crawler is allowed to fetch, and keep the robots.txt line and the response header saying the same thing for the same path.",
@@ -4963,6 +5068,8 @@ var RslLicensingTermsConformanceAudit = class extends Audit {
4963
5068
  weight: weightForGrade("B", "scored"),
4964
5069
  defaultPriority: "medium",
4965
5070
  dossier: "docs/evidence/audits/access-crawl-control/rsl-licensing-terms-conformance.md",
5071
+ // Gate exemption: being refused is what this category reports.
5072
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
4966
5073
  guidance: {
4967
5074
  impact: 'RSL is the machine-readable form of "here are my terms". A crawler that cannot find the document applies its own defaults instead, and a document it finds but cannot parse is worth no more than one it never found. The specification mandates no default location, so a licence reachable only at a guessed path is one no crawler is obliged to look for. The quiet failure is a `<content url>` prefix that does not cover the pages the licence was written for: the terms load, parse, and apply to nothing.',
4968
5075
  fix: 'Point at the licence from robots.txt with an absolute `License:` URI, and add the `Link: <...>; rel="license"; type="application/rsl+xml"` response header so a crawler that never reads robots.txt still finds it. Serve the document as `application/rsl+xml`, keep the `https://rslstandard.org/rsl` namespace on the root element, and make every `<content url>` prefix cover the paths it licenses.',
@@ -5270,6 +5377,8 @@ var MachineActionable402PaidAccessAudit = class extends Audit {
5270
5377
  weight: weightForGrade("B", "scored"),
5271
5378
  defaultPriority: "medium",
5272
5379
  dossier: "docs/evidence/audits/access-crawl-control/machine-actionable-402-paid-access.md",
5380
+ // Gate exemption: being refused is what this category reports.
5381
+ requires: ["origin-reachable", "rendered-body", "sample-adequate"],
5273
5382
  guidance: {
5274
5383
  impact: "Charging for crawler access is a legitimate choice, and 402 is the status code for it. But a crawler is a program: it can pay only what it can parse. A 402 whose body is an HTML page explaining your licensing terms reads, to the client, as an unexplained refusal \u2014 the same outcome as a 403, after you built a paywall meant to earn revenue. A 402 that a shared cache is allowed to store is worse: the next crawler gets a stored refusal even after paying.",
5275
5384
  fix: 'Send one of the machine-readable forms with the 402: Cloudflare\u2019s `crawler-price: USD 0.01`, an x402 `PAYMENT-REQUIRED` challenge listing what you accept, or a `Link: rel=license` pointing at an RSL document whose `<payment type="crawl">` covers the path. Mark the response `Cache-Control: no-store` so a proxy cannot hand your 402 to a crawler that already paid.',
@@ -5474,6 +5583,8 @@ var WebBotAuthRequestToleranceAudit = class _WebBotAuthRequestToleranceAudit ext
5474
5583
  weight: weightForGrade("B", "scored"),
5475
5584
  defaultPriority: "medium",
5476
5585
  dossier: "docs/evidence/audits/access-crawl-control/web-bot-auth-request-tolerance.md",
5586
+ // Gate exemption: being refused is what this category reports.
5587
+ requires: ["origin-reachable"],
5477
5588
  guidance: {
5478
5589
  impact: "Web Bot Auth is how an agent says who it is in a way an origin can check, and the operators building it are the ones whose traffic you would most want to identify. An edge that answers a signed request with 400 or 403 turns that identification into a reason for refusal: the agents willing to declare themselves are the ones you turn away, and the ones that lie carry no signature headers at all and sail through. A 431 is the same outcome from a different cause \u2014 a header-size limit \u2014 and it is fixed differently.",
5479
5590
  fix: "Let unknown request headers through: `Signature`, `Signature-Input` and `Signature-Agent` are additive and safe to ignore. If your edge enforces a header-size budget, raise it enough for an Ed25519 signature. If you do vary behaviour on those headers, list them in `Vary` so a shared cache cannot serve the rejected variant to everyone.",
@@ -5662,6 +5773,7 @@ var ServerResponsivenessAudit = class extends Audit {
5662
5773
  evidenceGrade: "B",
5663
5774
  tier: "scored",
5664
5775
  dossier: "docs/evidence/audits/content-extraction/server-responsiveness.md",
5776
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
5665
5777
  defaultPriority: "medium",
5666
5778
  guidance: {
5667
5779
  impact: 'Google documents that crawl capacity falls when a host slows down ("if the site slows down\u2026 the limit goes down and Google crawls less"), and slow origins are where logged HTTP 499 client-closed-request clusters from AI fetchers appear. A slow origin therefore gets less of its content into the indexes AI answers are drawn from.',
@@ -5740,6 +5852,7 @@ var LanguageAttributeAudit = class extends Audit {
5740
5852
  evidenceGrade: "A",
5741
5853
  tier: "scored",
5742
5854
  dossier: "docs/evidence/audits/content-extraction/language-attribute.md",
5855
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
5743
5856
  defaultPriority: "high",
5744
5857
  guidance: {
5745
5858
  impact: "AI agents use the lang attribute to select the correct language model and tokenizer when processing your content. Without it, agents may misinterpret content language, leading to poor translations or incorrect answers in multilingual AI systems.",
@@ -5889,6 +6002,7 @@ var MarkdownAlternateAudit = class extends Audit {
5889
6002
  weight: weightForGrade("A", "scored"),
5890
6003
  defaultPriority: "medium",
5891
6004
  dossier: "docs/evidence/audits/content-extraction/markdown-alternate.md",
6005
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
5892
6006
  guidance: {
5893
6007
  impact: "A markdown alternate is a promise that an agent can read the page cheaply and get the same answer. A stale or partial alternate breaks that promise silently: the agent gets a document that looks authoritative, costs less, and says less than the page it claims to mirror. Serving it as `text/plain` or `text/html` is the same failure one level down \u2014 the client that negotiated for markdown cannot tell it got any. The consumers this is graded on are interactive coding agents \u2014 Claude Code, Cursor, Copilot Chat and CLI, Codex CLI \u2014 and GPTBot, measured taking markdown on 34.8% of fetches where a `.md` URL exists.",
5894
6008
  fix: 'Serve the alternate from the same source as the HTML, so headings and prose cannot drift, with `Content-Type: text/markdown` (a `charset` parameter is fine). Publish it on the page URL plus `.md`, or answer `Accept: text/markdown` on the page URL itself \u2014 those are the two routes with documented consumers. Declaring it with `<link rel="alternate" type="text/markdown" href="...">` saves an agent a guess, but the link relation itself has one single-sourced consumer, so this audit reports it rather than scoring it.',
@@ -6119,6 +6233,7 @@ var JsonLdDuplicationMassAudit = class extends Audit {
6119
6233
  weight: weightForGrade("C", "informative"),
6120
6234
  defaultPriority: "low",
6121
6235
  dossier: "docs/evidence/audits/content-extraction/json-ld-duplication-mass.md",
6236
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6122
6237
  guidance: {
6123
6238
  impact: "A non-rendering agent tokenizes the whole document, JSON-LD included. Where a block repeats the article body the DOM already carries, the page ships that text twice and the agent pays for both copies out of one context window. The same holds for a node declared identically in two blocks: the second copy adds tokens and no facts.",
6124
6239
  fix: "Keep JSON-LD to the facts a parser needs \u2014 identifiers, prices, dates, relationships \u2014 and let the prose live in the DOM. Where a schema property genuinely needs body text, a summary is usually enough. Merge blocks that declare the same `@id` into one.",
@@ -6228,6 +6343,7 @@ var SingleH1Audit = class extends Audit {
6228
6343
  evidenceGrade: "B",
6229
6344
  tier: "scored",
6230
6345
  dossier: "docs/evidence/audits/content-extraction/single-h1.md",
6346
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6231
6347
  defaultPriority: "high",
6232
6348
  guidance: {
6233
6349
  impact: "AI agents use the single <h1> as the authoritative page title for content indexing and answer generation. Multiple <h1> elements create ambiguity about the page's primary topic, causing agents to misidentify or conflate subjects when generating answers.",
@@ -6290,6 +6406,7 @@ var SequentialHeadingsAudit = class extends Audit {
6290
6406
  evidenceGrade: "B",
6291
6407
  tier: "scored",
6292
6408
  dossier: "docs/evidence/audits/content-extraction/sequential-headings.md",
6409
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6293
6410
  defaultPriority: "high",
6294
6411
  guidance: {
6295
6412
  impact: "AI systems build content outlines from heading levels to understand document hierarchy. Skipped levels (e.g., h1 directly to h3) break this hierarchy, causing agents to misinterpret section nesting and produce inaccurate content summaries with wrong parent-child relationships.",
@@ -6379,6 +6496,7 @@ var MainElementAudit = class extends Audit {
6379
6496
  evidenceGrade: "A",
6380
6497
  tier: "scored",
6381
6498
  dossier: "docs/evidence/audits/content-extraction/main-element.md",
6499
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6382
6500
  defaultPriority: "high",
6383
6501
  guidance: {
6384
6502
  impact: "Without a <main> element, AI scrapers cannot distinguish primary content from navigation, sidebars, and footer boilerplate. This causes agents to ingest menus, disclaimers, and repeated chrome into their context window, increasing hallucination risk and reducing answer relevance.",
@@ -6436,6 +6554,7 @@ var ArticleElementAudit = class extends Audit {
6436
6554
  evidenceGrade: "A",
6437
6555
  tier: "scored",
6438
6556
  dossier: "docs/evidence/audits/content-extraction/article-element.md",
6557
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6439
6558
  applicablePageTypes: ["content"],
6440
6559
  defaultPriority: "medium",
6441
6560
  guidance: {
@@ -6494,6 +6613,7 @@ var HeaderFooterAudit = class extends Audit {
6494
6613
  evidenceGrade: "A",
6495
6614
  tier: "scored",
6496
6615
  dossier: "docs/evidence/audits/content-extraction/header-footer.md",
6616
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6497
6617
  defaultPriority: "medium",
6498
6618
  guidance: {
6499
6619
  impact: "AI agents use <header> and <footer> landmarks to identify and exclude boilerplate content (navigation menus, copyright notices, legal links) from primary content extraction. Without these landmarks, agents may include footer disclaimers or nav menus in their content summaries, reducing answer accuracy.",
@@ -6585,6 +6705,7 @@ var AsideElementAudit = class extends Audit {
6585
6705
  evidenceGrade: "B",
6586
6706
  tier: "scored",
6587
6707
  dossier: "docs/evidence/audits/content-extraction/aside-element.md",
6708
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6588
6709
  applicablePageTypes: ["content"],
6589
6710
  defaultPriority: "low",
6590
6711
  guidance: {
@@ -6667,6 +6788,7 @@ var SectionHeadingsAudit = class extends Audit {
6667
6788
  evidenceGrade: "B",
6668
6789
  tier: "scored",
6669
6790
  dossier: "docs/evidence/audits/content-extraction/section-headings.md",
6791
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6670
6792
  defaultPriority: "medium",
6671
6793
  guidance: {
6672
6794
  impact: "AI agents use section headings to build a topic map of your page for retrieval-augmented generation. Unlabeled <section> elements are opaque to AI chunking systems, preventing them from indexing and retrieving your content by topic, which reduces your visibility in AI-generated answers.",
@@ -6845,6 +6967,7 @@ var SemanticListsAudit = class extends Audit {
6845
6967
  evidenceGrade: "B",
6846
6968
  tier: "scored",
6847
6969
  dossier: "docs/evidence/audits/content-extraction/semantic-lists.md",
6970
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6848
6971
  defaultPriority: "medium",
6849
6972
  guidance: {
6850
6973
  impact: 'AI agents recognize <ul>, <ol>, and <dl> as structured lists and extract them as bullet points, numbered steps or term/definition pairs. Content formatted as styled <div> elements \u2014 or as paragraphs that start with "1.", "2." \u2014 collapses into undelimited prose when the page is converted to markdown or an accessibility tree, so the agent has to re-infer where each item begins.',
@@ -6916,6 +7039,7 @@ var DataTablesAudit = class extends Audit {
6916
7039
  evidenceGrade: "B",
6917
7040
  tier: "scored",
6918
7041
  dossier: "docs/evidence/audits/content-extraction/data-tables.md",
7042
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6919
7043
  defaultPriority: "medium",
6920
7044
  guidance: {
6921
7045
  impact: "AI agents rely on <thead> and <th> elements to understand column headers and map cell values to their meanings. Without proper table structure, agents cannot interpret tabular data correctly, leading to garbled comparisons and inaccurate data extraction in AI-generated summaries.",
@@ -6992,6 +7116,7 @@ var CodeLanguageAudit = class extends Audit {
6992
7116
  evidenceGrade: "C",
6993
7117
  tier: "informative",
6994
7118
  dossier: "docs/evidence/audits/content-extraction/code-language.md",
7119
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6995
7120
  applicablePageTypes: ["content"],
6996
7121
  defaultPriority: "low",
6997
7122
  guidance: {
@@ -7073,6 +7198,7 @@ var TimeElementAudit = class extends Audit {
7073
7198
  evidenceGrade: "C",
7074
7199
  tier: "informative",
7075
7200
  dossier: "docs/evidence/audits/content-extraction/time-element.md",
7201
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
7076
7202
  applicablePageTypes: ["content"],
7077
7203
  defaultPriority: "medium",
7078
7204
  guidance: {
@@ -7123,6 +7249,7 @@ var ContentDepthAudit = class extends Audit {
7123
7249
  evidenceGrade: "B",
7124
7250
  tier: "scored",
7125
7251
  dossier: "docs/evidence/audits/content-extraction/content-depth.md",
7252
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
7126
7253
  defaultPriority: "medium",
7127
7254
  guidance: {
7128
7255
  impact: "Pages with fewer than 300 words provide too little context for AI RAG systems to generate accurate, detailed answers. Thin content produces weak vector embeddings that rank poorly in retrieval, causing your pages to be excluded from AI-generated responses entirely.",
@@ -7205,6 +7332,7 @@ var ImageAltTextAudit = class extends Audit {
7205
7332
  evidenceGrade: "A",
7206
7333
  tier: "scored",
7207
7334
  dossier: "docs/evidence/audits/content-extraction/image-alt-text.md",
7335
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
7208
7336
  defaultPriority: "high",
7209
7337
  guidance: {
7210
7338
  impact: "An image with no text alternative has no accessible name, so it is an unnamed node in the accessibility-tree snapshots agent toolkits send to a model \u2014 Playwright MCP, Claude-in-Chrome read_page, Chrome DevTools take_snapshot \u2014 and it carries no subject matter for Google Images, which states it uses alt text to understand what an image shows. A multimodal agent that fetches the image bytes can caption it without one; a text-only crawler or a snapshot-driven agent cannot.",
@@ -7291,6 +7419,7 @@ var FigureFigcaptionAudit = class extends Audit {
7291
7419
  evidenceGrade: "C",
7292
7420
  tier: "informative",
7293
7421
  dossier: "docs/evidence/audits/content-extraction/figure-figcaption.md",
7422
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
7294
7423
  defaultPriority: "medium",
7295
7424
  guidance: {
7296
7425
  impact: "AI agents use <figcaption> to understand the purpose and context of visual content beyond what alt text provides. Without captions, figures are treated as opaque image containers, and your charts, diagrams, and illustrations cannot be meaningfully cited in AI-generated answers.",
@@ -7413,6 +7542,7 @@ var SvgBloatAudit = class extends Audit {
7413
7542
  evidenceGrade: "B",
7414
7543
  tier: "scored",
7415
7544
  dossier: "docs/evidence/audits/content-extraction/svg-bloat.md",
7545
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
7416
7546
  defaultPriority: "medium",
7417
7547
  guidance: {
7418
7548
  impact: "Large inline SVGs are inlined verbatim as path-data tokens when an LLM converts your page to Markdown. A single 10KB icon or chart can consume thousands of tokens of agent context per page load, inflating agent cost and pushing real content out of the context window \u2014 reducing the quality of what agents extract and say about your site.",
@@ -12823,6 +12953,7 @@ var TokenRatioAudit = class extends Audit {
12823
12953
  evidenceGrade: "B",
12824
12954
  tier: "scored",
12825
12955
  dossier: "docs/evidence/audits/content-extraction/token-ratio.md",
12956
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
12826
12957
  defaultPriority: "high",
12827
12958
  guidance: {
12828
12959
  impact: "When less than 15% of your HTML is actual content, AI agents burn most of their context window and token budget on markup noise: inline scripts, CSS, SVG sprites, tracking tags, and deeply nested divs. The useful text that remains gets weaker attention from the model, and pages with extreme bloat may be truncated before the real content is even read.",
@@ -12954,6 +13085,7 @@ var FakeHeadingsAudit = class extends Audit {
12954
13085
  evidenceGrade: "B",
12955
13086
  tier: "scored",
12956
13087
  dossier: "docs/evidence/audits/content-extraction/fake-headings.md",
13088
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
12957
13089
  defaultPriority: "medium",
12958
13090
  guidance: {
12959
13091
  impact: "AI agents build content outlines exclusively from <h1>\u2013<h6> elements. Text that only looks like a heading is treated as ordinary body copy, so agents miss your section structure entirely \u2014 summaries flatten into a wall of text, section-level citations become impossible, and chunking for retrieval splits content at arbitrary points instead of at your intended section boundaries.",
@@ -13016,7 +13148,189 @@ var FakeHeadingsAudit = class extends Audit {
13016
13148
  }
13017
13149
  };
13018
13150
 
13151
+ // src/gatherers/domains.ts
13152
+ var MULTI_SUFFIX = /* @__PURE__ */ new Set([
13153
+ "co.uk",
13154
+ "org.uk",
13155
+ "ac.uk",
13156
+ "gov.uk",
13157
+ "me.uk",
13158
+ "net.uk",
13159
+ "com.au",
13160
+ "net.au",
13161
+ "org.au",
13162
+ "edu.au",
13163
+ "gov.au",
13164
+ "co.nz",
13165
+ "co.jp",
13166
+ "or.jp",
13167
+ "ne.jp",
13168
+ "co.za",
13169
+ "co.kr",
13170
+ "co.il",
13171
+ "co.id",
13172
+ "co.th",
13173
+ "com.br",
13174
+ "com.mx",
13175
+ "com.ar",
13176
+ "com.co",
13177
+ "com.pe",
13178
+ "co.in",
13179
+ "com.sg",
13180
+ "com.tr",
13181
+ "com.cn",
13182
+ "com.hk",
13183
+ "com.tw",
13184
+ "com.my",
13185
+ "com.ph",
13186
+ "com.ua",
13187
+ "com.pl",
13188
+ "com.es",
13189
+ "com.pt",
13190
+ "com.gr"
13191
+ ]);
13192
+ function registrableDomain(host) {
13193
+ const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
13194
+ if (parts.length <= 2) return parts.join(".");
13195
+ const lastTwo = parts.slice(-2).join(".");
13196
+ return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
13197
+ }
13198
+ function registrableOf(url) {
13199
+ try {
13200
+ return registrableDomain(new URL(url).hostname);
13201
+ } catch {
13202
+ return "";
13203
+ }
13204
+ }
13205
+
13206
+ // src/scan-evidence.ts
13207
+ var ALL_PAGE_TYPES = ["homepage", "category", "product", "content"];
13208
+ var HTML_TYPES = ["text/html", "application/xhtml+xml"];
13209
+ var PERMANENT_REDIRECT = /* @__PURE__ */ new Set([301, 308]);
13210
+ function bareHost(url) {
13211
+ try {
13212
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
13213
+ } catch {
13214
+ return "";
13215
+ }
13216
+ }
13217
+ function registrableName(url) {
13218
+ const domain = registrableOf(url);
13219
+ if (!domain) return "";
13220
+ const parts = domain.split(".");
13221
+ return parts.length > 1 ? parts.slice(0, -1).join(".") : domain;
13222
+ }
13223
+ function reachedTheRequestedSite(requestedUrl, result) {
13224
+ const requested = bareHost(requestedUrl);
13225
+ const final = bareHost(result.finalUrl || result.url);
13226
+ if (!final) return { ok: false, reason: `The homepage response carried no usable URL.` };
13227
+ if (requested === final) return { ok: true };
13228
+ const requestedDomain = registrableOf(requestedUrl);
13229
+ const finalDomain = registrableOf(result.finalUrl || result.url);
13230
+ if (requestedDomain && requestedDomain === finalDomain) return { ok: true };
13231
+ const requestedName = registrableName(requestedUrl);
13232
+ if (requestedName && requestedName === registrableName(result.finalUrl || result.url)) {
13233
+ return { ok: true };
13234
+ }
13235
+ const chain = result.redirectChain ?? [];
13236
+ const leaving = chain.filter((hop) => registrableOf(hop.from) !== registrableOf(hop.to));
13237
+ if (leaving.length > 0 && leaving.every((hop) => PERMANENT_REDIRECT.has(hop.status))) {
13238
+ return { ok: true };
13239
+ }
13240
+ return {
13241
+ ok: false,
13242
+ reason: `The requested host redirected to ${final}, a different site, without a permanent redirect.`
13243
+ };
13244
+ }
13245
+ function originReachable(requestedUrl, result) {
13246
+ if (result.error) {
13247
+ return { met: false, reason: `The homepage could not be fetched: ${result.error}.` };
13248
+ }
13249
+ if (result.status < 200 || result.status > 299) {
13250
+ return { met: false, reason: `The homepage answered HTTP ${result.status}.` };
13251
+ }
13252
+ const type = (result.contentType || "").toLowerCase();
13253
+ if (!HTML_TYPES.some((html) => type.includes(html))) {
13254
+ return {
13255
+ met: false,
13256
+ reason: `The homepage served ${result.contentType || "no content type"}, not HTML.`
13257
+ };
13258
+ }
13259
+ const reached = reachedTheRequestedSite(requestedUrl, result);
13260
+ return reached.ok ? { met: true } : { met: false, reason: reached.reason };
13261
+ }
13262
+ function unblockedFetches(homepageResult, waf) {
13263
+ if (waf?.isBlocked) {
13264
+ return waf.isRateLimit ? {
13265
+ met: false,
13266
+ reason: `The scan was throttled (${waf.name}): ${waf.reason}.`
13267
+ } : { met: false, reason: `${waf.name} refused the scan: ${waf.reason}.` };
13268
+ }
13269
+ if (homepageResult.status === 429) {
13270
+ return { met: false, reason: "The homepage answered HTTP 429: the scan was throttled." };
13271
+ }
13272
+ return { met: true };
13273
+ }
13274
+ function pageRendersText(page) {
13275
+ const text3 = getRenderedText(page.$);
13276
+ const wordCount2 = text3.split(/\s+/).filter(Boolean).length;
13277
+ return wordCount2 > 50 || text3.length > 200;
13278
+ }
13279
+ function buildScanEvidence(input) {
13280
+ const origin = originReachable(input.requestedUrl, input.homepageResult);
13281
+ const unblocked = unblockedFetches(input.homepageResult, input.wafProtection);
13282
+ const renderedByPage = {};
13283
+ const usablePageTypes = /* @__PURE__ */ new Set();
13284
+ for (const page of input.pages) {
13285
+ const rendered = pageRendersText(page);
13286
+ renderedByPage[page.url] = rendered;
13287
+ if (rendered) usablePageTypes.add(page.pageType);
13288
+ }
13289
+ const renderedCount = Object.values(renderedByPage).filter(Boolean).length;
13290
+ const met = {
13291
+ "origin-reachable": origin.met,
13292
+ "unblocked-fetches": unblocked.met,
13293
+ "rendered-body": renderedCount > 0,
13294
+ "sample-adequate": usablePageTypes.size > 0
13295
+ };
13296
+ const reasons = {};
13297
+ if (origin.reason) reasons["origin-reachable"] = origin.reason;
13298
+ if (unblocked.reason) reasons["unblocked-fetches"] = unblocked.reason;
13299
+ if (!met["rendered-body"]) {
13300
+ reasons["rendered-body"] = input.pages.length === 0 ? "The scan fetched no pages." : `None of the ${input.pages.length} fetched page(s) served readable text.`;
13301
+ }
13302
+ if (!met["sample-adequate"]) {
13303
+ reasons["sample-adequate"] = input.pages.length === 0 ? "The scan fetched no pages." : "No fetched page of any type served readable text.";
13304
+ }
13305
+ return {
13306
+ met,
13307
+ reasons,
13308
+ renderedByPage,
13309
+ usablePageTypes,
13310
+ // A shell site was seen. What it serves is a finding about it, so
13311
+ // `rendered-body` and `sample-adequate` do not clear `judgeable`.
13312
+ judgeable: met["origin-reachable"] && met["unblocked-fetches"]
13313
+ };
13314
+ }
13315
+ function allEvidenceMet() {
13316
+ return {
13317
+ met: {
13318
+ "origin-reachable": true,
13319
+ "unblocked-fetches": true,
13320
+ "rendered-body": true,
13321
+ "sample-adequate": true
13322
+ },
13323
+ reasons: {},
13324
+ renderedByPage: {},
13325
+ usablePageTypes: new Set(ALL_PAGE_TYPES),
13326
+ judgeable: true
13327
+ };
13328
+ }
13329
+
13019
13330
  // src/audits/content-extraction/server-rendered.ts
13331
+ function withDetails(result, details) {
13332
+ return { ...result, details: { ...result.details ?? {}, ...details } };
13333
+ }
13020
13334
  var ServerRenderedAudit = class extends Audit {
13021
13335
  static meta = {
13022
13336
  id: "content-extraction/server-rendered",
@@ -13029,6 +13343,8 @@ var ServerRenderedAudit = class extends Audit {
13029
13343
  evidenceGrade: "B",
13030
13344
  tier: "scored",
13031
13345
  dossier: "docs/evidence/audits/content-extraction/server-rendered.md",
13346
+ // Gate exemption: A shell is what this audit reports. Gating it would delete the finding.
13347
+ requires: ["origin-reachable", "unblocked-fetches"],
13032
13348
  defaultPriority: "critical",
13033
13349
  guidance: {
13034
13350
  impact: "AI crawlers (GPTBot, ClaudeBot, PerplexityBot) do not execute JavaScript. If your content is only rendered client-side, these crawlers see an empty or near-empty page. Your products, articles, and brand information are completely absent from AI knowledge bases, meaning AI-generated answers never reference your site.",
@@ -13040,37 +13356,57 @@ var ServerRenderedAudit = class extends Audit {
13040
13356
  }
13041
13357
  };
13042
13358
  audit(ctx) {
13043
- const page = ctx.pages?.[0];
13044
- if (!page) {
13045
- return this.warn(
13046
- "No homepage data available to check server-rendered content.",
13047
- "Homepage <main> has > 50 words or > 200 characters of text content",
13048
- "No homepage fetched",
13049
- void 0,
13050
- void 0
13359
+ const pages = ctx.pages ?? [];
13360
+ if (pages.length === 0) {
13361
+ return this.notApplicable(
13362
+ "The scan fetched no page, so there is no served HTML to judge.",
13363
+ "Every fetched page serves > 50 words or > 200 characters of readable text",
13364
+ "No page fetched"
13365
+ );
13366
+ }
13367
+ const rendered = ctx.evidence.renderedByPage;
13368
+ const emptyPages = pages.filter((page) => !(rendered[page.url] ?? pageRendersText(page))).map((page) => page.url);
13369
+ const total = pages.length;
13370
+ const renderedCount = total - emptyPages.length;
13371
+ const expected = "Every fetched page serves > 50 words or > 200 characters of readable text";
13372
+ const found = `${renderedCount} of ${total} page(s) served readable text`;
13373
+ if (emptyPages.length === 0) {
13374
+ return withDetails(
13375
+ this.pass(
13376
+ `All ${total} fetched page(s) serve their content in the HTML response.`,
13377
+ expected,
13378
+ found,
13379
+ pages[0].url
13380
+ ),
13381
+ { pagesChecked: total, renderedPages: renderedCount }
13051
13382
  );
13052
13383
  }
13053
- const $ = page.$;
13054
- const wordCount2 = getWordCount($);
13055
- const mainText2 = getMainContentText($);
13056
- if (wordCount2 > 50 || mainText2.length > 200) {
13057
- return this.pass(
13058
- `Homepage has meaningful server-rendered content (${wordCount2} words, ${mainText2.length} characters).`,
13059
- "Homepage <main> has > 50 words or > 200 characters of text content",
13060
- `${wordCount2} words, ${mainText2.length} characters`,
13061
- page.url
13384
+ const failGuidance = {
13385
+ priority: "critical",
13386
+ description: "AI crawlers like GPTBot and ClaudeBot do not execute JavaScript. Content only visible after JS execution is completely invisible to them, meaning your site effectively has no content in AI knowledge bases. Use SSR (server-side rendering) or SSG (static site generation) to serve content in the initial HTML response.",
13387
+ code: "// Next.js SSR example:\nexport async function getServerSideProps() {\n const data = await fetchData();\n return { props: { data } };\n}"
13388
+ };
13389
+ if (renderedCount === 0) {
13390
+ return withDetails(
13391
+ this.fail(
13392
+ `None of the ${total} fetched page(s) serve readable content in the HTML response. AI agents cannot read client-side-only rendered content.`,
13393
+ expected,
13394
+ found,
13395
+ failGuidance,
13396
+ pages[0].url
13397
+ ),
13398
+ { pagesChecked: total, renderedPages: 0, emptyPages }
13062
13399
  );
13063
13400
  }
13064
- return this.fail(
13065
- `Homepage has minimal server-rendered content (${wordCount2} words, ${mainText2.length} characters). AI agents cannot read client-side-only rendered content.`,
13066
- "Homepage <main> has > 50 words or > 200 characters of text content",
13067
- `${wordCount2} words, ${mainText2.length} characters`,
13068
- {
13069
- priority: "critical",
13070
- description: "AI crawlers like GPTBot and ClaudeBot do not execute JavaScript. Content only visible after JS execution is completely invisible to them, meaning your site effectively has no content in AI knowledge bases. Use SSR (server-side rendering) or SSG (static site generation) to serve content in the initial HTML response.",
13071
- code: "// Next.js SSR example:\nexport async function getServerSideProps() {\n const data = await fetchData();\n return { props: { data } };\n}"
13072
- },
13073
- page.url
13401
+ return withDetails(
13402
+ this.warn(
13403
+ `${emptyPages.length} of ${total} fetched page(s) serve no readable content in the HTML response. AI agents read nothing on those pages.`,
13404
+ expected,
13405
+ found,
13406
+ failGuidance,
13407
+ emptyPages[0]
13408
+ ),
13409
+ { pagesChecked: total, renderedPages: renderedCount, emptyPages }
13074
13410
  );
13075
13411
  }
13076
13412
  };
@@ -13315,6 +13651,7 @@ var CssHiddenGhostContentAudit = class _CssHiddenGhostContentAudit extends Audit
13315
13651
  evidenceGrade: "A",
13316
13652
  tier: "scored",
13317
13653
  dossier: "docs/evidence/audits/content-extraction/css-hidden-ghost-content.md",
13654
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
13318
13655
  defaultPriority: "medium",
13319
13656
  guidance: {
13320
13657
  impact: "This is provable from source, not inferred. Readability's visibility test consults only node.style.display, node.style.visibility, the hidden attribute and aria-hidden \u2014 it explicitly does not evaluate class-based CSS rules from stylesheets. AI crawlers do not render, so no cascade is ever computed. Therefore any subtree hidden by `.mobile-only{display:none}`, `.tab-panel:not(.active){display:none}` or `[data-state=closed]{display:none}` reaches the model as ordinary body text with full weight. Consequence is not just cost: the agent sees three parallel copies of a nav, both the collapsed and expanded FAQ answers, and often stale price text from a hidden variant block, and irrelevant/contradictory context measurably degrades answers.",
@@ -13493,6 +13830,7 @@ var HydrationPayloadShareAudit = class _HydrationPayloadShareAudit extends Audit
13493
13830
  evidenceGrade: "A",
13494
13831
  tier: "scored",
13495
13832
  dossier: "docs/evidence/audits/content-extraction/hydration-payload-share.md",
13833
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
13496
13834
  defaultPriority: "medium",
13497
13835
  guidance: {
13498
13836
  impact: "These blobs are inlined into every HTML response by design, and the framework vendor itself flags > 128 kB as a defect. A browser parses them and throws them away after hydration; a non-rendering AI crawler cannot \u2014 it tokenizes the JSON verbatim, including escaped HTML, CDN image variants, GraphQL type metadata and the full body text a second time. The causal claim is falsifiable per page: strip these script nodes, re-tokenize, and the delta is the exact context cost that carries zero incremental information, since duplicate #3 is byte-identical content the agent already has.",
@@ -13647,6 +13985,7 @@ var PreambleTaxTokensBeforeTheFirstContentTokenAudit = class extends Audit {
13647
13985
  weight: weightForGrade("B", "scored"),
13648
13986
  defaultPriority: "medium",
13649
13987
  dossier: "docs/evidence/audits/content-extraction/preamble-tax.md",
13988
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
13650
13989
  guidance: {
13651
13990
  impact: "A non-rendering agent ingests the document as a linear stream, so DOM order is context order. A page that inlines a critical-CSS block and a serialized state blob ahead of its content does two things at once: it pushes the answer into the middle of the context window, where retrieval is measurably weakest, and it guarantees the answer is what gets cut when the fetching harness truncates to a byte or token cap.",
13652
13991
  fix: "Move inline `<style>` and `<script>` blocks below the main content or into external files, and put `<main>` as early in the body as the layout allows. Where critical CSS must be inline, keep it to the rules that paint the first screen rather than the whole stylesheet.",
@@ -13791,6 +14130,7 @@ var BoilerplateTaxAudit = class extends Audit {
13791
14130
  weight: weightForGrade("B", "scored"),
13792
14131
  defaultPriority: "medium",
13793
14132
  dossier: "docs/evidence/audits/content-extraction/boilerplate-tax.md",
14133
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
13794
14134
  guidance: {
13795
14135
  impact: "An agent answering a question about a site fetches several of its pages. If each fetch delivers the same navigation, the same promotional header and the same footer around a thin body, the agent pays for those tokens once per fetch and learns nothing new from them. The cost compounds with every page, and the distinct content it came for competes for what is left of the context window.",
13796
14136
  fix: "Cut repeated chrome down to what a reader needs on every page: collapse mega-menus to a short nav, move legal and marketing boilerplate to the pages that are about it, and let each page carry more of its own content. Where the chrome must stay for humans, keeping it out of `<main>` at least lets an extractor drop it.",
@@ -13916,6 +14256,7 @@ var ExtractionDeterminismAudit = class extends Audit {
13916
14256
  weight: weightForGrade("B", "scored"),
13917
14257
  defaultPriority: "high",
13918
14258
  dossier: "docs/evidence/audits/content-extraction/extraction-determinism.md",
14259
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
13919
14260
  guidance: {
13920
14261
  impact: "Every agent pipeline strips a page down before a model reads it, and they do not all strip the same way. When the extractors disagree, the same URL yields different answers depending on which tool fetched it \u2014 and the page cannot be tested, because there is no single thing it says. When readability declines a page outright, the most widely deployed extractor of the three hands an agent nothing at all.",
13921
14262
  fix: "Put the article in one container \u2014 `<main>` or `<article>` \u2014 with the chrome outside it, and keep the largest block of prose on the page the one you want quoted. Readability keys on paragraph density and link density, so a body split across many small wrappers, or padded with link-heavy blocks, is what makes the three disagree.",
@@ -14093,6 +14434,7 @@ var LlmsTxtExistsAudit = class extends Audit {
14093
14434
  evidenceGrade: "C",
14094
14435
  tier: "informative",
14095
14436
  dossier: "docs/evidence/audits/machine-discovery/llms-txt-exists.md",
14437
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
14096
14438
  defaultPriority: "low",
14097
14439
  guidance: {
14098
14440
  impact: "Thousands of sites publish an llms.txt, including every major AI lab, but as publishers rather than readers. No vendor documentation names an agent that fetches it, and Google Search Central states Search ignores it. Publishing one is cheap and harmless; it is not a documented path to any AI answer.",
@@ -14173,6 +14515,7 @@ var LlmsTxtStructureAudit = class extends Audit {
14173
14515
  evidenceGrade: "C",
14174
14516
  tier: "informative",
14175
14517
  dossier: "docs/evidence/audits/machine-discovery/llms-txt-structure.md",
14518
+ requires: ["origin-reachable"],
14176
14519
  defaultPriority: "low",
14177
14520
  guidance: {
14178
14521
  impact: "The reference llms.txt parser extracts the blockquote as a `summary` field and the H2 headings as a `sections` map, so a file that carries both is machine-navigable: an agent can read the summary and pick a section instead of consuming the whole file. No vendor documents an agent behaving differently when either element is absent, so this is reported, not scored.",
@@ -14236,6 +14579,7 @@ var LlmsTxtLinkDescriptionsAudit = class extends Audit {
14236
14579
  evidenceGrade: "C",
14237
14580
  tier: "informative",
14238
14581
  dossier: "docs/evidence/audits/machine-discovery/llms-txt-link-descriptions.md",
14582
+ requires: ["origin-reachable"],
14239
14583
  defaultPriority: "medium",
14240
14584
  guidance: {
14241
14585
  impact: "Links without descriptions force AI agents to visit every page to understand its content, wasting crawl budget and slowing down response generation. Described links let agents filter relevant pages instantly.",
@@ -14331,6 +14675,7 @@ var LlmsTxtLinksValidAudit = class extends Audit {
14331
14675
  evidenceGrade: "C",
14332
14676
  tier: "informative",
14333
14677
  dossier: "docs/evidence/audits/machine-discovery/llms-txt-links-valid.md",
14678
+ requires: ["origin-reachable"],
14334
14679
  defaultPriority: "low",
14335
14680
  guidance: {
14336
14681
  impact: "A broken link inside llms.txt points at nothing, the same as a broken link anywhere else. No documented agent consumer reads the file, so the cost is to any human or tool that follows it, not to a measured AI outcome.",
@@ -14412,6 +14757,7 @@ var LlmsFullTxtAudit = class extends Audit {
14412
14757
  evidenceGrade: "C",
14413
14758
  tier: "informative",
14414
14759
  dossier: "docs/evidence/audits/machine-discovery/llms-full-txt.md",
14760
+ requires: ["origin-reachable"],
14415
14761
  defaultPriority: "high",
14416
14762
  guidance: {
14417
14763
  impact: "Without llms-full.txt, AI agents must crawl your site page by page, which is slow and often incomplete. This means AI assistants give shallow or outdated answers about your products and services.",
@@ -14480,6 +14826,7 @@ var SitemapExistsAudit = class extends Audit {
14480
14826
  evidenceGrade: "A",
14481
14827
  tier: "scored",
14482
14828
  dossier: "docs/evidence/audits/machine-discovery/sitemap-exists.md",
14829
+ requires: ["origin-reachable"],
14483
14830
  defaultPriority: "critical",
14484
14831
  guidance: {
14485
14832
  impact: "Without a sitemap, AI crawlers must discover your pages solely through link-following, which is slow and incomplete. Pages deep in your site hierarchy may never be found, meaning AI search engines like Perplexity and ChatGPT Browse cannot surface your full content.",
@@ -14619,6 +14966,7 @@ var DiscoveryIndexCoverageAudit = class extends Audit {
14619
14966
  evidenceGrade: "B",
14620
14967
  tier: "scored",
14621
14968
  dossier: "docs/evidence/audits/machine-discovery/discovery-index-coverage.md",
14969
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
14622
14970
  defaultPriority: "medium",
14623
14971
  guidance: {
14624
14972
  impact: "A page listed in no discovery index is reachable only through the link graph, and the major AI crawlers do not execute JavaScript \u2014 so a page missing from both the sitemap and llms.txt can stay invisible to AI search even though it exists on your site.",
@@ -14751,6 +15099,7 @@ var SitemapAbsoluteUrlsAudit = class extends Audit {
14751
15099
  evidenceGrade: "B",
14752
15100
  tier: "scored",
14753
15101
  dossier: "docs/evidence/audits/machine-discovery/sitemap-absolute-urls.md",
15102
+ requires: ["origin-reachable"],
14754
15103
  defaultPriority: "high",
14755
15104
  guidance: {
14756
15105
  impact: "Relative URLs in your sitemap cannot be resolved by AI crawlers, causing them to silently skip those pages. Any page listed with a relative URL is effectively invisible to AI search engines.",
@@ -14855,6 +15204,7 @@ var SitemapLastmodAudit = class extends Audit {
14855
15204
  evidenceGrade: "A",
14856
15205
  tier: "scored",
14857
15206
  dossier: "docs/evidence/audits/machine-discovery/sitemap-lastmod.md",
15207
+ requires: ["origin-reachable"],
14858
15208
  defaultPriority: "medium",
14859
15209
  guidance: {
14860
15210
  impact: "Without <lastmod> dates, AI crawlers must re-fetch every page on every visit because they cannot tell which pages have changed. This wastes crawl budget and delays indexing of your freshest content.",
@@ -14991,6 +15341,7 @@ var RssFeedAudit = class extends Audit {
14991
15341
  evidenceGrade: "B",
14992
15342
  tier: "scored",
14993
15343
  dossier: "docs/evidence/audits/machine-discovery/rss-feed.md",
15344
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
14994
15345
  defaultPriority: "medium",
14995
15346
  guidance: {
14996
15347
  impact: "Without an RSS/Atom feed, AI agents have no efficient way to track new and updated content on your site. They must re-crawl your entire site to find changes, which means your latest posts and pages may take much longer to appear in AI search results.",
@@ -15079,6 +15430,7 @@ var RssFeedContentAudit = class extends Audit {
15079
15430
  evidenceGrade: "C",
15080
15431
  tier: "informative",
15081
15432
  dossier: "docs/evidence/audits/machine-discovery/rss-feed-content.md",
15433
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
15082
15434
  defaultPriority: "medium",
15083
15435
  guidance: {
15084
15436
  impact: "Truncated RSS feed items force AI agents to visit each page individually, increasing crawl time and often resulting in incomplete indexing. Full-content feeds let agents ingest all your articles in a single request, producing richer AI-generated answers.",
@@ -15234,6 +15586,7 @@ var InContentLinksAudit = class extends Audit {
15234
15586
  evidenceGrade: "A",
15235
15587
  tier: "scored",
15236
15588
  dossier: "docs/evidence/audits/machine-discovery/in-content-links.md",
15589
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
15237
15590
  defaultPriority: "medium",
15238
15591
  guidance: {
15239
15592
  impact: "Google can only crawl a link that is an <a> element with an href, and the measured behaviour of GPTBot and ClaudeBot is that they do not execute JavaScript \u2014 so a page whose only links are in a client-rendered nav is a dead end for them. Links inside the body copy also tell an agent which pages belong together, which template chrome (identical on every page) cannot.",
@@ -15310,6 +15663,7 @@ var NoBrokenLinksAudit = class _NoBrokenLinksAudit extends Audit {
15310
15663
  evidenceGrade: "A",
15311
15664
  tier: "scored",
15312
15665
  dossier: "docs/evidence/audits/machine-discovery/no-broken-links.md",
15666
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
15313
15667
  defaultPriority: "high",
15314
15668
  guidance: {
15315
15669
  impact: "Broken internal links waste AI crawlers' limited crawl budget by sending them to dead ends. This means fewer of your pages get indexed, and users asking AI about your site may encounter errors or missing information.",
@@ -15410,6 +15764,7 @@ var CorsAiFilesAudit = class extends Audit {
15410
15764
  evidenceGrade: "C",
15411
15765
  tier: "informative",
15412
15766
  dossier: "docs/evidence/audits/machine-discovery/cors-ai-files.md",
15767
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
15413
15768
  defaultPriority: "medium",
15414
15769
  guidance: {
15415
15770
  impact: "Browser-based AI tools, ChatGPT plugins, and MCP clients all run in browser contexts governed by the same-origin policy. Without CORS headers on your llms.txt and AI catalog, these agents receive a network error instead of your content \u2014 making your AI-facing files completely invisible to the fastest-growing category of AI consumers.",
@@ -15531,6 +15886,7 @@ var AiFileDeliveryAudit = class extends Audit {
15531
15886
  evidenceGrade: "B",
15532
15887
  tier: "informative",
15533
15888
  dossier: "docs/evidence/audits/machine-discovery/ai-file-delivery.md",
15889
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
15534
15890
  defaultPriority: "medium",
15535
15891
  guidance: {
15536
15892
  impact: "Incorrect Content-Type headers cause AI agents to misparse your files: JSON served as text/html breaks structured-data extraction, an XML sitemap served as text/plain hides it from crawl discovery, and llms.txt served as application/octet-stream triggers a download instead of a read. Missing caching headers make every agent re-download the full file on each visit rather than revalidating it.",
@@ -15624,6 +15980,7 @@ var NoBrokenAiEndpointsAudit = class extends Audit {
15624
15980
  evidenceGrade: "A",
15625
15981
  tier: "scored",
15626
15982
  dossier: "docs/evidence/audits/machine-discovery/no-broken-ai-endpoints.md",
15983
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
15627
15984
  defaultPriority: "high",
15628
15985
  guidance: {
15629
15986
  impact: "Broken URLs in your AI manifest files (ai-catalog.json, llms.txt, navigation.json) cause agents to lose trust in your entire manifest. After encountering broken links, AI systems may stop following any of your listed endpoints, effectively making all your AI-facing resources undiscoverable.",
@@ -15893,6 +16250,7 @@ var AiCrawlerSurfaceReachabilityAudit = class extends Audit {
15893
16250
  evidenceGrade: "A",
15894
16251
  tier: "scored",
15895
16252
  dossier: "docs/evidence/audits/machine-discovery/ai-crawler-surface-reachability.md",
16253
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
15896
16254
  defaultPriority: "high",
15897
16255
  guidance: {
15898
16256
  impact: "The Sitemap: directive is host-global and user-agent independent (RFC 9309 \xA72.2.3), but the sitemap file, the feed files and every URL they list obey per-crawler rules \u2014 and under \xA72.2.1 a crawler with a named group ignores the '*' group entirely. OpenAI documents the consequence at the extreme: 'Sites that are opted out of OAI-SearchBot will not be shown in ChatGPT search answers.' So for any crawler whose named group disallows the advertised sitemap or feed path, or a majority of the URLs the sitemap lists, the site's whole pull-indexing surface is unreachable to that agent no matter how good the sitemap is. The common trigger is a bot-blocking plugin adding a broad pattern (Disallow: /*.xml$, Disallow: /feed/, Disallow: /) to an AI-bot group while the site keeps advertising those exact paths.",
@@ -16078,6 +16436,7 @@ var SitemapLastmodVerifiabilityAudit = class extends Audit {
16078
16436
  evidenceGrade: "A",
16079
16437
  tier: "scored",
16080
16438
  dossier: "docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md",
16439
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
16081
16440
  defaultPriority: "medium",
16082
16441
  guidance: {
16083
16442
  impact: `Google states it uses <lastmod> "if it's consistently and verifiably (for example by comparing to the last modification of the page) accurate". lastmod is therefore a conditional signal an engine silently discards on divergence \u2014 and it is the only freshness hint a pull-based AI crawler gets from a sitemap. If sampled values disagree with every available page-level signal for a material share of URLs, the freshness channel is inert and re-crawl scheduling degrades to organic rediscovery. Two specific pathologies are detectable without guessing: over 90% of URLs sharing one lastmod equal to the last deploy date \u2014 a build stamp, exactly the pattern Google's "copyright date is not significant" rule disqualifies \u2014 and a lastmod in the future relative to the scan, which is never valid.`,
@@ -16459,6 +16818,7 @@ var CheckoutOfferFieldMappingAudit = class _CheckoutOfferFieldMappingAudit exten
16459
16818
  evidenceGrade: "A",
16460
16819
  tier: "scored",
16461
16820
  dossier: "docs/evidence/audits/agentic-commerce/checkout-offer-field-mapping.md",
16821
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
16462
16822
  applicablePageTypes: ["product"],
16463
16823
  defaultPriority: "high",
16464
16824
  guidance: {
@@ -16655,6 +17015,7 @@ var AgentCommerceFeedParityAudit = class extends Audit {
16655
17015
  evidenceGrade: "A",
16656
17016
  tier: "scored",
16657
17017
  dossier: "docs/evidence/audits/machine-discovery/agent-commerce-feed-parity.md",
17018
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
16658
17019
  defaultPriority: "high",
16659
17020
  guidance: {
16660
17021
  impact: `Google's automatic item updates repair feed/page discrepancies "using the structured data markup the crawlers find on your website", and state that where extractors cannot determine price, availability and condition, "your products will be subject to item-level disapprovals". Merchant Center separately requires that feed availability match the landing page and that price match the landing page and checkout. OpenAI's Product Feed Spec requires a strictly larger per-item set than Google's rich-result minimum: a stable item_id (<=100 chars), brand (<=70), seller_name, target_countries as ISO 3166-1 alpha-2, a plain-text description under 5000 characters, availability from a fixed enum, and price with an ISO 4217 currency. Falsifiable claim: a PDP missing brand, seller, itemCondition-as-URL, a stable SKU or a country signal passes every Google rich-result test yet cannot be reconciled by automatic item updates, so feed rejections are silent and unattributable. Second claim, sharper: where the JSON-LD price disagrees with the price the page renders, automatic item updates overwrite the feed with one value while an agent reading the page quotes the other.`,
@@ -17195,6 +17556,7 @@ var ConditionalRequestSupportAudit = class extends Audit {
17195
17556
  weight: weightForGrade("B", "scored"),
17196
17557
  defaultPriority: "medium",
17197
17558
  dossier: "docs/evidence/audits/machine-discovery/conditional-request-support.md",
17559
+ requires: ["origin-reachable"],
17198
17560
  guidance: {
17199
17561
  impact: 'A crawler that wants to know what changed re-reads your sitemap and your feed on a schedule. If those responses carry no `ETag` and no `Last-Modified`, it cannot ask "has this changed?" \u2014 it can only download the file again, every time, forever. The cost is yours as much as theirs: bandwidth you serve for no new information, and a crawl budget spent re-reading a list instead of fetching the pages on it. A validator that changes on every build is the same cost wearing a correct-looking header.',
17200
17562
  fix: "Emit a strong `ETag` derived from the file\u2019s content, not from the build, and a `Last-Modified` that moves only when the content does. Answer `If-None-Match` and `If-Modified-Since` with 304 and an empty body. Keep `no-store` and `private` off public discovery surfaces \u2014 they tell a crawler not to keep the copy it just paid for.",
@@ -17348,6 +17710,7 @@ var FeedEntryIdentityAndCanonicalIntegrityAudit = class extends Audit {
17348
17710
  weight: weightForGrade("B", "scored"),
17349
17711
  defaultPriority: "medium",
17350
17712
  dossier: "docs/evidence/audits/machine-discovery/feed-entry-identity-and-canonical-integrity.md",
17713
+ requires: ["origin-reachable"],
17351
17714
  guidance: {
17352
17715
  impact: 'A feed is how a consumer tracks what changed without re-crawling the site, and identity is what makes that possible: the id says "this is the same item you saw last time". An entry with no id, or with an id that repeats, forces the consumer to guess \u2014 usually by URL, which is exactly the thing that changes. A link that carries `utm_` parameters or redirects somewhere else creates a second address for one page, so the item the consumer stores is not the page the site considers canonical.',
17353
17716
  fix: "Give every entry a stable id \u2014 an `atom:id` that never changes, or an RSS `<guid>` that is an absolute URL when `isPermaLink` is true \u2014 and never reuse one. Point item links at the canonical URL itself, with no tracking parameters and no redirect in between. Serve the feed as its registered media type, with no byte-order mark before the first element.",
@@ -17527,6 +17890,7 @@ var RootTextFileResolutionIntegrityAudit = class extends Audit {
17527
17890
  weight: weightForGrade("B", "scored"),
17528
17891
  defaultPriority: "medium",
17529
17892
  dossier: "docs/evidence/audits/machine-discovery/root-text-file-resolution-integrity.md",
17893
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
17530
17894
  guidance: {
17531
17895
  impact: "IndexNow proves ownership by fetching `https://host/{key}.txt` and byte-comparing the body to the key, and six engines discard the submission when that comparison fails. The same property decides whether any other root `.txt` file means anything: if an origin answers 200 for a path that does not exist, then a 200 for `/llms.txt` is not evidence the file is there. A catch-all rewrite ahead of static file serving turns every one of those signals into noise, with no visible symptom on the site itself.",
17532
17896
  fix: "Serve root-level `.txt` paths from static files and let a missing one answer 404. Order the static-file handler ahead of any SPA or catch-all rewrite, and make sure the rewrite does not cover `*.txt`. Serve `/robots.txt` as `text/plain`, not as `text/html` or `application/octet-stream`.",
@@ -17702,6 +18066,7 @@ var ThreeWayFreshnessLagAudit = class extends Audit {
17702
18066
  weight: weightForGrade("B", "scored"),
17703
18067
  defaultPriority: "medium",
17704
18068
  dossier: "docs/evidence/audits/machine-discovery/three-way-freshness-lag.md",
18069
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
17705
18070
  guidance: {
17706
18071
  impact: "A pull-based crawler fetches the sitemap and the feed on a schedule and reads nothing else. When those two surfaces trail the site, everything published in between is discoverable only by link-following, which is the slow path the site published a sitemap to avoid. A feed whose `lastBuildDate` is older than its own newest item is worse than stale: consumers that poll conditionally on that timestamp skip the feed entirely, so the new items are never read at all.",
17707
18072
  fix: "Regenerate the sitemap and the feed when content changes, not on a nightly cron that can fail silently. Stamp `<lastBuildDate>` (or the Atom feed-level `<updated>`) from the newest item at generation time. Order feed items newest-first, since many consumers read only the head. Remove sitemap entries whose URLs 404 or are noindex.",
@@ -17856,6 +18221,7 @@ var WebsubHubAdvertisementAudit = class extends Audit {
17856
18221
  weight: 0,
17857
18222
  defaultPriority: "low",
17858
18223
  dossier: "docs/evidence/audits/machine-discovery/websub-hub-advertisement.md",
18224
+ requires: ["origin-reachable"],
17859
18225
  guidance: {
17860
18226
  impact: "A hub subscription is verified against the feed\u2019s own `rel=self`. When that link is missing, relative, or points at a different URL than the one the feed is served from, verification cannot complete, and the push path degrades to whatever polling cadence subscribers happen to use. The publisher sees a hub that looks configured and no error anywhere. The benefit side is unproven: WebSub is a W3C Recommendation, but no AI answer engine is documented as a subscriber, which is why this audit reports and does not score.",
17861
18227
  fix: "Advertise the hub and the canonical topic URL in the feed\u2019s `Link:` response headers, which is where a subscriber looks first. Emit exactly one `rel=self` with an absolute URL identical to the address the feed is served from, and at least one `rel=hub` over HTTPS. If you run no hub, a hosted one (Google\u2019s pubsubhubbub, Superfeedr, websub.rocks) needs only the two link relations.",
@@ -17999,6 +18365,7 @@ var JsonLdPresentAudit = class extends Audit {
17999
18365
  evidenceGrade: "A",
18000
18366
  tier: "scored",
18001
18367
  dossier: "docs/evidence/audits/structured-data/json-ld-present.md",
18368
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18002
18369
  defaultPriority: "critical",
18003
18370
  guidance: {
18004
18371
  impact: "Without any JSON-LD structured data, AI agents like ChatGPT and Perplexity treat your site as unstructured text with no machine-readable identity. Your brand, products, and services become invisible to AI-powered discovery, search, and recommendation systems.",
@@ -18062,6 +18429,7 @@ var SchemaValidationAudit = class extends Audit {
18062
18429
  evidenceGrade: "A",
18063
18430
  tier: "scored",
18064
18431
  dossier: "docs/evidence/audits/structured-data/schema-validation.md",
18432
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18065
18433
  defaultPriority: "critical",
18066
18434
  guidance: {
18067
18435
  impact: "JSON-LD blocks missing @context or @type are silently ignored by every schema consumer, including Google, ChatGPT plugins, and RAG pipelines. Even if you have structured data on the page, invalid blocks provide zero value to AI agents.",
@@ -18188,6 +18556,7 @@ var OrganizationSchemaAudit = class extends Audit {
18188
18556
  evidenceGrade: "A",
18189
18557
  tier: "scored",
18190
18558
  dossier: "docs/evidence/audits/structured-data/organization-schema.md",
18559
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18191
18560
  applicablePageTypes: ["homepage"],
18192
18561
  defaultPriority: "high",
18193
18562
  guidance: {
@@ -18291,6 +18660,7 @@ var BreadcrumbSchemaAudit = class extends Audit {
18291
18660
  evidenceGrade: "A",
18292
18661
  tier: "scored",
18293
18662
  dossier: "docs/evidence/audits/structured-data/breadcrumb-schema.md",
18663
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18294
18664
  applicablePageTypes: ["category", "product", "content"],
18295
18665
  defaultPriority: "medium",
18296
18666
  guidance: {
@@ -18413,6 +18783,7 @@ var ArticleSchemaAudit = class extends Audit {
18413
18783
  evidenceGrade: "A",
18414
18784
  tier: "scored",
18415
18785
  dossier: "docs/evidence/audits/structured-data/article-schema.md",
18786
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18416
18787
  applicablePageTypes: ["content"],
18417
18788
  defaultPriority: "high",
18418
18789
  guidance: {
@@ -18539,6 +18910,7 @@ var FaqPageSchemaAudit = class extends Audit {
18539
18910
  evidenceGrade: "C",
18540
18911
  tier: "informative",
18541
18912
  dossier: "docs/evidence/audits/structured-data/faqpage-schema.md",
18913
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18542
18914
  defaultPriority: "medium",
18543
18915
  guidance: {
18544
18916
  impact: "AI answer engines like Perplexity and Google SGE give priority to FAQ-structured content for direct answers. Without FAQPage schema, your Q&A content is treated as unstructured text and is less likely to be surfaced as a featured answer in AI-generated responses.",
@@ -18692,6 +19064,7 @@ var ServiceSchemaAudit = class _ServiceSchemaAudit extends Audit {
18692
19064
  evidenceGrade: "A",
18693
19065
  tier: "scored",
18694
19066
  dossier: "docs/evidence/audits/structured-data/service-schema.md",
19067
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18695
19068
  // Where a service business publishes its offerings. NOT ['product'] —
18696
19069
  // that was inherited from the pre-split audit and inverted this check:
18697
19070
  // it skipped every service site (no product page in the scan) and ran only
@@ -18835,6 +19208,7 @@ var SpeakableSchemaAudit = class _SpeakableSchemaAudit extends Audit {
18835
19208
  evidenceGrade: "B",
18836
19209
  tier: "scored",
18837
19210
  dossier: "docs/evidence/audits/structured-data/speakable-schema.md",
19211
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18838
19212
  // News and article publishing is the whole documented scope of the
18839
19213
  // feature, so a scan with no content page never runs this audit at all.
18840
19214
  // The runtime guard below repeats the precondition for the pages that
@@ -18933,6 +19307,7 @@ var HowToSchemaAudit = class extends Audit {
18933
19307
  evidenceGrade: "C",
18934
19308
  tier: "informative",
18935
19309
  dossier: "docs/evidence/audits/structured-data/howto-schema.md",
19310
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
18936
19311
  applicablePageTypes: ["content"],
18937
19312
  defaultPriority: "low",
18938
19313
  guidance: {
@@ -19081,6 +19456,7 @@ var LocalBusinessSchemaAudit = class extends Audit {
19081
19456
  evidenceGrade: "A",
19082
19457
  tier: "scored",
19083
19458
  dossier: "docs/evidence/audits/structured-data/local-business-schema.md",
19459
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19084
19460
  applicablePageTypes: ["homepage"],
19085
19461
  defaultPriority: "medium",
19086
19462
  guidance: {
@@ -19246,6 +19622,7 @@ var ReviewSchemaAudit = class extends Audit {
19246
19622
  evidenceGrade: "A",
19247
19623
  tier: "scored",
19248
19624
  dossier: "docs/evidence/audits/structured-data/review-schema.md",
19625
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19249
19626
  applicablePageTypes: ["homepage", "product"],
19250
19627
  defaultPriority: "medium",
19251
19628
  guidance: {
@@ -19363,6 +19740,7 @@ var AuthorSchemaAudit = class extends Audit {
19363
19740
  evidenceGrade: "C",
19364
19741
  tier: "informative",
19365
19742
  dossier: "docs/evidence/audits/structured-data/author-schema.md",
19743
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19366
19744
  applicablePageTypes: ["content"],
19367
19745
  defaultPriority: "medium",
19368
19746
  guidance: {
@@ -19485,6 +19863,7 @@ var ProductDetailsAudit = class extends Audit {
19485
19863
  evidenceGrade: "A",
19486
19864
  tier: "scored",
19487
19865
  dossier: "docs/evidence/audits/structured-data/advanced-product-details.md",
19866
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19488
19867
  applicablePageTypes: ["product"],
19489
19868
  defaultPriority: "medium",
19490
19869
  guidance: {
@@ -19638,6 +20017,7 @@ var ClaimreviewAdvisoryAudit = class extends Audit {
19638
20017
  evidenceGrade: "A",
19639
20018
  tier: "informative",
19640
20019
  dossier: "docs/evidence/audits/structured-data/claimreview-advisory.md",
20020
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19641
20021
  defaultPriority: "low",
19642
20022
  guidance: {
19643
20023
  impact: "Google's fact check documentation states plainly: 'We're phasing out support for ClaimReview markup in Google Search', with no deprecation date, and notes only one ClaimReview element per page qualifies for rich results. A check that scored ClaimReview coverage as an AI-readiness win would therefore push publishers to invest in a channel its largest documented consumer is actively withdrawing from. FALSIFIABLE and grade A on the evidence, but it measures the state of an external product, not the quality of the site \u2014 which is exactly why it must not contribute to a score.",
@@ -19793,6 +20173,7 @@ var MetaDescriptionAudit = class extends Audit {
19793
20173
  evidenceGrade: "B",
19794
20174
  tier: "scored",
19795
20175
  dossier: "docs/evidence/audits/answer-readiness/meta-description.md",
20176
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19796
20177
  defaultPriority: "high",
19797
20178
  guidance: {
19798
20179
  impact: "Google sometimes uses the meta description as the search snippet when it describes the page more accurately than the body text, and AI Overviews and AI Mode inherit that snippet pipeline. A missing, keyword-stuffed or off-topic description means the summary shown alongside your page is written by someone else.",
@@ -19887,6 +20268,7 @@ var MetaAuthorAudit = class extends Audit {
19887
20268
  evidenceGrade: "C",
19888
20269
  tier: "informative",
19889
20270
  dossier: "docs/evidence/audits/answer-readiness/meta-author.md",
20271
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19890
20272
  applicablePageTypes: ["content"],
19891
20273
  defaultPriority: "medium",
19892
20274
  guidance: {
@@ -19935,6 +20317,7 @@ var UniqueMetaAudit = class extends Audit {
19935
20317
  evidenceGrade: "C",
19936
20318
  tier: "informative",
19937
20319
  dossier: "docs/evidence/audits/answer-readiness/unique-meta.md",
20320
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
19938
20321
  defaultPriority: "high",
19939
20322
  guidance: {
19940
20323
  impact: "AI crawlers use title and description pairs to distinguish between pages. Duplicate meta across pages causes agents to merge or skip content, meaning some pages become invisible in AI-generated answers.",
@@ -20048,6 +20431,7 @@ var CoreOpenGraphAudit = class extends Audit {
20048
20431
  evidenceGrade: "A",
20049
20432
  tier: "scored",
20050
20433
  dossier: "docs/evidence/audits/answer-readiness/core-open-graph.md",
20434
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20051
20435
  defaultPriority: "high",
20052
20436
  guidance: {
20053
20437
  impact: "Link-preview crawlers use Open Graph tags to build the card shown wherever your page is shared, and Google uses og:title and og:site_name as inputs to the title link and site name on a result \u2014 the same labels that carry into AI Overviews source cards. Without them the crawler falls back to guessing.",
@@ -20131,6 +20515,7 @@ var OgTypeAudit = class extends Audit {
20131
20515
  evidenceGrade: "B",
20132
20516
  tier: "scored",
20133
20517
  dossier: "docs/evidence/audits/answer-readiness/og-type.md",
20518
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20134
20519
  defaultPriority: "medium",
20135
20520
  guidance: {
20136
20521
  impact: "AI agents use og:type to classify page content for type-specific handling. Without it, agents treat every page as generic content, missing opportunities for article freshness scoring or product-specific handling.",
@@ -20194,6 +20579,7 @@ var OgImageAltAudit = class extends Audit {
20194
20579
  evidenceGrade: "C",
20195
20580
  tier: "informative",
20196
20581
  dossier: "docs/evidence/audits/answer-readiness/og-image-alt.md",
20582
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20197
20583
  defaultPriority: "medium",
20198
20584
  guidance: {
20199
20585
  impact: "AI agents cannot process images directly and rely on og:image:alt text to understand your page's visual content. Without alt text, the OG image is invisible to text-based AI systems generating answers about your page.",
@@ -20265,6 +20651,7 @@ var FaqSectionsAudit = class _FaqSectionsAudit extends Audit {
20265
20651
  evidenceGrade: "C",
20266
20652
  tier: "informative",
20267
20653
  dossier: "docs/evidence/audits/answer-readiness/faq-sections.md",
20654
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20268
20655
  defaultPriority: "medium",
20269
20656
  guidance: {
20270
20657
  impact: 'FAQ sections with clear question headings are the highest-priority extraction target for AI-generated answers and "People Also Ask" results. Without them, your content misses the most direct path to appearing in AI answer snippets.',
@@ -20359,6 +20746,7 @@ var QuestionHeadingsAudit = class _QuestionHeadingsAudit extends Audit {
20359
20746
  evidenceGrade: "C",
20360
20747
  tier: "informative",
20361
20748
  dossier: "docs/evidence/audits/answer-readiness/question-headings.md",
20749
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20362
20750
  defaultPriority: "medium",
20363
20751
  guidance: {
20364
20752
  impact: "AI answer engines directly match user questions to heading text. Question-formatted headings are the primary signal for identifying which section answers a specific query. Without them, agents must guess which section is relevant, reducing your content's match rate.",
@@ -20527,6 +20915,7 @@ var DatesOnContentAudit = class extends Audit {
20527
20915
  evidenceGrade: "A",
20528
20916
  tier: "scored",
20529
20917
  dossier: "docs/evidence/audits/answer-readiness/dates-on-content.md",
20918
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20530
20919
  applicablePageTypes: ["content"],
20531
20920
  defaultPriority: "medium",
20532
20921
  guidance: {
@@ -20618,6 +21007,7 @@ var FirstParagraphAnswersAudit = class extends Audit {
20618
21007
  evidenceGrade: "C",
20619
21008
  tier: "informative",
20620
21009
  dossier: "docs/evidence/audits/answer-readiness/first-paragraph-answers.md",
21010
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20621
21011
  applicablePageTypes: ["content"],
20622
21012
  defaultPriority: "high",
20623
21013
  guidance: {
@@ -20783,6 +21173,7 @@ var DirectDefinitionsAudit = class extends Audit {
20783
21173
  evidenceGrade: "C",
20784
21174
  tier: "informative",
20785
21175
  dossier: "docs/evidence/audits/answer-readiness/direct-definitions.md",
21176
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20786
21177
  applicablePageTypes: ["content"],
20787
21178
  // Never a defect, so never above the actionable items.
20788
21179
  defaultPriority: "low",
@@ -20841,6 +21232,7 @@ var ComparisonTablesAudit = class _ComparisonTablesAudit extends Audit {
20841
21232
  evidenceGrade: "C",
20842
21233
  tier: "informative",
20843
21234
  dossier: "docs/evidence/audits/answer-readiness/comparison-tables.md",
21235
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20844
21236
  applicablePageTypes: ["category", "product", "content"],
20845
21237
  defaultPriority: "low",
20846
21238
  guidance: {
@@ -20916,6 +21308,7 @@ var SpecificNumbersAudit = class _SpecificNumbersAudit extends Audit {
20916
21308
  evidenceGrade: "B",
20917
21309
  tier: "scored",
20918
21310
  dossier: "docs/evidence/audits/answer-readiness/specific-numbers.md",
21311
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
20919
21312
  defaultPriority: "medium",
20920
21313
  guidance: {
20921
21314
  impact: "AI answer engines strongly prefer content with concrete data points over vague claims. Pages with specific numbers, percentages, and metrics are ranked higher for data-driven queries because agents can cite exact figures in generated answers.",
@@ -20974,17 +21367,6 @@ var SpecificNumbersAudit = class _SpecificNumbersAudit extends Audit {
20974
21367
  };
20975
21368
 
20976
21369
  // src/audits/answer-readiness/content-without-clickthrough.ts
20977
- function contentWordCount($) {
20978
- const main = $("main").first();
20979
- const extract = (sel) => {
20980
- const clone = sel.clone();
20981
- clone.find("script, style, noscript, template").remove();
20982
- return clone.text().replace(/\s+/g, " ").trim();
20983
- };
20984
- let text3 = main.length ? extract(main) : "";
20985
- if (!text3) text3 = extract($("body"));
20986
- return text3.split(/\s+/).filter(Boolean).length;
20987
- }
20988
21370
  var TEASER_PATTERNS = [
20989
21371
  /click\s+(here\s+)?to\s+read\s+more/i,
20990
21372
  /contact\s+us\s+to\s+learn/i,
@@ -21007,6 +21389,7 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
21007
21389
  evidenceGrade: "B",
21008
21390
  tier: "scored",
21009
21391
  dossier: "docs/evidence/audits/answer-readiness/content-without-clickthrough.md",
21392
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21010
21393
  defaultPriority: "high",
21011
21394
  guidance: {
21012
21395
  impact: 'AI answer engines skip pages dominated by teaser content ("click to read more", "sign up to access"). These pages provide no extractable answers, so agents will never surface your content in AI-generated responses, costing you visibility in AI search.',
@@ -21058,7 +21441,7 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
21058
21441
  return !head2.startsWith("<?xml");
21059
21442
  });
21060
21443
  if (checkPage) {
21061
- const wordCount2 = contentWordCount(checkPage.$);
21444
+ const wordCount2 = getWordCount(checkPage.$);
21062
21445
  if (wordCount2 < 50) {
21063
21446
  return this.warn(
21064
21447
  "Insufficient content to evaluate.",
@@ -21149,6 +21532,7 @@ var NamedAuthorAudit = class extends Audit {
21149
21532
  evidenceGrade: "C",
21150
21533
  tier: "informative",
21151
21534
  dossier: "docs/evidence/audits/answer-readiness/named-author.md",
21535
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21152
21536
  applicablePageTypes: ["content"],
21153
21537
  defaultPriority: "high",
21154
21538
  guidance: {
@@ -21275,6 +21659,7 @@ var AuthorSameAsAudit = class extends Audit {
21275
21659
  evidenceGrade: "C",
21276
21660
  tier: "informative",
21277
21661
  dossier: "docs/evidence/audits/answer-readiness/author-same-as.md",
21662
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21278
21663
  applicablePageTypes: ["content"],
21279
21664
  defaultPriority: "medium",
21280
21665
  guidance: {
@@ -21394,6 +21779,7 @@ var AuthorPageAudit = class extends Audit {
21394
21779
  evidenceGrade: "C",
21395
21780
  tier: "informative",
21396
21781
  dossier: "docs/evidence/audits/answer-readiness/author-page.md",
21782
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21397
21783
  applicablePageTypes: ["content"],
21398
21784
  defaultPriority: "medium",
21399
21785
  guidance: {
@@ -21528,6 +21914,7 @@ var AboutCredentialsAudit = class extends Audit {
21528
21914
  evidenceGrade: "C",
21529
21915
  tier: "informative",
21530
21916
  dossier: "docs/evidence/audits/answer-readiness/about-credentials.md",
21917
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21531
21918
  defaultPriority: "medium",
21532
21919
  guidance: {
21533
21920
  impact: "AI engines crawl your about page to build an organizational authority profile. Without credential-rich content (team bios, expertise areas, certifications), agents cannot assess your organization's authority, reducing your content's trust score in AI-generated recommendations.",
@@ -21637,6 +22024,7 @@ var ExternalCitationsAudit = class extends Audit {
21637
22024
  evidenceGrade: "B",
21638
22025
  tier: "scored",
21639
22026
  dossier: "docs/evidence/audits/answer-readiness/external-citations.md",
22027
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21640
22028
  applicablePageTypes: ["content"],
21641
22029
  defaultPriority: "medium",
21642
22030
  guidance: {
@@ -21751,6 +22139,7 @@ var BrandNameAudit = class extends Audit {
21751
22139
  evidenceGrade: "C",
21752
22140
  tier: "informative",
21753
22141
  dossier: "docs/evidence/audits/answer-readiness/brand-name.md",
22142
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21754
22143
  defaultPriority: "medium",
21755
22144
  guidance: {
21756
22145
  impact: "AI engines build entity graphs by matching Organization schema names to in-content mentions. If your brand name only appears in schema but not body text, agents cannot associate your content with your entity, weakening brand recognition in AI responses.",
@@ -21845,7 +22234,7 @@ function statesZeroReviews(record3) {
21845
22234
  }
21846
22235
  return false;
21847
22236
  }
21848
- function readableText(page) {
22237
+ function readableText2(page) {
21849
22238
  const body = page.$("body").clone();
21850
22239
  body.find("script, style, noscript, template").remove();
21851
22240
  return body.text().replace(/\s+/g, " ").trim();
@@ -21946,6 +22335,7 @@ var ReviewSignalsAudit = class extends Audit {
21946
22335
  evidenceGrade: "B",
21947
22336
  tier: "scored",
21948
22337
  dossier: "docs/evidence/audits/answer-readiness/review-signals.md",
22338
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
21949
22339
  applicablePageTypes: ["homepage", "product"],
21950
22340
  defaultPriority: "medium",
21951
22341
  guidance: {
@@ -21993,7 +22383,7 @@ var ReviewSignalsAudit = class extends Audit {
21993
22383
  ).toArray().filter((el) => p.$(el).text().trim() !== "" || p.$(el).children().length > 0);
21994
22384
  if (widget.length > 0) {
21995
22385
  noteWeak("review widget markup", p.url);
21996
- } else if (/\b\d[\d,]*\s+reviews?\b/i.test(readableText(p))) {
22386
+ } else if (/\b\d[\d,]*\s+reviews?\b/i.test(readableText2(p))) {
21997
22387
  noteWeak('"N reviews" text', p.url);
21998
22388
  }
21999
22389
  }
@@ -22053,7 +22443,7 @@ function isNonEnglish(page) {
22053
22443
  const lang = (page.$("html").attr("lang") ?? "").trim().toLowerCase();
22054
22444
  return lang !== "" && !lang.startsWith("en");
22055
22445
  }
22056
- function readableText2(page) {
22446
+ function readableText3(page) {
22057
22447
  const body = page.$("body").clone();
22058
22448
  body.find("script, style, noscript, template").remove();
22059
22449
  return body.text().replace(/\s+/g, " ").trim();
@@ -22105,6 +22495,7 @@ var TrustSignalsAudit = class _TrustSignalsAudit extends Audit {
22105
22495
  evidenceGrade: "B",
22106
22496
  tier: "scored",
22107
22497
  dossier: "docs/evidence/audits/answer-readiness/trust-signals.md",
22498
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
22108
22499
  applicablePageTypes: ["homepage"],
22109
22500
  defaultPriority: "low",
22110
22501
  guidance: {
@@ -22132,7 +22523,7 @@ var TrustSignalsAudit = class _TrustSignalsAudit extends Audit {
22132
22523
  "Non-English homepage \u2014 detector not applicable"
22133
22524
  );
22134
22525
  }
22135
- const text3 = readableText2(page);
22526
+ const text3 = readableText3(page);
22136
22527
  const satisfied = [];
22137
22528
  const missing = [];
22138
22529
  let counted = 0;
@@ -22242,6 +22633,7 @@ var PublicationDateAudit = class extends Audit {
22242
22633
  evidenceGrade: "B",
22243
22634
  tier: "scored",
22244
22635
  dossier: "docs/evidence/audits/answer-readiness/publication-date.md",
22636
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
22245
22637
  applicablePageTypes: ["content"],
22246
22638
  defaultPriority: "medium",
22247
22639
  guidance: {
@@ -22338,6 +22730,7 @@ var LastModifiedSchemaAudit = class extends Audit {
22338
22730
  evidenceGrade: "B",
22339
22731
  tier: "scored",
22340
22732
  dossier: "docs/evidence/audits/answer-readiness/last-modified-schema.md",
22733
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
22341
22734
  applicablePageTypes: ["content"],
22342
22735
  defaultPriority: "medium",
22343
22736
  guidance: {
@@ -22424,6 +22817,7 @@ var UniqueDataAudit = class extends Audit {
22424
22817
  evidenceGrade: "B",
22425
22818
  tier: "scored",
22426
22819
  dossier: "docs/evidence/audits/answer-readiness/unique-data.md",
22820
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
22427
22821
  defaultPriority: "medium",
22428
22822
  guidance: {
22429
22823
  impact: "AI generative engines prioritize content with unique, citable data points because agents can quote exact figures in generated answers. Content without specific numbers reads as opinion rather than evidence, reducing its chances of being cited.",
@@ -22521,6 +22915,7 @@ var DescriptiveUrlsAudit = class extends Audit {
22521
22915
  evidenceGrade: "C",
22522
22916
  tier: "informative",
22523
22917
  dossier: "docs/evidence/audits/answer-readiness/descriptive-urls.md",
22918
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
22524
22919
  defaultPriority: "high",
22525
22920
  guidance: {
22526
22921
  impact: "AI engines use URL text as a pre-fetch topic signal and display URLs in generated citations. Non-descriptive slugs with UUIDs or numeric IDs provide no topical context, reducing your content's relevance score before the page is even crawled.",
@@ -22776,6 +23171,7 @@ var SnippetGateCoverageAudit = class _SnippetGateCoverageAudit extends Audit {
22776
23171
  evidenceGrade: "A",
22777
23172
  tier: "scored",
22778
23173
  dossier: "docs/evidence/audits/answer-readiness/snippet-gate-coverage.md",
23174
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
22779
23175
  defaultPriority: "high",
22780
23176
  guidance: {
22781
23177
  impact: "Google states the eligibility gate directly: to appear as a supporting link a page 'must be indexed and eligible to be shown in Google Search with a snippet', and names nosnippet, data-nosnippet, max-snippet and noindex as the controls that limit what AI Overviews and AI Mode can show. This makes the causal chain fully documented rather than inferred: a max-snippet value shorter than the answer sentence truncates the answer below usefulness, and data-nosnippet wrapping the answer removes it from AI surfaces entirely while leaving it visible to humans \u2014 an invisible failure that page-level SEO reports do not surface because the directive itself is technically 'valid'.",
@@ -22988,6 +23384,7 @@ var TextFragmentAddressabilityAudit = class _TextFragmentAddressabilityAudit ext
22988
23384
  evidenceGrade: "A",
22989
23385
  tier: "scored",
22990
23386
  dossier: "docs/evidence/audits/answer-readiness/text-fragment-addressability.md",
23387
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
22991
23388
  defaultPriority: "medium",
22992
23389
  guidance: {
22993
23390
  impact: "Google Search auto-generates text-fragment URLs to land users on the exact featured-snippet text, and the spec requires each of prefix/start/end/suffix to match within a single block-level element. When an answer sentence is fragmented across block boundaries, or the header opt-out is set, the fragment silently fails and the link degrades to page-top. Falsifiable and directly testable: take the citing surface\u2019s own generated URL, load it, and observe whether the browser scrolls and highlights. Two failure classes are binary and deterministic \u2014 the opt-out header, and a start string that straddles two blocks.",
@@ -23135,6 +23532,7 @@ var ChunkBoundaryReferentIntegrityAudit = class extends Audit {
23135
23532
  weight: weightForGrade("B", "scored"),
23136
23533
  defaultPriority: "high",
23137
23534
  dossier: "docs/evidence/audits/answer-readiness/chunk-boundary-referent-integrity.md",
23535
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
23138
23536
  guidance: {
23139
23537
  impact: 'An answer engine retrieves a passage, not a page. A section that opens "This means you should..." and never names its subject is unusable on arrival: the model either drops it or attributes it to whatever else is in the window. The fix is per-sentence and cheap, and it is invisible to a reader of the whole page \u2014 which is why it survives editing.',
23140
23538
  fix: 'Open each section with its subject rather than a pronoun, name the product or topic once in every section over about forty words, and replace "as described above" with the name of the thing described.',
@@ -23333,6 +23731,7 @@ var ExtractorSurvivalRecallAudit = class extends Audit {
23333
23731
  weight: weightForGrade("B", "scored"),
23334
23732
  defaultPriority: "high",
23335
23733
  dossier: "docs/evidence/audits/answer-readiness/extractor-survival-recall.md",
23734
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
23336
23735
  guidance: {
23337
23736
  impact: 'An answer engine never sees the page; it sees whatever its extractor kept. A specification table inside `<aside class="related-specs">` is invisible to every pipeline that strips asides, and the answer about that product gets written without it. The loss is silent: the page looks complete to its author and to every human reviewer.',
23338
23737
  fix: 'Put facts inside the main content container, not in an aside, a footer, or a block whose class says "related" or "promo". Where a table must sit outside the article, repeat its facts in the prose so at least one copy survives.',
@@ -23465,6 +23864,7 @@ var SectionSplitRiskProfileAudit = class extends Audit {
23465
23864
  weight: weightForGrade("B", "scored"),
23466
23865
  defaultPriority: "medium",
23467
23866
  dossier: "docs/evidence/audits/answer-readiness/section-split-risk-profile.md",
23867
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
23468
23868
  guidance: {
23469
23869
  impact: "Retrieval pipelines cut pages into fixed windows. A section longer than the window becomes one chunk carrying the heading and one or more tail chunks carrying none \u2014 and a tail chunk is text with no subject, which retrieves badly and cites worse. A page with no headings at all is cut at arbitrary offsets throughout.",
23470
23870
  fix: "Add an `h2` or `h3` roughly every 400 tokens of prose, and split a specification table that runs past the window into per-topic tables so the header row stays with its rows.",
@@ -23654,6 +24054,7 @@ var SiteWidePassageUniquenessRatioAudit = class extends Audit {
23654
24054
  weight: weightForGrade("B", "scored"),
23655
24055
  defaultPriority: "medium",
23656
24056
  dossier: "docs/evidence/audits/answer-readiness/site-wide-passage-uniqueness-ratio.md",
24057
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
23657
24058
  guidance: {
23658
24059
  impact: "A search engine clusters duplicate and near-duplicate URLs and elects one canonical; the losers have their signals folded into the winner. A cluster of near-duplicate pages that each name themselves canonical therefore competes against itself, and at most one member stays citable however good the others are. Separately, a page whose sentences are mostly site-wide template produces chunks whose embeddings encode the template rather than the page, so every page built from that template lands in the same place in vector space and none is a distinctive match for any question.",
23659
24060
  fix: 'Merge near-duplicate pages into one, or point the weaker members at the strongest with rel="canonical" so the election has an answer. For pages that stay, raise the share of text that is theirs alone: cut the repeated intro, the repeated legal paragraph and the repeated call to action, and let each page carry the sentences only it can carry.',
@@ -23887,6 +24288,7 @@ var TableMarkdownRoundTripLossAudit = class extends Audit {
23887
24288
  weight: weightForGrade("B", "scored"),
23888
24289
  defaultPriority: "medium",
23889
24290
  dossier: "docs/evidence/audits/answer-readiness/table-markdown-round-trip-loss.md",
24291
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
23890
24292
  guidance: {
23891
24293
  impact: "A model does not read your table markup. Something converts it to markdown first, and GFM markdown has no merged cells, no second header row and no lists inside a cell. A header spanning two columns arrives heading one of them; the other column of numbers arrives with no header at all. The model still answers the question \u2014 with a number read from the wrong column, stated as confidently as a right one.",
23892
24294
  fix: "Flatten spanned headers into one header row of plain `th` cells, repeating the text where a span used to cover two columns. Put the unit or currency in the header cell rather than in the caption. Take paragraphs and lists out of cells. Where a table is genuinely two tables, publish it as two.",
@@ -24154,6 +24556,7 @@ var OpenApiExistsAudit = class _OpenApiExistsAudit extends Audit {
24154
24556
  evidenceGrade: "B",
24155
24557
  tier: "informative",
24156
24558
  dossier: "docs/evidence/audits/agent-interfaces/openapi-exists.md",
24559
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
24157
24560
  defaultPriority: "medium",
24158
24561
  guidance: {
24159
24562
  impact: "An agent that cannot find your API description cannot call it. Note that every documented consumer today (GPT Actions, Microsoft 365 Copilot API plugins) receives the document from a developer rather than fetching it from your site, so this check is informative and unscored.",
@@ -24294,6 +24697,7 @@ var OpenApiEndpointsAudit = class _OpenApiEndpointsAudit extends Audit {
24294
24697
  evidenceGrade: "B",
24295
24698
  tier: "scored",
24296
24699
  dossier: "docs/evidence/audits/agent-interfaces/openapi-endpoints.md",
24700
+ requires: ["origin-reachable"],
24297
24701
  defaultPriority: "high",
24298
24702
  guidance: {
24299
24703
  impact: "An OpenAPI spec without endpoints is unusable -- AI agents see a spec file but have zero actions they can perform. Your site remains a passive document that agents cannot interact with programmatically.",
@@ -24421,6 +24825,7 @@ var OpenApiOperationIdsAudit = class _OpenApiOperationIdsAudit extends Audit {
24421
24825
  evidenceGrade: "B",
24422
24826
  tier: "scored",
24423
24827
  dossier: "docs/evidence/audits/agent-interfaces/openapi-operation-ids.md",
24828
+ requires: ["origin-reachable"],
24424
24829
  defaultPriority: "medium",
24425
24830
  guidance: {
24426
24831
  impact: "AI agents use operationIds as stable function names when calling your API. Without unique operationIds, agents must infer endpoint names from URL paths, leading to ambiguous calls, naming collisions, and broken integrations.",
@@ -24600,6 +25005,7 @@ var OpenApiServersAudit = class _OpenApiServersAudit extends Audit {
24600
25005
  evidenceGrade: "B",
24601
25006
  tier: "scored",
24602
25007
  dossier: "docs/evidence/audits/agent-interfaces/openapi-servers.md",
25008
+ requires: ["origin-reachable"],
24603
25009
  defaultPriority: "high",
24604
25010
  guidance: {
24605
25011
  impact: "Without a servers array, AI agents cannot determine the base URL for your API. Even if your endpoints are perfectly documented, agents cannot construct valid request URLs, rendering the entire OpenAPI spec unusable.",
@@ -24764,6 +25170,7 @@ var OpenApiSchemasAudit = class _OpenApiSchemasAudit extends Audit {
24764
25170
  evidenceGrade: "B",
24765
25171
  tier: "scored",
24766
25172
  dossier: "docs/evidence/audits/agent-interfaces/openapi-schemas.md",
25173
+ requires: ["origin-reachable"],
24767
25174
  defaultPriority: "medium",
24768
25175
  guidance: {
24769
25176
  impact: "Without request/response schemas, AI agents must guess what data to send and what to expect back. This leads to malformed requests, failed API calls, and agents that cannot reliably use your endpoints.",
@@ -25168,6 +25575,7 @@ var AiCatalogExistsAudit = class _AiCatalogExistsAudit extends Audit {
25168
25575
  evidenceGrade: "C",
25169
25576
  tier: "informative",
25170
25577
  dossier: "docs/evidence/audits/agent-interfaces/ai-catalog-exists.md",
25578
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
25171
25579
  defaultPriority: "medium",
25172
25580
  guidance: {
25173
25581
  impact: "Without an AI catalog, agents must probe multiple endpoints to discover your services. This wastes time, increases error rates, and often results in agents skipping your site entirely in favor of competitors with a machine-readable capability manifest.",
@@ -25288,6 +25696,7 @@ var AiCatalogMetadataAudit = class _AiCatalogMetadataAudit extends Audit {
25288
25696
  evidenceGrade: "B",
25289
25697
  tier: "scored",
25290
25698
  dossier: "docs/evidence/audits/agent-interfaces/ai-catalog-metadata.md",
25699
+ requires: ["origin-reachable"],
25291
25700
  defaultPriority: "medium",
25292
25701
  guidance: {
25293
25702
  impact: "A thin catalog entry is a catalog entry nobody finds. Consumers match a user query against the entry text, so entries with no description, tags, capabilities or representative queries lose to better-described alternatives even when your service is the better answer.",
@@ -25423,6 +25832,7 @@ var AiCatalogUrlsAudit = class _AiCatalogUrlsAudit extends Audit {
25423
25832
  evidenceGrade: "B",
25424
25833
  tier: "scored",
25425
25834
  dossier: "docs/evidence/audits/agent-interfaces/ai-catalog-urls.md",
25835
+ requires: ["origin-reachable"],
25426
25836
  defaultPriority: "medium",
25427
25837
  guidance: {
25428
25838
  impact: "A broken entry url makes an agent fail mid-task: it read your manifest, followed the link you published, and got nothing. Entries whose url points at a nested catalog or registry cut off everything behind them as well.",
@@ -25550,6 +25960,7 @@ var AgentsJsonAudit = class extends Audit {
25550
25960
  evidenceGrade: "C",
25551
25961
  tier: "informative",
25552
25962
  dossier: "docs/evidence/audits/agent-interfaces/agents-json.md",
25963
+ requires: ["origin-reachable"],
25553
25964
  defaultPriority: "low",
25554
25965
  guidance: {
25555
25966
  impact: "Publishing agents.json is not known to make a site reachable to any agent: no vendor documents reading the file, and the specification has been dormant since 2025-08-21. What does matter is that a document already published at a well-known path can be read \u2014 a 200 carrying the site's HTML shell tells a conforming client the resource exists and then gives it nothing to parse, which is worse than a clean 404.",
@@ -25673,6 +26084,7 @@ var McpDiscoveryAudit = class _McpDiscoveryAudit extends Audit {
25673
26084
  evidenceGrade: "C",
25674
26085
  tier: "informative",
25675
26086
  dossier: "docs/evidence/audits/agent-interfaces/mcp-discovery.md",
26087
+ requires: ["origin-reachable"],
25676
26088
  defaultPriority: "medium",
25677
26089
  guidance: {
25678
26090
  impact: "No shipping MCP client is documented as fetching `/.well-known/mcp/servers.json` or `/.well-known/ucp`, so publishing one is not known to make a site reachable to any agent. What does matter is that a document published at a well-known path can be read: a 200 carrying HTML or unparseable JSON tells a conforming client the resource exists and then gives it nothing to parse.",
@@ -25992,6 +26404,7 @@ var McpEndpointAudit = class _McpEndpointAudit extends Audit {
25992
26404
  evidenceGrade: "C",
25993
26405
  tier: "informative",
25994
26406
  dossier: "docs/evidence/audits/agent-interfaces/mcp-endpoint.md",
26407
+ requires: ["origin-reachable"],
25995
26408
  defaultPriority: "high",
25996
26409
  guidance: {
25997
26410
  impact: "If your MCP endpoint does not answer an initialize handshake, AI assistants cannot connect at all. Capabilities and tool annotations come off the same connection: without them an agent cannot tell whether your server offers tools, or which of them are destructive enough to need user confirmation.",
@@ -26289,6 +26702,7 @@ var SearchEndpointAudit = class _SearchEndpointAudit extends Audit {
26289
26702
  evidenceGrade: "C",
26290
26703
  tier: "informative",
26291
26704
  dossier: "docs/evidence/audits/agent-interfaces/search-endpoint.md",
26705
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
26292
26706
  defaultPriority: "low",
26293
26707
  guidance: {
26294
26708
  impact: 'Without a declared search endpoint, an agent asked to "find pricing info on Example.com" has to crawl the site to answer. Note that no vendor documents an agent that reads SearchAction today \u2014 Google retired its only documented consumer in 2024 \u2014 so this check is informative and unscored.',
@@ -26459,6 +26873,7 @@ var WebmcpRegisteredToolsAudit = class extends Audit {
26459
26873
  // detector that cannot distinguish "no tools" from "cannot see the tools".
26460
26874
  tier: "experimental",
26461
26875
  dossier: "docs/evidence/audits/agent-interfaces/webmcp-registered-tools.md",
26876
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
26462
26877
  // Was `high` on an admittedly non-standard convention, so it outranked
26463
26878
  // genuinely actionable items in the recommendation list.
26464
26879
  defaultPriority: "low",
@@ -26574,6 +26989,7 @@ var WebmcpDeclarativeFormsAudit = class extends Audit {
26574
26989
  evidenceGrade: "B",
26575
26990
  tier: "scored",
26576
26991
  dossier: "docs/evidence/audits/agent-interfaces/webmcp-declarative-forms.md",
26992
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
26577
26993
  // Softened from 'high': the feature is Baseline "limited" (Chrome 149 /
26578
26994
  // Edge 150 origin trials, Brave Leo experimental) and Apple's WebKit
26579
26995
  // standards position is "oppose", so this is worth doing, not urgent.
@@ -26701,6 +27117,7 @@ var OpenApiDescriptionQualityAudit = class _OpenApiDescriptionQualityAudit exten
26701
27117
  evidenceGrade: "A",
26702
27118
  tier: "scored",
26703
27119
  dossier: "docs/evidence/audits/agent-interfaces/openapi-description-quality.md",
27120
+ requires: ["origin-reachable"],
26704
27121
  defaultPriority: "high",
26705
27122
  guidance: {
26706
27123
  impact: "LLM tool-calling treats your OpenAPI descriptions as the function-calling prompt. Missing or terse descriptions force the model to guess what each endpoint does and what each parameter accepts, producing wrong tool selection, malformed arguments, and failed API calls that erode user trust in agent-driven workflows on your site.",
@@ -26882,6 +27299,7 @@ var CorsApiRoutesAudit = class _CorsApiRoutesAudit extends Audit {
26882
27299
  evidenceGrade: "C",
26883
27300
  tier: "informative",
26884
27301
  dossier: "docs/evidence/audits/agent-interfaces/cors-api-routes.md",
27302
+ requires: ["origin-reachable"],
26885
27303
  // The affected consumer class is small; nothing here should outrank an
26886
27304
  // item that changes what a crawler or an MCP client can do.
26887
27305
  defaultPriority: "low",
@@ -27041,6 +27459,7 @@ var McpModernEraReachabilityAudit = class extends Audit {
27041
27459
  evidenceGrade: "A",
27042
27460
  tier: "scored",
27043
27461
  dossier: "docs/evidence/audits/agent-interfaces/mcp-modern-era-reachability.md",
27462
+ requires: ["origin-reachable"],
27044
27463
  defaultPriority: "high",
27045
27464
  guidance: {
27046
27465
  impact: "Revision 2026-07-28 abolished the `initialize` handshake and protocol-level sessions: version, client identity and capabilities now travel as per-request `_meta`, and `server/discover` is a MUST-implement RPC. The spec's own compatibility matrix states verbatim that a Modern client against a Legacy server FAILS, with no fall-forward path. Therefore: if a single POST of `server/discover` carrying `_meta` + `MCP-Protocol-Version: 2026-07-28` does not yield either a DiscoverResult or a recognized modern JSON-RPC error, then every client that has moved to the current revision cannot invoke a single tool on this server \u2014 the failure is total, not degraded. Conversely a 404/-32601 on `server/discover` from a server that otherwise answers modern requests is a direct MUST violation that breaks pre-consent capability presentation.",
@@ -27291,6 +27710,7 @@ var McpOauthDiscoveryChainAudit = class extends Audit {
27291
27710
  evidenceGrade: "A",
27292
27711
  tier: "scored",
27293
27712
  dossier: "docs/evidence/audits/agent-interfaces/mcp-oauth-discovery-chain.md",
27713
+ requires: ["origin-reachable"],
27294
27714
  defaultPriority: "high",
27295
27715
  guidance: {
27296
27716
  impact: "The spec makes RFC 9728 mandatory for MCP servers and makes clients apply two hard identity checks: RFC 9728 \xA73.3 requires the PRM's `resource` value to be string-identical to the resource identifier used to construct the request URL, and the MCP AS-discovery rules require the fetched AS metadata's `issuer` to be string-identical to the issuer used to construct the well-known URL \u2014 on either mismatch the client MUST NOT use the metadata. MCP additionally strengthens RFC 9728 by requiring `authorization_servers` to carry at least one entry (it is merely OPTIONAL in the RFC). Each of these is a silent, total blocker: the discovery chain either resolves end to end or the agent never reaches an authorization prompt, so a single character of drift between the deployed endpoint URL and the `resource` claim makes the server unusable to every conforming client while the server's own logs show nothing but 401s.",
@@ -27547,6 +27967,7 @@ var McpToolContractValidityAudit = class extends Audit {
27547
27967
  evidenceGrade: "A",
27548
27968
  tier: "scored",
27549
27969
  dossier: "docs/evidence/audits/agent-interfaces/mcp-tool-contract-validity.md",
27970
+ requires: ["origin-reachable"],
27550
27971
  defaultPriority: "critical",
27551
27972
  guidance: {
27552
27973
  impact: "The spec gives clients an explicit deletion instruction: 'Clients using the Streamable HTTP transport MUST reject tool definitions where any x-mcp-header value violates these constraints. Rejection means the client MUST exclude the invalid tool from the result of tools/list.' This makes malformed tool metadata a silent-invisibility bug rather than an error: the server returns the tool, logs a successful tools/list, and the model never sees it. The constraint set is fully machine-checkable with no network calls beyond the one list fetch \u2014 token syntax, no CR/LF, case-insensitive uniqueness, primitive types only with `number` explicitly excluded, and static reachability through a chain consisting solely of `properties` keys. Alongside it, `inputSchema` MUST be a valid JSON Schema object and not null; a null or scalar inputSchema breaks argument construction in every SDK.",
@@ -27803,6 +28224,7 @@ var McpToolsListDeterminismAudit = class extends Audit {
27803
28224
  evidenceGrade: "A",
27804
28225
  tier: "scored",
27805
28226
  dossier: "docs/evidence/audits/agent-interfaces/mcp-tools-list-determinism.md",
28227
+ requires: ["origin-reachable"],
27806
28228
  defaultPriority: "medium",
27807
28229
  guidance: {
27808
28230
  impact: "The spec states its own causal rationale verbatim: deterministic ordering 'enables clients to reliably cache the tool list and improves LLM prompt cache hit rates when tools are included in model context.' Tool definitions sit near the front of the model's prompt; if their serialized bytes change between turns, the provider-side prefix cache misses and the full tool block is re-billed at uncached rates on every single turn. Separately, servers MUST include caching hints on complete results, and when ttlMs is absent clients SHOULD assume 0 \u2014 immediately stale \u2014 so an omitted hint converts one cheap cached read into a network round-trip on every access. Both defects are invisible in functional testing and both are measurable with three identical requests.",
@@ -27987,6 +28409,7 @@ var McpVersionDowngradeAudit = class extends Audit {
27987
28409
  evidenceGrade: "A",
27988
28410
  tier: "scored",
27989
28411
  dossier: "docs/evidence/audits/agent-interfaces/mcp-version-downgrade.md",
28412
+ requires: ["origin-reachable"],
27990
28413
  defaultPriority: "medium",
27991
28414
  guidance: {
27992
28415
  impact: "With the handshake removed, the ONLY mechanism by which a client discovers a mutually supported version mid-flight is the `UnsupportedProtocolVersionError`: the spec requires code -32022 with `data.supported[]` listing the server's versions, and instructs clients to select from that list and retry. A server that instead returns a 500, a generic -32600/-32602, or a 400 with no `supported` array gives the client nothing to downgrade to \u2014 so a client whose preferred version is one revision ahead of the server's fails permanently even though a mutually supported version exists on both sides. Separately, the spec requires the header and the `_meta` value to agree, with a 400 + -32020 HeaderMismatch on divergence; a server that silently ignores the mismatch is trusting whichever source of truth its proxy layer did not, which is the exact split-brain the header-validation rules exist to prevent.",
@@ -28150,6 +28573,7 @@ var McpOriginValidationCorsAudit = class extends Audit {
28150
28573
  weight: weightForGrade("B", "scored"),
28151
28574
  defaultPriority: "high",
28152
28575
  dossier: "docs/evidence/audits/agent-interfaces/mcp-origin-validation-cors.md",
28576
+ requires: ["origin-reachable"],
28153
28577
  guidance: {
28154
28578
  impact: "The transport spec is unambiguous: servers MUST validate the Origin header on all incoming connections, and answer 403 when it is present and invalid, because a server that does not is reachable from any web page the user has open. The provable defect is the CORS pairing: an endpoint that reflects the requesting Origin into `Access-Control-Allow-Origin` and returns `Access-Control-Allow-Credentials: true` has authorized any page to enumerate its tool surface and invoke tools with the user\u2019s session.",
28155
28579
  fix: "Validate `Origin` on every request and answer 403 when it is present and not one you allow. Never reflect an arbitrary Origin while allowing credentials: return a fixed allow-list, or drop `Access-Control-Allow-Credentials`. `Access-Control-Allow-Origin: *` is only safe on an endpoint that accepts no credentials at all.",
@@ -28271,61 +28695,6 @@ var McpOriginValidationCorsAudit = class extends Audit {
28271
28695
  }
28272
28696
  };
28273
28697
 
28274
- // src/gatherers/domains.ts
28275
- var MULTI_SUFFIX = /* @__PURE__ */ new Set([
28276
- "co.uk",
28277
- "org.uk",
28278
- "ac.uk",
28279
- "gov.uk",
28280
- "me.uk",
28281
- "net.uk",
28282
- "com.au",
28283
- "net.au",
28284
- "org.au",
28285
- "edu.au",
28286
- "gov.au",
28287
- "co.nz",
28288
- "co.jp",
28289
- "or.jp",
28290
- "ne.jp",
28291
- "co.za",
28292
- "co.kr",
28293
- "co.il",
28294
- "co.id",
28295
- "co.th",
28296
- "com.br",
28297
- "com.mx",
28298
- "com.ar",
28299
- "com.co",
28300
- "com.pe",
28301
- "co.in",
28302
- "com.sg",
28303
- "com.tr",
28304
- "com.cn",
28305
- "com.hk",
28306
- "com.tw",
28307
- "com.my",
28308
- "com.ph",
28309
- "com.ua",
28310
- "com.pl",
28311
- "com.es",
28312
- "com.pt",
28313
- "com.gr"
28314
- ]);
28315
- function registrableDomain(host) {
28316
- const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
28317
- if (parts.length <= 2) return parts.join(".");
28318
- const lastTwo = parts.slice(-2).join(".");
28319
- return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
28320
- }
28321
- function registrableOf(url) {
28322
- try {
28323
- return registrableDomain(new URL(url).hostname);
28324
- } catch {
28325
- return "";
28326
- }
28327
- }
28328
-
28329
28698
  // src/audits/agent-interfaces/mcp-registry-listing-ownership.ts
28330
28699
  var REGISTRY = "https://registry.modelcontextprotocol.io/v0.1/servers";
28331
28700
  var PROOF_PATH = "/.well-known/mcp-registry-auth";
@@ -28381,6 +28750,7 @@ var McpRegistryListingOwnershipAudit = class extends Audit {
28381
28750
  weight: weightForGrade("B", "scored"),
28382
28751
  defaultPriority: "medium",
28383
28752
  dossier: "docs/evidence/audits/agent-interfaces/mcp-registry-listing-ownership.md",
28753
+ requires: ["origin-reachable"],
28384
28754
  guidance: {
28385
28755
  impact: 'The registry is the index a client resolves "the MCP server for this domain" against. A domain with no first-party entry is absent from it, so the only path to the server is a URL somebody pastes by hand. A listing under an aggregator\u2019s namespace is worse than absent in one way: the brand cannot update or revoke it, and agents routed through it reach a proxy rather than the origin. The reverse-DNS namespace that fixes this is granted on proof of domain control, and that proof has to keep being served.',
28386
28756
  fix: "Publish the server under your own reverse-DNS namespace (`com.example/...`), serve the proof at `/.well-known/mcp-registry-auth` in the exact `v=MCPv1; k=ed25519; p=<base64>` form and keep serving it after DNS migrations, keep the listing\u2019s version in step with what the server reports, and offer a `streamable-http` remote rather than only the deprecated `sse`.",
@@ -28597,6 +28967,7 @@ var McpToolDescriptionCoverageAudit = class extends Audit {
28597
28967
  weight: weightForGrade("B", "scored"),
28598
28968
  defaultPriority: "medium",
28599
28969
  dossier: "docs/evidence/audits/agent-interfaces/mcp-tool-description-coverage.md",
28970
+ requires: ["origin-reachable"],
28600
28971
  guidance: {
28601
28972
  impact: "A tool description and its parameter descriptions are the only prose a model ever sees about a tool \u2014 they are the whole basis on which it decides whether to call it and what to pass. A required parameter with no description, no enum and no pattern gives the model nothing to derive a legal value from, so it guesses. Guessed values come back as validation errors, and the agent spends retry turns per call until it gives up on the tool.",
28602
28973
  fix: "Describe every tool and every parameter, in prose long enough to say what a legal value looks like. Constrain string parameters with `enum`, `format` or `pattern` where the legal set is finite. Declare an `outputSchema` so a client can parse the result rather than re-reading it, give each tool a `title` for the consent prompt, and return top-level `instructions` telling a model how the tools fit together.",
@@ -28834,6 +29205,7 @@ var OfferSchemaAudit = class extends Audit {
28834
29205
  evidenceGrade: "A",
28835
29206
  tier: "scored",
28836
29207
  dossier: "docs/evidence/audits/agentic-commerce/offer-schema.md",
29208
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
28837
29209
  applicablePageTypes: ["product"],
28838
29210
  defaultPriority: "medium",
28839
29211
  guidance: {
@@ -28952,6 +29324,7 @@ var ProductIdentifiersAudit = class extends Audit {
28952
29324
  evidenceGrade: "A",
28953
29325
  tier: "scored",
28954
29326
  dossier: "docs/evidence/audits/agentic-commerce/product-identifiers.md",
29327
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
28955
29328
  applicablePageTypes: ["product"],
28956
29329
  defaultPriority: "high",
28957
29330
  guidance: {
@@ -29069,6 +29442,7 @@ var ProductTransactionCertaintyAudit = class extends Audit {
29069
29442
  evidenceGrade: "A",
29070
29443
  tier: "scored",
29071
29444
  dossier: "docs/evidence/audits/agentic-commerce/product-transaction-certainty.md",
29445
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
29072
29446
  applicablePageTypes: ["product"],
29073
29447
  defaultPriority: "high",
29074
29448
  guidance: {
@@ -29401,6 +29775,7 @@ var BuyableVariantResolutionAudit = class extends Audit {
29401
29775
  weight: weightForGrade("B", "scored"),
29402
29776
  defaultPriority: "high",
29403
29777
  dossier: "docs/evidence/audits/agentic-commerce/buyable-variant-resolution.md",
29778
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
29404
29779
  applicablePageTypes: ["product"],
29405
29780
  guidance: {
29406
29781
  impact: "The agentic-commerce feed models a catalogue variant-first: every sellable thing is a variant with its own id, price and availability. A page that shows five sizes and three colours but publishes one Offer \u2014 or an AggregateOffer with only lowPrice and highPrice \u2014 gives an agent no purchasable unit to name and no single price to quote. The row is dropped at feed validation, or the checkout session comes back with `invalid` on the line item.",
@@ -29614,6 +29989,7 @@ var CartHandoffReachabilityAudit = class extends Audit {
29614
29989
  weight: weightForGrade("B", "scored"),
29615
29990
  defaultPriority: "high",
29616
29991
  dossier: "docs/evidence/audits/agentic-commerce/cart-handoff-reachability.md",
29992
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
29617
29993
  guidance: {
29618
29994
  impact: "Every upstream signal can be perfect and the purchase still dies at the last click. If the cart 302s to a login form because guest checkout is off, or Turnstile is mounted on the checkout document alone, the agent walks the buyer to a wall it cannot pass. ACP reserves a `requires_sign_in` message code for exactly this case, which is a description of the failure, not a fix for it.",
29619
29995
  fix: "Allow guest checkout, or at least let an unauthenticated buyer reach the cart and see the totals. Keep bot challenges off the cart and checkout documents \u2014 challenge the payment submission instead, where a human is present. Allow ChatGPT-User in robots.txt and at the edge on cart paths: blocking GPTBot does not block it, and the two are separately tokened.",
@@ -29819,6 +30195,7 @@ var OfferTruthConsistencyAudit = class extends Audit {
29819
30195
  weight: weightForGrade("B", "scored"),
29820
30196
  defaultPriority: "high",
29821
30197
  dossier: "docs/evidence/audits/agentic-commerce/offer-truth-consistency.md",
30198
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
29822
30199
  applicablePageTypes: ["product"],
29823
30200
  guidance: {
29824
30201
  impact: "An agent quotes from the structured data; the seller recomputes the real amount at checkout. When the two disagree the buyer has already committed, and the session comes back with `invalid` or `out_of_stock` \u2014 the most expensive moment at which a purchase can fail. Google says the same thing from the other side: structured data must be a true representation of the page content. Markup that is present and lying passes every syntax validator on the market.",
@@ -30168,6 +30545,7 @@ var AcpPolicyLinkSurfaceAudit = class _AcpPolicyLinkSurfaceAudit extends Audit {
30168
30545
  evidenceGrade: "A",
30169
30546
  tier: "scored",
30170
30547
  dossier: "docs/evidence/audits/agentic-commerce/acp-policy-link-surface.md",
30548
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
30171
30549
  defaultPriority: "high",
30172
30550
  guidance: {
30173
30551
  impact: "Falsifiable claim: ACP spec 2026-04-17 makes `links` one of the 9 REQUIRED fields on every CheckoutSession response, with type enum {terms_of_use, privacy_policy, return_policy, shipping_policy, contact_us, about_us, faq, support}. Independently, the OpenAI product feed spec makes `seller_privacy_policy` and `seller_tos` HARD-REQUIRED whenever `is_eligible_checkout=true`. Therefore a merchant that cannot produce a resolvable HTTPS URL for terms_of_use and privacy_policy CANNOT set is_eligible_checkout=true and its catalogue is excluded from Instant Checkout no matter how good the feed is. Disproof condition: if a merchant with no reachable ToS URL is observed transacting via ACP Instant Checkout, the check is wrong.",
@@ -30418,6 +30796,7 @@ var LandedCostAndReturnsAudit = class _LandedCostAndReturnsAudit extends Audit {
30418
30796
  evidenceGrade: "A",
30419
30797
  tier: "scored",
30420
30798
  dossier: "docs/evidence/audits/agentic-commerce/landed-cost-and-returns.md",
30799
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
30421
30800
  applicablePageTypes: ["product"],
30422
30801
  defaultPriority: "high",
30423
30802
  guidance: {
@@ -30546,6 +30925,7 @@ var AgentUaCommerceParityAudit = class extends Audit {
30546
30925
  evidenceGrade: "A",
30547
30926
  tier: "scored",
30548
30927
  dossier: "docs/evidence/audits/agentic-commerce/agent-ua-commerce-parity.md",
30928
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
30549
30929
  defaultPriority: "critical",
30550
30930
  guidance: {
30551
30931
  impact: "OpenAI operates four separately-tokened agents with separately published IP ranges: OAI-SearchBot (search indexing), ChatGPT-User (user-initiated fetches \u2014 the shopper's agent), GPTBot (training) and OAI-AdsBot (ad landing-page validation). Falsifiable claim: if a product page returns 403, 429, 503 or a challenge interstitial to ChatGPT-User or OAI-SearchBot while returning 200 to a browser, ChatGPT cannot read live price and availability nor follow the buy link, so the product cannot be surfaced or transacted no matter how good the feed is. That block lives at the WAF or CDN edge, which is why an audit that only parses robots.txt is structurally blind to it. Disproof condition: a site 403ing ChatGPT-User on its product pages that still shows live, accurate prices in ChatGPT.",
@@ -30689,6 +31069,7 @@ var ContactFormAudit = class _ContactFormAudit extends Audit {
30689
31069
  evidenceGrade: "C",
30690
31070
  tier: "informative",
30691
31071
  dossier: "docs/evidence/audits/operability-safety/contact-form.md",
31072
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
30692
31073
  defaultPriority: "high",
30693
31074
  guidance: {
30694
31075
  impact: 'When users ask AI agents to "contact this company for a quote" or "send a message to their support team," the agent needs a machine-submittable form or API endpoint. Without one, the agent cannot complete the request and users turn to competitors.',
@@ -30797,6 +31178,8 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
30797
31178
  evidenceGrade: "A",
30798
31179
  tier: "scored",
30799
31180
  dossier: "docs/evidence/audits/operability-safety/no-blocking-captcha.md",
31181
+ // Gate exemption: A captcha wall is what this audit reports.
31182
+ requires: ["origin-reachable"],
30800
31183
  defaultPriority: "high",
30801
31184
  guidance: {
30802
31185
  impact: 'Blocking CAPTCHAs completely prevent AI agents from submitting forms on behalf of users. When a user asks an agent to "fill out the contact form," the CAPTCHA blocks the action entirely, forcing the user to do it manually or go to a competitor.',
@@ -30819,6 +31202,23 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
30819
31202
  }
30820
31203
  };
30821
31204
  audit(ctx) {
31205
+ const waf = ctx.wafProtection;
31206
+ if (waf?.isBlocked && !waf.isRateLimit) {
31207
+ return this.fail(
31208
+ `The site answered the scanner with a bot wall (${waf.name}). An AI agent acting for a user meets the same wall.`,
31209
+ "No bot wall or blocking CAPTCHA between an agent and the page",
31210
+ `${waf.name}: ${waf.reason}`,
31211
+ { priority: "high", description: _NoBlockingCaptchaAudit.meta.description },
31212
+ ctx.baseUrl
31213
+ );
31214
+ }
31215
+ if (ctx.pages.length === 0) {
31216
+ return this.notApplicable(
31217
+ "No page was fetched, so no form could be inspected for a blocking CAPTCHA.",
31218
+ "No recaptcha, hcaptcha, or turnstile script includes detected",
31219
+ "No page fetched"
31220
+ );
31221
+ }
30822
31222
  const detectedCaptchas = [];
30823
31223
  for (const page of ctx.pages) {
30824
31224
  const html = page.fetchResult.body.toLowerCase();
@@ -30875,6 +31275,7 @@ var FormsNoJsAudit = class _FormsNoJsAudit extends Audit {
30875
31275
  evidenceGrade: "C",
30876
31276
  tier: "informative",
30877
31277
  dossier: "docs/evidence/audits/operability-safety/forms-no-js.md",
31278
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
30878
31279
  defaultPriority: "medium",
30879
31280
  guidance: {
30880
31281
  impact: "Most AI agents do not execute JavaScript. If your forms rely on JS for submission (e.g., React/Vue event handlers with no HTML action), agents cannot submit them at all. This blocks lead capture, contact requests, and any form-based interaction.",
@@ -31054,6 +31455,7 @@ var FormActionabilityAudit = class extends Audit {
31054
31455
  evidenceGrade: "A",
31055
31456
  tier: "scored",
31056
31457
  dossier: "docs/evidence/audits/operability-safety/form-actionability.md",
31458
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
31057
31459
  defaultPriority: "high",
31058
31460
  guidance: {
31059
31461
  impact: "AI agents do not render your page visually. Unlabeled fields, div-based fake inputs, and missing autocomplete attributes mean agents cannot tell which field is the email address or the name, so submissions fail silently or land in the wrong fields \u2014 lost leads, broken signups, and abandoned checkouts.",
@@ -31207,6 +31609,7 @@ var AriaLandmarksAudit = class extends Audit {
31207
31609
  evidenceGrade: "A",
31208
31610
  tier: "scored",
31209
31611
  dossier: "docs/evidence/audits/operability-safety/aria-landmarks.md",
31612
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
31210
31613
  defaultPriority: "high",
31211
31614
  guidance: {
31212
31615
  impact: "Claude computer use and browser agents rely on ARIA landmarks to identify page regions (navigation, main content, footer). Missing landmarks force agents to guess page structure from raw HTML, leading to misclicked elements and incorrect content extraction.",
@@ -31346,7 +31749,19 @@ function defineA11yAudit(spec) {
31346
31749
  };
31347
31750
  }
31348
31751
  var base = {
31349
- category: "operability-safety"
31752
+ category: "operability-safety",
31753
+ /**
31754
+ * Every audit built on this base reads the sampled pages through
31755
+ * `A11yBackedAudit`, so they all carry the same requirement set. Declared
31756
+ * once here; `scripts/check-requires.mjs` resolves it for each audit that
31757
+ * spreads `base`.
31758
+ */
31759
+ requires: [
31760
+ "origin-reachable",
31761
+ "unblocked-fetches",
31762
+ "rendered-body",
31763
+ "sample-adequate"
31764
+ ]
31350
31765
  };
31351
31766
  function graded(grade, slug) {
31352
31767
  const tier = grade === "A" || grade === "B" ? "scored" : "informative";
@@ -31456,6 +31871,7 @@ var FormErrorMessagesAudit = class _FormErrorMessagesAudit extends Audit {
31456
31871
  evidenceGrade: "A",
31457
31872
  tier: "scored",
31458
31873
  dossier: "docs/evidence/audits/operability-safety/form-error-messages.md",
31874
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
31459
31875
  defaultPriority: "medium",
31460
31876
  guidance: {
31461
31877
  impact: "A field with no aria-errormessage or aria-describedby reference has no message attached to it in the accessibility tree, so an agent that submits a form and gets it back rejected cannot tell which field was wrong or why. It retries the same values or abandons the form.",
@@ -31861,6 +32277,7 @@ var SecurityHeaderHygieneAudit = class extends Audit {
31861
32277
  evidenceGrade: "C",
31862
32278
  tier: "informative",
31863
32279
  dossier: "docs/evidence/audits/operability-safety/security-header-hygiene.md",
32280
+ requires: ["origin-reachable"],
31864
32281
  defaultPriority: "low",
31865
32282
  guidance: {
31866
32283
  impact: "Vulnerability-disclosure hygiene, reported for completeness. A conformant security.txt tells a security researcher who to contact; it is read by researchers and disclosure scanners, not by AI agents. Publishing one changes nothing about how an agent retrieves, parses or cites the site, which is why nothing here moves your score. If you do publish one, an expired or contactless file is worse than none: it advertises a disclosure route that no longer works.",
@@ -32119,6 +32536,7 @@ var FormAutofillTokenCoverageAudit = class _FormAutofillTokenCoverageAudit exten
32119
32536
  evidenceGrade: "A",
32120
32537
  tier: "scored",
32121
32538
  dossier: "docs/evidence/audits/operability-safety/form-autofill-token-coverage.md",
32539
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
32122
32540
  defaultPriority: "high",
32123
32541
  guidance: {
32124
32542
  impact: 'Falsifiable claim: an agent filling a checkout must map each field to a value from user profile data. When the field declares autocomplete="postal-code", that mapping is a table lookup against a ratified vocabulary; when it declares name="field_7" with a visual-only label, the mapping is an inference that fails on ambiguous cases (address-line2 vs address-level2, cc-exp vs bday, tel-national vs tel). WebSuite measures the consequence directly: complex form filling succeeds 12.5% and 0% for the two agents tested, against 85%/76% for simple operational clicks. Test: add correct autocomplete tokens to a failing form and re-run the same fill task.',
@@ -32290,6 +32708,7 @@ var NativeControlSubstitutionAudit = class _NativeControlSubstitutionAudit exten
32290
32708
  evidenceGrade: "A",
32291
32709
  tier: "scored",
32292
32710
  dossier: "docs/evidence/audits/operability-safety/native-control-substitution.md",
32711
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
32293
32712
  defaultPriority: "high",
32294
32713
  guidance: {
32295
32714
  impact: `Falsifiable claim: native <select>, <input type="date">, and <input type="file"> are single-call primitives in every mainstream agent toolkit (selectOption, fill, setInputFiles) and are keyboard-operable, so they succeed in one action with no actionability risk. A custom equivalent requires open \u2192 wait for popup \u2192 scroll the option list into view \u2192 locate the option \u2192 click, where each step is independently subject to Playwright's visible/stable/receives-events gates, and Anthropic documents dropdowns specifically as 'tricky for Claude to manipulate using mouse movements'. Test: instrument the same form with native vs custom controls and count tool calls and retries to reach an identical value.`,
@@ -32628,6 +33047,7 @@ var InvisibleInstructionScanAudit = class _InvisibleInstructionScanAudit extends
32628
33047
  evidenceGrade: "A",
32629
33048
  tier: "scored",
32630
33049
  dossier: "docs/evidence/audits/operability-safety/invisible-instruction-scan.md",
33050
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
32631
33051
  defaultPriority: "critical",
32632
33052
  guidance: {
32633
33053
  impact: "If a page carries text nodes that a sighted human cannot perceive but that survive DOM-to-text serialization, an LLM browsing agent ingests them with the same weight as body copy and can act on them. Brave demonstrated exactly this against Comet (white-on-white text, HTML comments, invisible elements hidden in a Reddit spoiler tag) and confirmed Opera Neon was exploitable through 'hidden HTML elements and other non-rendered markup'. Falsifier: an agent that ingests only visually perceivable, rendered text would be immune \u2014 the disclosed incidents show current agents are not. Google's spam policy independently enumerates the same hiding techniques and their legitimate exceptions, giving the detector a canonical technique list and a false-positive allowlist.",
@@ -32904,6 +33324,7 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
32904
33324
  evidenceGrade: "A",
32905
33325
  tier: "scored",
32906
33326
  dossier: "docs/evidence/audits/operability-safety/aria-layer-injection-scan.md",
33327
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
32907
33328
  defaultPriority: "critical",
32908
33329
  guidance: {
32909
33330
  impact: "Computer-use and browser agents drive pages through the DOM and accessibility tree, not pixels, so a11y attributes enter the model context with the same weight as visible text while remaining invisible to a sighted human. Anthropic names the vector explicitly: 'hidden malicious form fields in a webpage's DOM invisible to humans, and other hard-to-catch injections such as through the URL text and tab title that only an agent might see.' The divergence sub-check is a defect in its own right independent of injection: an agent that clicks by accessible name will actuate an aria-label that contradicts the rendered label. Falsifier: if every a11y attribute is short, descriptive, and token-consistent with its element's visible text, this channel carries no payload.",
@@ -33067,6 +33488,7 @@ var GhostClickableElementRatioAudit = class _GhostClickableElementRatioAudit ext
33067
33488
  evidenceGrade: "B",
33068
33489
  tier: "scored",
33069
33490
  dossier: "docs/evidence/audits/operability-safety/ghost-clickable-element-ratio.md",
33491
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
33070
33492
  defaultPriority: "high",
33071
33493
  guidance: {
33072
33494
  impact: "An element whose click behaviour comes only from a JS listener on a non-interactive tag, or from cursor:pointer styling, and which carries no role and no accessible name, is omitted from the serialized accessibility snapshot that agent toolkits send to the model. Playwright MCP's default mode is the accessibility tree, not pixel input: every action tool takes an exact element reference from the snapshot, and coordinate clicking exists only behind the optional vision capability. An element absent from the snapshot is therefore unaddressable by the default toolchain \u2014 the agent cannot emit a valid click and must fail or guess a URL. The accessibility linters cannot warn about it either: axe's button-name and link-name rules only fire on elements that already declare button or link semantics, so a bare unroled div is invisible to them by construction.",
@@ -33289,6 +33711,7 @@ var StatefulControlIntrospectabilityAudit = class _StatefulControlIntrospectabil
33289
33711
  evidenceGrade: "B",
33290
33712
  tier: "scored",
33291
33713
  dossier: "docs/evidence/audits/operability-safety/stateful-control-introspectability.md",
33714
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
33292
33715
  defaultPriority: "high",
33293
33716
  guidance: {
33294
33717
  impact: 'An agent works as observe, act, verify. If a toggle\'s only "on" signal is `class="is-active"` and a colour change, the accessibility snapshot is byte-identical before and after the click, so the agent cannot verify the post-condition: it either clicks again and flips the state back, or reports success with no evidence. The accessibility linters cannot catch this, because `aria-required-attr` fires only once the element already declares `role="switch"` or `role="checkbox"` \u2014 the common class-only toggle declares no role and passes silently. Benchmarks put the cost high: WebSuite measures switch, accordion and dropdown primitives among the worst-performing interactions for web agents, and Operator\'s confirmation design assumes the agent can observe a state transition before acting on it.',
@@ -33498,6 +33921,7 @@ var HoverOnlyContentAndNavigationAudit = class _HoverOnlyContentAndNavigationAud
33498
33921
  evidenceGrade: "B",
33499
33922
  tier: "scored",
33500
33923
  dossier: "docs/evidence/audits/operability-safety/hover-only-content-and-navigation.md",
33924
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
33501
33925
  defaultPriority: "high",
33502
33926
  guidance: {
33503
33927
  impact: "A submenu revealed only by an ancestor `:hover` rule is `display:none` or `visibility:hidden` in the resting DOM, and Playwright's actionability contract defines such an element as not visible \u2014 so every Playwright-derived agent refuses to click it, and the snapshot serializer omits it entirely. The agent never learns those destinations exist: it does not fail loudly, it simply reports that the site has no page for what the user asked. WebSuite measures the information half of the same defect at 0% success for tooltip-based retrieval across both agents it tested. The fix is cheap and it is the same fix keyboard users need, which is why it is worth doing once.",
@@ -33725,6 +34149,7 @@ var DragAndSliderDependencyAudit = class _DragAndSliderDependencyAudit extends A
33725
34149
  evidenceGrade: "B",
33726
34150
  tier: "scored",
33727
34151
  dossier: "docs/evidence/audits/operability-safety/drag-and-slider-dependency.md",
34152
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
33728
34153
  defaultPriority: "high",
33729
34154
  guidance: {
33730
34155
  impact: 'A continuous pointer gesture asks an agent to synthesise a pointerdown, a run of intermediate pointermove events and a pointerup at a computed pixel offset, with no feedback between steps and no way to check the interim value. Every other agent action is discrete and verifiable. WebSuite measures slider interaction at 0% success for both agents it tested \u2014 the worst primitive in its taxonomy \u2014 and Anthropic separately documents scrollbars and dropdowns as unreliable under mouse control, recommending keyboard paths instead. Pair the slider with a numeric input bound to the same value and "set max price to 300" stops being a gesture and becomes a fill.',
@@ -33973,6 +34398,7 @@ var UrlAddressableStateAndPaginationFallbackAudit = class _UrlAddressableStateAn
33973
34398
  evidenceGrade: "B",
33974
34399
  tier: "scored",
33975
34400
  dossier: "docs/evidence/audits/operability-safety/url-addressable-state-and-pagination-fallback.md",
34401
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
33976
34402
  defaultPriority: "high",
33977
34403
  applicablePageTypes: ["category"],
33978
34404
  guidance: {
@@ -34174,6 +34600,7 @@ var FirstContactConsentGateOperabilityAudit = class _FirstContactConsentGateOper
34174
34600
  evidenceGrade: "C",
34175
34601
  tier: "informative",
34176
34602
  dossier: "docs/evidence/audits/operability-safety/first-contact-consent-gate-operability.md",
34603
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
34177
34604
  defaultPriority: "low",
34178
34605
  guidance: {
34179
34606
  impact: "An agent arriving with no cookies spends its first actions on the consent layer, before any step of the actual task. Three properties decide whether it can. A layer rendered inside a cross-origin iframe is invisible to a DOM-text extractor that reads only the top document, so the agent's text and its screenshot disagree and it acts on content it cannot actually see. Accept and reject controls built as unroled, unnamed divs are unaddressable in a snapshot for the same reason a ghost-clickable div is. And main content set `inert` or `aria-hidden=\"true\"` while the layer is open empties every snapshot until the layer is gone \u2014 axe's own guidance is that `aria-hidden` removes the element and all its children from the accessibility API. The evidence here is convention rather than documented consumer behaviour, which is why this audit reports rather than scores.",
@@ -34425,6 +34852,7 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
34425
34852
  evidenceGrade: "B",
34426
34853
  tier: "scored",
34427
34854
  dossier: "docs/evidence/audits/operability-safety/unicode-covert-channel-scan.md",
34855
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
34428
34856
  defaultPriority: "critical",
34429
34857
  guidance: {
34430
34858
  impact: "Tag-block codepoints mirror ASCII and, per Unicode, render as nothing in tag-unaware implementations \u2014 while modern LLM tokenizers process them normally. A complete instruction can therefore ride inside a product description that no human and no visual QA pass can see. Bidi controls make the rendered order differ from the logical order a text-extracting agent reads, which is the Trojan Source class (CVE-2021-42574). Zero-width characters defeat naive substring matching on both sides at once: the site\u2019s own filters and the agent\u2019s. None of this is visible in a screenshot, a browser, or a review \u2014 only in the bytes.",
@@ -34664,6 +35092,7 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
34664
35092
  evidenceGrade: "B",
34665
35093
  tier: "scored",
34666
35094
  dossier: "docs/evidence/audits/operability-safety/third-party-dom-write-blast-radius.md",
35095
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
34667
35096
  defaultPriority: "high",
34668
35097
  guidance: {
34669
35098
  impact: 'An agent reads the DOM as one document with one level of trust. It has no way to tell text the site wrote from text a vendor script injected after load, so every third-party origin that can write to the page can write instructions the agent will read as the site\'s own. The count is the risk: eleven uncontrolled origins is eleven independent companies \u2014 and their own supply chains \u2014 with the same authority over what an agent believes about the site. A Content-Security-Policy with a nonce, a hash or `strict-dynamic` is what turns that list from "whoever" into "these, and only these". A policy whose sources include `https:` or `*` is present in the response and constrains nothing.',
@@ -34859,6 +35288,7 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
34859
35288
  evidenceGrade: "B",
34860
35289
  tier: "scored",
34861
35290
  dossier: "docs/evidence/audits/operability-safety/unsafe-agent-triggerable-affordances.md",
35291
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
34862
35292
  defaultPriority: "critical",
34863
35293
  guidance: {
34864
35294
  impact: "An agent exploring a site follows links, and a link that changes state changes it on the first fetch \u2014 no click, no intent, no confirmation. The same property makes the site a target for indirect prompt injection: text on a page can name the URL, and an agent that reads it as an instruction performs the action with the user's own session. Disallowing the path in robots.txt is only a partial mitigation, because a user-initiated fetch is documented as not necessarily bound by robots.txt. The underlying rule is older than agents: a GET is a safe method, meaning it must not have side effects, and everything here is a violation of that rule that agents simply make expensive.",
@@ -34996,6 +35426,7 @@ var ReflectedParameterInjectionCanaryAudit = class extends Audit {
34996
35426
  weight: weightForGrade("B", "scored"),
34997
35427
  defaultPriority: "critical",
34998
35428
  dossier: "docs/evidence/audits/operability-safety/reflected-parameter-injection-canary.md",
35429
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
34999
35430
  guidance: {
35000
35431
  impact: "Agents and answer engines weight a source by domain authority, and a reflected-input URL passes human inspection because the hostname is genuine. If attacker-controlled query or path input lands in the page's own title, meta description, canonical link, or JSON-LD strings, the domain becomes a self-serve injection host: the attacker does not need to compromise anything, only to share a link. Reflection into rendered text is the same defect one step down, and it is only contained while the page stays out of an index.",
35001
35432
  fix: 'Escape URL-derived input before it reaches any template, and keep it out of `<title>`, `<meta name="description">`, `og:description`, `rel="canonical"` and JSON-LD entirely \u2014 those fields should describe the page, not the request. Where a search page must echo the query back to the visitor, render it as escaped text inside the body and mark the page `noindex`.',
@@ -35264,6 +35695,7 @@ var UgcTrustBoundaryMarkersAudit = class extends Audit {
35264
35695
  weight: weightForGrade("B", "scored"),
35265
35696
  defaultPriority: "high",
35266
35697
  dossier: "docs/evidence/audits/operability-safety/ugc-trust-boundary-markers.md",
35698
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
35267
35699
  guidance: {
35268
35700
  impact: "Attacker-controllable text sits in the same DOM as first-party copy with no boundary, so anything a visitor types reads, to a fetching agent, as a statement the domain made. Google excludes text inside a `data-nosnippet` span, div or section from snippets across web search, Discover and AI Overviews, and includes everything outside it. The sanitizer arm matters most: if a comment body can carry an inline style or an iframe, hiding an instruction inside visitor text becomes self-serve on this site.",
35269
35701
  fix: 'Wrap each visitor-written region in a `<div data-nosnippet>` \u2014 the attribute is honoured on span, div and section only \u2014 and add `rel="ugc"` to links inside it. Strip inline `style`, `iframe`, `script` and remote `img` from submitted markup at render time rather than at submit time, so already-stored content is covered too.',
@@ -35385,6 +35817,7 @@ var AgentUaContentDivergenceDiffAudit = class extends Audit {
35385
35817
  weight: weightForGrade("B", "scored"),
35386
35818
  defaultPriority: "high",
35387
35819
  dossier: "docs/evidence/audits/operability-safety/agent-ua-content-divergence-diff.md",
35820
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
35388
35821
  guidance: {
35389
35822
  impact: "An agent that reads a different page from the one a human sees cannot be checked by the human it answers to. Where the crawler copy is thinner, the answer engine quotes a page the visitor will never find; where it carries text the browser copy does not, the site is speaking to the model privately \u2014 which is the delivery mechanism for every instruction-injection attack that does not need a compromise. A JSON-LD block that differs between variants is the same problem in the field a machine trusts most.",
35390
35823
  fix: "Serve one document to every User-Agent. Where a bot-management rule reduces the page for unknown clients, allow the published AI-crawler UAs through it rather than branching on them, and keep the JSON-LD identical across variants. If a crawler should not read the site at all, block it in robots.txt and at the edge rather than serving it a different story.",
@@ -35755,6 +36188,7 @@ var C2paManifestSurvivesDeliveryAudit = class extends Audit {
35755
36188
  weight: weightForGrade("B", "scored"),
35756
36189
  defaultPriority: "medium",
35757
36190
  dossier: "docs/evidence/audits/operability-safety/c2pa-manifest-survives-delivery.md",
36191
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
35758
36192
  guidance: {
35759
36193
  impact: "Signing an image at creation proves nothing if the bytes a crawler downloads are unsigned. Image transformation layers discard Content Credentials by default \u2014 Cloudflare states outright that with preservation disabled, existing Content Credentials are always discarded \u2014 so the publisher sees signed assets in their library while every consumer sees stripped ones. The provenance work is done and none of it reaches the reader.",
35760
36194
  fix: "Turn on Content Credentials preservation in the image pipeline (Cloudflare Images has an explicit setting; Next.js image optimization and most CDN resizers need the manifest copied through or the asset served unoptimized). Verify by fetching the URL the page actually renders, not the asset in the library.",
@@ -35904,6 +36338,7 @@ var C2paSignerTrustStatusAudit = class extends Audit {
35904
36338
  weight: weightForGrade("B", "scored"),
35905
36339
  defaultPriority: "medium",
35906
36340
  dossier: "docs/evidence/audits/operability-safety/c2pa-signer-trust-status.md",
36341
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
35907
36342
  guidance: {
35908
36343
  impact: "A manifest that exists is not a manifest that verifies. A conforming C2PA validator resolves the signing certificate against the published Trust List and shows the credential as untrusted when it cannot \u2014 which is what a self-signed certificate always produces, and what an expired one produces the day it lapses. The publisher sees Content Credentials on every asset; the consumer sees a warning, or nothing at all.",
35909
36344
  fix: "Sign with a certificate from a CA on the C2PA Trust List rather than a self-signed one, renew before it expires, and include an RFC 3161 timestamp so credentials stay valid past the certificate\u2019s own expiry.",
@@ -36082,6 +36517,7 @@ var OrganizationIdentifierRegistryResolutionAudit = class extends Audit {
36082
36517
  weight: weightForGrade("B", "scored"),
36083
36518
  defaultPriority: "medium",
36084
36519
  dossier: "docs/evidence/audits/operability-safety/organization-identifier-registry-resolution.md",
36520
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
36085
36521
  guidance: {
36086
36522
  impact: "A shopping or payment agent transacting with an unfamiliar merchant needs one thing no amount of markup can self-assert: a legal identity it can check against an authority. The LEI is the only schema.org organization identifier backed by a free, queryable, authoritative registry, which makes it the only one whose truth an outside party can establish. An identifier that resolves to nothing, or to a lapsed registration, or to a different legal name, is worse than none: it looks like verification and is not.",
36087
36523
  fix: 'Publish the LEI as `iso6523Code: "0199:<LEI>"` \u2014 Google documents a preference for the prefixed form over bare `leiCode` \u2014 keep the GLEIF registration renewed so its status stays ISSUED, and make sure the `legalName` in your markup is the name GLEIF has on record, not the trading name.',
@@ -36295,6 +36731,7 @@ var SyntheticMediaDisclosureValidityAudit = class extends Audit {
36295
36731
  weight: weightForGrade("B", "scored"),
36296
36732
  defaultPriority: "medium",
36297
36733
  dossier: "docs/evidence/audits/operability-safety/synthetic-media-disclosure-validity.md",
36734
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
36298
36735
  guidance: {
36299
36736
  impact: "Disclosure only counts if a machine can read it. IPTC types `DigitalSourceType` as a URI from a controlled vocabulary, so a consumer matching against that vocabulary silently ignores `AI-generated`, a bare `trainedAlgorithmicMedia`, or an `https://` spelling of the `http://` vocabulary URI. The publisher believes the image is disclosed; every machine reader sees an undisclosed image. Worse is an asset whose XMP and C2PA manifest disagree about whether a human took the photo \u2014 two provenance channels, one of them wrong.",
36300
36737
  fix: "Write the full vocabulary URI, exactly: `http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia`. Keep the `http` scheme the vocabulary itself uses, no trailing slash, no free text, and make sure the value agrees with the digital source type asserted in the asset\u2019s C2PA manifest.",
@@ -36464,6 +36901,7 @@ var TrustTxtReciprocityCoherenceAudit = class extends Audit {
36464
36901
  weight: 0,
36465
36902
  defaultPriority: "low",
36466
36903
  dossier: "docs/evidence/audits/operability-safety/trust-txt-reciprocity-coherence.md",
36904
+ requires: ["origin-reachable"],
36467
36905
  guidance: {
36468
36906
  impact: "trust.txt association attributes are defined as reciprocal: `belongto=<association>` means something only if that association\u2019s own trust.txt carries `member=<this domain>`. That makes the claim checkable rather than self-asserted, which is the whole point of publishing it. Separately, `datatrainingallowed=no` beside a robots.txt that leaves GPTBot and ClaudeBot free to crawl states two opposite policies, and the channel that actually gates crawlers is the one that says yes. Adoption caveat: no AI engine, answer engine or crawler is documented as reading trust.txt.",
36469
36907
  fix: "Ask each association you claim to belong to for a reciprocal `member=` line, drop the ones that will not reciprocate, and make `datatrainingallowed=` say the same thing your robots.txt AI-bot groups say.",
@@ -36656,6 +37094,7 @@ var WikidataRoundTripVerificationAudit = class extends Audit {
36656
37094
  weight: weightForGrade("B", "scored"),
36657
37095
  defaultPriority: "medium",
36658
37096
  dossier: "docs/evidence/audits/operability-safety/wikidata-round-trip-verification.md",
37097
+ requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
36659
37098
  guidance: {
36660
37099
  impact: "A knowledge-graph consumer that grounds a brand to an entity needs corroboration from the authority side, because `sameAs` carries no reciprocity requirement \u2014 Google documents it as a link to a page with more information, nothing more. Wikidata publishes that corroboration for free as P856. A claim whose entity points at an unrelated domain is either the wrong entity or an unbacked identity claim, and an answer engine that resolves it grounds the brand to somebody else.",
36661
37100
  fix: "Claim the entity that really is your organization, and make sure the Wikidata item carries your domain as its official website (P856). If the item has no P856 at all, add one: until it does, the claim cannot be corroborated by anyone.",
@@ -36860,6 +37299,7 @@ function outcomeOf(check) {
36860
37299
  const tags = check.tags ?? [];
36861
37300
  if (tags.includes(TAG_SCAN_ERROR)) return "error";
36862
37301
  if (tags.includes(TAG_SKIPPED_PAGE_TYPE)) return "skipped";
37302
+ if (tags.includes(TAG_SKIPPED_NO_EVIDENCE)) return "gated";
36863
37303
  return "ran";
36864
37304
  }
36865
37305
  function traceFromCheck(check, durationMs) {
@@ -36921,7 +37361,31 @@ function stubCheck(meta2, tag2, explanation) {
36921
37361
  tier: meta2.tier
36922
37362
  };
36923
37363
  }
36924
- function planAudits(ctx, config) {
37364
+ function unmetRequirements(ctx, meta2) {
37365
+ const required = meta2.requires ?? [];
37366
+ if (required.length === 0) return [];
37367
+ const evidence = ctx.evidence;
37368
+ const unmet = [];
37369
+ for (const key2 of required) {
37370
+ if (key2 === "sample-adequate") {
37371
+ const wanted = meta2.applicablePageTypes?.length ? meta2.applicablePageTypes : ["homepage"];
37372
+ if (!wanted.some((type) => evidence.usablePageTypes.has(type))) unmet.push(key2);
37373
+ continue;
37374
+ }
37375
+ if (!evidence.met[key2]) unmet.push(key2);
37376
+ }
37377
+ return unmet;
37378
+ }
37379
+ function gateExplanation(ctx, meta2, unmet) {
37380
+ const reasons = unmet.map((key2) => ctx.evidence.reasons[key2]).filter(Boolean);
37381
+ if (unmet.includes("sample-adequate") && reasons.length === 0) {
37382
+ const wanted = meta2.applicablePageTypes?.length ? meta2.applicablePageTypes.join("/") : "homepage";
37383
+ return `Not assessed: no scanned ${wanted} page served readable text.`;
37384
+ }
37385
+ const why = reasons.length > 0 ? ` ${reasons.join(" ")}` : "";
37386
+ return `Not assessed: this scan has no ${unmet.join(", ")} evidence.${why}`;
37387
+ }
37388
+ function planAudits(ctx, config, options = {}) {
36925
37389
  const scannedPageTypes = new Set(ctx.pages.map((p) => p.pageType));
36926
37390
  const runnable = [];
36927
37391
  const skipped = [];
@@ -36941,6 +37405,15 @@ function planAudits(ctx, config) {
36941
37405
  continue;
36942
37406
  }
36943
37407
  }
37408
+ if (options.enforceEvidence) {
37409
+ const unmet = unmetRequirements(ctx, reg2.meta);
37410
+ if (unmet.length > 0) {
37411
+ skipped.push(
37412
+ stubCheck(reg2.meta, TAG_SKIPPED_NO_EVIDENCE, gateExplanation(ctx, reg2.meta, unmet))
37413
+ );
37414
+ continue;
37415
+ }
37416
+ }
36944
37417
  runnable.push({ reg: reg2, categoryId: cat.id });
36945
37418
  }
36946
37419
  }
@@ -37263,6 +37736,18 @@ function detectWafProtection(targetUrl, homepageResult, rootFiles, scannedPagesC
37263
37736
 
37264
37737
  // src/orchestrator.ts
37265
37738
  var A11Y_MAX_PAGES = Math.max(0, Number(process.env.SCANNER_A11Y_MAX_PAGES ?? 3));
37739
+ var RATE_LIMIT_BACKOFF_MS = 5e3;
37740
+ var MAX_RETRY_AFTER_MS = 3e4;
37741
+ async function fetchHomepage(fetcher, url, signal) {
37742
+ const first5 = await fetcher.fetch({ url, signal });
37743
+ if (first5.status !== 429) return first5;
37744
+ const header = Number(first5.headers["retry-after"]);
37745
+ const waitMs = Number.isFinite(header) && header > 0 ? Math.min(header * 1e3, MAX_RETRY_AFTER_MS) : RATE_LIMIT_BACKOFF_MS;
37746
+ logger.debug({ url, waitMs }, `[orchestrator] Homepage answered 429; retrying once in ${waitMs}ms`);
37747
+ await new Promise((resolve4) => setTimeout(resolve4, waitMs));
37748
+ signal?.throwIfAborted();
37749
+ return fetcher.fetch({ url, signal });
37750
+ }
37266
37751
  function discoverPages(homepageUrl, domain, rootFiles, homepage$, exclude, maxAdditional) {
37267
37752
  const discovered = /* @__PURE__ */ new Set();
37268
37753
  const sitemapBody = rootFiles["/sitemap.xml"]?.status === 200 ? rootFiles["/sitemap.xml"].body : rootFiles["/sitemap-index.xml"]?.status === 200 ? rootFiles["/sitemap-index.xml"].body : "";
@@ -37430,7 +37915,7 @@ async function runScan(url, options) {
37430
37915
  signal?.throwIfAborted();
37431
37916
  logger.debug("[orchestrator] Phase 2: Fetching pages");
37432
37917
  tracker.phaseStart("fetch-pages", 1);
37433
- const homepageResult = await fetcher.fetch({ url, signal });
37918
+ const homepageResult = await fetchHomepage(fetcher, url, signal);
37434
37919
  tracker.unitDone(displayUrl);
37435
37920
  const homepage$ = homepageResult.status === 200 && homepageResult.body ? parseHtml(homepageResult.body) : null;
37436
37921
  const discoverLimit = Math.max(0, MAX_PAGES_PER_SCAN - 1 - overrideUrls.length);
@@ -37489,19 +37974,29 @@ async function runScan(url, options) {
37489
37974
  signal?.throwIfAborted();
37490
37975
  logger.debug("[orchestrator] Phase 3: Running audits");
37491
37976
  const wafProtection = detectWafProtection(url, homepageResult, rootFiles, pages.length);
37977
+ const evidence = buildScanEvidence({
37978
+ requestedUrl: url,
37979
+ homepageResult,
37980
+ pages,
37981
+ rootFiles,
37982
+ wafProtection: wafProtection ?? null
37983
+ });
37492
37984
  const ctx = {
37493
37985
  rootFiles,
37494
37986
  pages,
37495
37987
  domain,
37496
37988
  baseUrl,
37497
37989
  fetch: (options2) => fetcher.fetch({ ...options2, signal }),
37498
- wafProtection: wafProtection ?? void 0
37990
+ wafProtection: wafProtection ?? void 0,
37991
+ evidence
37499
37992
  };
37500
37993
  const config = filterConfig(defaultConfig, {
37501
37994
  categories: options?.categories,
37502
37995
  includeExperimental: options?.includeExperimental ?? false
37503
37996
  });
37504
- const auditPlan = planAudits(ctx, config);
37997
+ const auditPlan = planAudits(ctx, config, {
37998
+ enforceEvidence: options?.enforceEvidenceGate ?? true
37999
+ });
37505
38000
  tracker.phaseStart("audits", auditPlan.runnable.length);
37506
38001
  const {
37507
38002
  checks: allChecks,
@@ -37522,7 +38017,7 @@ async function runScan(url, options) {
37522
38017
  tracker.phaseStart("report", 1);
37523
38018
  logger.debug("[orchestrator] Phase 4: Building final report");
37524
38019
  const durationMs = Math.round(performance.now() - start);
37525
- const recommendations = allChecks.filter((c) => c.status !== "pass" && !isInformative(c)).slice().sort((a, b) => {
38020
+ const recommendations = allChecks.filter((c) => (c.status === "fail" || c.status === "warn") && !isInformative(c)).slice().sort((a, b) => {
37526
38021
  const order = { critical: 0, high: 1, medium: 2, low: 3 };
37527
38022
  return (order[a.priority] ?? 3) - (order[b.priority] ?? 3);
37528
38023
  });
@@ -37534,13 +38029,23 @@ async function runScan(url, options) {
37534
38029
  const readinessScore = Math.round(
37535
38030
  readinessVitals.commerce * READINESS_WEIGHTS.commerce + readinessVitals.content * READINESS_WEIGHTS.content + readinessVitals.botAccessibility * READINESS_WEIGHTS.botAccessibility + readinessVitals.technical * READINESS_WEIGHTS.technical
37536
38031
  );
38032
+ const gatedShare = gatedMassShare(allChecks);
38033
+ const escalated = gatedShare > GATED_MASS_UNSCORED_THRESHOLD;
38034
+ const unscoredReason = !evidence.judgeable ? Object.values(evidence.reasons).filter(Boolean).join(" ") || "The scan obtained too little evidence to judge this site." : escalated ? `The scan could not feed ${Math.round(gatedShare * 100)}% of the registry's evidence mass, so what remains is not a reading of this site.` : void 0;
38035
+ const scored = unscoredReason === void 0;
37537
38036
  const report = {
37538
38037
  scanId: "",
37539
38038
  // Set by the caller
37540
38039
  url: displayUrl,
37541
38040
  domain,
37542
- overallScore,
37543
- scoreTier: getScoreTier(overallScore),
38041
+ overallScore: scored ? overallScore : null,
38042
+ scoreTier: scored ? getScoreTier(overallScore) : null,
38043
+ scanValidity: {
38044
+ judgeable: evidence.judgeable,
38045
+ evidence: evidence.met,
38046
+ reasons: evidence.reasons,
38047
+ ...unscoredReason ? { unscoredReason } : {}
38048
+ },
37544
38049
  summary: "",
37545
38050
  // Set below
37546
38051
  categories,
@@ -37561,7 +38066,7 @@ async function runScan(url, options) {
37561
38066
  report.summary = generateScanSummary(report);
37562
38067
  tracker.unitDone();
37563
38068
  tracker.phaseDone();
37564
- tracker.scanDone(overallScore);
38069
+ tracker.scanDone(report.overallScore);
37565
38070
  logger.debug({ durationMs, score: overallScore }, "[orchestrator] runScan complete");
37566
38071
  return report;
37567
38072
  }
@@ -37730,6 +38235,7 @@ export {
37730
38235
  DEFAULT_SCAN_LIMIT,
37731
38236
  DeprecationNoticeSchema,
37732
38237
  EvidenceGradeSchema,
38238
+ EvidenceKeySchema,
37733
38239
  FixEffortSchema,
37734
38240
  MAX_CONCURRENT_REQUESTS,
37735
38241
  MAX_PAGES_PER_SCAN,
@@ -37745,9 +38251,12 @@ export {
37745
38251
  SCORE_TIER_LABELS,
37746
38252
  ScoreDisplayModeSchema,
37747
38253
  TAG_SCAN_ERROR,
38254
+ TAG_SKIPPED_NO_EVIDENCE,
37748
38255
  TAG_SKIPPED_PAGE_TYPE,
38256
+ allEvidenceMet,
37749
38257
  allJsonLdNodes,
37750
38258
  buildCategoryResult,
38259
+ buildScanEvidence,
37751
38260
  calculateCategoryScore,
37752
38261
  calculateOverallScore,
37753
38262
  classifyFetch,
@@ -37778,6 +38287,7 @@ export {
37778
38287
  formatTrace,
37779
38288
  getMainContentText,
37780
38289
  getPreset,
38290
+ getRenderedText,
37781
38291
  getScoreTier,
37782
38292
  getTierColor,
37783
38293
  getTierLabel,