Skip to content

Commit dbb3eec

Browse files
committed
core(ard): add Agent Resource Discovery gatherer and schema audit
1 parent 875033e commit dbb3eec

21 files changed

Lines changed: 2088 additions & 38 deletions

File tree

core/audits/agentic/ard-schema.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* @fileoverview Audit that validates ai-catalog.json manifests against the Agentic Resource Discovery (ARD) specification.
9+
*
10+
* This implementation is a direct JavaScript port of `validate_manifest` from the official ARD Conformance Test suite:
11+
* @see https://github.com/ards-project/ard-spec/blob/main/conformance/bin/conformance-test
12+
* @see https://agenticresourcediscovery.org/spec/
13+
* @version ARD Spec 1.0 / ADR-0003
14+
*/
15+
16+
import {Audit} from '../audit.js';
17+
import * as i18n from '../../lib/i18n/i18n.js';
18+
import {ConformanceTester} from '../../../third-party/ard/ard.js';
19+
20+
const UIStrings = {
21+
/** Title of a Lighthouse audit that evaluates whether ai-catalog.json conforms to the ARD specification. Shown when valid. */
22+
title: 'ai-catalog.json schema is valid',
23+
/** Title of a Lighthouse audit that evaluates whether ai-catalog.json conforms to the ARD specification. Shown when invalid. */
24+
failureTitle: 'ai-catalog.json schema is invalid or has warnings',
25+
/** Description of a Lighthouse audit that tells the user why ai-catalog.json must match the ARD specification. */
26+
description: 'Valid ai-catalog.json manifests are required for autonomous ' +
27+
'AI agents and registries to discover and verify your resources. ' +
28+
'[Learn more about the ARD specification](https://agenticresourcediscovery.org/spec/).',
29+
};
30+
31+
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
32+
33+
class ArdSchema extends Audit {
34+
/**
35+
* @return {LH.Audit.Meta}
36+
*/
37+
static get meta() {
38+
return {
39+
id: 'ard-schema',
40+
title: str_(UIStrings.title),
41+
failureTitle: str_(UIStrings.failureTitle),
42+
description: str_(UIStrings.description),
43+
requiredArtifacts: ['AgentResourceDiscovery'],
44+
supportedModes: ['navigation', 'snapshot'],
45+
};
46+
}
47+
48+
/**
49+
* @param {LH.Artifacts} artifacts
50+
* @return {LH.Audit.Product}
51+
*/
52+
static audit(artifacts) {
53+
const ard = artifacts.AgentResourceDiscovery;
54+
const signals = ard.discoverySignals;
55+
56+
const hasExplicitSignal = Boolean(
57+
signals.robotsTxtAgentmap ||
58+
signals.htmlLink ||
59+
signals.httpHeaderLink
60+
);
61+
const hasCatalog = hasExplicitSignal || ard.status === 200;
62+
63+
if (!hasCatalog) {
64+
return {
65+
score: 1,
66+
notApplicable: true,
67+
};
68+
}
69+
70+
if (ard.status !== 200 || !ard.content) {
71+
return {
72+
score: 0,
73+
explanation: 'Catalog file could not be loaded for schema validation.',
74+
};
75+
}
76+
77+
/** @type {Array<{element: string, issue: string, severity: 'Error' | 'Warning'}>} */
78+
const issues = [];
79+
80+
/** @type {LH.Audit.Details.Table['headings']} */
81+
const headings = [
82+
{key: 'element', valueType: 'text', label: 'Element'},
83+
{key: 'issue', valueType: 'text', label: 'Issue'},
84+
{key: 'severity', valueType: 'text', label: 'Severity'},
85+
];
86+
87+
const tester = new ConformanceTester();
88+
tester.validate_manifest(ard.content, 'ai-catalog.json');
89+
90+
for (const msg of tester.errors) {
91+
let element = 'Root';
92+
let issue = msg;
93+
const match = msg.match(/^\[(.*?)\] (.*)/);
94+
if (match) {
95+
element = match[1];
96+
issue = match[2];
97+
}
98+
issues.push({element, issue, severity: 'Error'});
99+
}
100+
101+
for (const msg of tester.warnings) {
102+
let element = 'Root';
103+
let issue = msg;
104+
const match = msg.match(/^\[(.*?)\] (.*)/);
105+
if (match) {
106+
element = match[1];
107+
issue = match[2];
108+
}
109+
issues.push({element, issue, severity: 'Warning'});
110+
}
111+
112+
// Lighthouse Best Practice: Recommend representativeQueries for better discoverability
113+
try {
114+
const manifest = JSON.parse(ard.content);
115+
if (manifest && Array.isArray(manifest.entries)) {
116+
for (let i = 0; i < manifest.entries.length; i++) {
117+
const entry = manifest.entries[i];
118+
if (!entry.representativeQueries || entry.representativeQueries.length === 0) {
119+
const label = entry.displayName || entry.identifier || `Entry #${i}`;
120+
issues.push({
121+
element: label,
122+
issue: 'Missing \'representativeQueries\'. Providing examples significantly improves discoverability.',
123+
severity: 'Warning',
124+
});
125+
}
126+
}
127+
}
128+
} catch (e) {
129+
// Ignore parse errors as ConformanceTester catches them
130+
}
131+
132+
const hasErrors = issues.some(i => i.severity === 'Error');
133+
const hasWarnings = issues.some(i => i.severity === 'Warning');
134+
135+
let score = 1;
136+
if (hasErrors) {
137+
score = 0;
138+
} else if (hasWarnings) {
139+
score = 0.5;
140+
}
141+
142+
return {
143+
score,
144+
details: issues.length ? Audit.makeTableDetails(headings, issues) : undefined,
145+
};
146+
}
147+
}
148+
149+
export default ArdSchema;
150+
export {UIStrings};

