|
| 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}; |
0 commit comments