lighthouse 13.5.0-dev.20261008 → 13.5.0-dev.20261012

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.agents/skills/update-ard-port/SKILL.md +170 -0
  2. package/cli/cli-flags.js +0 -1
  3. package/cli/sentry-prompt.js +3 -4
  4. package/cli/test/smokehouse/config/exclusions.js +1 -1
  5. package/core/audits/agentic/ard-schema.d.ts +13 -0
  6. package/core/audits/agentic/ard-schema.js +81 -33
  7. package/core/audits/agentic/llms-txt.js +5 -2
  8. package/core/audits/long-tasks.js +2 -1
  9. package/core/audits/screenshot-thumbnails.d.ts +0 -13
  10. package/core/audits/screenshot-thumbnails.js +0 -45
  11. package/core/audits/seo/robots-txt.js +3 -8
  12. package/core/audits/unsized-images.d.ts +0 -7
  13. package/core/audits/unsized-images.js +7 -26
  14. package/core/computed/page-dependency-graph.js +0 -4
  15. package/core/computed/speedline.js +20 -5
  16. package/core/gather/gatherers/agentic/ard.d.ts +59 -4
  17. package/core/gather/gatherers/agentic/ard.js +218 -56
  18. package/core/gather/gatherers/image-elements.d.ts +7 -0
  19. package/core/gather/gatherers/image-elements.js +75 -24
  20. package/dist/report/flow.js +13 -13
  21. package/package.json +5 -8
  22. package/shared/localization/locales/en-US.json +13 -4
  23. package/shared/localization/locales/en-XL.json +13 -4
  24. package/third-party/ard/README.md +18 -9
  25. package/third-party/ard/ard-test.js +183 -266
  26. package/third-party/ard/ard.d.ts +17 -1
  27. package/third-party/ard/ard.js +276 -45
  28. package/third-party/ard/schema-validator.d.ts +14 -5
  29. package/third-party/ard/schema-validator.js +921 -1177
  30. package/third-party/ard/spec/schemas/ard-entry.schema.json +176 -0
  31. package/types/artifacts.d.ts +35 -6
  32. package/build-tracker.config.js +0 -16
  33. package/third-party/ard/spec/schemas/ai-catalog.schema.json +0 -292
@@ -4,12 +4,216 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
+ /**
8
+ * @fileoverview JavaScript port of the manifest validation in the ARD conformance test
9
+ * (`conformance/bin/conformance-test`, ARD v0.91). See README.md for the pinned upstream commit
10
+ * and the Lighthouse-specific adaptations.
11
+ */
12
+
7
13
  import log from 'lighthouse-logger';
8
- import validateAiCatalog from './schema-validator.js';
14
+ import {ArdManifest as validateArdManifest, ArdEntry as validateArdEntry} from './schema-validator.js';
9
15
 
10
16
  // Strict URN Regex matching urn:air:<publisher>:<namespace>:<agent-name>
11
17
  const URN_REGEX = /^urn:air:([a-zA-Z0-9.-]+)(?::([a-zA-Z0-9._:-]+))?:([a-zA-Z0-9._-]+)$/;
12
18
 
