@forkpoint/agent-lighthouse-core 0.2.4 → 0.3.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
@@ -3910,6 +3910,175 @@ var NoBotDetectionAudit = class extends Audit {
3910
3910
  }
3911
3911
  };
3912
3912
 
3913
+ // src/audits/crawler-permissions/tdm-rep.ts
3914
+ var TDMREP_PATH = "/.well-known/tdmrep.json";
3915
+ async function getTdmRepFile(ctx) {
3916
+ const cached = ctx.rootFiles[TDMREP_PATH];
3917
+ if (cached) return cached;
3918
+ try {
3919
+ return await ctx.fetch({ url: `${ctx.baseUrl}${TDMREP_PATH}` });
3920
+ } catch {
3921
+ return null;
3922
+ }
3923
+ }
3924
+ function describeReservation(value) {
3925
+ return value.trim() === "1" ? "rights reserved (mining denied by default)" : "mining explicitly permitted";
3926
+ }
3927
+ var TdmRepAudit = class extends Audit {
3928
+ static meta = {
3929
+ id: "2.27",
3930
+ category: "crawler-permissions",
3931
+ title: "TDM-Rep data mining rights declared",
3932
+ failureTitle: "TDM-Rep data mining rights declared",
3933
+ description: 'The TDM-Rep (Text and Data Mining Reservation) protocol is the emerging machine-readable standard for declaring whether your content may be used for text and data mining, anchored in EU DSM Directive Article 4. Without an explicit declaration \u2014 either a <meta name="tdm-reservation"> tag or a /.well-known/tdmrep.json policy file \u2014 AI crawlers and licensing agents cannot tell whether you reserve your mining rights, leaving your content in a legal gray zone where well-behaved agents guess and the rest assume permission. Declaring your terms explicitly puts you in control of how AI systems may use your content.',
3934
+ scoreDisplayMode: "ternary",
3935
+ weight: 0.7,
3936
+ defaultPriority: "medium",
3937
+ guidance: {
3938
+ impact: "Without an explicit TDM declaration, AI crawlers and content-licensing agents have no machine-readable signal about your data mining terms. Rights-respecting agents may skip your content to be safe, while others assume permission \u2014 you lose control either way, and you forfeit the EU DSM Article 4 opt-out protection that requires a machine-readable reservation.",
3939
+ fix: 'Declare your text and data mining policy explicitly. Add <meta name="tdm-reservation" content="1"> to reserve mining rights (or "0" to permit mining) on every page, and optionally point to a full policy with <meta name="tdm-policy" content="https://yoursite.com/tdm-policy">. For a site-wide declaration, publish a JSON policy at /.well-known/tdmrep.json instead.',
3940
+ code: '<!-- Reserve mining rights on every page -->\n<meta name="tdm-reservation" content="1" />\n<meta name="tdm-policy" content="https://yoursite.com/tdm-policy" />\n\n<!-- Or site-wide at /.well-known/tdmrep.json -->\n{\n "tdm-reservation": 1,\n "tdm-policy": "https://yoursite.com/tdm-policy"\n}',
3941
+ effort: "trivial",
3942
+ docsUrl: "https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/",
3943
+ tags: ["tdm-rep", "licensing", "crawler-permissions", "compliance"]
3944
+ }
3945
+ };
3946
+ async audit(ctx) {
3947
+ const pageUrl = ctx.pages[0]?.url;
3948
+ for (const page of ctx.pages) {
3949
+ const reservation = page.meta["tdm-reservation"];
3950
+ if (reservation !== void 0) {
3951
+ const policy = page.meta["tdm-policy"];
3952
+ const policyNote = policy ? ` Policy URL: ${policy}.` : "";
3953
+ return this.pass(
3954
+ `Explicit TDM-Rep declaration found on ${page.url}: tdm-reservation="${reservation.trim()}" (${describeReservation(reservation)}).${policyNote}`,
3955
+ "Explicit TDM reservation via meta tag or /.well-known/tdmrep.json",
3956
+ `meta tdm-reservation="${reservation.trim()}"`,
3957
+ page.url
3958
+ );
3959
+ }
3960
+ }
3961
+ const file = await getTdmRepFile(ctx);
3962
+ if (file && file.status === 200) {
3963
+ try {
3964
+ const policy = JSON.parse(file.body);
3965
+ const reservation = policy["tdm-reservation"];
3966
+ const reservationNote = reservation !== void 0 ? ` tdm-reservation=${String(reservation)} (${describeReservation(String(reservation))}).` : "";
3967
+ return this.pass(
3968
+ `TDM-Rep policy file found at ${TDMREP_PATH}.${reservationNote}`,
3969
+ "Explicit TDM reservation via meta tag or /.well-known/tdmrep.json",
3970
+ `Valid JSON policy at ${TDMREP_PATH}`,
3971
+ pageUrl
3972
+ );
3973
+ } catch {
3974
+ return this.warn(
3975
+ `A file exists at ${TDMREP_PATH} but is not valid JSON, so agents cannot parse your data mining policy.`,
3976
+ "Valid JSON policy at /.well-known/tdmrep.json",
3977
+ "Malformed tdmrep.json",
3978
+ "medium",
3979
+ pageUrl
3980
+ );
3981
+ }
3982
+ }
3983
+ const detail = file && file.status !== 200 && file.status !== 0 ? ` (${TDMREP_PATH} returned HTTP ${file.status})` : "";
3984
+ return this.warn(
3985
+ `No TDM-Rep declaration found. No <meta name="tdm-reservation"> tag on any page and no policy file at ${TDMREP_PATH}${detail}. Your site has not declared its text and data mining terms, so AI crawlers cannot tell whether you reserve your mining rights.`,
3986
+ "Explicit TDM reservation via meta tag or /.well-known/tdmrep.json",
3987
+ "No TDM-Rep declaration found",
3988
+ "medium",
3989
+ pageUrl
3990
+ );
3991
+ }
3992
+ };
3993
+
3994
+ // src/audits/crawler-permissions/agent-governance.ts
3995
+ function explicitlyNamed(groups, bots) {
3996
+ const agents = new Set(groups.map((g) => g.userAgent.toLowerCase()));
3997
+ return bots.filter((bot) => {
3998
+ const names = [bot.botName, ...bot.aliases ?? []];
3999
+ return names.some((name) => agents.has(name.toLowerCase()));
4000
+ });
4001
+ }
4002
+ function categoryBlocked(groups, bots) {
4003
+ const names = new Set(
4004
+ bots.flatMap(
4005
+ (bot) => [bot.botName, ...bot.aliases ?? []].map((n) => n.toLowerCase())
4006
+ )
4007
+ );
4008
+ const rules = groups.filter((g) => names.has(g.userAgent.toLowerCase())).flatMap((g) => g.rules);
4009
+ return isBlanketBlocked(rules);
4010
+ }
4011
+ var AgentGovernanceAudit = class extends Audit {
4012
+ static meta = {
4013
+ id: "2.28",
4014
+ category: "crawler-permissions",
4015
+ title: "AI crawler vs conversational agent separation",
4016
+ failureTitle: "No separation between training crawlers and live agents",
4017
+ description: "Not all AI bots are the same. Training crawlers like GPTBot, CCBot, and Google-Extended scrape your content to build datasets, while conversational and retrieval agents like ChatGPT-User, Claude-User, and OAI-SearchBot fetch pages live to answer real user questions and can send referral traffic back to you. Many sites want to block the former while welcoming the latter \u2014 but a single catch-all User-agent: * cannot express that distinction. Granular robots.txt governance names both categories explicitly so each gets the access policy you actually intend.",
4018
+ scoreDisplayMode: "ternary",
4019
+ weight: 0.8,
4020
+ defaultPriority: "medium",
4021
+ guidance: {
4022
+ 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.",
4023
+ fix: "Add explicit User-agent groups in robots.txt for both categories: name training crawlers (GPTBot, CCBot, Google-Extended, anthropic-ai) with the policy you want, and separately name live agents (ChatGPT-User, Claude-User, OAI-SearchBot) \u2014 typically with Allow: / so your site stays visible in AI answers.",
4024
+ code: "# Block dataset-training crawlers\nUser-agent: GPTBot\nDisallow: /\n\nUser-agent: CCBot\nDisallow: /\n\n# Welcome live conversational agents\nUser-agent: ChatGPT-User\nAllow: /\n\nUser-agent: Claude-User\nAllow: /\n\nUser-agent: *\nAllow: /",
4025
+ effort: "easy",
4026
+ docsUrl: "https://platform.openai.com/docs/bots",
4027
+ tags: ["robots-txt", "crawler-permissions", "ai-governance"]
4028
+ }
4029
+ };
4030
+ audit(ctx) {
4031
+ const robotsFile = ctx.rootFiles["/robots.txt"];
4032
+ if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
4033
+ return this.notApplicable(
4034
+ "No robots.txt found \u2014 agentic governance cannot be evaluated.",
4035
+ "robots.txt with explicit rules for both training crawlers and live conversational agents",
4036
+ "No robots.txt found"
4037
+ );
4038
+ }
4039
+ const groups = parseRobotsTxt(robotsFile.body);
4040
+ const trainingNamed = explicitlyNamed(groups, TRAINING_CRAWLERS);
4041
+ const realtimeNamed = explicitlyNamed(groups, REALTIME_CRAWLERS);
4042
+ const hasCatchAll = groups.some((g) => g.userAgent === "*");
4043
+ const details = {
4044
+ trainingAgents: trainingNamed.map((b) => b.displayName),
4045
+ realtimeAgents: realtimeNamed.map((b) => b.displayName),
4046
+ hasCatchAll
4047
+ };
4048
+ const expected = "Explicit User-agent groups for both training crawlers (GPTBot, CCBot, ...) and live conversational agents (ChatGPT-User, Claude-User, ...)";
4049
+ if (trainingNamed.length === 0 && realtimeNamed.length === 0) {
4050
+ const result2 = this.fail(
4051
+ hasCatchAll ? "robots.txt only defines a catch-all User-agent: * \u2014 no AI-agent-specific rules found." : "robots.txt contains no AI-agent-specific rules.",
4052
+ expected,
4053
+ hasCatchAll ? "Only User-agent: * present" : "No AI crawler user-agents named",
4054
+ { priority: "medium" }
4055
+ );
4056
+ result2.details = details;
4057
+ return result2;
4058
+ }
4059
+ const differentiated = trainingNamed.length > 0 && realtimeNamed.length > 0 && categoryBlocked(groups, trainingNamed) !== categoryBlocked(groups, realtimeNamed);
4060
+ if (trainingNamed.length >= 2 && realtimeNamed.length >= 2 || differentiated) {
4061
+ const result2 = this.pass(
4062
+ `Granular agentic governance: ${trainingNamed.length} training crawler(s) and ${realtimeNamed.length} live agent(s) explicitly named${differentiated ? " with different policies" : ""}.`,
4063
+ expected,
4064
+ `Training: ${trainingNamed.map((b) => b.displayName).join(", ") || "none"}; Realtime: ${realtimeNamed.map((b) => b.displayName).join(", ") || "none"}`
4065
+ );
4066
+ result2.details = details;
4067
+ return result2;
4068
+ }
4069
+ const covered = trainingNamed.length > 0 ? "training crawlers" : "live conversational agents";
4070
+ const missing = trainingNamed.length > 0 ? "live conversational agents" : "training crawlers";
4071
+ const result = this.warn(
4072
+ `Only ${covered} are explicitly governed in robots.txt \u2014 no rules for ${missing}.`,
4073
+ expected,
4074
+ `Training: ${trainingNamed.map((b) => b.displayName).join(", ") || "none"}; Realtime: ${realtimeNamed.map((b) => b.displayName).join(", ") || "none"}`,
4075
+ { priority: "medium" }
4076
+ );
4077
+ result.details = details;
4078
+ return result;
4079
+ }
4080
+ };
4081
+
3913
4082
  // src/audits/structured-data/json-ld-present.ts
