diff --git a/core/audits/agentic/ard-schema.js b/core/audits/agentic/ard-schema.js new file mode 100644 index 000000000000..876ec6104eea --- /dev/null +++ b/core/audits/agentic/ard-schema.js @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Audit that validates ai-catalog.json manifests against the Agentic Resource Discovery (ARD) specification. + * + * This implementation is a direct JavaScript port of `validate_manifest` from the official ARD Conformance Test suite: + * @see https://github.com/ards-project/ard-spec/blob/main/conformance/bin/conformance-test + * @see https://agenticresourcediscovery.org/spec/ + * @version ARD Spec 1.0 / ADR-0003 + */ + +import {Audit} from '../audit.js'; +import * as i18n from '../../lib/i18n/i18n.js'; +import {ConformanceTester} from '../../../third-party/ard/ard.js'; + +const UIStrings = { + /** Title of a Lighthouse audit that evaluates whether ai-catalog.json conforms to the ARD specification. Shown when valid. */ + title: '`ai-catalog.json` schema is valid', + /** Title of a Lighthouse audit that evaluates whether ai-catalog.json conforms to the ARD specification. Shown when invalid. */ + failureTitle: '`ai-catalog.json` schema is invalid or has warnings', + /** Description of a Lighthouse audit that tells the user why ai-catalog.json must match the ARD specification. */ + description: 'Valid `ai-catalog.json` manifests are required for autonomous ' + + 'AI agents and registries to discover and verify your resources. ' + + '[Learn more about the ARD specification](https://agenticresourcediscovery.org/spec/).', + /** Explanatory message stating that ai-catalog.json could not be loaded for schema validation. */ + explanation: 'Catalog file could not be loaded for schema validation.', + /** Warning message explaining that representativeQueries are missing for an entry. */ + missingRepresentativeQueries: 'Missing `representativeQueries`. Providing examples ' + + 'significantly improves discoverability.', + /** Header of the table column which displays the issue. */ + columnIssue: 'Issue', + /** Header of the table column which displays the severity. */ + columnSeverity: 'Severity', + /** Table item value for an error severity. */ + itemSeverityError: 'Error', + /** Table item value for a warning severity. */ + itemSeverityWarning: 'Warning', +}; + +const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); + +class ArdSchema extends Audit { + /** + * @return {LH.Audit.Meta} + */ + static get meta() { + return { + id: 'ard-schema', + title: str_(UIStrings.title), + failureTitle: str_(UIStrings.failureTitle), + description: str_(UIStrings.description), + requiredArtifacts: ['AgentResourceDiscovery'], + supportedModes: ['navigation', 'snapshot'], + }; + } + + /** + * @param {LH.Artifacts} artifacts + * @return {LH.Audit.Product} + */ + static audit(artifacts) { + const ard = artifacts.AgentResourceDiscovery; + const signals = ard.discoverySignals; + + const hasExplicitSignal = Boolean( + signals.robotsTxtAgentmap || + signals.htmlLink || + signals.httpHeaderLink + ); + const hasCatalog = hasExplicitSignal || ard.status === 200; + + if (!hasCatalog) { + return { + score: 1, + notApplicable: true, + }; + } + + if (ard.status !== 200 || !ard.content) { + return { + score: 0, + explanation: str_(UIStrings.explanation), + }; + } + + const itemSeverityError = str_(UIStrings.itemSeverityError); + const itemSeverityWarning = str_(UIStrings.itemSeverityWarning); + + /** @type {Array<{element: string, issue: string | LH.IcuMessage, severity: LH.IcuMessage}>} */ + const issues = []; + + /** @type {LH.Audit.Details.Table['headings']} */ + const headings = [ + {key: 'element', valueType: 'text', label: str_(i18n.UIStrings.columnElement)}, + {key: 'issue', valueType: 'text', label: str_(UIStrings.columnIssue)}, + {key: 'severity', valueType: 'text', label: str_(UIStrings.columnSeverity)}, + ]; + + const tester = new ConformanceTester(); + tester.validate_manifest(ard.content, 'ai-catalog.json'); + + for (const err of tester.errors) { + issues.push({ + element: err.element, + issue: err.message, + severity: itemSeverityError, + }); + } + + for (const warn of tester.warnings) { + issues.push({ + element: warn.element, + issue: warn.message, + severity: itemSeverityWarning, + }); + } + + // Lighthouse Best Practice: Recommend representativeQueries for better discoverability + try { + const manifest = JSON.parse(ard.content); + if (manifest && Array.isArray(manifest.entries)) { + for (let i = 0; i < manifest.entries.length; i++) { + const entry = manifest.entries[i]; + if (!entry.representativeQueries || entry.representativeQueries.length === 0) { + const label = entry.displayName || entry.identifier || `Entry #${i}`; + issues.push({ + element: label, + issue: str_(UIStrings.missingRepresentativeQueries), + severity: itemSeverityWarning, + }); + } + } + } + } catch (e) { + // Ignore parse errors as ConformanceTester catches them + } + + const hasErrors = issues.some(i => i.severity === itemSeverityError); + const hasWarnings = issues.some(i => i.severity === itemSeverityWarning); + + let score = 1; + if (hasErrors) { + score = 0; + } else if (hasWarnings) { + score = 0.5; + } + + return { + score, + details: issues.length ? Audit.makeTableDetails(headings, issues) : undefined, + }; + } +} + +export default ArdSchema; +export {UIStrings}; diff --git a/core/config/agentic-browsing-config.js b/core/config/agentic-browsing-config.js index 04772f842e1e..ad319530dcb3 100644 --- a/core/config/agentic-browsing-config.js +++ b/core/config/agentic-browsing-config.js @@ -23,6 +23,11 @@ const UIStrings = { /** Description of the Agent Accessibility group of audits. */ agentAccessibilityGroupDescription: 'These audits highlight best practices for improving the ' + 'accessibility of the website for AI agents.', + /** Title of the Agentic Resource Discovery (ARD, https://agenticresourcediscovery.org/spec/) group of audits. */ + ardGroupTitle: 'Agentic Resource Discovery', + /** Description of the Agentic Resource Discovery (ARD, https://agenticresourcediscovery.org/spec/) group of audits. */ + ardGroupDescription: 'These audits validate that agentic resources are ' + + 'discoverable, reachable, and conform to the ARD specification.', }; const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); @@ -36,11 +41,13 @@ const config = { 'webmcp-form-coverage', 'webmcp-schema-validity', 'agentic/llms-txt', + 'agentic/ard-schema', ], artifacts: [ {id: 'WebMCP', gatherer: 'webmcp'}, {id: 'WebMcpSchemaIssues', gatherer: 'webmcp-schema'}, {id: 'LlmsTxt', gatherer: 'agentic/llms-txt'}, + {id: 'AgentResourceDiscovery', gatherer: 'agentic/ard'}, ], groups: { 'webmcp': { @@ -51,6 +58,10 @@ const config = { title: str_(UIStrings.agentAccessibilityGroupTitle), description: str_(UIStrings.agentAccessibilityGroupDescription), }, + 'agent-resource-discovery': { + title: str_(UIStrings.ardGroupTitle), + description: str_(UIStrings.ardGroupDescription), + }, }, categories: { 'agentic-browsing': { @@ -65,6 +76,7 @@ const config = { {id: 'webmcp-schema-validity', weight: 1, group: 'webmcp'}, {id: 'cumulative-layout-shift', weight: 1, acronym: 'CLS'}, {id: 'llms-txt', weight: 1, group: 'agent-accessibility'}, + {id: 'ard-schema', weight: 1, group: 'agent-resource-discovery'}, ], }, }, diff --git a/core/config/default-config.js b/core/config/default-config.js index 5e88b87b61a7..0e419e62f50e 100644 --- a/core/config/default-config.js +++ b/core/config/default-config.js @@ -115,6 +115,11 @@ const UIStrings = { /** Description of the Agent Accessibility group of audits. */ agentAccessibilityGroupDescription: 'These audits highlight best practices for improving the ' + 'accessibility of the website for AI agents.', + /** Title of the Agentic Resource Discovery (ARD, https://agenticresourcediscovery.org/spec/) group of audits. */ + ardGroupTitle: 'Agentic Resource Discovery', + /** Description of the Agentic Resource Discovery (ARD, https://agenticresourcediscovery.org/spec/) group of audits. */ + ardGroupDescription: 'These audits validate that agentic resources are ' + + 'discoverable, reachable, and conform to the ARD specification.', }; const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); @@ -145,6 +150,7 @@ const defaultConfig = { {id: 'MetaElements', gatherer: 'meta-elements'}, {id: 'NetworkUserAgent', gatherer: 'network-user-agent'}, {id: 'RobotsTxt', gatherer: 'seo/robots-txt'}, + {id: 'AgentResourceDiscovery', gatherer: 'agentic/ard'}, {id: 'Scripts', gatherer: 'scripts'}, {id: 'SourceMaps', gatherer: 'source-maps'}, {id: 'Stacks', gatherer: 'stacks'}, @@ -302,6 +308,7 @@ const defaultConfig = { 'webmcp-form-coverage', 'webmcp-schema-validity', 'agentic/llms-txt', + 'agentic/ard-schema', 'bf-cache', 'insights/cache-insight', 'insights/cls-culprits-insight', @@ -397,6 +404,10 @@ const defaultConfig = { title: str_(UIStrings.agentAccessibilityGroupTitle), description: str_(UIStrings.agentAccessibilityGroupDescription), }, + 'agent-resource-discovery': { + title: str_(UIStrings.ardGroupTitle), + description: str_(UIStrings.ardGroupDescription), + }, // Group for audits that should not be displayed. 'hidden': {title: ''}, }, @@ -644,6 +655,7 @@ const defaultConfig = { {id: 'webmcp-schema-validity', weight: 1, group: 'webmcp'}, {id: 'cumulative-layout-shift', weight: 1, acronym: 'CLS'}, {id: 'llms-txt', weight: 1, group: 'agent-accessibility'}, + {id: 'ard-schema', weight: 1, group: 'agent-resource-discovery'}, ], }, }, diff --git a/core/gather/fetcher.js b/core/gather/fetcher.js index 77e1330a30d1..ca807f287db2 100644 --- a/core/gather/fetcher.js +++ b/core/gather/fetcher.js @@ -10,7 +10,7 @@ import * as LH from '../../types/lh.js'; * ignoring normal browser constraints such as CORS. */ -/** @typedef {{content: string|null, status: number|null}} FetchResponse */ +/** @typedef {{content: string|null, status: number|null, headers?: Record|null}} FetchResponse */ class Fetcher { /** @@ -37,6 +37,26 @@ class Fetcher { return this._fetchResourceOverProtocol(url, options); } + /** + * @param {Headers|Record|null|undefined} rawHeaders + * @return {Record|null} + */ + _normalizeHeaders(rawHeaders) { + if (!rawHeaders) return null; + + /** @type {Record} */ + const headers = {}; + const entries = typeof /** @type {Headers} */ (rawHeaders).entries === 'function' ? + /** @type {Headers} */ (rawHeaders).entries() : + Object.entries(rawHeaders); + + for (const [key, value] of entries) { + headers[key.toLowerCase()] = String(value); + } + + return headers; + } + /** * @param {string} url * @return {Promise} @@ -49,9 +69,12 @@ class Fetcher { content = await response.text(); } catch {} + const headers = this._normalizeHeaders(response.headers); + return { content, status: response.status, + headers, }; } @@ -82,7 +105,7 @@ class Fetcher { /** * @param {string} url - * @return {Promise<{stream: LH.Crdp.IO.StreamHandle|null, status: number|null}>} + * @return {Promise<{stream: LH.Crdp.IO.StreamHandle|null, status: number|null, headers?: Record|null}>} */ async _loadNetworkResource(url) { const frameTreeResponse = await this.session.sendCommand('Page.getFrameTree'); @@ -98,6 +121,7 @@ class Fetcher { return { stream: networkResponse.resource.success ? (networkResponse.resource.stream || null) : null, status: networkResponse.resource.httpStatusCode || null, + headers: networkResponse.resource.headers || null, }; } @@ -110,12 +134,14 @@ class Fetcher { const startTime = Date.now(); const response = await this._wrapWithTimeout(this._loadNetworkResource(url), options.timeout); + const headers = this._normalizeHeaders(response.headers); + const isOk = response.status && response.status >= 200 && response.status <= 299; - if (!response.stream || !isOk) return {status: response.status, content: null}; + if (!response.stream || !isOk) return {status: response.status, content: null, headers}; const timeout = options.timeout - (Date.now() - startTime); const content = await this._readIOStream(response.stream, {timeout}); - return {status: response.status, content}; + return {status: response.status, content, headers}; } /** diff --git a/core/gather/gatherers/agentic/ard.js b/core/gather/gatherers/agentic/ard.js new file mode 100644 index 000000000000..fa33ce147b52 --- /dev/null +++ b/core/gather/gatherers/agentic/ard.js @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import log from 'lighthouse-logger'; + +import BaseGatherer from '../../base-gatherer.js'; +import RobotsTxt from '../seo/robots-txt.js'; +import {pageFunctions} from '../../../lib/page-functions.js'; + +/* c8 ignore start */ +function getAiCatalogLinkInDOM() { + const link = document.querySelector('link[rel~="ai-catalog" i]'); + return link instanceof HTMLLinkElement ? link.href : null; +} +/* c8 ignore stop */ + +class AgentResourceDiscovery extends BaseGatherer { + /** @type {LH.Gatherer.GathererMeta<'RobotsTxt'>} */ + meta = { + supportedModes: ['snapshot', 'navigation'], + dependencies: {RobotsTxt: RobotsTxt.symbol}, + }; + + /** + * @param {LH.Artifacts['RobotsTxt']|null|undefined} robotsTxt + * @param {string} finalDisplayedUrl + * @return {string|null} + */ + static getRobotsTxtAgentmap(robotsTxt, finalDisplayedUrl) { + if (!robotsTxt?.content) return null; + const match = robotsTxt.content.match(/^\s*Agentmap:\s*(\S+)/im); + if (!match) return null; + try { + return new URL(match[1], finalDisplayedUrl).href; + } catch { + return null; + } + } + + /** + * @param {LH.Gatherer.Context} context + * @param {string} finalDisplayedUrl + * @return {Promise} + */ + static async getHtmlLinkFromDom(context, finalDisplayedUrl) { + try { + const href = await context.driver.executionContext.evaluate(getAiCatalogLinkInDOM, { + args: [], + useIsolation: true, + deps: [pageFunctions.getNodeDetails], + }); + if (!href) return null; + return new URL(href, finalDisplayedUrl).href; + } catch { + return null; + } + } + + /** + * @param {LH.Gatherer.Context} context + * @param {string} finalDisplayedUrl + * @return {Promise} + */ + static async getHttpHeaderLink(context, finalDisplayedUrl) { + // Only check in navigation mode + if (context.gatherMode !== 'navigation') return null; + + try { + const mainResourceResponse = await context.driver.fetcher.fetchResource(finalDisplayedUrl); + const linkHeader = mainResourceResponse.headers?.['link'] || ''; + const match = linkHeader.match(/<([^>]+)>;\s*rel=["']?ai-catalog["']?/i); + if (!match) return null; + return new URL(match[1], finalDisplayedUrl).href; + } catch { + return null; + } + } + + /** + * @param {LH.Gatherer.Context<'RobotsTxt'>} context + * @return {Promise} + */ + async getArtifact(context) { + const {finalDisplayedUrl} = context.baseArtifacts.URL; + const robotsTxt = context.dependencies.RobotsTxt; + const robotsTxtAgentmap = AgentResourceDiscovery.getRobotsTxtAgentmap( + robotsTxt, finalDisplayedUrl); + const [htmlLink, httpHeaderLink] = await Promise.all([ + AgentResourceDiscovery.getHtmlLinkFromDom(context, finalDisplayedUrl), + AgentResourceDiscovery.getHttpHeaderLink(context, finalDisplayedUrl), + ]); + const wellKnown = new URL('/.well-known/ai-catalog.json', finalDisplayedUrl).href; + const catalogUrl = robotsTxtAgentmap || htmlLink || httpHeaderLink || wellKnown; + + /** @type {string|undefined} */ + let errorMessage; + const fetchResult = await context.driver.fetcher.fetchResource(catalogUrl) + .catch(err => { + log.error('AgentResourceDiscovery', err); + errorMessage = err.message; + return {status: null, content: null, headers: null}; + }); + + return { + status: fetchResult.status, + content: fetchResult.content, + headers: fetchResult.headers || null, + catalogUrl, + discoverySignals: { + robotsTxtAgentmap, + htmlLink, + httpHeaderLink, + wellKnown, + }, + errorMessage, + }; + } +} + +export default AgentResourceDiscovery; diff --git a/core/gather/gatherers/seo/robots-txt.js b/core/gather/gatherers/seo/robots-txt.js index 145e44e9b731..6db1cd2d1cd5 100644 --- a/core/gather/gatherers/seo/robots-txt.js +++ b/core/gather/gatherers/seo/robots-txt.js @@ -9,8 +9,11 @@ import log from 'lighthouse-logger'; import BaseGatherer from '../../base-gatherer.js'; class RobotsTxt extends BaseGatherer { + static symbol = Symbol('RobotsTxt'); + /** @type {LH.Gatherer.GathererMeta} */ meta = { + symbol: RobotsTxt.symbol, supportedModes: ['snapshot', 'navigation'], }; diff --git a/core/scripts/i18n/collect-strings.js b/core/scripts/i18n/collect-strings.js index f4bbfb2711e3..e859bfbff442 100644 --- a/core/scripts/i18n/collect-strings.js +++ b/core/scripts/i18n/collect-strings.js @@ -740,6 +740,8 @@ function checkKnownFixedCollisions(strings) { 'Element', 'Element', 'Element', + 'Error', + 'Error', 'Est Savings', 'Est Savings', 'Failing Elements', @@ -755,6 +757,7 @@ function checkKnownFixedCollisions(strings) { 'Severity', 'Severity', 'Severity', + 'Severity', 'Total', 'Total', 'Use $MARKDOWN_SNIPPET_0$ to detect unused JavaScript code. $LINK_START_0$Learn more$LINK_END_0$', diff --git a/core/test/audits/agentic/ard-schema-test.js b/core/test/audits/agentic/ard-schema-test.js new file mode 100644 index 000000000000..78a353df5794 --- /dev/null +++ b/core/test/audits/agentic/ard-schema-test.js @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for ARD Schema Conformance Audit. + */ + +import ArdSchema from '../../../audits/agentic/ard-schema.js'; + +describe('ARD Schema Audit', () => { + /** + * Helper to construct mock artifacts for testing. + * @param {number} status + * @param {any} manifest + * @param {Partial=} customSignals + * @return {LH.Artifacts} + */ + function createArtifacts(status, manifest, customSignals = {}) { + let content = null; + if (manifest !== null) { + content = typeof manifest === 'string' ? manifest : JSON.stringify(manifest); + } + return { + AgentResourceDiscovery: { + status, + content, + headers: {'content-type': 'application/json'}, + discoverySignals: { + robotsTxtAgentmap: 'https://example.com/ai-catalog.json', + htmlLink: null, + httpHeaderLink: null, + wellKnown: 'https://example.com/.well-known/ai-catalog.json', + ...customSignals, + }, + }, + }; + } + + it('is not applicable when no catalog URL is found', () => { + const artifacts = { + AgentResourceDiscovery: { + status: 404, + content: null, + headers: null, + discoverySignals: { + robotsTxtAgentmap: null, + htmlLink: null, + httpHeaderLink: null, + wellKnown: 'https://example.com/.well-known/ai-catalog.json', + }, + }, + }; + + const result = ArdSchema.audit(artifacts); + expect(result.score).toEqual(1); + expect(result.notApplicable).toEqual(true); + }); + + it('fails with score 0 when manifest content could not be loaded', () => { + const artifacts = createArtifacts(500, null); + const result = ArdSchema.audit(artifacts); + expect(result.score).toEqual(0); + expect(result.explanation).toBeDisplayString( + 'Catalog file could not be loaded for schema validation.' + ); + }); + + it('fails with score 0 when manifest is malformed JSON', () => { + const artifacts = createArtifacts(200, '{ invalid json'); + const result = ArdSchema.audit(artifacts); + expect(result.score).toEqual(0); + expect(result.details.items[0].severity).toBeDisplayString('Error'); + expect(result.details.items[0].issue).toContain('Malformed JSON in manifest'); + }); + + it('passes completely when schema is fully conformant with URL', () => { + const validManifestUrl = { + specVersion: '1.0', + entries: [ + { + identifier: 'urn:air:google:search:web-search', + displayName: 'Web Search API', + type: 'application/mcp-server-card+json', + url: 'https://example.com/mcp.json', + representativeQueries: ['search web', 'find articles'], + trustManifest: {identity: 'google.com'}, + }, + ], + }; + const resultUrl = ArdSchema.audit(createArtifacts(200, validManifestUrl)); + expect(resultUrl.score).toEqual(1); + expect(resultUrl.details).toBeUndefined(); + }); + + it('returns a warning when representativeQueries is missing (Lighthouse best practice)', () => { + const manifestWithoutQueries = { + specVersion: '1.0', + entries: [ + { + identifier: 'urn:air:google:search:web-search', + displayName: 'Web Search API', + type: 'application/mcp-server-card+json', + url: 'https://example.com/mcp.json', + }, + ], + }; + const result = ArdSchema.audit(createArtifacts(200, manifestWithoutQueries)); + expect(result.score).toEqual(0.5); + expect(result.details.items[0].severity).toBeDisplayString('Warning'); + expect(result.details.items[0].issue).toBeDisplayString( + 'Missing `representativeQueries`. Providing examples significantly improves discoverability.' + ); + }); +}); diff --git a/core/test/fixtures/user-flows/reports/sample-flow-result.json b/core/test/fixtures/user-flows/reports/sample-flow-result.json index 94e3e6d82705..bb6a6d0ec747 100644 --- a/core/test/fixtures/user-flows/reports/sample-flow-result.json +++ b/core/test/fixtures/user-flows/reports/sample-flow-result.json @@ -3356,6 +3356,15 @@ "errorMessage": "Required LlmsTxt gatherer did not run.", "errorStack": "LighthouseError: MISSING_REQUIRED_ARTIFACT\n at Runner._runAudit (file:///core/runner.js)\n at Runner._runAudits (file:///core/runner.js)\n at async Runner.audit (file:///core/runner.js)\n at async auditGatherSteps (file:///core/user-flow.js)\n at async Module.auditFlowArtifacts (file:///core/index.js)\n at async generateFlowResult (file:///core/scripts/update-flow-fixtures.js)\n at async file:///core/scripts/update-flow-fixtures.js" }, + "ard-schema": { + "id": "ard-schema", + "title": "`ai-catalog.json` schema is valid", + "description": "Valid `ai-catalog.json` manifests are required for autonomous AI agents and registries to discover and verify your resources. [Learn more about the ARD specification](https://agenticresourcediscovery.org/spec/).", + "score": null, + "scoreDisplayMode": "error", + "errorMessage": "Required AgentResourceDiscovery gatherer did not run.", + "errorStack": "LighthouseError: MISSING_REQUIRED_ARTIFACT\n at Runner._runAudit (file:///core/runner.js)\n at Runner._runAudits (file:///core/runner.js)\n at async Runner.audit (file:///core/runner.js)\n at async auditGatherSteps (file:///core/user-flow.js)\n at async Module.auditFlowArtifacts (file:///core/index.js)\n at async generateFlowResult (file:///core/scripts/update-flow-fixtures.js)\n at async file:///core/scripts/update-flow-fixtures.js" + }, "bf-cache": { "id": "bf-cache", "title": "Page didn't prevent back/forward cache restoration", @@ -4442,6 +4451,11 @@ "id": "llms-txt", "weight": 1, "group": "agent-accessibility" + }, + { + "id": "ard-schema", + "weight": 1, + "group": "agent-resource-discovery" } ], "id": "agentic-browsing", @@ -4524,6 +4538,10 @@ "title": "Agent Accessibility", "description": "These audits highlight best practices for improving the accessibility of the website for AI agents." }, + "agent-resource-discovery": { + "title": "Agentic Resource Discovery", + "description": "These audits validate that agentic resources are discoverable, reachable, and conform to the ARD specification." + }, "hidden": { "title": "" } @@ -6423,114 +6441,120 @@ }, { "startTime": 224, - "name": "lh:audit:bf-cache", + "name": "lh:audit:ard-schema", "duration": 1, "entryType": "measure" }, { "startTime": 225, - "name": "lh:audit:cache-insight", + "name": "lh:audit:bf-cache", "duration": 1, "entryType": "measure" }, { "startTime": 226, - "name": "lh:audit:cls-culprits-insight", + "name": "lh:audit:cache-insight", "duration": 1, "entryType": "measure" }, { "startTime": 227, - "name": "lh:audit:document-latency-insight", + "name": "lh:audit:cls-culprits-insight", "duration": 1, "entryType": "measure" }, { "startTime": 228, - "name": "lh:audit:dom-size-insight", + "name": "lh:audit:document-latency-insight", "duration": 1, "entryType": "measure" }, { "startTime": 229, - "name": "lh:audit:duplicated-javascript-insight", + "name": "lh:audit:dom-size-insight", "duration": 1, "entryType": "measure" }, { "startTime": 230, - "name": "lh:audit:font-display-insight", + "name": "lh:audit:duplicated-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 231, - "name": "lh:audit:forced-reflow-insight", + "name": "lh:audit:font-display-insight", "duration": 1, "entryType": "measure" }, { "startTime": 232, - "name": "lh:audit:image-delivery-insight", + "name": "lh:audit:forced-reflow-insight", "duration": 1, "entryType": "measure" }, { "startTime": 233, - "name": "lh:audit:inp-breakdown-insight", + "name": "lh:audit:image-delivery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 234, - "name": "lh:audit:lcp-breakdown-insight", + "name": "lh:audit:inp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 235, - "name": "lh:audit:lcp-discovery-insight", + "name": "lh:audit:lcp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 236, - "name": "lh:audit:legacy-javascript-insight", + "name": "lh:audit:lcp-discovery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 237, - "name": "lh:audit:network-dependency-tree-insight", + "name": "lh:audit:legacy-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 238, - "name": "lh:audit:render-blocking-insight", + "name": "lh:audit:network-dependency-tree-insight", "duration": 1, "entryType": "measure" }, { "startTime": 239, - "name": "lh:audit:third-parties-insight", + "name": "lh:audit:render-blocking-insight", "duration": 1, "entryType": "measure" }, { "startTime": 240, - "name": "lh:audit:viewport-insight", + "name": "lh:audit:third-parties-insight", "duration": 1, "entryType": "measure" }, { "startTime": 241, + "name": "lh:audit:viewport-insight", + "duration": 1, + "entryType": "measure" + }, + { + "startTime": 242, "name": "lh:runner:generate", "duration": 1, "entryType": "measure" } ], - "total": 242 + "total": 243 }, "i18n": { "rendererFormattedStrings": { @@ -6768,6 +6792,13 @@ }, "path": "audits[llms-txt].errorMessage" }, + { + "values": { + "errorCode": "MISSING_REQUIRED_ARTIFACT", + "artifactName": "AgentResourceDiscovery" + }, + "path": "audits[ard-schema].errorMessage" + }, { "values": { "errorCode": "MISSING_REQUIRED_ARTIFACT", @@ -7846,6 +7877,12 @@ "core/audits/agentic/llms-txt.js | description": [ "audits[llms-txt].description" ], + "core/audits/agentic/ard-schema.js | title": [ + "audits[ard-schema].title" + ], + "core/audits/agentic/ard-schema.js | description": [ + "audits[ard-schema].description" + ], "core/audits/bf-cache.js | title": [ "audits[bf-cache].title" ], @@ -8083,6 +8120,12 @@ "core/config/default-config.js | agentAccessibilityGroupDescription": [ "categoryGroups[agent-accessibility].description" ], + "core/config/default-config.js | ardGroupTitle": [ + "categoryGroups[agent-resource-discovery].title" + ], + "core/config/default-config.js | ardGroupDescription": [ + "categoryGroups[agent-resource-discovery].description" + ], "node_modules/lighthouse-stack-packs/packs/next.js.js | unused-css-rules": [ "stackPacks[0].descriptions[unused-css-rules]" ], @@ -10079,6 +10122,10 @@ "title": "Agent Accessibility", "description": "These audits highlight best practices for improving the accessibility of the website for AI agents." }, + "agent-resource-discovery": { + "title": "Agentic Resource Discovery", + "description": "These audits validate that agentic resources are discoverable, reachable, and conform to the ARD specification." + }, "hidden": { "title": "" } @@ -11617,6 +11664,12 @@ ], "core/config/default-config.js | agentAccessibilityGroupDescription": [ "categoryGroups[agent-accessibility].description" + ], + "core/config/default-config.js | ardGroupTitle": [ + "categoryGroups[agent-resource-discovery].title" + ], + "core/config/default-config.js | ardGroupDescription": [ + "categoryGroups[agent-resource-discovery].description" ] } } @@ -14600,6 +14653,15 @@ "scoreDisplayMode": "error", "errorMessage": "Required LlmsTxt gatherer did not run.", "errorStack": "LighthouseError: MISSING_REQUIRED_ARTIFACT\n at Runner._runAudit (file:///core/runner.js)\n at Runner._runAudits (file:///core/runner.js)\n at async Runner.audit (file:///core/runner.js)\n at async auditGatherSteps (file:///core/user-flow.js)\n at async Module.auditFlowArtifacts (file:///core/index.js)\n at async generateFlowResult (file:///core/scripts/update-flow-fixtures.js)\n at async file:///core/scripts/update-flow-fixtures.js" + }, + "ard-schema": { + "id": "ard-schema", + "title": "`ai-catalog.json` schema is valid", + "description": "Valid `ai-catalog.json` manifests are required for autonomous AI agents and registries to discover and verify your resources. [Learn more about the ARD specification](https://agenticresourcediscovery.org/spec/).", + "score": null, + "scoreDisplayMode": "error", + "errorMessage": "Required AgentResourceDiscovery gatherer did not run.", + "errorStack": "LighthouseError: MISSING_REQUIRED_ARTIFACT\n at Runner._runAudit (file:///core/runner.js)\n at Runner._runAudits (file:///core/runner.js)\n at async Runner.audit (file:///core/runner.js)\n at async auditGatherSteps (file:///core/user-flow.js)\n at async Module.auditFlowArtifacts (file:///core/index.js)\n at async generateFlowResult (file:///core/scripts/update-flow-fixtures.js)\n at async file:///core/scripts/update-flow-fixtures.js" } }, "configSettings": { @@ -15158,6 +15220,11 @@ "id": "llms-txt", "weight": 1, "group": "agent-accessibility" + }, + { + "id": "ard-schema", + "weight": 1, + "group": "agent-resource-discovery" } ], "id": "agentic-browsing", @@ -15240,6 +15307,10 @@ "title": "Agent Accessibility", "description": "These audits highlight best practices for improving the accessibility of the website for AI agents." }, + "agent-resource-discovery": { + "title": "Agentic Resource Discovery", + "description": "These audits validate that agentic resources are discoverable, reachable, and conform to the ARD specification." + }, "hidden": { "title": "" } @@ -16684,12 +16755,18 @@ }, { "startTime": 112, + "name": "lh:audit:ard-schema", + "duration": 1, + "entryType": "measure" + }, + { + "startTime": 113, "name": "lh:runner:generate", "duration": 1, "entryType": "measure" } ], - "total": 113 + "total": 114 }, "i18n": { "rendererFormattedStrings": { @@ -17298,8 +17375,21 @@ "artifactName": "LlmsTxt" }, "path": "audits[llms-txt].errorMessage" + }, + { + "values": { + "errorCode": "MISSING_REQUIRED_ARTIFACT", + "artifactName": "AgentResourceDiscovery" + }, + "path": "audits[ard-schema].errorMessage" } ], + "core/audits/agentic/ard-schema.js | title": [ + "audits[ard-schema].title" + ], + "core/audits/agentic/ard-schema.js | description": [ + "audits[ard-schema].description" + ], "core/config/default-config.js | performanceCategoryTitle": [ "categories.performance.title" ], @@ -17435,6 +17525,12 @@ "core/config/default-config.js | agentAccessibilityGroupDescription": [ "categoryGroups[agent-accessibility].description" ], + "core/config/default-config.js | ardGroupTitle": [ + "categoryGroups[agent-resource-discovery].title" + ], + "core/config/default-config.js | ardGroupDescription": [ + "categoryGroups[agent-resource-discovery].description" + ], "node_modules/lighthouse-stack-packs/packs/next.js.js | unused-css-rules": [ "stackPacks[0].descriptions[unused-css-rules]" ], @@ -20966,6 +21062,15 @@ "errorMessage": "Required LlmsTxt gatherer did not run.", "errorStack": "LighthouseError: MISSING_REQUIRED_ARTIFACT\n at Runner._runAudit (file:///core/runner.js)\n at Runner._runAudits (file:///core/runner.js)\n at async Runner.audit (file:///core/runner.js)\n at async auditGatherSteps (file:///core/user-flow.js)\n at async Module.auditFlowArtifacts (file:///core/index.js)\n at async generateFlowResult (file:///core/scripts/update-flow-fixtures.js)\n at async file:///core/scripts/update-flow-fixtures.js" }, + "ard-schema": { + "id": "ard-schema", + "title": "`ai-catalog.json` schema is valid", + "description": "Valid `ai-catalog.json` manifests are required for autonomous AI agents and registries to discover and verify your resources. [Learn more about the ARD specification](https://agenticresourcediscovery.org/spec/).", + "score": null, + "scoreDisplayMode": "error", + "errorMessage": "Required AgentResourceDiscovery gatherer did not run.", + "errorStack": "LighthouseError: MISSING_REQUIRED_ARTIFACT\n at Runner._runAudit (file:///core/runner.js)\n at Runner._runAudits (file:///core/runner.js)\n at async Runner.audit (file:///core/runner.js)\n at async auditGatherSteps (file:///core/user-flow.js)\n at async Module.auditFlowArtifacts (file:///core/index.js)\n at async generateFlowResult (file:///core/scripts/update-flow-fixtures.js)\n at async file:///core/scripts/update-flow-fixtures.js" + }, "bf-cache": { "id": "bf-cache", "title": "Page didn't prevent back/forward cache restoration", @@ -22052,6 +22157,11 @@ "id": "llms-txt", "weight": 1, "group": "agent-accessibility" + }, + { + "id": "ard-schema", + "weight": 1, + "group": "agent-resource-discovery" } ], "id": "agentic-browsing", @@ -22134,6 +22244,10 @@ "title": "Agent Accessibility", "description": "These audits highlight best practices for improving the accessibility of the website for AI agents." }, + "agent-resource-discovery": { + "title": "Agentic Resource Discovery", + "description": "These audits validate that agentic resources are discoverable, reachable, and conform to the ARD specification." + }, "hidden": { "title": "" } @@ -24090,114 +24204,120 @@ }, { "startTime": 223, - "name": "lh:audit:bf-cache", + "name": "lh:audit:ard-schema", "duration": 1, "entryType": "measure" }, { "startTime": 224, - "name": "lh:audit:cache-insight", + "name": "lh:audit:bf-cache", "duration": 1, "entryType": "measure" }, { "startTime": 225, - "name": "lh:audit:cls-culprits-insight", + "name": "lh:audit:cache-insight", "duration": 1, "entryType": "measure" }, { "startTime": 226, - "name": "lh:audit:document-latency-insight", + "name": "lh:audit:cls-culprits-insight", "duration": 1, "entryType": "measure" }, { "startTime": 227, - "name": "lh:audit:dom-size-insight", + "name": "lh:audit:document-latency-insight", "duration": 1, "entryType": "measure" }, { "startTime": 228, - "name": "lh:audit:duplicated-javascript-insight", + "name": "lh:audit:dom-size-insight", "duration": 1, "entryType": "measure" }, { "startTime": 229, - "name": "lh:audit:font-display-insight", + "name": "lh:audit:duplicated-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 230, - "name": "lh:audit:forced-reflow-insight", + "name": "lh:audit:font-display-insight", "duration": 1, "entryType": "measure" }, { "startTime": 231, - "name": "lh:audit:image-delivery-insight", + "name": "lh:audit:forced-reflow-insight", "duration": 1, "entryType": "measure" }, { "startTime": 232, - "name": "lh:audit:inp-breakdown-insight", + "name": "lh:audit:image-delivery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 233, - "name": "lh:audit:lcp-breakdown-insight", + "name": "lh:audit:inp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 234, - "name": "lh:audit:lcp-discovery-insight", + "name": "lh:audit:lcp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 235, - "name": "lh:audit:legacy-javascript-insight", + "name": "lh:audit:lcp-discovery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 236, - "name": "lh:audit:network-dependency-tree-insight", + "name": "lh:audit:legacy-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 237, - "name": "lh:audit:render-blocking-insight", + "name": "lh:audit:network-dependency-tree-insight", "duration": 1, "entryType": "measure" }, { "startTime": 238, - "name": "lh:audit:third-parties-insight", + "name": "lh:audit:render-blocking-insight", "duration": 1, "entryType": "measure" }, { "startTime": 239, - "name": "lh:audit:viewport-insight", + "name": "lh:audit:third-parties-insight", "duration": 1, "entryType": "measure" }, { "startTime": 240, + "name": "lh:audit:viewport-insight", + "duration": 1, + "entryType": "measure" + }, + { + "startTime": 241, "name": "lh:runner:generate", "duration": 1, "entryType": "measure" } ], - "total": 241 + "total": 242 }, "i18n": { "rendererFormattedStrings": { @@ -24435,6 +24555,13 @@ }, "path": "audits[llms-txt].errorMessage" }, + { + "values": { + "errorCode": "MISSING_REQUIRED_ARTIFACT", + "artifactName": "AgentResourceDiscovery" + }, + "path": "audits[ard-schema].errorMessage" + }, { "values": { "errorCode": "MISSING_REQUIRED_ARTIFACT", @@ -25513,6 +25640,12 @@ "core/audits/agentic/llms-txt.js | description": [ "audits[llms-txt].description" ], + "core/audits/agentic/ard-schema.js | title": [ + "audits[ard-schema].title" + ], + "core/audits/agentic/ard-schema.js | description": [ + "audits[ard-schema].description" + ], "core/audits/bf-cache.js | title": [ "audits[bf-cache].title" ], @@ -25750,6 +25883,12 @@ "core/config/default-config.js | agentAccessibilityGroupDescription": [ "categoryGroups[agent-accessibility].description" ], + "core/config/default-config.js | ardGroupTitle": [ + "categoryGroups[agent-resource-discovery].title" + ], + "core/config/default-config.js | ardGroupDescription": [ + "categoryGroups[agent-resource-discovery].description" + ], "node_modules/lighthouse-stack-packs/packs/next.js.js | unused-css-rules": [ "stackPacks[0].descriptions[unused-css-rules]" ], diff --git a/core/test/gather/fetcher-test.js b/core/test/gather/fetcher-test.js index cceecabca9c5..b9e241239b43 100644 --- a/core/test/gather/fetcher-test.js +++ b/core/test/gather/fetcher-test.js @@ -84,7 +84,27 @@ describe('._fetchResourceOverProtocol', () => { }); const data = await fetcher._fetchResourceOverProtocol('https://example.com', {timeout: 500}); - expect(data).toEqual({content: streamContents, status: 200}); + expect(data).toEqual({content: streamContents, status: 200, headers: null}); + }); + + it('fetches a file with headers', async () => { + mockSession.sendCommand + .mockResponse('Page.getFrameTree', {frameTree: {frame: {id: 'FRAME'}}}) + .mockResponse('Network.loadNetworkResource', { + resource: { + success: true, + httpStatusCode: 200, + stream: '1', + headers: {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + }, + }); + + const data = await fetcher._fetchResourceOverProtocol('https://example.com', {timeout: 500}); + expect(data).toEqual({ + content: streamContents, + status: 200, + headers: {'content-type': 'application/json', 'access-control-allow-origin': '*'}, + }); }); it('returns null when resource could not be fetched', async () => { @@ -95,7 +115,7 @@ describe('._fetchResourceOverProtocol', () => { }); const data = await fetcher._fetchResourceOverProtocol('https://example.com', {timeout: 500}); - expect(data).toEqual({content: null, status: 404}); + expect(data).toEqual({content: null, status: 404, headers: null}); }); it('throws on timeout', async () => { @@ -125,3 +145,34 @@ describe('._fetchResourceOverProtocol', () => { expect(timeout).toBeCloseTo(500, -2); }); }); + +describe('._normalizeHeaders', () => { + it('returns null when passed null or undefined', () => { + expect(fetcher._normalizeHeaders(null)).toBeNull(); + expect(fetcher._normalizeHeaders(undefined)).toBeNull(); + }); + + it('normalizes plain object headers with lowercase keys and string values', () => { + const raw = { + 'Content-Type': 'application/json', + 'Content-Length': 1234, + 'X-Custom-Header': 'CustomValue', + }; + expect(fetcher._normalizeHeaders(raw)).toEqual({ + 'content-type': 'application/json', + 'content-length': '1234', + 'x-custom-header': 'CustomValue', + }); + }); + + it('normalizes Headers instance with lowercase keys', () => { + const headers = new Headers(); + headers.append('Content-Type', 'text/html'); + headers.append('X-Rate-Limit', '100'); + expect(fetcher._normalizeHeaders(headers)).toEqual({ + 'content-type': 'text/html', + 'x-rate-limit': '100', + }); + }); +}); + diff --git a/core/test/gather/gatherers/agentic/ard-test.js b/core/test/gather/gatherers/agentic/ard-test.js new file mode 100644 index 000000000000..edcff07534ee --- /dev/null +++ b/core/test/gather/gatherers/agentic/ard-test.js @@ -0,0 +1,337 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import AgentResourceDiscovery from '../../../../gather/gatherers/agentic/ard.js'; + +describe('ARD Gatherer Static Helpers', () => { + const finalUrl = 'https://example.com/page'; + + describe('getRobotsTxtAgentmap', () => { + it('returns null if robotsTxt is null or has no content', () => { + expect(AgentResourceDiscovery.getRobotsTxtAgentmap(null, finalUrl)).toBeNull(); + expect(AgentResourceDiscovery.getRobotsTxtAgentmap( + {status: 200, content: null}, finalUrl)).toBeNull(); + expect(AgentResourceDiscovery.getRobotsTxtAgentmap( + {status: 404, content: null}, finalUrl)).toBeNull(); + }); + + it('returns null if no Agentmap directive exists', () => { + const robotsTxt = { + status: 200, + content: 'User-agent: *\nDisallow: /admin\nSitemap: https://example.com/sitemap.xml\n', + }; + expect(AgentResourceDiscovery.getRobotsTxtAgentmap(robotsTxt, finalUrl)).toBeNull(); + }); + + it('extracts absolute URL from Agentmap directive', () => { + const robotsTxt = { + status: 200, + content: 'User-agent: *\nAgentmap: https://example.com/custom-catalog.json\n', + }; + expect(AgentResourceDiscovery.getRobotsTxtAgentmap(robotsTxt, finalUrl)) + .toEqual('https://example.com/custom-catalog.json'); + }); + + it('resolves relative URL from Agentmap directive', () => { + const robotsTxt = { + status: 200, + content: 'User-agent: *\nAgentmap: /ai-catalog.json\n', + }; + expect(AgentResourceDiscovery.getRobotsTxtAgentmap(robotsTxt, finalUrl)) + .toEqual('https://example.com/ai-catalog.json'); + }); + + it('handles case-insensitivity in directive name', () => { + const robotsTxt = { + status: 200, + content: 'agentmap: https://example.com/ai.json\n', + }; + expect(AgentResourceDiscovery.getRobotsTxtAgentmap(robotsTxt, finalUrl)) + .toEqual('https://example.com/ai.json'); + }); + }); + + describe('getHtmlLinkFromDom', () => { + it('returns resolved absolute URL when DOM link exists', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + driver: { + executionContext: { + evaluate: () => Promise.resolve('/dom-catalog.json'), + }, + }, + })); + const result = await AgentResourceDiscovery.getHtmlLinkFromDom(context, finalUrl); + expect(result).toEqual('https://example.com/dom-catalog.json'); + }); + + it('returns null when DOM element does not exist', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + driver: { + executionContext: { + evaluate: () => Promise.resolve(null), + }, + }, + })); + const result = await AgentResourceDiscovery.getHtmlLinkFromDom(context, finalUrl); + expect(result).toBeNull(); + }); + + it('returns null when evaluate throws', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + driver: { + executionContext: { + evaluate: () => Promise.reject(new Error('DOM evaluate error')), + }, + }, + })); + const result = await AgentResourceDiscovery.getHtmlLinkFromDom(context, finalUrl); + expect(result).toBeNull(); + }); + }); + + describe('getHttpHeaderLink', () => { + it('returns null when gatherMode is not navigation', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + gatherMode: 'snapshot', + })); + const result = await AgentResourceDiscovery.getHttpHeaderLink(context, finalUrl); + expect(result).toBeNull(); + }); + + it('extracts and resolves URL from Link header with quotes', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + gatherMode: 'navigation', + driver: { + fetcher: { + fetchResource: () => Promise.resolve({ + status: 200, + content: '', + headers: {'link': '; rel="ai-catalog"'}, + }), + }, + }, + })); + const result = await AgentResourceDiscovery.getHttpHeaderLink(context, finalUrl); + expect(result).toEqual('https://example.com/header-catalog.json'); + }); + + it('extracts and resolves relative URL with unquoted rel attribute', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + gatherMode: 'navigation', + driver: { + fetcher: { + fetchResource: () => Promise.resolve({ + status: 200, + content: '', + headers: {'link': '; rel=ai-catalog'}, + }), + }, + }, + })); + const result = await AgentResourceDiscovery.getHttpHeaderLink(context, finalUrl); + expect(result).toEqual('https://example.com/relative-catalog.json'); + }); + + it('returns null when Link header does not have ai-catalog rel', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + gatherMode: 'navigation', + driver: { + fetcher: { + fetchResource: () => Promise.resolve({ + status: 200, + content: '', + headers: {'link': '; rel="stylesheet"'}, + }), + }, + }, + })); + const result = await AgentResourceDiscovery.getHttpHeaderLink(context, finalUrl); + expect(result).toBeNull(); + }); + + it('returns null when fetchResource throws', async () => { + const context = /** @type {LH.Gatherer.Context} */ (/** @type {unknown} */ ({ + gatherMode: 'navigation', + driver: { + fetcher: { + fetchResource: () => Promise.reject(new Error('Network error')), + }, + }, + })); + const result = await AgentResourceDiscovery.getHttpHeaderLink(context, finalUrl); + expect(result).toBeNull(); + }); + }); +}); + +describe('AgentResourceDiscovery Gatherer', () => { + /** + * @param {object} options + * @param {string|null} [options.robotsTxtContent] + * @param {number|null} [options.robotsTxtStatus] + * @param {string|null} [options.domLink] + * @param {Record|null} [options.headers] + * @param {{status: number|null, content: string|null, headers?: Record|null}} [options.fetcherResponse] + * @return {LH.Gatherer.Context<'RobotsTxt'>} + */ + function getContext({ + robotsTxtContent = null, + robotsTxtStatus = 200, + domLink = null, + headers = null, + fetcherResponse = { + status: 200, + content: '{"specVersion": "1.0", "entries": []}', + headers: null, + }, + }) { + const url = 'https://example.com/page'; + return /** @type {LH.Gatherer.Context<'RobotsTxt'>} */ (/** @type {unknown} */ ({ + gatherMode: 'navigation', + baseArtifacts: { + URL: {finalDisplayedUrl: url}, + }, + dependencies: { + RobotsTxt: {status: robotsTxtStatus, content: robotsTxtContent}, + }, + driver: { + executionContext: { + evaluate: () => Promise.resolve(domLink), + }, + fetcher: { + fetchResource: (/** @type {string} */ targetUrl) => { + if (targetUrl === url && headers) { + return Promise.resolve({status: 200, content: '', headers}); + } + return Promise.resolve(fetcherResponse); + }, + }, + }, + })); + } + + it('discovers catalog via robots.txt Agentmap directive', async () => { + const gatherer = new AgentResourceDiscovery(); + const context = getContext({ + robotsTxtContent: 'User-agent: *\nAgentmap: https://example.com/custom-catalog.json\n', + fetcherResponse: { + status: 200, + content: '{"specVersion": "1.0", "entries": []}', + headers: {'content-type': 'application/json', 'access-control-allow-origin': '*'}, + }, + }); + + const artifact = await gatherer.getArtifact(context); + + expect(artifact.catalogUrl).toEqual('https://example.com/custom-catalog.json'); + expect(artifact.discoverySignals.robotsTxtAgentmap).toEqual('https://example.com/custom-catalog.json'); + expect(artifact.status).toEqual(200); + expect(artifact.headers?.['content-type']).toEqual('application/json'); + expect(artifact.headers?.['access-control-allow-origin']).toEqual('*'); + }); + + it('discovers catalog via DOM link tag', async () => { + const gatherer = new AgentResourceDiscovery(); + const context = getContext({ + robotsTxtStatus: 404, + domLink: 'https://example.com/dom-catalog.json', + fetcherResponse: { + status: 200, + content: '{"specVersion": "1.0", "entries": []}', + headers: {'content-type': 'application/json'}, + }, + }); + + const artifact = await gatherer.getArtifact(context); + + expect(artifact.catalogUrl).toEqual('https://example.com/dom-catalog.json'); + expect(artifact.discoverySignals.htmlLink).toEqual('https://example.com/dom-catalog.json'); + }); + + it('discovers catalog via HTTP Link header', async () => { + const gatherer = new AgentResourceDiscovery(); + const context = getContext({ + robotsTxtStatus: 404, + headers: {'link': '; rel="ai-catalog"'}, + fetcherResponse: { + status: 200, + content: '{"specVersion": "1.0", "entries": []}', + headers: {'content-type': 'application/json'}, + }, + }); + + const artifact = await gatherer.getArtifact(context); + + expect(artifact.catalogUrl).toEqual('https://example.com/header-catalog.json'); + expect(artifact.discoverySignals.httpHeaderLink).toEqual('https://example.com/header-catalog.json'); + }); + + it('falls back to .well-known path when no discovery signals found', async () => { + const gatherer = new AgentResourceDiscovery(); + const context = getContext({ + robotsTxtStatus: 404, + fetcherResponse: { + status: 404, + content: null, + headers: null, + }, + }); + + const artifact = await gatherer.getArtifact(context); + + expect(artifact.catalogUrl).toEqual('https://example.com/.well-known/ai-catalog.json'); + expect(artifact.status).toEqual(404); + }); + + it('prioritizes robots.txt over DOM link and HTTP header', async () => { + const gatherer = new AgentResourceDiscovery(); + const context = getContext({ + robotsTxtContent: 'Agentmap: https://example.com/robots-catalog.json\n', + domLink: 'https://example.com/dom-catalog.json', + headers: {'link': '; rel="ai-catalog"'}, + fetcherResponse: { + status: 200, + content: '{"specVersion": "1.0", "entries": []}', + headers: {'content-type': 'application/json'}, + }, + }); + + const artifact = await gatherer.getArtifact(context); + + expect(artifact.catalogUrl).toEqual('https://example.com/robots-catalog.json'); + expect(artifact.discoverySignals.robotsTxtAgentmap).toEqual('https://example.com/robots-catalog.json'); + expect(artifact.discoverySignals.htmlLink).toEqual('https://example.com/dom-catalog.json'); + expect(artifact.discoverySignals.httpHeaderLink).toEqual('https://example.com/header-catalog.json'); + }); + + it('handles fetch failure gracefully and captures errorMessage', async () => { + const gatherer = new AgentResourceDiscovery(); + const url = 'https://example.com/page'; + const context = /** @type {LH.Gatherer.Context<'RobotsTxt'>} */ (/** @type {unknown} */ ({ + gatherMode: 'navigation', + baseArtifacts: { + URL: {finalDisplayedUrl: url}, + }, + dependencies: { + RobotsTxt: {status: 404, content: null}, + }, + driver: { + executionContext: { + evaluate: () => Promise.resolve(null), + }, + fetcher: { + fetchResource: () => Promise.reject(new Error('Connection refused')), + }, + }, + })); + + const artifact = await gatherer.getArtifact(context); + + expect(artifact.status).toBeNull(); + expect(artifact.content).toBeNull(); + expect(artifact.errorMessage).toEqual('Connection refused'); + }); +}); diff --git a/core/test/results/artifacts/artifacts.json b/core/test/results/artifacts/artifacts.json index 8947efbebfba..dafe0122b861 100644 --- a/core/test/results/artifacts/artifacts.json +++ b/core/test/results/artifacts/artifacts.json @@ -8377,5 +8377,17 @@ "LlmsTxt": { "status": 404, "content": null + }, + "AgentResourceDiscovery": { + "status": 404, + "content": null, + "headers": null, + "catalogUrl": "http://localhost:10200/.well-known/ai-catalog.json", + "discoverySignals": { + "robotsTxtAgentmap": null, + "htmlLink": null, + "httpHeaderLink": null, + "wellKnown": "http://localhost:10200/.well-known/ai-catalog.json" + } } } diff --git a/core/test/results/sample_v2.json b/core/test/results/sample_v2.json index 0db901dac1ca..b2a0973e4043 100644 --- a/core/test/results/sample_v2.json +++ b/core/test/results/sample_v2.json @@ -4865,6 +4865,13 @@ "score": null, "scoreDisplayMode": "notApplicable" }, + "ard-schema": { + "id": "ard-schema", + "title": "`ai-catalog.json` schema is valid", + "description": "Valid `ai-catalog.json` manifests are required for autonomous AI agents and registries to discover and verify your resources. [Learn more about the ARD specification](https://agenticresourcediscovery.org/spec/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, "bf-cache": { "id": "bf-cache", "title": "Page prevented back/forward cache restoration", @@ -7004,6 +7011,11 @@ "id": "llms-txt", "weight": 0, "group": "agent-accessibility" + }, + { + "id": "ard-schema", + "weight": 0, + "group": "agent-resource-discovery" } ], "id": "agentic-browsing", @@ -7086,6 +7098,10 @@ "title": "Agent Accessibility", "description": "These audits highlight best practices for improving the accessibility of the website for AI agents." }, + "agent-resource-discovery": { + "title": "Agentic Resource Discovery", + "description": "These audits validate that agentic resources are discoverable, reachable, and conform to the ARD specification." + }, "hidden": { "title": "" } @@ -9985,6 +10001,12 @@ "duration": 100, "entryType": "measure" }, + { + "startTime": 0, + "name": "lh:audit:ard-schema", + "duration": 100, + "entryType": "measure" + }, { "startTime": 0, "name": "lh:audit:bf-cache", @@ -11384,6 +11406,12 @@ "core/audits/agentic/llms-txt.js | description": [ "audits[llms-txt].description" ], + "core/audits/agentic/ard-schema.js | title": [ + "audits[ard-schema].title" + ], + "core/audits/agentic/ard-schema.js | description": [ + "audits[ard-schema].description" + ], "core/audits/bf-cache.js | failureTitle": [ "audits[bf-cache].title" ], @@ -11780,6 +11808,12 @@ "core/config/default-config.js | agentAccessibilityGroupDescription": [ "categoryGroups[agent-accessibility].description" ], + "core/config/default-config.js | ardGroupTitle": [ + "categoryGroups[agent-resource-discovery].title" + ], + "core/config/default-config.js | ardGroupDescription": [ + "categoryGroups[agent-resource-discovery].description" + ], "node_modules/lighthouse-stack-packs/packs/wordpress.js | unused-css-rules": [ "stackPacks[0].descriptions[unused-css-rules]" ], diff --git a/core/test/scenarios/__snapshots__/api-test-pptr.js.snap b/core/test/scenarios/__snapshots__/api-test-pptr.js.snap index c4a8707c7803..9c893a5073c2 100644 --- a/core/test/scenarios/__snapshots__/api-test-pptr.js.snap +++ b/core/test/scenarios/__snapshots__/api-test-pptr.js.snap @@ -4,6 +4,7 @@ exports[`Individual modes API navigation should compute both snapshot & timespan Array [ "accesskeys", "agent-accessibility-tree", + "ard-schema", "aria-allowed-attr", "aria-allowed-role", "aria-command-name", @@ -169,6 +170,7 @@ exports[`Individual modes API navigation should compute results with callback re Array [ "accesskeys", "agent-accessibility-tree", + "ard-schema", "aria-allowed-attr", "aria-allowed-role", "aria-command-name", @@ -334,6 +336,7 @@ exports[`Individual modes API snapshot should compute accessibility results on t Array [ "accesskeys", "agent-accessibility-tree", + "ard-schema", "aria-allowed-attr", "aria-allowed-role", "aria-command-name", diff --git a/package.json b/package.json index 7e60556e4cf3..3714bbac732a 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,8 @@ "dependencies": { "@paulirish/trace_engine": "0.0.65", "@sentry/node": "^10.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "axe-core": "^4.12.1", "chrome-launcher": "^1.2.1", "configstore": "^7.0.0", diff --git a/report/renderer/report-utils.js b/report/renderer/report-utils.js index 13efbc2802fe..9555273a6504 100644 --- a/report/renderer/report-utils.js +++ b/report/renderer/report-utils.js @@ -330,8 +330,11 @@ class ReportUtils { ++numPassableAudits; totalWeight += auditRef.weight; - if (auditPassed) numPassed++; + if (typeof auditRef.result.score === 'number' && auditRef.result.score > 0) { + numPassed += auditRef.result.score; + } } + numPassed = Math.round(numPassed * 100) / 100; return {numPassed, numPassableAudits, numInformative, totalWeight}; } diff --git a/report/test/renderer/report-utils-test.js b/report/test/renderer/report-utils-test.js index 6f996219b281..156a2237df36 100644 --- a/report/test/renderer/report-utils-test.js +++ b/report/test/renderer/report-utils-test.js @@ -235,5 +235,23 @@ describe('util helpers', () => { totalWeight: 2, }); }); + + it('supports partial scores in fraction calculation', () => { + const category = { + id: 'agentic-browsing', + auditRefs: [ + {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'a11y'}, + {weight: 1, result: {score: 0.5, scoreDisplayMode: 'binary'}, group: 'ard'}, + {weight: 1, result: {score: 0, scoreDisplayMode: 'binary'}, group: 'a11y'}, + ], + }; + const fraction = ReportUtils.calculateCategoryFraction(category); + expect(fraction).toEqual({ + numPassableAudits: 3, + numPassed: 1.5, + numInformative: 0, + totalWeight: 3, + }); + }); }); }); diff --git a/shared/localization/locales/en-US.json b/shared/localization/locales/en-US.json index 2a819b573d04..d5fc3eefd328 100644 --- a/shared/localization/locales/en-US.json +++ b/shared/localization/locales/en-US.json @@ -617,6 +617,33 @@ "core/audits/agentic/agent-accessibility-tree.js | title": { "message": "Accessibility tree is well-formed" }, + "core/audits/agentic/ard-schema.js | columnIssue": { + "message": "Issue" + }, + "core/audits/agentic/ard-schema.js | columnSeverity": { + "message": "Severity" + }, + "core/audits/agentic/ard-schema.js | description": { + "message": "Valid `ai-catalog.json` manifests are required for autonomous AI agents and registries to discover and verify your resources. [Learn more about the ARD specification](https://agenticresourcediscovery.org/spec/)." + }, + "core/audits/agentic/ard-schema.js | explanation": { + "message": "Catalog file could not be loaded for schema validation." + }, + "core/audits/agentic/ard-schema.js | failureTitle": { + "message": "`ai-catalog.json` schema is invalid or has warnings" + }, + "core/audits/agentic/ard-schema.js | itemSeverityError": { + "message": "Error" + }, + "core/audits/agentic/ard-schema.js | itemSeverityWarning": { + "message": "Warning" + }, + "core/audits/agentic/ard-schema.js | missingRepresentativeQueries": { + "message": "Missing `representativeQueries`. Providing examples significantly improves discoverability." + }, + "core/audits/agentic/ard-schema.js | title": { + "message": "`ai-catalog.json` schema is valid" + }, "core/audits/agentic/llms-txt.js | description": { "message": "If your llms.txt file does not follow recommendations, large language models may not be able to understand how you want your website to be crawled or used for training. The [llms.txt](https://llmstxt.org/) file should be a Markdown file containing at least one H1 header. [Learn more about the llms.txt audit](https://developer.chrome.com/docs/lighthouse/agentic-browsing/llms-txt)." }, @@ -1433,6 +1460,12 @@ "core/config/agentic-browsing-config.js | agenticBrowsingCategoryTitle": { "message": "Agentic Browsing" }, + "core/config/agentic-browsing-config.js | ardGroupDescription": { + "message": "These audits validate that agentic resources are discoverable, reachable, and conform to the ARD specification." + }, + "core/config/agentic-browsing-config.js | ardGroupTitle": { + "message": "Agentic Resource Discovery" + }, "core/config/agentic-browsing-config.js | webmcpGroupDescription": { "message": "Audits validating WebMCP integration." }, @@ -1508,6 +1541,12 @@ "core/config/default-config.js | agenticBrowsingCategoryTitle": { "message": "Agentic Browsing" }, + "core/config/default-config.js | ardGroupDescription": { + "message": "These audits validate that agentic resources are discoverable, reachable, and conform to the ARD specification." + }, + "core/config/default-config.js | ardGroupTitle": { + "message": "Agentic Resource Discovery" + }, "core/config/default-config.js | bestPracticesBrowserCompatGroupTitle": { "message": "Browser Compatibility" }, diff --git a/shared/localization/locales/en-XL.json b/shared/localization/locales/en-XL.json index 992c8baad417..4dc37cea18bc 100644 --- a/shared/localization/locales/en-XL.json +++ b/shared/localization/locales/en-XL.json @@ -617,6 +617,33 @@ "core/audits/agentic/agent-accessibility-tree.js | title": { "message": "Âćĉéŝśîb́îĺît́ŷ t́r̂éê íŝ ẃêĺl̂-f́ôŕm̂éd̂" }, + "core/audits/agentic/ard-schema.js | columnIssue": { + "message": "Îśŝúê" + }, + "core/audits/agentic/ard-schema.js | columnSeverity": { + "message": "Ŝév̂ér̂ít̂ý" + }, + "core/audits/agentic/ard-schema.js | description": { + "message": "V̂ál̂íd̂ `ai-catalog.json` ḿâńîf́êśt̂ś âŕê ŕêq́ûír̂éd̂ f́ôŕ âút̂ón̂óm̂óûś ÂÍ âǵêńt̂ś âńd̂ ŕêǵîśt̂ŕîéŝ t́ô d́îśĉóv̂ér̂ án̂d́ v̂ér̂íf̂ý ŷóûŕ r̂éŝóûŕĉéŝ. [Ĺêár̂ń m̂ór̂é âb́ôút̂ t́ĥé ÂŔD̂ śp̂éĉíf̂íĉát̂íôń](https://agenticresourcediscovery.org/spec/)." + }, + "core/audits/agentic/ard-schema.js | explanation": { + "message": "Ĉát̂ál̂óĝ f́îĺê ćôúl̂d́ n̂ót̂ b́ê ĺôád̂éd̂ f́ôŕ ŝćĥém̂á v̂ál̂íd̂át̂íôń." + }, + "core/audits/agentic/ard-schema.js | failureTitle": { + "message": "`ai-catalog.json` ŝćĥém̂á îś îńv̂ál̂íd̂ ór̂ h́âś ŵár̂ńîńĝś" + }, + "core/audits/agentic/ard-schema.js | itemSeverityError": { + "message": "Êŕr̂ór̂" + }, + "core/audits/agentic/ard-schema.js | itemSeverityWarning": { + "message": "Ŵár̂ńîńĝ" + }, + "core/audits/agentic/ard-schema.js | missingRepresentativeQueries": { + "message": "M̂íŝśîńĝ `representativeQueries`. Ṕr̂óv̂íd̂ín̂ǵ êx́âḿp̂ĺêś ŝíĝńîf́îćâńt̂ĺŷ ím̂ṕr̂óv̂éŝ d́îśĉóv̂ér̂áb̂íl̂ít̂ý." + }, + "core/audits/agentic/ard-schema.js | title": { + "message": "`ai-catalog.json` ŝćĥém̂á îś v̂ál̂íd̂" + }, "core/audits/agentic/llms-txt.js | description": { "message": "Îf́ ŷóûŕ l̂ĺm̂ś.t̂x́t̂ f́îĺê d́ôéŝ ńôt́ f̂ól̂ĺôẃ r̂éĉóm̂ḿêńd̂át̂íôńŝ, ĺâŕĝé l̂án̂ǵûáĝé m̂ód̂él̂ś m̂áŷ ńôt́ b̂é âb́l̂é t̂ó ûńd̂ér̂śt̂án̂d́ ĥóŵ ýôú ŵán̂t́ ŷóûŕ ŵéb̂śît́ê t́ô b́ê ćr̂áŵĺêd́ ôŕ ûśêd́ f̂ór̂ t́r̂áîńîńĝ. T́ĥé [l̂ĺm̂ś.t̂x́t̂](https://llmstxt.org/) f́îĺê śĥóûĺd̂ b́ê á M̂ár̂ḱd̂óŵń f̂íl̂é ĉón̂t́âín̂ín̂ǵ ât́ l̂éâśt̂ ón̂é Ĥ1 h́êád̂ér̂. [Ĺêár̂ń m̂ór̂é âb́ôút̂ t́ĥé l̂ĺm̂ś.t̂x́t̂ áûd́ît́](https://developer.chrome.com/docs/lighthouse/agentic-browsing/llms-txt)." }, @@ -1433,6 +1460,12 @@ "core/config/agentic-browsing-config.js | agenticBrowsingCategoryTitle": { "message": "Âǵêńt̂íĉ B́r̂óŵśîńĝ" }, + "core/config/agentic-browsing-config.js | ardGroupDescription": { + "message": "T̂h́êśê áûd́ît́ŝ v́âĺîd́ât́ê t́ĥát̂ áĝén̂t́îć r̂éŝóûŕĉéŝ ár̂é d̂íŝćôv́êŕâb́l̂é, r̂éâćĥáb̂ĺê, án̂d́ ĉón̂f́ôŕm̂ t́ô t́ĥé ÂŔD̂ śp̂éĉíf̂íĉát̂íôń." + }, + "core/config/agentic-browsing-config.js | ardGroupTitle": { + "message": "Âǵêńt̂íĉ Ŕêśôúr̂ćê D́îśĉóv̂ér̂ý" + }, "core/config/agentic-browsing-config.js | webmcpGroupDescription": { "message": "Âúd̂ít̂ś v̂ál̂íd̂át̂ín̂ǵ Ŵéb̂ḾĈṔ îńt̂éĝŕât́îón̂." }, @@ -1508,6 +1541,12 @@ "core/config/default-config.js | agenticBrowsingCategoryTitle": { "message": "Âǵêńt̂íĉ B́r̂óŵśîńĝ" }, + "core/config/default-config.js | ardGroupDescription": { + "message": "T̂h́êśê áûd́ît́ŝ v́âĺîd́ât́ê t́ĥát̂ áĝén̂t́îć r̂éŝóûŕĉéŝ ár̂é d̂íŝćôv́êŕâb́l̂é, r̂éâćĥáb̂ĺê, án̂d́ ĉón̂f́ôŕm̂ t́ô t́ĥé ÂŔD̂ śp̂éĉíf̂íĉát̂íôń." + }, + "core/config/default-config.js | ardGroupTitle": { + "message": "Âǵêńt̂íĉ Ŕêśôúr̂ćê D́îśĉóv̂ér̂ý" + }, "core/config/default-config.js | bestPracticesBrowserCompatGroupTitle": { "message": "B̂ŕôẃŝér̂ Ćôḿp̂át̂íb̂íl̂ít̂ý" }, diff --git a/third-party/ard/LICENSE b/third-party/ard/LICENSE new file mode 100644 index 000000000000..9bd0ef25cb6b --- /dev/null +++ b/third-party/ard/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Agentic Resource Discovery project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third-party/ard/README.md b/third-party/ard/README.md new file mode 100644 index 000000000000..4112578ac3ef --- /dev/null +++ b/third-party/ard/README.md @@ -0,0 +1,13 @@ +# ARD Conformance Validator + +This is a JavaScript port of the Agentic Resource Discovery (ARD) Conformance Testing Tool. + +The original Python script is located in the `ards-project/ard-spec` repository: +[https://github.com/ards-project/ard-spec/blob/main/conformance/bin/conformance-test](https://github.com/ards-project/ard-spec/blob/main/conformance/bin/conformance-test) + +## Modifications for Lighthouse: +While the validation rules and test suite maintain 1:1 parity with the reference suite, the following adaptations were made for Lighthouse integration: +- **Structured Error Return:** `ConformanceTester` stores structured objects (`{ element, message }`) in `errors` and `warnings` instead of formatted ANSI CLI strings. +- **Ajv (Draft 2020-12):** Uses `Ajv2020` with `ajv-formats` for strict Draft 2020-12 JSON Schema validation. +- **Inlined JSON Schema:** Statically inlines `ai-catalog.schema.json` via `fs.readFileSync(path.join(__dirname, ...))` and `build/plugins/inline-fs.js` for DevTools and browser bundling compatibility. +- **Lighthouse Logger:** Replaced raw `console.log` with `lighthouse-logger`. diff --git a/third-party/ard/ard-test.js b/third-party/ard/ard-test.js new file mode 100644 index 000000000000..9f014253ec0c --- /dev/null +++ b/third-party/ard/ard-test.js @@ -0,0 +1,343 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for ARD Schema validation logic. + * Tests mirror the validation rules from + * ards-project/ard-spec/conformance/bin/conformance-test (validate_manifest). + */ + +import {ConformanceTester} from './ard.js'; + +describe('ARD Schema validation (ported from ard-spec)', () => { + /** @type {ConformanceTester} */ + let tester; + + beforeEach(() => { + tester = new ConformanceTester(); + }); + + it('fails with Error when manifest root is not an object', () => { + tester.validate_manifest(JSON.stringify('just a string'), 'test'); + expect( + tester.errors.some(i => i.message.includes('Missing required \'specVersion\' root property')) + ).toEqual(true); + }); + + it('fails with Error when root is missing specVersion', () => { + const manifest = { + entries: [], + }; + tester.validate_manifest(JSON.stringify(manifest), 'test'); + expect( + tester.errors.some(i => i.message.includes('Missing required \'specVersion\'')) + ).toEqual(true); + }); + + it('returns warning when specVersion is unrecognized', () => { + const manifest = { + specVersion: '2.0', + entries: [ + { + identifier: 'urn:air:google:search:web-search', + displayName: 'Web Search', + type: 'application/mcp-server-card+json', + url: 'https://example.com/api', + representativeQueries: ['query 1', 'query 2'], + }, + ], + }; + tester.validate_manifest(JSON.stringify(manifest), 'test'); + expect( + tester.warnings.some( + i => i.message.includes('Unrecognized \'specVersion\'') + ) + ).toEqual(true); + }); + + it('fails with Error when root contains deprecated collections field (ADR-0003)', () => { + const manifest = { + specVersion: '1.0', + collections: [], + entries: [], + }; + tester.validate_manifest(JSON.stringify(manifest), 'test'); + expect( + tester.errors.some( + i => i.message.includes('Deprecated field check: Found \'collections\'') + ) + ).toEqual(true); + }); + + it('fails with Error when entries is missing or not an array', () => { + const manifestMissing = {specVersion: '1.0'}; + tester.validate_manifest(JSON.stringify(manifestMissing), 'test'); + expect( + tester.errors.some(i => i.message.includes('Missing required \'entries\' array')) + ).toEqual(true); + + tester = new ConformanceTester(); + const manifestNotArray = {specVersion: '1.0', entries: 'not-an-array'}; + tester.validate_manifest(JSON.stringify(manifestNotArray), 'test'); + expect( + tester.errors.some(i => i.message.includes('\'entries\' must be a JSON array')) + ).toEqual(true); + }); + + it('fails with Error when entry is not a JSON object', () => { + const manifest = { + specVersion: '1.0', + entries: ['not an object'], + }; + tester.validate_manifest(JSON.stringify(manifest), 'test'); + expect( + tester.errors.some(i => i.message.includes('Missing required \'identifier\'')) + ).toEqual(true); + }); + + it('fails with Error when entry is missing identifier or has invalid RFC 8141 URN', () => { + const manifestMissingId = { + specVersion: '1.0', + entries: [ + {displayName: 'Test', type: 'application/ai-catalog+json', url: 'https://example.com'}, + ], + }; + tester.validate_manifest(JSON.stringify(manifestMissingId), 'test'); + expect( + tester.errors.some(i => i.message.includes('Missing required \'identifier\'')) + ).toEqual(true); + + tester = new ConformanceTester(); + const manifestInvalidUrn = { + specVersion: '1.0', + entries: [ + { + identifier: 'http://not-a-urn', + displayName: 'Test', + type: 'application/ai-catalog+json', + url: 'https://example.com', + }, + ], + }; + tester.validate_manifest(JSON.stringify(manifestInvalidUrn), 'test'); + expect( + tester.errors.some( + i => i.message.includes('does not match RFC 8141 URN pattern') + ) + ).toEqual(true); + }); + + it('fails with Error when entry is missing displayName or type', () => { + const manifestMissingName = { + specVersion: '1.0', + entries: [ + { + identifier: 'urn:air:google:test:agent', + type: 'application/ai-catalog+json', + url: 'https://example.com', + }, + ], + }; + tester.validate_manifest(JSON.stringify(manifestMissingName), 'test'); + expect( + tester.errors.some( + i => i.message.includes('Missing required \'displayName\'') + ) + ).toEqual(true); + + tester = new ConformanceTester(); + const manifestMissingType = { + specVersion: '1.0', + entries: [ + { + identifier: 'urn:air:google:test:agent', + displayName: 'Test Agent', + url: 'https://example.com', + }, + ], + }; + tester.validate_manifest(JSON.stringify(manifestMissingType), 'test'); + expect( + tester.errors.some(i => i.message.includes('Missing required \'type\'')) + ).toEqual(true); + }); + + it('returns warning when entry uses a non-standard media type', () => { + const manifest = { + specVersion: '1.0', + entries: [ + { + identifier: 'urn:air:google:test:agent', + displayName: 'Test Agent', + type: 'application/custom-unknown+json', + url: 'https://example.com', + representativeQueries: ['q1', 'q2'], + }, + ], + }; + tester.validate_manifest(JSON.stringify(manifest), 'test'); + expect( + tester.warnings.some( + i => i.message.includes('not one of standard discovery types') + ) + ).toEqual(true); + }); + + it('fails with Error when value-or-reference constraint is violated', () => { + const manifestBoth = { + specVersion: '1.0', + entries: [{ + identifier: 'urn:air:google:test:agent', + displayName: 'Test', + type: 'application/ai-catalog+json', + url: 'https://example.com', + data: {foo: 'bar'}, + }], + }; + tester.validate_manifest(JSON.stringify(manifestBoth), 'test'); + expect( + tester.errors.some( + i => i.message.includes('both \'url\' and \'data\' are provided') + ) + ).toEqual(true); + + tester = new ConformanceTester(); + const manifestNeither = { + specVersion: '1.0', + entries: [{ + identifier: 'urn:air:google:test:agent', + displayName: 'Test', + type: 'application/ai-catalog+json', + }], + }; + tester.validate_manifest(JSON.stringify(manifestNeither), 'test'); + expect( + tester.errors.some( + i => i.message.includes('neither \'url\' nor \'data\' is provided') + ) + ).toEqual(true); + }); + + it('validates representativeQueries type and size constraints', () => { + const manifestNonArray = { + specVersion: '1.0', + entries: [{ + identifier: 'urn:air:google:test:agent', + displayName: 'Test', + type: 'application/ai-catalog+json', + url: 'https://example.com', + representativeQueries: 'not an array', + }], + }; + tester.validate_manifest(JSON.stringify(manifestNonArray), 'test'); + expect( + tester.errors.some(i => i.message.includes('must be an array of strings')) + ).toEqual(true); + + tester = new ConformanceTester(); + const manifestNonString = { + specVersion: '1.0', + entries: [{ + identifier: 'urn:air:google:test:agent', + displayName: 'Test', + type: 'application/ai-catalog+json', + url: 'https://example.com', + representativeQueries: [123, 456], + }], + }; + tester.validate_manifest(JSON.stringify(manifestNonString), 'test'); + expect( + tester.errors.some(i => i.message.includes('is not a string')) + ).toEqual(true); + + tester = new ConformanceTester(); + const manifestFewQueries = { + specVersion: '1.0', + entries: [{ + identifier: 'urn:air:google:test:agent', + displayName: 'Test', + type: 'application/ai-catalog+json', + url: 'https://example.com', + representativeQueries: ['only one query'], + }], + }; + tester.validate_manifest(JSON.stringify(manifestFewQueries), 'test'); + expect( + tester.warnings.some(i => i.message.includes('2 to 5 queries are recommended')) + ).toEqual(true); + }); + + it('validates trustManifest constraints', () => { + const manifestInvalidTrust = { + specVersion: '1.0', + entries: [{ + identifier: 'urn:air:google:test:agent', + displayName: 'Test', + type: 'application/ai-catalog+json', + url: 'https://example.com', + trustManifest: 'not-an-object', + }], + }; + tester.validate_manifest(JSON.stringify(manifestInvalidTrust), 'test'); + expect( + tester.errors.some( + i => i.message.includes('\'trustManifest\' must be a JSON object') + ) + ).toEqual(true); + + tester = new ConformanceTester(); + const manifestMissingIdentity = { + specVersion: '1.0', + entries: [{ + identifier: 'urn:air:google:test:agent', + displayName: 'Test', + type: 'application/ai-catalog+json', + url: 'https://example.com', + trustManifest: {}, + }], + }; + tester.validate_manifest(JSON.stringify(manifestMissingIdentity), 'test'); + expect( + tester.errors.some( + i => i.message.includes('missing required \'identity\' field') + ) + ).toEqual(true); + }); + + it('passes completely when schema is fully conformant with URL or inline data', () => { + const validManifestUrl = { + specVersion: '1.0', + entries: [ + { + identifier: 'urn:air:google:search:web-search', + displayName: 'Web Search API', + type: 'application/mcp-server-card+json', + url: 'https://example.com/mcp.json', + representativeQueries: ['search web', 'find articles'], + trustManifest: {identity: 'google.com'}, + }, + ], + }; + tester.validate_manifest(JSON.stringify(validManifestUrl), 'test'); + expect(tester.errors.length).toEqual(0); + + tester = new ConformanceTester(); + const validManifestData = { + specVersion: '1.0', + entries: [ + { + identifier: 'urn:air:google:tools:math-helper', + displayName: 'Math Helper', + type: 'application/agent-card+json', + data: {functions: ['add', 'subtract']}, + representativeQueries: ['calculate sum', 'compute subtraction'], + }, + ], + }; + tester.validate_manifest(JSON.stringify(validManifestData), 'test'); + expect(tester.errors.length).toEqual(0); + }); +}); diff --git a/third-party/ard/ard.js b/third-party/ard/ard.js new file mode 100644 index 000000000000..457f4c3364cf --- /dev/null +++ b/third-party/ard/ard.js @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import log from 'lighthouse-logger'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +// Strict URN Regex matching urn:air::: +const URN_REGEX = /^urn:air:([a-zA-Z0-9.-]+)(?::([a-zA-Z0-9._:-]+))?:([a-zA-Z0-9._-]+)$/; + +/** + * @typedef {{ + * element: string, + * message: string, + * }} ValidationError + */ + +class ConformanceTester { + constructor() { + /** @type {ValidationError[]} */ + this.errors = []; + /** @type {ValidationError[]} */ + this.warnings = []; + } + + /** + * @param {string} message + * @param {string} [element='Root'] + */ + add_error(message, element = 'Root') { + this.errors.push({element, message}); + log.verbose('ARD', `Validation Error [${element}]: ${message}`); + } + + /** + * @param {string} message + * @param {string} [element='Root'] + */ + add_warning(message, element = 'Root') { + this.warnings.push({element, message}); + log.verbose('ARD', `Validation Warning [${element}]: ${message}`); + } + + /** @param {any} manifest_data */ + run_json_schema_validation(manifest_data) { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + try { + const schema_data = JSON.parse( + fs.readFileSync(path.join(__dirname, 'spec/schemas/ai-catalog.schema.json'), 'utf8') + ); + const validator = new Ajv2020({allErrors: true, allowUnionTypes: true}); + addFormats(validator, ['uri', 'date-time']); + + const validate = validator.compile(schema_data); + const valid = validate(manifest_data); + + if (valid) { + log.verbose('ARD', 'Strict JSON Schema validation passed.'); + return true; + } else { + const e = validate.errors?.[0]; + const propertyPath = e?.instancePath ? e.instancePath.replace(/^\//, '').replace(/\//g, '.') : 'root'; + let msg = e?.message || 'unknown schema error'; + if (e?.keyword === 'required') { + msg = `'${e.params.missingProperty}' is a required property`; + } + this.add_error(`JSON Schema Validation Failed: ${msg} at path '${propertyPath}'`, 'Root'); + return false; + } + } catch (e) { + this.add_warning(`Failed to run JSON Schema validator: ${e}`, 'Root'); + return true; + } + } + + /** + * @param {string} raw_content + * @param {string} source_label + */ + validate_manifest(raw_content, source_label) { + log.verbose('ARD', `Validating Manifest: ${source_label}`); + + // 1. Basic JSON Parsing + let data; + try { + data = JSON.parse(raw_content); + log.verbose('ARD', 'Manifest parsed successfully as valid JSON.'); + } catch (e) { + this.add_error(`Malformed JSON in manifest: ${e}`, 'Root'); + return false; + } + + // 2. Strict JSON Schema Validation + this.run_json_schema_validation(data); + + // 3. Custom Semantic and Protocol-Specific Validation + log.verbose('ARD', 'Running custom semantic checks...'); + + const spec_ver = data["specVersion"]; + if (!spec_ver) { + this.add_error("Missing required 'specVersion' root property.", 'Root'); + } else if (spec_ver !== "1.0") { + this.add_warning(`Unrecognized 'specVersion': ${spec_ver}. Expected '1.0'.`, 'Root'); + } + + const entries = data["entries"]; + if (entries === undefined || entries === null) { + this.add_error("Missing required 'entries' array.", 'Root'); + return false; + } else if (!Array.isArray(entries)) { + this.add_error("'entries' must be a JSON array.", 'Root'); + return false; + } + + log.verbose('ARD', `Found ${entries.length} entries to validate.`); + entries.forEach((entry, idx) => { + const label = entry["displayName"] || entry["identifier"] || `Entry #${idx}`; + + // Required properties + const ident = entry["identifier"]; + if (!ident) { + this.add_error("Missing required 'identifier'.", label); + } else { + // URN pattern checks + const match = URN_REGEX.exec(ident); + if (!match) { + this.add_error(`Identifier '${ident}' does not match RFC 8141 URN pattern 'urn:air:::'.`, label); + } else { + const publisher = match[1]; + const name = match[3]; + log.verbose('ARD', `[${label}] Valid URN format. Publisher: '${publisher}', Name: '${name}'.`); + } + } + + const disp_name = entry["displayName"]; + if (!disp_name) { + this.add_error("Missing required 'displayName'.", label); + } + + const media_type = entry["type"]; + if (!media_type) { + this.add_error("Missing required 'type' (mediaType).", label); + } else { + const valid_types = [ + "application/ai-catalog+json", + "application/agent-card+json", + "application/a2a-agent-card+json", + "application/mcp-server-card+json", + "application/agent-skills+zip", + "application/agent-skills+gzip", + "text/markdown; profile=\"urn:air:agent-skills\"", + "application/ai-registry", + "application/ai-registry+json" + ]; + if (!valid_types.includes(media_type)) { + this.add_warning(`Media type '${media_type}' is not one of standard discovery types: ${valid_types}.`, label); + } + } + + // Strict Value-or-Reference checks + const has_url = entry["url"] !== undefined; + const has_data = entry["data"] !== undefined; + if (has_url && has_data) { + this.add_error("Constraint violation: both 'url' and 'data' are provided. MUST provide exactly one.", label); + } else if (!has_url && !has_data) { + this.add_error("Constraint violation: neither 'url' nor 'data' is provided. MUST provide exactly one.", label); + } + + // Custom constraints for representativeQueries + const queries = entry["representativeQueries"]; + if (queries !== undefined && queries !== null) { + if (!Array.isArray(queries)) { + this.add_error("'representativeQueries' must be an array of strings.", label); + } else { + if (queries.length < 2 || queries.length > 5) { + this.add_warning(`'representativeQueries' array has size ${queries.length}. 2 to 5 queries are recommended for vector index embedding.`, label); + } + for (const q of queries) { + if (typeof q !== 'string') { + this.add_error(`Query '${q}' is not a string.`, label); + } + } + } + } + + // Progressive trust checks + const trust = entry["trustManifest"]; + if (trust !== undefined && trust !== null) { + if (typeof trust !== 'object' || Array.isArray(trust)) { + this.add_error("'trustManifest' must be a JSON object.", label); + } else { + const trust_id = trust["identity"]; + if (!trust_id) { + this.add_error("'trustManifest' is missing required 'identity' field.", label); + } + } + } + }); + + // Top-level deprecated property check + if ("collections" in data) { + this.add_error("Deprecated field check: Found 'collections' array at root. Top-level collections were REMOVED in ADR-0003. Catalog hierarchies MUST be modeled inside 'entries' using 'type: application/ai-catalog+json'.", 'Root'); + } + + return this.errors.length === 0; + } +} + +export { ConformanceTester }; diff --git a/third-party/ard/spec/schemas/ai-catalog.schema.json b/third-party/ard/spec/schemas/ai-catalog.schema.json new file mode 100644 index 000000000000..37c4cb743b29 --- /dev/null +++ b/third-party/ard/spec/schemas/ai-catalog.schema.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ards-project/ard-spec/main/spec/schemas/ai-catalog.schema.json", + "title": "AICatalogManifest", + "description": "JSON Schema for the ai-catalog.json capability manifest, defining how AI artifacts are advertised.", + "type": "object", + "required": ["specVersion", "entries"], + "properties": { + "specVersion": { + "type": "string", + "description": "Version of the ai-catalog specification used in this manifest.", + "enum": ["1.0"] + }, + "host": { + "type": "object", + "description": "Information about the catalog publisher or hosting entity.", + "required": ["displayName"], + "properties": { + "displayName": { + "type": "string", + "description": "Human-readable name of the host." + }, + "identifier": { + "type": "string", + "description": "Cryptographic or verifiable identifier of the host (e.g. did:web:domain.com)." + }, + "documentationUrl": { + "type": "string", + "format": "uri", + "description": "URL to publisher documentation." + }, + "logoUrl": { + "type": "string", + "format": "uri", + "description": "URL to the publisher's logo image." + }, + "trustManifest": { + "$ref": "#/$defs/trustManifest" + } + }, + "additionalProperties": false + }, + "entries": { + "type": "array", + "description": "List of AI agent or capability catalog entries.", + "items": { + "$ref": "#/$defs/catalogEntry" + } + } + }, + "additionalProperties": false, + "$defs": { + "catalogEntry": { + "type": "object", + "description": "A single AI capability entry within the catalog.", + "required": ["identifier", "displayName", "type"], + "oneOf": [ + { + "required": ["url"], + "not": { "required": ["data"] } + }, + { + "required": ["data"], + "not": { "required": ["url"] } + } + ], + "properties": { + "identifier": { + "type": "string", + "pattern": "^urn:air:[a-zA-Z0-9.-]+(:[a-zA-Z0-9._-]+)+$", + "description": "RFC 8141-compliant unique logical URN formatted as: urn:air:::" + }, + "displayName": { + "type": "string", + "description": "Human-friendly display name." + }, + "type": { + "type": "string", + "description": "IANA Media Type indicating the specific protocol wrapper or payload structure (e.g. application/mcp-server-card+json)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "HTTP URL reference to retrieve the full artifact document." + }, + "data": { + "type": "object", + "description": "Embedded JSON object containing the complete artifact specification inline." + }, + "description": { + "type": "string", + "description": "Brief natural-language description of the capability." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags or keywords for basic categorizing and filtering." + }, + "capabilities": { + "type": "array", + "items": { "type": "string" }, + "description": "Explicit high-performance indexing tags representing skills, tools, or functions (e.g. ['WeatherTool', 'ForecastTool'])." + }, + "representativeQueries": { + "type": "array", + "minItems": 2, + "maxItems": 5, + "items": { "type": "string" }, + "description": "Representative natural-language search queries used for indexing and vector embedding generation." + }, + "version": { + "type": "string", + "description": "Semantic version of the capability." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the last modification." + }, + "metadata": { + "type": "object", + "description": "Arbitrary key-value pairs for custom extensions.", + "additionalProperties": { + "type": ["string", "number", "boolean", "null"] + } + }, + "trustManifest": { + "$ref": "#/$defs/trustManifest" + } + } + }, + "trustManifest": { + "type": "object", + "description": "Zero-trust security, identity, and compliance envelope metadata.", + "required": ["identity"], + "properties": { + "identity": { + "type": "string", + "description": "Globally unique cryptographic workload identifier principal (e.g. SPIFFE ID or DID URI)." + }, + "identityType": { + "type": "string", + "enum": ["spiffe", "did", "https", "other"], + "description": "Hint representing the cryptographic format of the identity." + }, + "trustSchema": { + "$ref": "#/$defs/trustSchema" + }, + "attestations": { + "type": "array", + "description": "List of verifiable claims (SOC 2 audits, compliance statements, HIPAA checks).", + "items": { + "type": "object", + "required": ["type", "uri", "mediaType"], + "properties": { + "type": { + "type": "string", + "description": "Attestation standard type (e.g., SOC2-Type2, HIPAA)." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "Location of the attestation certificate or document." + }, + "mediaType": { + "type": "string", + "description": "Format of the attestation document (e.g. application/pdf, text/html)." + }, + "digest": { + "type": "string", + "description": "Cryptographic sha256 digest for document integrity verification." + } + }, + "additionalProperties": false + } + }, + "provenance": { + "type": "array", + "description": "Cryptographic lineage trail of the artifact.", + "items": { + "type": "object", + "required": ["relation", "sourceId"], + "properties": { + "relation": { + "type": "string", + "enum": ["derivedFrom", "publishedFrom", "copiedFrom"], + "description": "Lineage relation." + }, + "sourceId": { + "type": "string", + "description": "Identifier of the source artifact." + }, + "sourceDigest": { + "type": "string", + "description": "Optional sha256 digest for safety." + } + }, + "additionalProperties": false + } + }, + "signature": { + "type": "string", + "description": "Detached JWS signature generated over the fields of the trustManifest." + } + }, + "additionalProperties": false + }, + "trustSchema": { + "type": "object", + "description": "Describes the trust framework applied to the artifact.", + "required": ["identifier", "version"], + "properties": { + "identifier": { + "type": "string", + "description": "Unique identifier of the trust schema/framework (e.g. URN)." + }, + "version": { + "type": "string", + "description": "Version of the trust schema/framework." + }, + "governanceUri": { + "type": "string", + "format": "uri", + "description": "URI pointing to the governance policy document." + }, + "verificationMethods": { + "type": "array", + "items": { "type": "string" }, + "description": "Verification methods supported by the schema (e.g., ['did', 'x509', 'dns-01'])." + } + }, + "additionalProperties": false + } + } +} diff --git a/tsconfig.json b/tsconfig.json index b280450cf9b1..e0e7beef789f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "eslint-local-rules.cjs", "third-party/axe/valid-langs.js", "third-party/esbuild-plugins-polyfills/esbuild-polyfills.js", + "third-party/ard/**/*.js", "core/test/results/sample_v2.json", "core/test/fixtures/unresolved-perflog.json", diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts index 63e48ea2845a..d83960542f0e 100644 --- a/types/artifacts.d.ts +++ b/types/artifacts.d.ts @@ -138,6 +138,20 @@ export interface GathererArtifacts extends PublicGathererArtifacts { RobotsTxt: { status: number | null, content: string | null, errorMessage?: string }; /** Information on fetching and the content of the /llm.txt file. */ LlmsTxt: { status: number | null, content: string | null, errorMessage?: string }; + /** Information on fetching and the content of the /ai-catalog.json manifest file. */ + AgentResourceDiscovery: { + status: number | null; + content: string | null; + headers: Record | null; + catalogUrl: string | null; + discoverySignals: { + robotsTxtAgentmap: string | null; + htmlLink: string | null; + httpHeaderLink: string | null; + wellKnown: string; + }; + errorMessage?: string; + }; /** Source maps of scripts executed in the page. */ SourceMaps: Array; /** Information on detected tech stacks (e.g. JS libraries) used by the page. */ diff --git a/yarn.lock b/yarn.lock index 6aff1db832c4..ca0fc9e6c942 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2074,6 +2074,13 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -2084,6 +2091,16 @@ ajv@^6.12.3, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.0, ajv@^8.20.0: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + angular@^1.7.4: version "1.8.0" resolved "https://registry.yarnpkg.com/angular/-/angular-1.8.0.tgz#b1ec179887869215cab6dfd0df2e42caa65b1b51" @@ -3783,6 +3800,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-uri@^3.0.1: + version "3.1.6" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.6.tgz#bfdd308ef763f835fed059f3c6c925f708e33e3a" + integrity sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q== + faye-websocket@0.11.4: version "0.11.4" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" @@ -5079,6 +5101,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" @@ -6416,6 +6443,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-in-the-middle@^8.0.0: version "8.0.1" resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz#dbde2587f669398626d56b20c868ab87bf01cce4"