19
+ // RFC 6838 restricted-name for type/subtype plus RFC 9110 parameters.
20
+ const RESTRICTED_NAME = '[0-9A-Za-z][0-9A-Za-z!#$&^_.+\\-]{0,126}';
21
+ const TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+";
22
+ const QUOTED_STRING = '"(?:[\\t !#-\\[\\]-~]|\\\\[\\t !-~])*"';
23
+ const MEDIA_TYPE_REGEX = new RegExp(
24
+ `^(?<type>${RESTRICTED_NAME})/(?<subtype>${RESTRICTED_NAME})` +
25
+ `(?<parameters>(?:[ \\t]*;[ \\t]*${TOKEN}[ \\t]*=[ \\t]*(?:${TOKEN}|${QUOTED_STRING}))*)$`
26
+ );
27
+ const MEDIA_TYPE_PARAMETER_REGEX = new RegExp(
28
+ `[ \\t]*;[ \\t]*(?<name>${TOKEN})[ \\t]*=[ \\t]*` +
29
+ `(?<value>${TOKEN}|${QUOTED_STRING})`,
30
+ 'g'
31
+ );
32
+
33
+ /**
34
+ * @typedef {[string, Array<[string, string]>]} ParsedMediaType
35
+ */
36
+
37
+ /**
38
+ * @param {string} media_type
39
+ * @return {ParsedMediaType|null}
40
+ */
41
+ function parse_media_type(media_type) {
42
+ const match = MEDIA_TYPE_REGEX.exec(media_type);
43
+ if (!match?.groups) {
44
+ return null;
45
+ }
46
+
47
+ const base_media_type =
48
+ `${match.groups.type.toLowerCase()}/${match.groups.subtype.toLowerCase()}`;
49
+ /** @type {Array<[string, string]>} */
50
+ const parameters = [...match.groups.parameters.matchAll(MEDIA_TYPE_PARAMETER_REGEX)]
51
+ .map(parameter => /** @type {[string, string]} */ ([
52
+ /** @type {Record<string, string>} */ (parameter.groups).name.toLowerCase(),
53
+ /** @type {Record<string, string>} */ (parameter.groups).value,
54
+ ]))
55
+ // Python sorts (name, value) tuples by code point.
56
+ .sort((a, b) => {
57
+ if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
58
+ if (a[1] !== b[1]) return a[1] < b[1] ? -1 : 1;
59
+ return 0;
60
+ });
61
+ const parameter_names = parameters.map(([name]) => name);
62
+ if (parameter_names.length !== new Set(parameter_names).size) {
63
+ return null;
64
+ }
65
+ return [base_media_type, parameters];
66
+ }
67
+
68
+ /** @param {ParsedMediaType} parsed */
69
+ const mediaTypeKey = parsed => JSON.stringify(parsed);
70
+
71
+ const STANDARD_MEDIA_TYPES = [
72
+ "application/ai-catalog+json",
73
+ "application/agent-card+json",
74
+ "application/a2a-agent-card+json",
75
+ "application/mcp-server-card+json",
76
+ "application/agent-skills+zip",
77
+ "application/agent-skills+gzip",
78
+ 'text/markdown; profile="urn:air:agent-skills"',
79
+ "application/ai-registry",
80
+ "application/ai-registry+json",
81
+ ];
82
+ const STANDARD_MEDIA_TYPES_PARSED = STANDARD_MEDIA_TYPES.map(media_type =>
83
+ /** @type {ParsedMediaType} */ (parse_media_type(media_type)));
84
+ const STANDARD_MEDIA_TYPE_KEYS = new Set(STANDARD_MEDIA_TYPES_PARSED.map(mediaTypeKey));
85
+ const STANDARD_BASE_MEDIA_TYPES = new Set(STANDARD_MEDIA_TYPES_PARSED.map(([base]) => base));
86
+ /** @type {Map<string, Map<string, string>>} */
87
+ const STANDARD_PARAMETERS_BY_BASE = new Map(STANDARD_MEDIA_TYPES_PARSED.map(
88
+ ([base, parameters]) => [base, new Map(parameters)]));
89
+ /** @type {Record<string, string>} */
90
+ const DEPRECATED_MEDIA_TYPES = {
91
+ "application/mcp-server+json": "application/mcp-server-card+json",
92
+ };
93
+
94
+ /**
95
+ * Python's `repr()` of the list of standard media types, to keep messages identical to upstream.
96
+ */
97
+ const STANDARD_MEDIA_TYPES_REPR = `[${STANDARD_MEDIA_TYPES.map(t => `'${t}'`).join(', ')}]`;
98
+
99
+ /**
100
+ * Name of the Python type a JSON value would parse to, for messages matching upstream.
101
+ * @param {unknown} value
102
+ * @return {string}
103
+ */
104
+ function python_type_name(value) {
105
+ if (value === null) return 'NoneType';
106
+ if (Array.isArray(value)) return 'list';
107
+ if (typeof value === 'boolean') return 'bool';
108
+ if (typeof value === 'number') return Number.isInteger(value) ? 'int' : 'float';
109
+ if (typeof value === 'object') return 'dict';
110
+ return typeof value;
111
+ }
112
+
113
+ /**
114
+ * @param {unknown} media_type
115
+ * @return {['warning'|'info', string]|null}
116
+ */
117
+ function classify_media_type(media_type) {
118
+ if (typeof media_type !== 'string') {
119
+ return [
120
+ "warning",
121
+ `Media type must be a string; got ${python_type_name(media_type)}.`,
122
+ ];
123
+ }
124
+
125
+ const parsed_media_type = parse_media_type(media_type);
126
+ if (parsed_media_type === null) {
127
+ return [
128
+ "warning",
129
+ `Media type '${media_type}' is not a valid IANA media type. ` +
130
+ "Expected 'type/subtype' with optional parameters.",
131
+ ];
132
+ }
133
+
134
+ const [base_media_type, parameters] = parsed_media_type;
135
+ if (STANDARD_MEDIA_TYPE_KEYS.has(mediaTypeKey(parsed_media_type))) {
136
+ return null;
137
+ }
138
+
139
+ const replacement = DEPRECATED_MEDIA_TYPES[base_media_type];
140
+ if (replacement !== undefined) {
141
+ return [
142
+ "warning",
143
+ `Media type '${media_type}' was renamed by ADR-0008. ` +
144
+ `Use '${replacement}'.`,
145
+ ];
146
+ }
147
+
148
+ const expected_parameters = STANDARD_PARAMETERS_BY_BASE.get(base_media_type);
149
+ if (STANDARD_BASE_MEDIA_TYPES.has(base_media_type) && expected_parameters) {
150
+ const actual_parameters = new Map(parameters);
151
+ const missing_parameters = [...expected_parameters]
152
+ .filter(([name, value]) => actual_parameters.get(name) !== value)
153
+ .map(([name, value]) => `${name}=${value}`);
154
+ const unrecognized_parameters = parameters
155
+ .filter(([name, value]) => expected_parameters.get(name) !== value)
156
+ .map(([name]) => name);
157
+ const differences = [];
158
+ if (missing_parameters.length) {
159
+ differences.push(
160
+ "missing required parameters: " + missing_parameters.join(", ")
161
+ );
162
+ }
163
+ if (unrecognized_parameters.length) {
164
+ differences.push(
165
+ "unrecognized parameters: " + unrecognized_parameters.join(", ")
166
+ );
167
+ }
168
+ return [
169
+ "warning",
170
+ `Media type '${media_type}' is based on standard discovery type ` +
171
+ `'${base_media_type}' but has ` +
172
+ `${differences.join('; ')}.`,
173
+ ];
174
+ }
175
+
176
+ if (base_media_type.startsWith("application/")) {
177
+ return [
178
+ "info",
179
+ `Media type '${media_type}' is a valid application extension media type. ` +
180
+ "ARD permits extension types without core registration.",
181
+ ];
182
+ }
183
+
184
+ return [
185
+ "warning",
186
+ `Media type '${media_type}' is not one of standard discovery types: ` +
187
+ `${STANDARD_MEDIA_TYPES_REPR}.`,
188
+ ];
189
+ }
190
+
191
+ /**
192
+ * @param {unknown} value
193
+ * @return {value is Record<string, any>}
194
+ */
195
+ function is_object(value) {
196
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
197
+ }
198
+
199
+ // Errors reported inside a oneOf/anyOf branch or a `not` are restated by the combinator itself.
200
+ const COMBINATOR_BRANCH_REGEX = /\/(?:oneOf|anyOf)\/\d+(?:\/|$)|\/not(?:\/|$)/;
201
+
202
+ /**
203
+ * Formats the most relevant Ajv error as `<message> at path '<path>'`.
204
+ * @param {Array<any>|null|undefined} errors
205
+ * @return {string}
206
+ */
207
+ function format_schema_error(errors) {
208
+ const e = errors?.find(err => !COMBINATOR_BRANCH_REGEX.test(err.schemaPath)) || errors?.[0];
209
+ const propertyPath = e?.instancePath ? e.instancePath.replace(/^\//, '').replace(/\//g, '.') : 'root';
210
+ let msg = e?.message || 'unknown schema error';
211
+ if (e?.keyword === 'required') {
212
+ msg = `'${e.params.missingProperty}' is a required property`;
213
+ }
214
+ return `${msg} at path '${propertyPath}'`;
215
+ }
216
+
13
217
  /**
14
218
  * @typedef {{
15
219
  * element: string,
@@ -23,6 +227,8 @@ class ConformanceTester {
23
227
  this.errors = [];
24
228
  /** @type {ValidationError[]} */
25
229
  this.warnings = [];
230
+ /** @type {ValidationError[]} */
231
+ this.infos = [];
26
232
  }