core/config/agentic-browsing-config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ const UIStrings = {
2323
/** Description of the Agent Accessibility group of audits. */
2424
agentAccessibilityGroupDescription: 'These audits highlight best practices for improving the ' +
2525
'accessibility of the website for AI agents.',
26+
/** Title of the Agentic Resource Discovery group of audits. */
27+
ardGroupTitle: 'Agentic Resource Discovery',
28+
/** Description of the Agentic Resource Discovery group of audits. */
29+
ardGroupDescription: 'These audits validate that agentic resources are ' +
30+
'discoverable, reachable, and conform to the ARD specification.',
2631
};
2732

2833
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
@@ -36,11 +41,13 @@ const config = {
3641
'webmcp-form-coverage',
3742
'webmcp-schema-validity',
3843
'agentic/llms-txt',
44+
'agentic/ard-schema',
3945
],
4046
artifacts: [
4147
{id: 'WebMCP', gatherer: 'webmcp'},
4248
{id: 'WebMcpSchemaIssues', gatherer: 'webmcp-schema'},
4349
{id: 'LlmsTxt', gatherer: 'agentic/llms-txt'},
50+
{id: 'AgentResourceDiscovery', gatherer: 'agentic/ard'},
4451
],
4552
groups: {
4653
'webmcp': {
@@ -51,6 +58,10 @@ const config = {
5158
title: str_(UIStrings.agentAccessibilityGroupTitle),
5259
description: str_(UIStrings.agentAccessibilityGroupDescription),
5360
},
61+
'agent-resource-discovery': {
62+
title: str_(UIStrings.ardGroupTitle),
63+
description: str_(UIStrings.ardGroupDescription),
64+
},
5465
},
5566
categories: {
5667
'agentic-browsing': {
@@ -65,6 +76,7 @@ const config = {
6576
{id: 'webmcp-schema-validity', weight: 1, group: 'webmcp'},
6677
{id: 'cumulative-layout-shift', weight: 1, acronym: 'CLS'},
6778
{id: 'llms-txt', weight: 1, group: 'agent-accessibility'},
79+
{id: 'ard-schema', weight: 1, group: 'agent-resource-discovery'},
6880
],
6981
},
7082
},

core/config/default-config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ const UIStrings = {
115115
/** Description of the Agent Accessibility group of audits. */
116116
agentAccessibilityGroupDescription: 'These audits highlight best practices for improving the ' +
117117
'accessibility of the website for AI agents.',
118+
/** Title of the Agentic Resource Discovery group of audits. */
119+
ardGroupTitle: 'Agentic Resource Discovery',
120+
/** Description of the Agentic Resource Discovery group of audits. */
121+
ardGroupDescription: 'These audits validate that agentic resources are ' +
122+
'discoverable, reachable, and conform to the ARD specification.',
118123
};
119124

120125
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
@@ -145,6 +150,7 @@ const defaultConfig = {
145150
{id: 'MetaElements', gatherer: 'meta-elements'},
146151
{id: 'NetworkUserAgent', gatherer: 'network-user-agent'},
147152
{id: 'RobotsTxt', gatherer: 'seo/robots-txt'},
153+
{id: 'AgentResourceDiscovery', gatherer: 'agentic/ard'},
148154
{id: 'Scripts', gatherer: 'scripts'},
149155
{id: 'SourceMaps', gatherer: 'source-maps'},
150156
{id: 'Stacks', gatherer: 'stacks'},
@@ -302,6 +308,7 @@ const defaultConfig = {
302308
'webmcp-form-coverage',
303309
'webmcp-schema-validity',
304310
'agentic/llms-txt',
311+
'agentic/ard-schema',
305312
'bf-cache',
306313
'insights/cache-insight',
307314
'insights/cls-culprits-insight',
@@ -397,6 +404,10 @@ const defaultConfig = {
397404
title: str_(UIStrings.agentAccessibilityGroupTitle),
398405
description: str_(UIStrings.agentAccessibilityGroupDescription),
399406
},
407+
'agent-resource-discovery': {
408+
title: str_(UIStrings.ardGroupTitle),
409+
description: str_(UIStrings.ardGroupDescription),
410+
},
400411
// Group for audits that should not be displayed.
401412
'hidden': {title: ''},
402413
},
@@ -644,6 +655,7 @@ const defaultConfig = {
644655
{id: 'webmcp-schema-validity', weight: 1, group: 'webmcp'},
645656
{id: 'cumulative-layout-shift', weight: 1, acronym: 'CLS'},
646657
{id: 'llms-txt', weight: 1, group: 'agent-accessibility'},
658+
{id: 'ard-schema', weight: 1, group: 'agent-resource-discovery'},
647659
],
648660
},
649661
},
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import log from 'lighthouse-logger';
8+
9+
import BaseGatherer from '../../base-gatherer.js';
10+
import RobotsTxt from '../seo/robots-txt.js';
11+
import {pageFunctions} from '../../../lib/page-functions.js';
12+
13+
/* c8 ignore start */
14+
function getAiCatalogLinkInDOM() {
15+
const link = document.querySelector('link[rel="ai-catalog"]');
16+
return link instanceof HTMLLinkElement ? link.href : null;
17+
}
18+
/* c8 ignore stop */
19+
20+
class AgentResourceDiscovery extends BaseGatherer {
21+
/** @type {LH.Gatherer.GathererMeta<'RobotsTxt'>} */
22+
meta = {
23+
supportedModes: ['snapshot', 'navigation'],
24+
dependencies: {RobotsTxt: RobotsTxt.symbol},
25+
};
26+
27+
/**
28+
* @param {LH.Artifacts['RobotsTxt']|null|undefined} robotsTxt
29+
* @param {string} finalDisplayedUrl
30+
* @return {string|null}
31+
*/
32+
static getRobotsTxtAgentmap(robotsTxt, finalDisplayedUrl) {
33+
if (!robotsTxt?.content) return null;
34+
const match = robotsTxt.content.match(/^\s*Agentmap:\s*(\S+)/im);
35+
if (!match) return null;
36+
try {
37+
return new URL(match[1], finalDisplayedUrl).href;
38+
} catch {
39+
return null;
40+
}
41+
}
42+
43+
/**
44+
* @param {LH.Gatherer.Context} context
45+
* @param {string} finalDisplayedUrl
46+
* @return {Promise<string|null>}
47+
*/
48+
static async getHtmlLinkFromDom(context, finalDisplayedUrl) {
49+
try {
50+
const href = await context.driver.executionContext.evaluate(getAiCatalogLinkInDOM, {
51+
args: [],
52+
useIsolation: true,
53+
deps: [pageFunctions.getNodeDetails],
54+
});
55+
if (!href) return null;
56+
return new URL(href, finalDisplayedUrl).href;
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
/**
63+
* @param {LH.Gatherer.Context} context
64+
* @param {string} finalDisplayedUrl
65+
* @return {Promise<string|null>}
66+
*/
67+
static async getHttpHeaderLink(context, finalDisplayedUrl) {
68+
// Only check in navigation mode
69+
if (context.gatherMode !== 'navigation') return null;
70+
71+
try {
72+
const mainResourceResponse = await context.driver.fetcher.fetchResource(finalDisplayedUrl);
73+
const linkHeader = mainResourceResponse.headers?.['link'] || '';
74+
const match = linkHeader.match(/<([^>]+)>;\s*rel=["']?ai-catalog["']?/i);
75+
if (!match) return null;
76+
return new URL(match[1], finalDisplayedUrl).href;
77+
} catch {
78+
return null;
79+
}
80+
}
81+
82+
/**
83+
* @param {LH.Gatherer.Context<'RobotsTxt'>} context
84+
* @return {Promise<LH.Artifacts['AgentResourceDiscovery']>}
85+
*/
86+
async getArtifact(context) {
87+
const {finalDisplayedUrl} = context.baseArtifacts.URL;
88+
const robotsTxt = context.dependencies.RobotsTxt;
89+
const robotsTxtAgentmap = AgentResourceDiscovery.getRobotsTxtAgentmap(
90+
robotsTxt, finalDisplayedUrl);
91+
const [htmlLink, httpHeaderLink] = await Promise.all([
92+
AgentResourceDiscovery.getHtmlLinkFromDom(context, finalDisplayedUrl),
93+
AgentResourceDiscovery.getHttpHeaderLink(context, finalDisplayedUrl),
94+
]);
95+
const wellKnown = new URL('/.well-known/ai-catalog.json', finalDisplayedUrl).href;
96+
const catalogUrl = robotsTxtAgentmap || htmlLink || httpHeaderLink || wellKnown;
97+
98+
/** @type {string|undefined} */
99+
let errorMessage;
100+
const fetchResult = await context.driver.fetcher.fetchResource(catalogUrl)
101+
.catch(err => {
102+
log.error('AgentResourceDiscovery', err);
103+
errorMessage = err.message;
104+
return {status: null, content: null, headers: null};
105+
});
106+
107+
return {
108+
status: fetchResult.status,
109+
content: fetchResult.content,
110+
headers: fetchResult.headers || null,
111+
catalogUrl,
112+
discoverySignals: {
113+
robotsTxtAgentmap,
114+
htmlLink,
115+
httpHeaderLink,
116+
wellKnown,
117+
},
118+
errorMessage,
119+
};
120+
}
121+
}
122+
123+
export default AgentResourceDiscovery;

core/gather/gatherers/seo/robots-txt.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import log from 'lighthouse-logger';
99
import BaseGatherer from '../../base-gatherer.js';
1010

1111
class RobotsTxt extends BaseGatherer {
12+
static symbol = Symbol('RobotsTxt');
13+
1214
/** @type {LH.Gatherer.GathererMeta} */
1315
meta = {
16+
symbol: RobotsTxt.symbol,
1417
supportedModes: ['snapshot', 'navigation'],
1518
};
1619

0 commit comments

Comments
 (0)