Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions core/audits/agentic/ard-schema.js
Original file line number Diff line number Diff line change
@@ -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};
12 changes: 12 additions & 0 deletions core/config/agentic-browsing-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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': {
Expand All @@ -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': {
Expand All @@ -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'},
],
},
},
Expand Down
12 changes: 12 additions & 0 deletions core/config/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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'},
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: ''},
},
Expand Down Expand Up @@ -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'},
],
},
},
Expand Down
34 changes: 30 additions & 4 deletions core/gather/fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>|null}} FetchResponse */

class Fetcher {
/**
Expand All @@ -37,6 +37,26 @@ class Fetcher {
return this._fetchResourceOverProtocol(url, options);
}

/**
* @param {Headers|Record<string, string|number|boolean>|null|undefined} rawHeaders
* @return {Record<string, string>|null}
*/
_normalizeHeaders(rawHeaders) {
if (!rawHeaders) return null;

/** @type {Record<string, string>} */
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<FetchResponse>}
Expand All @@ -49,9 +69,12 @@ class Fetcher {
content = await response.text();
} catch {}

const headers = this._normalizeHeaders(response.headers);

return {
content,
status: response.status,
headers,
};
}

Expand Down Expand Up @@ -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<string, string>|null}>}
*/
async _loadNetworkResource(url) {
const frameTreeResponse = await this.session.sendCommand('Page.getFrameTree');
Expand All @@ -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,
};
}

Expand All @@ -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};
}

/**
Expand Down
Loading
Loading