27
233
 
28
234
  /**
@@ -43,29 +249,53 @@ class ConformanceTester {
43
249
  log.verbose('ARD', `Validation Warning [${element}]: ${message}`);
44
250
  }
45
251
 
46
- /** @param {any} manifest_data */
252
+ /**
253
+ * @param {string} message
254
+ * @param {string} [element='Root']
255
+ */
256
+ add_info(message, element = 'Root') {
257
+ this.infos.push({element, message});
258
+ log.verbose('ARD', `Validation Info [${element}]: ${message}`);
259
+ }
260
+
261
+ /**
262
+ * Validate the manifest as an ArdManifest, then every entry as an ArdEntry.
263
+ * @param {any} manifest_data
264
+ */
47
265
  run_json_schema_validation(manifest_data) {
266
+ let ok = true;
48
267
  try {
49
- const valid = validateAiCatalog(manifest_data);
50
-
51
- if (valid) {
52
- log.verbose('ARD', 'Strict JSON Schema validation passed.');
53
- return true;
268
+ if (validateArdManifest(manifest_data)) {
269
+ log.verbose('ARD', 'Manifest validates against ArdManifest.');
54
270
  } else {
55
- const errors = /** @type {any} */ (validateAiCatalog).errors;
56
- const e = errors?.[0];
57
- const propertyPath = e?.instancePath ? e.instancePath.replace(/^\//, '').replace(/\//g, '.') : 'root';
58
- let msg = e?.message || 'unknown schema error';
59
- if (e?.keyword === 'required') {
60
- msg = `'${e.params.missingProperty}' is a required property`;
61
- }
62
- this.add_error(`JSON Schema Validation Failed: ${msg} at path '${propertyPath}'`, 'Root');
63
- return false;
271
+ const errors = /** @type {any} */ (validateArdManifest).errors;
272
+ this.add_error(`ArdManifest validation failed: ${format_schema_error(errors)}`, 'Root');
273
+ ok = false;
64
274
  }
65
275
  } catch (e) {
66
276
  this.add_warning(`Failed to run JSON Schema validator: ${e}`, 'Root');
67
277
  return true;
68
278
  }
279
+
280
+ const entries = is_object(manifest_data) ? manifest_data["entries"] : undefined;
281
+ if (Array.isArray(entries)) {
282
+ let bad = 0;
283
+ entries.forEach((entry, idx) => {
284
+ const label = is_object(entry) ?
285
+ String(entry["displayName"] || entry["identifier"] || `Entry #${idx}`) :
286
+ `Entry #${idx}`;
287
+ if (!validateArdEntry(entry)) {
288
+ const errors = /** @type {any} */ (validateArdEntry).errors;
289
+ this.add_error(`ArdEntry validation failed: ${format_schema_error(errors)}`, label);
290
+ bad += 1;
291
+ ok = false;
292
+ }
293
+ });
294
+ if (!bad) {
295
+ log.verbose('ARD', `All ${entries.length} entries validate against ArdEntry.`);
296
+ }
297
+ }
298
+ return ok;
69
299
  }
70
300
 
71
301
  /**
@@ -91,14 +321,18 @@ class ConformanceTester {
91
321
  // 3. Custom Semantic and Protocol-Specific Validation
92
322
  log.verbose('ARD', 'Running custom semantic checks...');
93
323
 
94
- const spec_ver = data["specVersion"];
95
- if (!spec_ver) {
96
- this.add_error("Missing required 'specVersion' root property.", 'Root');
97
- } else if (spec_ver !== "1.0") {
98
- this.add_warning(`Unrecognized 'specVersion': ${spec_ver}. Expected '1.0'.`, 'Root');
324
+ // Upstream assumes a JSON object here; treat anything else as an empty root so the
325
+ // schema error above is accompanied by the missing 'entries' error instead of a crash.
326
+ const root = is_object(data) ? data : {};
327
+
328
+ // ARD requires only 'entries' at the top level (§5.1). Other members are
329
+ // transport-defined and ignored, so their presence is noted, never an error.
330
+ const extra_roots = Object.keys(root).filter(k => k !== "entries");
331
+ if (extra_roots.length) {
332
+ log.verbose('ARD', `Top-level members ignored by ARD (transport-defined): ${extra_roots.sort().join(', ')}.`);
99
333
  }
100
334
 
101
- const entries = data["entries"];
335
+ const entries = root["entries"];
102
336
  if (entries === undefined || entries === null) {
103
337
  this.add_error("Missing required 'entries' array.", 'Root');
104
338
  return false;
@@ -108,8 +342,10 @@ class ConformanceTester {
108
342
  }
109
343
 
110
344
  log.verbose('ARD', `Found ${entries.length} entries to validate.`);
111
- entries.forEach((entry, idx) => {
112
- const label = entry["displayName"] || entry["identifier"] || `Entry #${idx}`;
345
+ entries.forEach((raw_entry, idx) => {
346
+ // Upstream assumes each entry is a JSON object; treat anything else as empty.
347
+ const entry = is_object(raw_entry) ? raw_entry : {};
348
+ const label = String(entry["displayName"] || entry["identifier"] || `Entry #${idx}`);
113
349
 
114
350
  // Required properties
115
351
  const ident = entry["identifier"];
@@ -136,35 +372,30 @@ class ConformanceTester {
136
372
  if (!media_type) {
137
373
  this.add_error("Missing required 'type' (mediaType).", label);
138
374
  } else {
139
- const valid_types = [
140
- "application/ai-catalog+json",
141
- "application/agent-card+json",
142
- "application/a2a-agent-card+json",
143
- "application/mcp-server-card+json",
144
- "application/agent-skills+zip",
145
- "application/agent-skills+gzip",
146
- "text/markdown; profile=\"urn:air:agent-skills\"",
147
- "application/ai-registry",
148
- "application/ai-registry+json"
149
- ];
150
- if (!valid_types.includes(media_type)) {
151
- this.add_warning(`Media type '${media_type}' is not one of standard discovery types: ${valid_types}.`, label);
375
+ const diagnostic = classify_media_type(media_type);
376
+ if (diagnostic !== null) {
377
+ const [severity, message] = diagnostic;
378
+ if (severity === "info") {
379
+ this.add_info(message, label);
380
+ } else {
381
+ this.add_warning(message, label);
382
+ }
152
383
  }
153
384
  }
154
385
 
155
386
  // Strict Value-or-Reference checks
156
- const has_url = entry["url"] !== undefined;
157
- const has_data = entry["data"] !== undefined;
387
+ const has_url = "url" in entry;
388
+ const has_data = "data" in entry;
158
389
  if (has_url && has_data) {
159
390
  this.add_error("Constraint violation: both 'url' and 'data' are provided. MUST provide exactly one.", label);
160
391
  } else if (!has_url && !has_data) {
161
392
  this.add_error("Constraint violation: neither 'url' nor 'data' is provided. MUST provide exactly one.", label);
162
393
  }
163
394
 
164
- // Custom constraints for representativeQueries
395
+ // Custom constraints for representativeQueries (§4.2, §D.2)
165
396
  const queries = entry["representativeQueries"];
166
397
  if (queries === undefined || queries === null) {
167
- this.add_warning("Missing 'representativeQueries'. 2 to 5 queries are recommended for vector index embedding.", label);
398
+ this.add_warning("No 'representativeQueries'. The semantic index is built from this term, so the entry will not be found by search — it is a valid catalog entry but not a discoverable ARD entry.", label);
168
399
  } else if (!Array.isArray(queries)) {
169
400
  this.add_error("'representativeQueries' must be an array of strings.", label);
170
401
  } else {
@@ -181,7 +412,7 @@ class ConformanceTester {
181
412
  // Progressive trust checks
182
413
  const trust = entry["trustManifest"];
183
414
  if (trust !== undefined && trust !== null) {
184
- if (typeof trust !== 'object' || Array.isArray(trust)) {
415
+ if (!is_object(trust)) {
185
416
  this.add_error("'trustManifest' must be a JSON object.", label);
186
417
  } else {
187
418
  const trust_id = trust["identity"];
@@ -193,12 +424,12 @@ class ConformanceTester {
193
424
  });
194
425
 
195
426
  // Top-level deprecated property check
196
- if ("collections" in data) {
197
- 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');
427
+ if ("collections" in root) {
428
+ this.add_warning("Found 'collections' at root. Top-level collections were removed in ADR-0003; ARD ignores unrecognized top-level members, so this does not invalidate the manifest, but hierarchies should be modeled inside 'entries'.", 'Root');
198
429
  }
199
430
 
200
431
  return this.errors.length === 0;
201
432
  }
202
433
  }
203
434
 
204
- export { ConformanceTester };
435
+ export { ConformanceTester, classify_media_type };
@@ -1,20 +1,29 @@
1
- export function validate(data: any, { instancePath, parentData, parentDataProperty, rootData, dynamicAnchors }?: {
1
+ export function ArdManifest(data: any, { instancePath, parentData, parentDataProperty, rootData, dynamicAnchors }?: {
2
2
  instancePath?: string | undefined;
3
3
  rootData?: any;
4
4
  dynamicAnchors?: {} | undefined;
5
5
  }): boolean;
6
- export namespace validate {
6
+ export namespace ArdManifest {
7
7
  namespace evaluated {
8
8
  let props: boolean;
9
9
  let dynamicProps: boolean;
10
10
  let dynamicItems: boolean;
11
11
  }
12
12
  }
13
- export default validate20;
14
- declare function validate20(data: any, { instancePath, parentData, parentDataProperty, rootData, dynamicAnchors }?: {
13
+ export function ArdEntry(data: any, { instancePath, parentData, parentDataProperty, rootData, dynamicAnchors }?: {
15
14
  instancePath?: string | undefined;
16
15
  rootData?: any;
17
16
  dynamicAnchors?: {} | undefined;
18
17
  }): boolean;
19
- declare namespace validate20 { }
18
+ export namespace ArdEntry {
19
+ export namespace evaluated_1 {
20
+ let props_1: boolean;
21
+ export { props_1 as props };
22
+ let dynamicProps_1: boolean;
23
+ export { dynamicProps_1 as dynamicProps };
24
+ let dynamicItems_1: boolean;
25
+ export { dynamicItems_1 as dynamicItems };
26
+ }
27
+ export { evaluated_1 as evaluated };
28
+ }
20
29
  //# sourceMappingURL=schema-validator.d.ts.map