3914
4083
  var JsonLdPresentAudit = class extends Audit {
3915
4084
  static meta = {
@@ -5849,6 +6018,138 @@ var ProductReviewsAudit = class extends Audit {
5849
6018
  }
5850
6019
  };
5851
6020
 
6021
+ // src/audits/structured-data/product-transaction-certainty.ts
6022
+ function matchesAnyType11(schema, types) {
6023
+ return types.some((t) => {
6024
+ const st = schema["@type"];
6025
+ if (typeof st === "string") return st === t;
6026
+ if (Array.isArray(st)) return st.includes(t);
6027
+ return false;
6028
+ });
6029
+ }
6030
+ function asOfferList(offers) {
6031
+ if (!offers) return [];
6032
+ const list = Array.isArray(offers) ? offers : [offers];
6033
+ return list.filter((o) => !!o && typeof o === "object");
6034
+ }
6035
+ var SIGNAL_LABELS = {
6036
+ availability: "offers.availability",
6037
+ priceValidUntil: "offers.priceValidUntil",
6038
+ pricePair: "offers.price + offers.priceCurrency",
6039
+ returnPolicy: "hasMerchantReturnPolicy"
6040
+ };
6041
+ var ProductTransactionCertaintyAudit = class extends Audit {
6042
+ static meta = {
6043
+ id: "3.24",
6044
+ category: "structured-data",
6045
+ title: "Product transactional certainty",
6046
+ failureTitle: "Product transactional certainty",
6047
+ description: "AI shopping assistants need more than a product name and price to make an authoritative recommendation: they must know whether the item is in stock, how long the quoted price is valid, and what the return policy is before they commit a user to a purchase. A Product schema that only carries name and price forces agents to guess at availability, quote potentially stale prices, and stay silent on returns \u2014 all of which erode transactional certainty in agentic commerce flows. Complete your Offer with availability, priceValidUntil, and a valid price + priceCurrency pair, and attach hasMerchantReturnPolicy to the Product or Offer.",
6048
+ scoreDisplayMode: "ternary",
6049
+ weight: 1,
6050
+ applicablePageTypes: ["product"],
6051
+ defaultPriority: "high",
6052
+ guidance: {
6053
+ impact: "Without availability, priceValidUntil, a valid price/currency pair, and a return policy in your Product schema, AI shopping assistants cannot make an authoritative recommendation. Agents either skip your product or answer with guessed availability, stale prices, and unknown return terms \u2014 costing you conversions in agent-driven purchases.",
6054
+ fix: "Complete the Offer block in your Product JSON-LD with availability (a schema.org ItemAvailability value), priceValidUntil (ISO date), and a valid price + priceCurrency pair. Add hasMerchantReturnPolicy to the Product or Offer. Also ensure unique identifiers (GTIN/MPN/SKU) are present \u2014 see the Product identifiers audit \u2014 so agents can match the exact item.",
6055
+ code: `{
6056
+ "@context": "https://schema.org",
6057
+ "@type": "Product",
6058
+ "name": "Product Name",
6059
+ "offers": {
6060
+ "@type": "Offer",
6061
+ "price": "99.00",
6062
+ "priceCurrency": "USD",
6063
+ "availability": "https://schema.org/InStock",
6064
+ "priceValidUntil": "2026-12-31",
6065
+ "hasMerchantReturnPolicy": {
6066
+ "@type": "MerchantReturnPolicy",
6067
+ "applicableCountry": "US",
6068
+ "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
6069
+ "merchantReturnDays": 30
6070
+ }
6071
+ }
6072
+ }`,
6073
+ effort: "moderate",
6074
+ docsUrl: "https://schema.org/Offer",
6075
+ tags: ["json-ld", "schema", "product", "ecommerce", "agentic-commerce"]
6076
+ }
6077
+ };
6078
+ audit(ctx) {
6079
+ const schemas = ctx.pages.flatMap((p) => flattenJsonLd(p.structuredData ?? p.jsonLd));
6080
+ const products = schemas.filter(
6081
+ (s) => matchesAnyType11(s, [
6082
+ "Product",
6083
+ "IndividualProduct",
6084
+ "ProductModel"
6085
+ ])
6086
+ );
6087
+ if (products.length === 0) {
6088
+ return this.notApplicable(
6089
+ "No Product schema found on any page to evaluate transactional certainty.",
6090
+ "Product schema with offers containing availability, priceValidUntil, price + priceCurrency, and hasMerchantReturnPolicy.",
6091
+ "None"
6092
+ );
6093
+ }
6094
+ let best = null;
6095
+ for (const product of products) {
6096
+ const obj = product;
6097
+ const offers = asOfferList(obj["offers"]);
6098
+ if (offers.length === 0) continue;
6099
+ const signals = {
6100
+ availability: offers.some((o) => !!o["availability"]),
6101
+ priceValidUntil: offers.some((o) => !!o["priceValidUntil"]),
6102
+ pricePair: offers.some(
6103
+ (o) => o["price"] !== void 0 && o["price"] !== null && o["price"] !== "" && !!o["priceCurrency"]
6104
+ ),
6105
+ returnPolicy: !!obj["hasMerchantReturnPolicy"] || offers.some((o) => !!o["hasMerchantReturnPolicy"])
6106
+ };
6107
+ const count = Object.values(signals).filter(Boolean).length;
6108
+ if (!best || count > best.count) best = { signals, count };
6109
+ }
6110
+ const expected = "Product schema with offers containing availability, priceValidUntil, price + priceCurrency, and hasMerchantReturnPolicy.";
6111
+ if (!best) {
6112
+ return this.fail(
6113
+ "Product schema found but no Offer block \u2014 no transactional data for agents to act on.",
6114
+ expected,
6115
+ "0/4 certainty signals (no offers)",
6116
+ {
6117
+ priority: "high",
6118
+ description: "AI shopping assistants cannot quote a price, check stock, or state return terms without an Offer block. Add offers with price, priceCurrency, availability, priceValidUntil, and hasMerchantReturnPolicy."
6119
+ }
6120
+ );
6121
+ }
6122
+ const missing = Object.keys(best.signals).filter((k) => !best.signals[k]).map((k) => SIGNAL_LABELS[k]);
6123
+ if (best.count === 4) {
6124
+ return this.pass(
6125
+ "All transactional certainty signals present: availability, priceValidUntil, price + priceCurrency, and return policy.",
6126
+ expected,
6127
+ "4/4 certainty signals"
6128
+ );
6129
+ }
6130
+ if (best.count >= 2) {
6131
+ return this.warn(
6132
+ `Missing purchasing data points: ${missing.join(", ")}.`,
6133
+ expected,
6134
+ `${best.count}/4 certainty signals (missing: ${missing.join(", ")})`,
6135
+ {
6136
+ priority: "high",
6137
+ description: `AI shopping assistants are missing ${missing.join(", ")} and cannot give an authoritative recommendation for this product. Complete the Offer block so agents can quote stock, price validity, and return terms with certainty.`
6138
+ }
6139
+ );
6140
+ }
6141
+ return this.fail(
6142
+ `Product relies on name and price alone \u2014 missing: ${missing.join(", ")}.`,
6143
+ expected,
6144
+ `${best.count}/4 certainty signals (missing: ${missing.join(", ")})`,
6145
+ {
6146
+ priority: "high",
6147
+ description: `AI shopping assistants are missing ${missing.join(", ")} and cannot give an authoritative recommendation for this product. Complete the Offer block so agents can quote stock, price validity, and return terms with certainty.`
6148
+ }
6149
+ );
6150
+ }
6151
+ };
6152
+
5852
6153
  // src/audits/meta-tags/meta-description.ts
5853
6154
  var MetaDescriptionAudit = class extends Audit {
5854
6155
  static meta = {
@@ -10398,6 +10699,368 @@ var WebmcpActionCoverageAudit = class extends Audit {
10398
10699
  }
10399
10700
  };
10400
10701
 
10702
+ // src/audits/agent-tools/openapi-description-quality.ts
10703
+ function tryParseJson21(body) {
10704
+ try {
10705
+ return JSON.parse(body);
10706
+ } catch {
10707
+ return void 0;
10708
+ }
10709
+ }
10710
+ function isObject21(val) {
10711
+ return typeof val === "object" && val !== null && !Array.isArray(val);
10712
+ }
10713
+ var HTTP_METHODS6 = ["get", "post", "put", "patch", "delete", "options", "head", "trace"];
10714
+ var MIN_DESCRIPTION_LENGTH2 = 15;
10715
+ function getOpenApiSpec8(ctx) {
10716
+ const jsonResult = ctx.rootFiles["/openapi.json"];
10717
+ if (jsonResult && jsonResult.status === 200 && jsonResult.body) {
10718
+ const parsed = tryParseJson21(jsonResult.body);
10719
+ if (isObject21(parsed)) return parsed;
10720
+ }
10721
+ return void 0;
10722
+ }
10723
+ function hasGoodDescription(val) {
10724
+ return typeof val === "string" && val.trim().length > MIN_DESCRIPTION_LENGTH2;
10725
+ }
10726
+ function getCheckableItems(spec) {
10727
+ const paths = spec["paths"];
10728
+ if (!isObject21(paths)) return [];
10729
+ const items = [];
10730
+ for (const [path, pathItem] of Object.entries(paths)) {
10731
+ if (!isObject21(pathItem)) continue;
10732
+ for (const method of HTTP_METHODS6) {
10733
+ const op = pathItem[method];
10734
+ if (!isObject21(op)) continue;
10735
+ const operation = op;
10736
+ const opLabel = `${method.toUpperCase()} ${path}`;
10737
+ items.push({
10738
+ label: `${opLabel} (operation)`,
10739
+ described: hasGoodDescription(operation["description"])
10740
+ });
10741
+ const parameters = operation["parameters"];
10742
+ if (Array.isArray(parameters)) {
10743
+ for (const param of parameters) {
10744
+ if (!isObject21(param)) continue;
10745
+ const name = typeof param["name"] === "string" ? param["name"] : "(unnamed)";
10746
+ items.push({
10747
+ label: `${opLabel} param '${name}'`,
10748
+ described: hasGoodDescription(param["description"])
10749
+ });
10750
+ }
10751
+ }
10752
+ }
10753
+ }
10754
+ return items;
10755
+ }
10756
+ var OpenApiDescriptionQualityAudit = class _OpenApiDescriptionQualityAudit extends Audit {
10757
+ static meta = {
10758
+ id: "5.26",
10759
+ category: "agent-tools",
10760
+ title: "OpenAPI description quality for tool-calling",
10761
+ failureTitle: "OpenAPI descriptions too thin for tool-calling",
10762
+ description: 'When an AI agent converts your OpenAPI spec into callable tools, the description fields become the prompt the LLM uses to decide when and how to call each function. A one-word description like "search" tells the model nothing about what the endpoint does, what the parameter means, or what values are valid \u2014 so the agent guesses, calls the wrong tool, or fills parameters with hallucinated values. Every operation and every parameter needs a verbose description (more than 15 characters) that explains purpose, expected input, and behavior.',
10763
+ scoreDisplayMode: "ternary",
10764
+ weight: 0.9,
10765
+ defaultPriority: "high",
10766
+ guidance: {
10767
+ 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.",
10768
+ fix: "Write a verbose description (>15 characters) for every operation and every parameter in your OpenAPI spec. Explain what the operation does, when an agent should use it, and what each parameter means including format and example values.",
10769
+ code: `"/search": {
10770
+ "get": {
10771
+ "operationId": "searchProducts",
10772
+ "description": "Search the product catalog by keyword. Returns matching products with name, price, and availability. Use this when the user asks to find or browse products.",
10773
+ "parameters": [{
10774
+ "name": "q",
10775
+ "in": "query",
10776
+ "description": "Full-text search query, e.g. 'red running shoes'. Matched against product name and description.",
10777
+ "schema": { "type": "string" }
10778
+ }],
10779
+ "responses": { "200": { "description": "List of matching products" } }
10780
+ }
10781
+ }`,
10782
+ effort: "moderate",
10783
+ docsUrl: "https://swagger.io/specification/#operation-object",
10784
+ tags: ["openapi", "descriptions", "tool-calling", "llm", "api"]
10785
+ }
10786
+ };
10787
+ audit(ctx) {
10788
+ const spec = getOpenApiSpec8(ctx);
10789
+ if (!spec) {
10790
+ return this.notApplicable(
10791
+ "No parseable OpenAPI JSON spec found at /openapi.json.",
10792
+ "OpenAPI spec with verbose descriptions on all operations and parameters",
10793
+ "No spec"
10794
+ );
10795
+ }
10796
+ const items = getCheckableItems(spec);
10797
+ if (items.length === 0) {
10798
+ return this.notApplicable(
10799
+ "OpenAPI spec has no operations or parameters to check.",
10800
+ "OpenAPI spec with verbose descriptions on all operations and parameters",
10801
+ "0 checkable items"
10802
+ );
10803
+ }
10804
+ const described = items.filter((i) => i.described).length;
10805
+ const ratio = described / items.length;
10806
+ const missing = items.filter((i) => !i.described).map((i) => i.label);
10807
+ const truncatedMissing = missing.slice(0, 10);
10808
+ const missingSummary = truncatedMissing.join("; ") + (missing.length > 10 ? `; ... and ${missing.length - 10} more` : "");
10809
+ const expected = "Every operation and parameter has a description longer than 15 characters";
10810
+ const found = `${described}/${items.length} described (${Math.round(ratio * 100)}%)`;
10811
+ const details = missing.length > 0 ? { missingDescriptions: truncatedMissing } : void 0;
10812
+ if (ratio >= 0.9) {
10813
+ return {
10814
+ ...this.pass(
10815
+ `${described}/${items.length} operation/parameter description(s) are verbose enough for LLM tool-calling.${missing.length > 0 ? ` Still thin: ${missingSummary}` : ""}`,
10816
+ expected,
10817
+ found
10818
+ ),
10819
+ details
10820
+ };
10821
+ }
10822
+ const recommendation = {
10823
+ priority: "high",
10824
+ description: _OpenApiDescriptionQualityAudit.meta.description,
10825
+ code: _OpenApiDescriptionQualityAudit.meta.guidance?.code
10826
+ };
10827
+ if (ratio >= 0.5) {
10828
+ return {
10829
+ ...this.warn(
10830
+ `Only ${described}/${items.length} operation/parameter description(s) are verbose enough: ${missingSummary}`,
10831
+ expected,
10832
+ found,
10833
+ recommendation
10834
+ ),
10835
+ details
10836
+ };
10837
+ }
10838
+ return {
10839
+ ...this.fail(
10840
+ `Only ${described}/${items.length} operation/parameter description(s) are verbose enough: ${missingSummary}`,
10841
+ expected,
10842
+ found,
10843
+ recommendation
10844
+ ),
10845
+ details
10846
+ };
10847
+ }
10848
+ };
10849
+
10850
+ // src/audits/agent-tools/form-actionability.ts
10851
+ var SKIP_TYPES = ["hidden", "submit", "button", "reset", "image"];
10852
+ var IDENTITY_FIELDS = [
10853
+ { pattern: /email/i, token: "email" },
10854
+ { pattern: /(^|[^a-z])(tel|phone|mobile)([^a-z]|$)/i, token: "tel" },
10855
+ { pattern: /first.?name|given.?name|fname/i, token: "given-name" },
10856
+ { pattern: /last.?name|family.?name|surname|lname/i, token: "family-name" },
10857
+ { pattern: /(^|[^a-z])(full.?)?name([^a-z]|$)/i, token: "name" },
10858
+ { pattern: /street|address/i, token: "street-address" },
10859
+ { pattern: /city|town/i, token: "address-level2" },
10860
+ { pattern: /zip|postal|postcode/i, token: "postal-code" },
10861
+ { pattern: /country/i, token: "country-name" }
10862
+ ];
10863
+ var STANDARD_AUTOCOMPLETE_TOKENS = /* @__PURE__ */ new Set([
10864
+ "name",
10865
+ "honorific-prefix",
10866
+ "given-name",
10867
+ "additional-name",
10868
+ "family-name",
10869
+ "honorific-suffix",
10870
+ "nickname",
10871
+ "username",
10872
+ "new-password",
10873
+ "current-password",
10874
+ "one-time-code",
10875
+ "organization-title",
10876
+ "organization",
10877
+ "street-address",
10878
+ "address-line1",
10879
+ "address-line2",
10880
+ "address-line3",
10881
+ "address-level4",
10882
+ "address-level3",
10883
+ "address-level2",
10884
+ "address-level1",
10885
+ "country",
10886
+ "country-name",
10887
+ "postal-code",
10888
+ "cc-name",
10889
+ "cc-given-name",
10890
+ "cc-additional-name",
10891
+ "cc-family-name",
10892
+ "cc-number",
10893
+ "cc-exp",
10894
+ "cc-exp-month",
10895
+ "cc-exp-year",
10896
+ "cc-csc",
10897
+ "cc-type",
10898
+ "transaction-currency",
10899
+ "transaction-amount",
10900
+ "language",
10901
+ "bday",
10902
+ "bday-day",
10903
+ "bday-month",
10904
+ "bday-year",
10905
+ "sex",
10906
+ "url",
10907
+ "photo",
10908
+ "tel",
10909
+ "tel-country-code",
10910
+ "tel-national",
10911
+ "tel-area-code",
10912
+ "tel-local",
10913
+ "tel-local-prefix",
10914
+ "tel-local-suffix",
10915
+ "tel-extension",
10916
+ "email",
10917
+ "impp"
10918
+ ]);
10919
+ var AUTOCOMPLETE_QUALIFIERS = /* @__PURE__ */ new Set(["shipping", "billing", "home", "work", "mobile", "fax", "pager"]);
10920
+ function expectedAutocompleteToken(tag, type, name, id, labelText2) {
10921
+ if (tag === "input" && type === "email") return "email";
10922
+ if (tag === "input" && type === "tel") return "tel";
10923
+ const signal = `${name} ${id} ${labelText2}`;
10924
+ for (const { pattern, token } of IDENTITY_FIELDS) {
10925
+ if (pattern.test(signal)) return token;
10926
+ }
10927
+ return void 0;
10928
+ }
10929
+ function isStandardAutocomplete(value) {
10930
+ const tokens = value.toLowerCase().split(/\s+/).filter((t) => t.length > 0 && !t.startsWith("section-") && !AUTOCOMPLETE_QUALIFIERS.has(t));
10931
+ return tokens.length > 0 && STANDARD_AUTOCOMPLETE_TOKENS.has(tokens[tokens.length - 1]);
10932
+ }
10933
+ function labelTextFor($, formEl, id) {
10934
+ if (!id) return "";
10935
+ const escapedId = id.replace(/(["\\\]:])/g, "\\$1");
10936
+ const label2 = $(formEl).find(`label[for="${escapedId}"]`).first();
10937
+ return label2.text().trim();
10938
+ }
10939
+ var FormActionabilityAudit = class extends Audit {
10940
+ static meta = {
10941
+ id: "5.27",
10942
+ category: "agent-tools",
10943
+ title: "Form backend actionability",
10944
+ failureTitle: "Form backend actionability",
10945
+ description: "Autonomous agents fill forms by reading the DOM directly \u2014 they cannot see placeholders rendered visually or guess what a custom div-based widget expects. Fields without a native element, an explicit label (label[for], wrapping label, aria-label, or aria-labelledby), or a standard autocomplete attribute for identity data (email, phone, name, address) force agents to guess, producing failed or incorrect submissions. Keep every fillable field a native input/select/textarea with an explicit label and standard autocomplete tokens.",
10946
+ scoreDisplayMode: "ternary",
10947
+ weight: 1,
10948
+ defaultPriority: "high",
10949
+ guidance: {
10950
+ 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.",
10951
+ fix: 'Use native input/select/textarea elements (never divs with role="textbox" or contenteditable), associate every field with a <label for="id">, wrapping <label>, aria-label, or aria-labelledby, and add standard autocomplete tokens (email, tel, name, street-address, postal-code, country-name) to identity fields.',
10952
+ code: `<form action="/api/contact" method="POST">
10953
+ <label for="full-name">Full name</label>
10954
+ <input id="full-name" name="name" type="text" autocomplete="name" required />
10955
+
10956
+ <label for="email">Email</label>
10957
+ <input id="email" name="email" type="email" autocomplete="email" required />
10958
+
10959
+ <label for="phone">Phone</label>
10960
+ <input id="phone" name="phone" type="tel" autocomplete="tel" />
10961
+
10962
+ <button type="submit">Send</button>
10963
+ </form>`,
10964
+ effort: "easy",
10965
+ docsUrl: "https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill",
10966
+ tags: ["forms", "labels", "autocomplete", "agent-fillable"]
10967
+ }
10968
+ };
10969
+ audit(ctx) {
10970
+ let totalFields = 0;
10971
+ let actionableFields = 0;
10972
+ const issues = [];
10973
+ for (const page of ctx.pages) {
10974
+ const $ = page.$;
10975
+ $("form").each((formIndex, formEl) => {
10976
+ $(formEl).find('[role="textbox"], [contenteditable="true"]').filter((_, el) => !["input", "select", "textarea"].includes(el.tagName?.toLowerCase() ?? "")).each((_, el) => {
10977
+ const name = $(el).attr("name") ?? $(el).attr("id") ?? el.tagName;
10978
+ totalFields++;
10979
+ issues.push({
10980
+ pageUrl: page.url,
10981
+ formIndex,
10982
+ field: `${el.tagName.toLowerCase()}[name="${name}"]`,
10983
+ failedChecks: ["not a native form element (div pretending to be an input)"]
10984
+ });
10985
+ });
10986
+ $(formEl).find("input, select, textarea").each((_, inputEl) => {
10987
+ const $input = $(inputEl);
10988
+ const tag = inputEl.tagName.toLowerCase();
10989
+ const type = ($input.attr("type") ?? (tag === "input" ? "text" : tag)).toLowerCase();
10990
+ if (SKIP_TYPES.includes(type)) return;
10991
+ totalFields++;
10992
+ const failedChecks = [];
10993
+ const name = $input.attr("name") ?? "";
10994
+ const id = $input.attr("id");
10995
+ const escapedId = id ? id.replace(/(["\\\]:])/g, "\\$1") : "";
10996
+ const hasForLabel = escapedId !== "" && $(formEl).find(`label[for="${escapedId}"]`).length > 0;
10997
+ const hasWrappingLabel = $input.closest("label").length > 0;
10998
+ const hasAriaLabel = ($input.attr("aria-label") ?? "").trim().length > 0;
10999
+ const hasAriaLabelledby = ($input.attr("aria-labelledby") ?? "").trim().length > 0;
11000
+ if (!hasForLabel && !hasWrappingLabel && !hasAriaLabel && !hasAriaLabelledby) {
11001
+ failedChecks.push("no explicit label (missing label[for], wrapping label, aria-label, or aria-labelledby)");
11002
+ }
11003
+ const labelText2 = hasForLabel ? labelTextFor($, formEl, id) : $input.closest("label").text().trim();
11004
+ const expectedToken = expectedAutocompleteToken(tag, type, name, id ?? "", labelText2);
11005
+ const autocomplete2 = $input.attr("autocomplete");
11006
+ if (expectedToken && (!autocomplete2 || !isStandardAutocomplete(autocomplete2))) {
11007
+ failedChecks.push(
11008
+ autocomplete2 ? `non-standard autocomplete="${autocomplete2}" (expected token like "${expectedToken}")` : `missing autocomplete (identity field, expected token like "${expectedToken}")`
11009
+ );
11010
+ }
11011
+ if (failedChecks.length === 0) {
11012
+ actionableFields++;
11013
+ } else {
11014
+ issues.push({
11015
+ pageUrl: page.url,
11016
+ formIndex,
11017
+ field: `${tag}[name="${name || "(unnamed)"}" type="${type}"]`,
11018
+ failedChecks
11019
+ });
11020
+ }
11021
+ });
11022
+ });
11023
+ }
11024
+ if (totalFields === 0) {
11025
+ return this.notApplicable(
11026
+ "No forms with fillable fields found on scanned pages \u2014 nothing to check.",
11027
+ "Forms contain native, labeled fields with standard autocomplete attributes",
11028
+ "0 fillable fields"
11029
+ );
11030
+ }
11031
+ const ratio = actionableFields / totalFields;
11032
+ const issueSummary = issues.slice(0, 10).map((i) => `form #${i.formIndex} ${i.field}: ${i.failedChecks.join("; ")}`).join(" | ");
11033
+ const truncated = issues.length > 10 ? ` (and ${issues.length - 10} more)` : "";
11034
+ const foundBase = `${actionableFields}/${totalFields} fields actionable (${Math.round(ratio * 100)}%)`;
11035
+ const found = issues.length > 0 ? `${foundBase}. Non-actionable: ${issueSummary}${truncated}` : foundBase;
11036
+ const firstIssueUrl = issues[0]?.pageUrl;
11037
+ if (ratio >= 0.9) {
11038
+ return this.pass(
11039
+ `${actionableFields}/${totalFields} form fields are fully actionable for agents (native element, labeled, standard autocomplete).`,
11040
+ "At least 90% of form fields are native, labeled, and use standard autocomplete",
11041
+ found,
11042
+ firstIssueUrl
11043
+ );
11044
+ }
11045
+ if (ratio >= 0.5) {
11046
+ return this.warn(
11047
+ `${actionableFields}/${totalFields} form fields are fully actionable \u2014 agents will struggle with the rest.`,
11048
+ "At least 90% of form fields are native, labeled, and use standard autocomplete",
11049
+ found,
11050
+ "high",
11051
+ firstIssueUrl
11052
+ );
11053
+ }
11054
+ return this.fail(
11055
+ `Only ${actionableFields}/${totalFields} form fields are actionable \u2014 agents cannot reliably fill these forms.`,
11056
+ "At least 90% of form fields are native, labeled, and use standard autocomplete",
11057
+ found,
11058
+ "high",
11059
+ firstIssueUrl
11060
+ );
11061
+ }
11062
+ };
11063
+
10401
11064
  // src/audits/semantic-html/single-h1.ts
10402
11065
  var SingleH1Audit = class extends Audit {
10403
11066
  static meta = {
@@ -11500,6 +12163,293 @@ var FigureFigcaptionAudit = class extends Audit {
11500
12163
  }
11501
12164
  };
11502
12165
 
12166
+ // src/audits/semantic-html/svg-bloat.ts
12167
+ var FLAG_THRESHOLD_BYTES = 2048;
12168
+ var SINGLE_FAIL_BYTES = 10240;
12169
+ var TOTAL_FAIL_BYTES = 20480;
12170
+ var TOTAL_WARN_BYTES = 8192;
12171
+ var MARKUP_SNIPPET_CHARS = 120;
12172
+ function formatBytes(bytes) {
12173
+ return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)}KB` : `${bytes}B`;
12174
+ }
12175
+ var SvgBloatAudit = class extends Audit {
12176
+ static meta = {
12177
+ id: "6.18",
12178
+ category: "semantic-html",
12179
+ title: "SVGs not bloating agent context",
12180
+ failureTitle: "Large inline SVGs bloating agent context",
12181
+ description: 'When an LLM converts your HTML to Markdown or reads raw markup, every inline SVG is inlined as thousands of path-data tokens. Decorative icon sprites, charts, and complex illustrations can silently consume tens of thousands of tokens of agent context per page \u2014 "SVG context poisoning" \u2014 crowding out the actual content the agent should read. SVGs marked aria-hidden="true" or role="presentation" are stripped by most accessibility-tree extractors and do not count. Keep visible SVGs small, move decorative ones behind aria-hidden, and prefer raster images or CSS for complex graphics.',
12182
+ scoreDisplayMode: "ternary",
12183
+ weight: 0.6,
12184
+ defaultPriority: "medium",
12185
+ guidance: {
12186
+ 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.",
12187
+ fix: 'Mark decorative SVGs with aria-hidden="true" so agent pipelines strip them. For visible graphics, simplify path data with SVGO, extract complex SVGs to external files referenced via <img>, or replace them with raster images when they exceed a few kilobytes.',
12188
+ code: '<svg aria-hidden="true" focusable="false" ...>...</svg>',
12189
+ effort: "easy",
12190
+ docsUrl: "https://github.com/svg/svgo",
12191
+ tags: ["svg", "context-window", "tokens", "performance"]
12192
+ }
12193
+ };
12194
+ audit(ctx) {
12195
+ let totalCount = 0;
12196
+ let unhiddenCount = 0;
12197
+ let unhiddenBytes = 0;
12198
+ const flagged = [];
12199
+ for (const page of ctx.pages) {
12200
+ page.$("svg").each((_, el) => {
12201
+ totalCount++;
12202
+ const $el = page.$(el);
12203
+ if ($el.attr("aria-hidden") === "true" || $el.attr("role") === "presentation") {
12204
+ return;
12205
+ }
12206
+ unhiddenCount++;
12207
+ const markup = page.$.html(el) ?? "";
12208
+ const bytes = Buffer.byteLength(markup);
12209
+ unhiddenBytes += bytes;
12210
+ if (bytes > FLAG_THRESHOLD_BYTES) {
12211
+ const oneLine = markup.replace(/\s+/g, " ").trim();
12212
+ const snippet = oneLine.length > MARKUP_SNIPPET_CHARS ? `${oneLine.slice(0, MARKUP_SNIPPET_CHARS)}...` : oneLine;
12213
+ flagged.push({ pageUrl: page.url, bytes, snippet });
12214
+ }
12215
+ });
12216
+ }
12217
+ if (totalCount === 0) {
12218
+ return this.notApplicable(
12219
+ "No inline SVG elements found on any page.",
12220
+ "No oversized unhidden inline SVGs.",
12221
+ "No SVGs present"
12222
+ );
12223
+ }
12224
+ const summary = `${totalCount} SVG(s) total, ${unhiddenCount} unhidden, ${formatBytes(unhiddenBytes)} unhidden bytes across ${ctx.pages.length} page(s).`;
12225
+ if (flagged.length === 0 && unhiddenBytes <= TOTAL_WARN_BYTES) {
12226
+ return this.pass(
12227
+ `${summary} All unhidden SVGs are small enough for agent context.`,
12228
+ "Unhidden SVGs stay under 2KB each and under 8KB total.",
12229
+ `${formatBytes(unhiddenBytes)} of unhidden SVG markup`
12230
+ );
12231
+ }
12232
+ flagged.sort((a, b) => b.bytes - a.bytes);
12233
+ const largest = flagged[0];
12234
+ const offenders = flagged.length ? ` Top offenders:
12235
+ ${flagged.slice(0, 5).map((f) => `${formatBytes(f.bytes)} at ${f.pageUrl}: ${f.snippet}`).join("\n")}` : "";
12236
+ const found = `${summary}${offenders}`;
12237
+ const expected = "Unhidden SVGs stay under 2KB each and under 8KB total; nothing over 10KB per SVG or 20KB total.";
12238
+ if (largest && largest.bytes > SINGLE_FAIL_BYTES || unhiddenBytes > TOTAL_FAIL_BYTES) {
12239
+ const largestNote = largest ? ` \u2014 largest unhidden SVG is ${formatBytes(largest.bytes)}` : "";
12240
+ return this.fail(
12241
+ `${summary} Severe SVG context bloat detected${largestNote}.`,
12242
+ expected,
12243
+ found,
12244
+ {
12245
+ priority: "medium",
12246
+ description: 'Large unhidden inline SVGs are inlined as path-data tokens when an LLM reads your page, consuming thousands of tokens of agent context. Mark decorative SVGs aria-hidden="true", simplify paths with SVGO, or move complex graphics to external files.',
12247
+ code: '<svg aria-hidden="true" focusable="false" ...>...</svg>'
12248
+ }
12249
+ );
12250
+ }
12251
+ const warnReason = flagged.length ? `${flagged.length} unhidden SVG(s) exceed 2KB and may bloat agent context.` : `unhidden SVGs total ${formatBytes(unhiddenBytes)}, bloating agent context.`;
12252
+ return this.warn(
12253
+ `${summary} ${warnReason}`,
12254
+ expected,
12255
+ found,
12256
+ {
12257
+ priority: "medium",
12258
+ description: 'Unhidden inline SVGs over 2KB add meaningful token overhead when an LLM converts your page to Markdown. Mark decorative SVGs aria-hidden="true" or optimize them with SVGO to keep agent context focused on real content.',
12259
+ code: '<svg aria-hidden="true" focusable="false" ...>...</svg>'
12260
+ }
12261
+ );
12262
+ }
12263
+ };
12264
+
12265
+ // src/audits/semantic-html/token-ratio.ts
12266
+ var CHARS_PER_TOKEN = 4;
12267
+ var FAIL_RATIO = 0.05;
12268
+ var WARN_RATIO = 0.15;
12269
+ var TokenRatioAudit = class extends Audit {
12270
+ static meta = {
12271
+ id: "6.19",
12272
+ category: "semantic-html",
12273
+ title: "Lean token-to-content ratio",
12274
+ failureTitle: "Lean token-to-content ratio",
12275
+ description: 'AI agents pay for every token of raw HTML they download, but only the visible text carries meaning. This audit compares the character weight of the raw HTML against the extracted main-content text to produce a "context bloat score": the share of the page that is actual content rather than markup, scripts, and styles. The ratio is evaluated on the homepage (the first crawled page), which is the entry point agents most often fetch. A ratio under 5% means an agent parses 20 tokens of noise for every token of content; under 15% still wastes most of the context window on boilerplate. Unlike content depth (which measures text volume), this measures how efficiently that text is packaged.',
12276
+ scoreDisplayMode: "ternary",
12277
+ weight: 0.8,
12278
+ defaultPriority: "high",
12279
+ guidance: {
12280
+ 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.",
12281
+ fix: "Move inline scripts and styles to external cached files, remove unused framework boilerplate and hidden DOM subtrees, flatten excessive wrapper divs, and serve content in semantic HTML rather than JSON blobs that need client-side hydration. Aim for at least 15% of the raw HTML weight to be visible content text.",
12282
+ code: '<!-- Before: content buried in markup noise -->\n<div class="w1"><div class="w2"><div class="w3">\n <script>/* 50KB of hydration data */</script>\n <p>Buy our product</p>\n</div></div></div>\n\n<!-- After: lean, content-first markup -->\n<main>\n <h1>Our product</h1>\n <p>Buy our product. It solves your problem by...</p>\n</main>\n<script src="/app.js" defer></script>',
12283
+ effort: "moderate",
12284
+ tags: ["tokens", "performance", "content", "context-window"]
12285
+ }
12286
+ };
12287
+ audit(ctx) {
12288
+ const page = ctx.pages[0];
12289
+ const rawHtml = page?.fetchResult.body ?? "";
12290
+ if (!page || rawHtml.trim().length === 0) {
12291
+ return this.notApplicable(
12292
+ "No HTML body available to measure token-to-content ratio.",
12293
+ "A non-empty HTML body",
12294
+ "Empty body"
12295
+ );
12296
+ }
12297
+ const cleanText = getMainContentText(page.$);
12298
+ const rawChars = rawHtml.length;
12299
+ const contentChars = cleanText.length;
12300
+ const ratio = contentChars / rawChars;
12301
+ const pct = `${(ratio * 100).toFixed(1)}%`;
12302
+ const displayValue = `${pct} content (${Math.round(contentChars / CHARS_PER_TOKEN)} of ${Math.round(rawChars / CHARS_PER_TOKEN)} est. tokens)`;
12303
+ const expected = `At least ${(WARN_RATIO * 100).toFixed(0)}% of the raw HTML weight is visible content text`;
12304
+ if (ratio >= WARN_RATIO) {
12305
+ return {
12306
+ ...this.pass(
12307
+ `Homepage is ${pct} content by character weight \u2014 markup overhead is within a healthy range.`,
12308
+ expected,
12309
+ displayValue,
12310
+ page.url
12311
+ ),
12312
+ displayValue
12313
+ };
12314
+ }
12315
+ if (ratio >= FAIL_RATIO) {
12316
+ return {
12317
+ ...this.warn(
12318
+ `Homepage is only ${pct} content by character weight \u2014 agents spend most of their context on markup noise.`,
12319
+ expected,
12320
+ displayValue,
12321
+ {
12322
+ priority: "high",
12323
+ description: "AI agents pay for every token of raw HTML they download, but only the visible text carries meaning. A content share under 15% means most of the context window is consumed by scripts, styles, and wrapper markup instead of your actual content. Move inline assets to external files, remove unused boilerplate, and flatten markup."
12324
+ },
12325
+ page.url
12326
+ ),
12327
+ displayValue
12328
+ };
12329
+ }
12330
+ return {
12331
+ ...this.fail(
12332
+ `Homepage is only ${pct} content by character weight \u2014 the page is almost entirely markup noise.`,
12333
+ expected,
12334
+ displayValue,
12335
+ {
12336
+ priority: "high",
12337
+ description: "AI agents pay for every token of raw HTML they download, but only the visible text carries meaning. A content share under 5% means an agent parses more than 20 tokens of noise for every token of content, and the page may be truncated before the real content is read. Move inline assets to external files, remove unused boilerplate, and flatten markup."
12338
+ },
12339
+ page.url
12340
+ ),
12341
+ displayValue
12342
+ };
12343
+ }
12344
+ };
12345
+
12346
+ // src/audits/semantic-html/fake-headings.ts
12347
+ var FAKE_HEADING_CLASS = /(text-(xl|2xl|3xl|4xl|5xl)|font-(bold|semibold|extrabold)|heading|headline)/i;
12348
+ var MIN_FONT_SIZE_PX = 20;
12349
+ var MIN_FONT_WEIGHT = 600;
12350
+ var MAX_HEADING_TEXT_LENGTH = 120;
12351
+ var BLOCK_CHILDREN = "div, p, section, article, aside, ul, ol, dl, table, figure, blockquote, pre, form";
12352
+ var EXCLUDED_ANCESTORS = "nav, footer, button, a";
12353
+ function looksLikeHeading(el, $) {
12354
+ const $el = $(el);
12355
+ const tag = el.tagName?.toLowerCase() ?? "";
12356
+ const text = $el.text().replace(/\s+/g, " ").trim();
12357
+ if (text.length === 0 || text.length > MAX_HEADING_TEXT_LENGTH) return null;
12358
+ if ($el.find("h1, h2, h3, h4, h5, h6").length > 0) return null;
12359
+ if ($el.find(BLOCK_CHILDREN).length > 0) return null;
12360
+ if ($el.parents(EXCLUDED_ANCESTORS).length > 0) return null;
12361
+ const className = $el.attr("class") ?? "";
12362
+ const style = $el.attr("style") ?? "";
12363
+ const classHit = FAKE_HEADING_CLASS.test(className);
12364
+ let styleHit = false;
12365
+ if (style) {
12366
+ const sizeMatch = /font-size\s*:\s*(\d+(?:\.\d+)?)px/i.exec(style);
12367
+ if (sizeMatch && parseFloat(sizeMatch[1]) >= MIN_FONT_SIZE_PX) styleHit = true;
12368
+ const weightMatch = /font-weight\s*:\s*(\d+|bold|bolder)/i.exec(style);
12369
+ if (weightMatch) {
12370
+ const w = weightMatch[1].toLowerCase();
12371
+ if (w === "bold" || w === "bolder" || parseInt(w, 10) >= MIN_FONT_WEIGHT) styleHit = true;
12372
+ }
12373
+ }
12374
+ if (!classHit && !styleHit) return null;
12375
+ return {
12376
+ tag,
12377
+ className: className || void 0,
12378
+ style: style || void 0,
12379
+ text
12380
+ };
12381
+ }
12382
+ var FakeHeadingsAudit = class extends Audit {
12383
+ static meta = {
12384
+ id: "6.20",
12385
+ category: "semantic-html",
12386
+ title: "No fake headings",
12387
+ failureTitle: "Fake headings detected",
12388
+ description: `AI agents chunk and outline page content by reading real <h1>\u2013<h6> tags. When a page styles a <div>, <span>, <p>, or <b> to look like a heading (large text, bold weight, "heading" classes) instead of using a semantic heading element, that text is invisible to the agent's document outline \u2014 sections cannot be navigated, summarized, or cited correctly. This audit is distinct from the sequential-heading check (6.2): 6.2 verifies that real headings appear in the right order, while this audit catches content that impersonates headings without using heading tags at all. Replace styled generic elements with the appropriate <h1>\u2013<h6> level.`,
12389
+ scoreDisplayMode: "ternary",
12390
+ weight: 0.7,
12391
+ defaultPriority: "medium",
12392
+ guidance: {
12393
+ 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.",
12394
+ fix: "Replace generic elements styled to look like headings with real heading tags. Pick the level that reflects the content's position in the outline (h2 for major sections under the h1, h3 for subsections, and so on), and move the visual styling to CSS targeting those heading elements instead of utility classes on divs and spans.",
12395
+ code: '<!-- Before: looks like a heading, invisible to agents -->\n<div class="text-2xl font-bold">Pricing Plans</div>\n\n<!-- After: semantic and styleable -->\n<h2 class="text-2xl font-bold">Pricing Plans</h2>',
12396
+ effort: "easy",
12397
+ docsUrl: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements",
12398
+ tags: ["headings", "semantic", "chunking", "structure"]
12399
+ }
12400
+ };
12401
+ audit(ctx) {
12402
+ const found = [];
12403
+ for (const page of ctx.pages) {
12404
+ const $ = page.$;
12405
+ const flagged = /* @__PURE__ */ new Set();
12406
+ $("div, span, p, b, strong").each((_i, el) => {
12407
+ if ($(el).parents().toArray().some((ancestor) => flagged.has(ancestor))) {
12408
+ return;
12409
+ }
12410
+ const hit = looksLikeHeading(el, $);
12411
+ if (hit) {
12412
+ flagged.add(el);
12413
+ found.push({ url: page.url, heading: hit });
12414
+ }
12415
+ });
12416
+ }
12417
+ const describe = (h) => {
12418
+ const via = h.className ? `class="${h.className}"` : `style="${h.style}"`;
12419
+ const text = h.text.length > 60 ? `${h.text.slice(0, 57)}...` : h.text;
12420
+ return `<${h.tag} ${via}> "${text}"`;
12421
+ };
12422
+ const foundSummary = found.slice(0, 5).map((f) => `${f.url}: ${describe(f.heading)}`).join("; ");
12423
+ const expected = "All heading-like text uses semantic <h1>-<h6> elements";
12424
+ if (found.length === 0) {
12425
+ return this.pass(
12426
+ "No fake headings detected \u2014 heading-like text uses semantic heading elements.",
12427
+ expected,
12428
+ "No styled <div>/<span>/<p>/<b> elements impersonating headings"
12429
+ );
12430
+ }
12431
+ const recommendation = {
12432
+ priority: "medium",
12433
+ description: "AI agents chunk and outline page content by reading real <h1>\u2013<h6> tags. Elements styled to look like headings are invisible to that outline, so sections cannot be navigated, summarized, or cited correctly. Replace styled generic elements with the appropriate heading level.",
12434
+ code: '<!-- Before -->\n<div class="text-2xl font-bold">Pricing Plans</div>\n\n<!-- After -->\n<h2 class="text-2xl font-bold">Pricing Plans</h2>'
12435
+ };
12436
+ if (found.length >= 5) {
12437
+ return this.fail(
12438
+ `Found ${found.length} fake heading(s) \u2014 generic elements styled to look like headings instead of <h1>-<h6> tags.`,
12439
+ expected,
12440
+ foundSummary,
12441
+ recommendation
12442
+ );
12443
+ }
12444
+ return this.warn(
12445
+ `Found ${found.length} fake heading(s) \u2014 generic elements styled to look like headings instead of <h1>-<h6> tags.`,
12446
+ expected,
12447
+ foundSummary,
12448
+ recommendation
12449
+ );
12450
+ }
12451
+ };
12452
+
11503
12453
  // src/audits/accessibility/skip-nav.ts
11504
12454
  var SkipNavAudit = class extends Audit {
11505
12455
  static meta = {
@@ -16250,7 +17200,9 @@ var defaultConfig = {
16250
17200
  reg(SensitivePathsAudit),
16251
17201
  reg(CrawlDelayAudit),
16252
17202
  reg(MetaRobotsNotBlockingAudit),
16253
- reg(NoBotDetectionAudit)
17203
+ reg(NoBotDetectionAudit),
17204
+ reg(TdmRepAudit),
17205
+ reg(AgentGovernanceAudit)
16254
17206
  ],
16255
17207
  "structured-data": [
16256
17208
  reg(JsonLdPresentAudit),
@@ -16271,7 +17223,8 @@ var defaultConfig = {
16271
17223
  reg(ActionSchemaAudit),
16272
17224
  reg(ProductIdentifiersAudit),
16273
17225
  reg(ProductDetailsAudit),
16274
- reg(ProductReviewsAudit)
17226
+ reg(ProductReviewsAudit),
17227
+ reg(ProductTransactionCertaintyAudit)
16275
17228
  ],
16276
17229
  "meta-tags": [
16277
17230
  reg(MetaDescriptionAudit),
@@ -16320,7 +17273,9 @@ var defaultConfig = {
16320
17273
  reg(WebmcpInputQualityAudit),
16321
17274
  reg(WebmcpToolNamingAudit),
16322
17275
  reg(WebmcpToolAnnotationsAudit),
16323
- reg(WebmcpActionCoverageAudit)
17276
+ reg(WebmcpActionCoverageAudit),
17277
+ reg(OpenApiDescriptionQualityAudit),
17278
+ reg(FormActionabilityAudit)
16324
17279
  ],
16325
17280
  "semantic-html": [
16326
17281
  reg(SingleH1Audit),
@@ -16339,7 +17294,10 @@ var defaultConfig = {
16339
17294
  reg(ContentDepthAudit),
16340
17295
  reg(ImageAltTextAudit),
16341
17296
  reg(DecorativeImagesAudit),
16342
- reg(FigureFigcaptionAudit)
17297
+ reg(FigureFigcaptionAudit),
17298
+ reg(SvgBloatAudit),
17299
+ reg(TokenRatioAudit),
17300
+ reg(FakeHeadingsAudit)
16343
17301
  ],
16344
17302
  accessibility: [
16345
17303
  reg(SkipNavAudit),
@@ -22084,6 +23042,7 @@ async function runScan(url, onProgress, pageOverrides, signal) {
22084
23042
  "/.well-known/ai-plugin.json",
22085
23043
  "/.well-known/webmcp",
22086
23044
  "/.well-known/security.txt",
23045
+ "/.well-known/tdmrep.json",
22087
23046
  "/navigation.json",
22088
23047
  "/privacy-policy/",
22089
23048
  "/privacy/",
@@ -22241,7 +23200,7 @@ function calculateReadinessVitals(checks2) {
22241
23200
  return Math.round(matching.reduce((sum, c) => sum + c.score, 0) / matching.length * 100);
22242
23201
  };
22243
23202
  return {
22244
- commerce: getScore(["3.8", "3.14", "3.21", "3.22", "3.23"]),
23203
+ commerce: getScore(["3.8", "3.14", "3.21", "3.22", "3.23", "3.24"]),
22245
23204
  content: getScore([
22246
23205
  "1.1",
22247
23206
  "1.2",