@netdata/site-build-gate 0.0.0-stage → 10.1.0-test.0

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.
@@ -0,0 +1,1049 @@
1
+ #!/usr/bin/env node
2
+ /** Fail a static site build when accepted technical defects regress. */
3
+
4
+ import { createHash } from "node:crypto";
5
+ import {
6
+ existsSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ statSync,
11
+ } from "node:fs";
12
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import * as csstree from "css-tree";
16
+ import { parse as parseHtml } from "parse5";
17
+ import { SaxesParser } from "saxes";
18
+
19
+ export const CONTRACT_SCHEMA = "netdata-site-build-gate-v1";
20
+ export const BASELINE_SCHEMA = "netdata-site-build-gate-baseline-v1";
21
+ export const REPORT_SCHEMA = "netdata-site-build-gate-report-v1";
22
+ export const RULESET_VERSION = 10;
23
+ export const REQUIRED_NODE_MAJOR = 22;
24
+ export const HEAVY_BYTES = 500_000;
25
+
26
+ const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
27
+ const SITEMAP_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9";
28
+ const HTML_SUFFIXES = new Set([".html", ".htm", ".xhtml"]);
29
+ const TRACKED_CSS_PROPERTIES = ["content-visibility", "display", "visibility"];
30
+ const UA_DISPLAY_NONE_TAGS = new Set(["datalist", "rp"]);
31
+ const SEMANTICALLY_INERT = new Set([
32
+ "iframe",
33
+ "noembed",
34
+ "noframes",
35
+ "noscript",
36
+ "script",
37
+ "style",
38
+ "template",
39
+ "xmp",
40
+ ]);
41
+
42
+ export const RULES = Object.freeze({
43
+ "missing-title": {
44
+ classification: "accessibility",
45
+ basis: "WCAG 2.4.2 Page Titled, Level A",
46
+ },
47
+ "images-missing-alt": {
48
+ classification: "accessibility",
49
+ basis: "WCAG 1.1.1 Non-text Content, Level A",
50
+ },
51
+ "missing-description": {
52
+ classification: "presentation",
53
+ basis: "Google may compose a snippet; an authored description is a presentation choice",
54
+ },
55
+ "missing-h1": {
56
+ classification: "presentation",
57
+ basis: "Neither WCAG nor Google requires one h1; the site contract requires a semantic heading",
58
+ },
59
+ "multiple-h1": {
60
+ classification: "presentation",
61
+ basis: "Neither WCAG nor Google requires one h1; the site contract keeps one document heading",
62
+ },
63
+ "duplicate-title": {
64
+ classification: "presentation",
65
+ basis: "Identical result titles are ambiguous to readers, not a Google penalty",
66
+ },
67
+ "duplicate-description": {
68
+ classification: "presentation",
69
+ basis: "Identical descriptions make distinct pages read as interchangeable",
70
+ },
71
+ "heavy-page": {
72
+ classification: "diagnostic",
73
+ basis: "500000 bytes is an estate performance guardrail, not a Google threshold",
74
+ },
75
+ });
76
+
77
+ export class GateError extends Error {
78
+ constructor(message) {
79
+ super(message);
80
+ this.name = "GateError";
81
+ }
82
+ }
83
+
84
+ class MissingBuiltHtml extends GateError {}
85
+
86
+ function compareText(left, right) {
87
+ return left < right ? -1 : left > right ? 1 : 0;
88
+ }
89
+
90
+ function isPlainObject(value) {
91
+ return value !== null && typeof value === "object" && !Array.isArray(value);
92
+ }
93
+
94
+ function exactKeys(value, expected, label) {
95
+ if (!isPlainObject(value)) throw new GateError(`${label} must be a JSON object`);
96
+ const actual = Object.keys(value).sort(compareText);
97
+ const wanted = [...expected].sort(compareText);
98
+ if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
99
+ throw new GateError(`${label} fields must be exactly: ${wanted.join(", ")}`);
100
+ }
101
+ }
102
+
103
+ function loadJson(path, label) {
104
+ try {
105
+ const value = JSON.parse(readFileSync(path, "utf8"));
106
+ if (!isPlainObject(value)) throw new GateError(`${label} must be a JSON object: ${path}`);
107
+ return value;
108
+ } catch (error) {
109
+ if (error instanceof GateError) throw error;
110
+ throw new GateError(`cannot read ${label} ${path}: ${error.message}`);
111
+ }
112
+ }
113
+
114
+ function sha256Bytes(payload) {
115
+ return createHash("sha256").update(payload).digest("hex");
116
+ }
117
+
118
+ function requireNode22() {
119
+ const major = Number.parseInt(process.versions.node.split(".", 1)[0], 10);
120
+ if (major !== REQUIRED_NODE_MAJOR) {
121
+ throw new GateError(`Node.js ${REQUIRED_NODE_MAJOR}.x is required; running ${process.version}`);
122
+ }
123
+ }
124
+
125
+ function preNormalizationPath(value, label) {
126
+ if (/[\\\u0000-\u0020\u007f]/u.test(value)) {
127
+ throw new GateError(`unsafe ${label}: raw controls, whitespace, and backslashes are forbidden`);
128
+ }
129
+ const match = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/su.exec(value);
130
+ if (!match) throw new GateError(`${label} must be an absolute HTTP(S) URL: ${JSON.stringify(value)}`);
131
+ if (match[1].includes("@")) throw new GateError(`${label} cannot contain user information: ${JSON.stringify(value)}`);
132
+ if (match[3] !== undefined || match[4] !== undefined) {
133
+ throw new GateError(`${label} cannot carry a query or fragment: ${JSON.stringify(value)}`);
134
+ }
135
+ const rawPath = match[2] || "/";
136
+ if (rawPath.includes("//") || rawPath.includes("\\") || rawPath.includes("\0") || /%(?:2f|5c)/iu.test(rawPath)) {
137
+ throw new GateError(`unsafe ${label} path: ${JSON.stringify(rawPath)}`);
138
+ }
139
+ for (const rawPart of rawPath.split("/")) {
140
+ let decoded;
141
+ try {
142
+ decoded = decodeURIComponent(rawPart);
143
+ } catch {
144
+ throw new GateError(`${label} path has malformed or non-UTF-8 percent encoding: ${JSON.stringify(rawPath)}`);
145
+ }
146
+ if (decoded === "." || decoded === ".." || decoded.includes("\\") || decoded.includes("\0")) {
147
+ throw new GateError(`unsafe ${label} path: ${JSON.stringify(rawPath)}`);
148
+ }
149
+ }
150
+ return rawPath;
151
+ }
152
+
153
+ function checkedUrl(value, expectedOrigin, label) {
154
+ preNormalizationPath(value, label);
155
+ let parsed;
156
+ try {
157
+ parsed = new URL(value);
158
+ } catch (error) {
159
+ throw new GateError(`${label} is not a valid URL: ${JSON.stringify(value)} (${error.message})`);
160
+ }
161
+ if (!new Set(["http:", "https:"]).has(parsed.protocol) || !parsed.hostname) {
162
+ throw new GateError(`${label} must be an absolute HTTP(S) URL: ${JSON.stringify(value)}`);
163
+ }
164
+ if (parsed.username || parsed.password) {
165
+ throw new GateError(`${label} cannot contain user information: ${JSON.stringify(value)}`);
166
+ }
167
+ if (expectedOrigin !== null && parsed.origin !== expectedOrigin) {
168
+ throw new GateError(`${label} origin ${JSON.stringify(parsed.origin)} does not match site origin ${JSON.stringify(expectedOrigin)}: ${JSON.stringify(value)}`);
169
+ }
170
+ if (parsed.search || parsed.hash) {
171
+ throw new GateError(`${label} cannot carry a query or fragment: ${JSON.stringify(value)}`);
172
+ }
173
+ return parsed;
174
+ }
175
+
176
+ export function normalizeSiteOrigin(value) {
177
+ const parsed = checkedUrl(value, null, "site origin");
178
+ if (parsed.pathname !== "/") {
179
+ throw new GateError(`site origin must not contain a path, query, or fragment: ${JSON.stringify(value)}`);
180
+ }
181
+ return parsed.origin;
182
+ }
183
+
184
+ export function normalizeIntegrationRoutePrefixes(values = []) {
185
+ if (!Array.isArray(values)) throw new GateError("integration route prefixes must be a list");
186
+ const policyOrigin = "https://integration-policy.invalid";
187
+ const normalized = values.map((value) => {
188
+ if (typeof value !== "string" || !value.startsWith("/")) {
189
+ throw new GateError(`integration route prefix must be an absolute path: ${JSON.stringify(value)}`);
190
+ }
191
+ const parsed = checkedUrl(`${policyOrigin}${value}`, policyOrigin, "integration route prefix");
192
+ const decoded = safeUrlPath(parsed, "integration route prefix").decodedPath;
193
+ const prefix = decoded.length > 1 && decoded.endsWith("/") ? decoded.slice(0, -1) : decoded;
194
+ if (prefix === "/") throw new GateError("integration route prefix cannot cover the site root");
195
+ return prefix;
196
+ }).sort(compareText);
197
+ if (new Set(normalized).size !== normalized.length) {
198
+ throw new GateError("integration route prefixes must be unique after normalization");
199
+ }
200
+ return normalized;
201
+ }
202
+
203
+ function safeUrlPath(parsed, label) {
204
+ const rawPath = parsed.pathname || "/";
205
+ const parts = [];
206
+ for (const rawPart of rawPath.split("/")) {
207
+ if (!rawPart) continue;
208
+ let decoded;
209
+ try {
210
+ decoded = decodeURIComponent(rawPart);
211
+ } catch {
212
+ throw new GateError(`${label} path is not UTF-8: ${JSON.stringify(rawPath)}`);
213
+ }
214
+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) {
215
+ throw new GateError(`unsafe ${label} path: ${JSON.stringify(rawPath)}`);
216
+ }
217
+ parts.push(decoded);
218
+ }
219
+ const trailingSlash = rawPath.endsWith("/") && parts.length > 0;
220
+ return { parts, decodedPath: parts.length ? `/${parts.join("/")}${trailingSlash ? "/" : ""}` : "/" };
221
+ }
222
+
223
+ function insideRoot(root, candidate) {
224
+ const delta = relative(root, candidate);
225
+ return delta === "" || (!delta.startsWith(`..${sep}`) && delta !== ".." && !delta.startsWith(sep));
226
+ }
227
+
228
+ function exactLexicalPath(buildRoot, parts, location) {
229
+ let lexical = buildRoot;
230
+ for (const part of parts) {
231
+ let names;
232
+ try {
233
+ if (!statSync(lexical).isDirectory()) return null;
234
+ names = readdirSync(lexical);
235
+ } catch (error) {
236
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
237
+ throw new GateError(`cannot enumerate built path for ${JSON.stringify(location)}: ${error.message}`);
238
+ }
239
+ if (!names.includes(part)) return null;
240
+ lexical = join(lexical, part);
241
+ const actual = realpathSync(lexical);
242
+ if (!insideRoot(buildRoot, actual)) throw new GateError(`path symlink escapes build directory: ${JSON.stringify(location)}`);
243
+ }
244
+ return lexical;
245
+ }
246
+
247
+ function safeExistingArtifact(buildRoot, parts, location) {
248
+ const lexical = exactLexicalPath(buildRoot, parts, location);
249
+ if (lexical === null) return null;
250
+ if (!insideRoot(buildRoot, lexical)) throw new GateError(`path escapes build directory: ${JSON.stringify(location)}`);
251
+ const actual = realpathSync(lexical);
252
+ if (!insideRoot(buildRoot, actual)) throw new GateError(`path symlink escapes build directory: ${JSON.stringify(location)}`);
253
+ return statSync(actual).isFile() ? { publicPath: lexical, file: actual } : null;
254
+ }
255
+
256
+ function safeExistingPath(buildRoot, parts, location) {
257
+ return safeExistingArtifact(buildRoot, parts, location)?.file || null;
258
+ }
259
+
260
+ function safeExistingDirectory(buildRoot, parts, location) {
261
+ const lexical = exactLexicalPath(buildRoot, parts, location);
262
+ if (lexical === null) return null;
263
+ const actual = realpathSync(lexical);
264
+ if (!insideRoot(buildRoot, actual)) throw new GateError(`path symlink escapes build directory: ${JSON.stringify(location)}`);
265
+ return statSync(actual).isDirectory() ? actual : null;
266
+ }
267
+
268
+ function parseStrictSitemap(xml, label) {
269
+ const stack = [];
270
+ let root = null;
271
+ let failure = null;
272
+ const parser = new SaxesParser({ xmlns: true });
273
+ parser.on("error", (error) => {
274
+ failure ||= new GateError(`cannot parse local sitemap ${label}: ${error.message}`);
275
+ });
276
+ parser.on("opentag", (tag) => {
277
+ const node = { local: tag.local, uri: tag.uri, text: "", directLocs: [] };
278
+ if (stack.length === 0) root = node;
279
+ stack.push(node);
280
+ });
281
+ parser.on("text", (value) => {
282
+ if (stack.length) stack.at(-1).text += value;
283
+ });
284
+ parser.on("cdata", (value) => {
285
+ if (stack.length) stack.at(-1).text += value;
286
+ });
287
+ parser.on("closetag", () => {
288
+ const node = stack.pop();
289
+ const parent = stack.at(-1);
290
+ if (node?.local === "loc" && node.uri === SITEMAP_NAMESPACE && parent) {
291
+ parent.directLocs.push(node.text.trim());
292
+ }
293
+ });
294
+ try {
295
+ parser.write(xml).close();
296
+ } catch (error) {
297
+ failure ||= new GateError(`cannot parse local sitemap ${label}: ${error.message}`);
298
+ }
299
+ if (failure) throw failure;
300
+ if (!root || root.uri !== SITEMAP_NAMESPACE || !new Set(["urlset", "sitemapindex"]).has(root.local)) {
301
+ throw new GateError(`unsupported sitemap root or namespace: ${label}`);
302
+ }
303
+
304
+ // Parse again to retain only direct root/entry/loc elements and reject malformed entries.
305
+ const entries = [];
306
+ const frames = [];
307
+ const direct = new SaxesParser({ xmlns: true });
308
+ direct.on("error", (error) => { throw error; });
309
+ direct.on("opentag", (tag) => frames.push({ local: tag.local, uri: tag.uri, text: "", locs: [] }));
310
+ direct.on("text", (value) => { if (frames.length) frames.at(-1).text += value; });
311
+ direct.on("cdata", (value) => { if (frames.length) frames.at(-1).text += value; });
312
+ direct.on("closetag", () => {
313
+ const node = frames.pop();
314
+ const parent = frames.at(-1);
315
+ if (node.local === "loc" && node.uri === SITEMAP_NAMESPACE && frames.length === 2) {
316
+ parent.locs.push(node.text.trim());
317
+ }
318
+ const expectedEntry = root.local === "urlset" ? "url" : "sitemap";
319
+ if (node.local === expectedEntry && node.uri === SITEMAP_NAMESPACE && frames.length === 1) {
320
+ if (node.locs.length !== 1 || !node.locs[0]) {
321
+ throw new GateError(`sitemap ${expectedEntry} must contain exactly one direct loc element`);
322
+ }
323
+ entries.push(node.locs[0]);
324
+ }
325
+ });
326
+ try {
327
+ direct.write(xml).close();
328
+ } catch (error) {
329
+ if (error instanceof GateError) throw error;
330
+ throw new GateError(`cannot parse local sitemap ${label}: ${error.message}`);
331
+ }
332
+ if (entries.length === 0) throw new GateError(`sitemap has no usable locations: ${label}`);
333
+ return { kind: root.local, locations: entries };
334
+ }
335
+
336
+ function localSitemapPath(buildRoot, location, siteOrigin) {
337
+ const parsed = checkedUrl(location, siteOrigin, "sitemap location");
338
+ const safe = safeUrlPath(parsed, "sitemap URL");
339
+ const path = safeExistingPath(buildRoot, safe.parts, location);
340
+ if (path === null) throw new GateError(`local sitemap does not exist: ${location}`);
341
+ return path;
342
+ }
343
+
344
+ export function sitemapUrls(buildDir, siteOrigin) {
345
+ const buildRoot = realpathSync(resolve(buildDir));
346
+ const origin = typeof siteOrigin === "string" ? normalizeSiteOrigin(siteOrigin) : siteOrigin;
347
+ const rootSitemap = safeExistingPath(buildRoot, ["sitemap.xml"], "sitemap.xml");
348
+ if (rootSitemap === null) throw new GateError("local root sitemap does not exist: sitemap.xml");
349
+ const pending = [rootSitemap];
350
+ const visited = new Set();
351
+ const urls = [];
352
+ while (pending.length) {
353
+ const sitemap = realpathSync(pending.pop());
354
+ if (!insideRoot(buildRoot, sitemap)) throw new GateError(`sitemap escapes build directory: ${sitemap}`);
355
+ if (visited.has(sitemap)) throw new GateError(`sitemap index cycle or duplicate: ${sitemap}`);
356
+ visited.add(sitemap);
357
+ let parsed;
358
+ try {
359
+ parsed = parseStrictSitemap(readFileSync(sitemap, "utf8"), sitemap);
360
+ } catch (error) {
361
+ if (error instanceof GateError) throw error;
362
+ throw new GateError(`cannot read local sitemap ${sitemap}: ${error.message}`);
363
+ }
364
+ if (parsed.kind === "sitemapindex") {
365
+ for (const value of parsed.locations) pending.push(localSitemapPath(buildRoot, value, origin));
366
+ } else {
367
+ for (const value of parsed.locations) {
368
+ checkedUrl(value, origin, "page location");
369
+ urls.push(value);
370
+ }
371
+ }
372
+ }
373
+ const duplicates = urls.filter((value, index) => urls.indexOf(value) !== index);
374
+ if (duplicates.length) throw new GateError(`duplicate sitemap URL: ${[...new Set(duplicates)].sort(compareText).slice(0, 5).join(", ")}`);
375
+ return urls.sort(compareText);
376
+ }
377
+
378
+ function htmlFilesWithStem(buildRoot, directoryParts, stem, location) {
379
+ const directory = safeExistingDirectory(buildRoot, directoryParts, location);
380
+ if (directory === null) return [];
381
+ let children;
382
+ try {
383
+ children = readdirSync(directory, { withFileTypes: true });
384
+ } catch (error) {
385
+ throw new GateError(`cannot enumerate built HTML directory ${directory}: ${error.message}`);
386
+ }
387
+ return children
388
+ .filter((entry) => entry.isFile() || entry.isSymbolicLink())
389
+ .filter((entry) => basename(entry.name, extname(entry.name)) === stem && HTML_SUFFIXES.has(extname(entry.name).toLowerCase()))
390
+ .map((entry) => safeExistingPath(buildRoot, [...directoryParts, entry.name], location))
391
+ .filter((path) => path !== null)
392
+ .sort(compareText);
393
+ }
394
+
395
+ function pageFile(buildRoot, location, siteOrigin) {
396
+ const parsed = checkedUrl(location, siteOrigin, "page location");
397
+ const route = parsed.pathname || "/";
398
+ const safe = safeUrlPath(parsed, "page URL");
399
+ const parts = safe.parts;
400
+ let candidates;
401
+ if (route.endsWith("/")) {
402
+ candidates = htmlFilesWithStem(buildRoot, parts, "index", location);
403
+ } else if (parts.length && HTML_SUFFIXES.has(extname(parts.at(-1)).toLowerCase())) {
404
+ candidates = [join(buildRoot, ...parts)];
405
+ } else {
406
+ const nested = htmlFilesWithStem(buildRoot, parts, "index", location);
407
+ const flat = htmlFilesWithStem(buildRoot, parts.slice(0, -1), parts.at(-1) || "index", location);
408
+ candidates = [...nested, ...flat];
409
+ }
410
+ const existing = [];
411
+ for (const candidate of candidates) {
412
+ const actual = safeExistingPath(buildRoot, relative(buildRoot, candidate).split(sep), location);
413
+ if (actual !== null && !existing.includes(actual)) existing.push(actual);
414
+ }
415
+ if (existing.length === 0) throw new MissingBuiltHtml(`missing built HTML for ${JSON.stringify(location)}`);
416
+ if (existing.length !== 1) throw new GateError(`ambiguous built HTML for ${JSON.stringify(location)}`);
417
+ return { route, policyPath: safe.decodedPath, file: existing[0] };
418
+ }
419
+
420
+ function exactBuiltArtifact(buildRoot, location, siteOrigin) {
421
+ const parsed = checkedUrl(location, siteOrigin, "page location");
422
+ if (parsed.pathname.endsWith("/")) return null;
423
+ return safeExistingArtifact(buildRoot, safeUrlPath(parsed, "page URL").parts, location);
424
+ }
425
+
426
+ function encodedRelativeRoute(relativePath) {
427
+ return relativePath.split(sep).map((part) => encodeURIComponent(part)).join("/");
428
+ }
429
+
430
+ function inferredRoute(buildRoot, publicFile) {
431
+ const relativePath = relative(buildRoot, publicFile);
432
+ const sourceSuffix = extname(relativePath);
433
+ const suffix = sourceSuffix.toLowerCase();
434
+ const stem = basename(relativePath, sourceSuffix);
435
+ if (HTML_SUFFIXES.has(suffix) && stem === "index") {
436
+ const parent = dirname(relativePath);
437
+ return {
438
+ route: parent === "." ? "/" : `/${encodedRelativeRoute(parent)}/`,
439
+ policyPath: parent === "." ? "/" : `/${parent.split(sep).join("/")}/`,
440
+ };
441
+ }
442
+ return { route: `/${encodedRelativeRoute(relativePath)}`, policyPath: `/${relativePath.split(sep).join("/")}` };
443
+ }
444
+
445
+ function allBuiltHtml(buildRoot) {
446
+ const output = [];
447
+ const seenFiles = new Set();
448
+ const seenDirectories = new Set();
449
+ function visit(publicDirectory) {
450
+ const actualDirectory = realpathSync(publicDirectory);
451
+ if (!insideRoot(buildRoot, actualDirectory)) throw new GateError(`built HTML symlink escapes build directory: ${publicDirectory}`);
452
+ if (seenDirectories.has(actualDirectory)) throw new GateError(`built directory symlink creates a cycle or duplicate: ${publicDirectory}`);
453
+ seenDirectories.add(actualDirectory);
454
+ const entries = readdirSync(publicDirectory, { withFileTypes: true }).sort((a, b) => compareText(a.name, b.name));
455
+ for (const entry of entries) {
456
+ const publicPath = join(publicDirectory, entry.name);
457
+ const info = entry.isSymbolicLink() ? statSync(publicPath) : null;
458
+ if (entry.isDirectory() || info?.isDirectory()) {
459
+ visit(publicPath);
460
+ } else if ((entry.isFile() || info?.isFile()) && HTML_SUFFIXES.has(extname(entry.name).toLowerCase())) {
461
+ const actual = realpathSync(publicPath);
462
+ if (!insideRoot(buildRoot, actual)) throw new GateError(`built HTML symlink escapes build directory: ${publicPath}`);
463
+ if (seenFiles.has(actual)) throw new GateError(`more than one built path maps to the same HTML file: ${publicPath}`);
464
+ seenFiles.add(actual);
465
+ output.push({ file: actual, publicFile: publicPath });
466
+ }
467
+ }
468
+ }
469
+ visit(buildRoot);
470
+ if (output.length === 0) throw new GateError(`build directory contains no HTML files: ${buildRoot}`);
471
+ return output;
472
+ }
473
+
474
+ function attrsOf(node) {
475
+ const attrs = new Map();
476
+ for (const attr of node.attrs || []) if (!attrs.has(attr.name.toLowerCase())) attrs.set(attr.name.toLowerCase(), attr.value);
477
+ return attrs;
478
+ }
479
+
480
+ function normalizedWhitespace(value) {
481
+ return value.replace(/[\t\n\f\r ]+/gu, " ").trim();
482
+ }
483
+
484
+ function normalizedTitleWhitespace(value) {
485
+ return value.replace(/[\t\n\f\r ]+/gu, " ").replace(/^ /u, "").replace(/ $/u, "");
486
+ }
487
+
488
+ function textContent(node) {
489
+ if (node.nodeName === "#text") return node.value || "";
490
+ return (node.childNodes || []).map(textContent).join("");
491
+ }
492
+
493
+ function htmlElements(root) {
494
+ const result = [];
495
+ function visit(node) {
496
+ if (node.tagName && node.namespaceURI === HTML_NAMESPACE) result.push(node);
497
+ for (const child of node.childNodes || []) visit(child);
498
+ }
499
+ visit(root);
500
+ return result;
501
+ }
502
+
503
+ function cssValueTokens(value) {
504
+ const tokens = [];
505
+ csstree.tokenize(value, (type, start, end) => tokens.push({ type, text: value.slice(start, end) }));
506
+ return tokens;
507
+ }
508
+
509
+ function decodedFunctionName(token) {
510
+ return csstree.ident.decode(token.text.slice(0, -1)).toLowerCase();
511
+ }
512
+
513
+ function matchingFunctionEnd(tokens, start) {
514
+ let depth = 1;
515
+ for (let index = start + 1; index < tokens.length; index += 1) {
516
+ if (tokens[index].type === csstree.tokenTypes.Function || tokens[index].type === csstree.tokenTypes.LeftParenthesis) depth += 1;
517
+ else if (tokens[index].type === csstree.tokenTypes.RightParenthesis) {
518
+ depth -= 1;
519
+ if (depth === 0) return index;
520
+ }
521
+ }
522
+ return -1;
523
+ }
524
+
525
+ function significantCssTokens(tokens) {
526
+ return tokens.filter((token) => token.type !== csstree.tokenTypes.WhiteSpace && token.type !== csstree.tokenTypes.Comment);
527
+ }
528
+
529
+ function topLevelComma(tokens, start, end) {
530
+ let depth = 0;
531
+ for (let index = start; index < end; index += 1) {
532
+ const type = tokens[index].type;
533
+ if (type === csstree.tokenTypes.Function || type === csstree.tokenTypes.LeftParenthesis) depth += 1;
534
+ else if (type === csstree.tokenTypes.RightParenthesis) depth -= 1;
535
+ else if (type === csstree.tokenTypes.Comma && depth === 0) return index;
536
+ }
537
+ return end;
538
+ }
539
+
540
+ function validDynamicFunction(tokens, start, end, name) {
541
+ const comma = topLevelComma(tokens, start + 1, end);
542
+ const head = significantCssTokens(tokens.slice(start + 1, comma));
543
+ if (name === "var") {
544
+ return head.length === 1
545
+ && head[0].type === csstree.tokenTypes.Ident
546
+ && csstree.ident.decode(head[0].text).startsWith("--")
547
+ && csstree.ident.decode(head[0].text).length > 2;
548
+ }
549
+ if (name === "env") {
550
+ if (!head.length || head[0].type !== csstree.tokenTypes.Ident) return false;
551
+ return head.slice(1).every((token) => token.type === csstree.tokenTypes.Number && /^\+?[0-9]+$/u.test(token.text));
552
+ }
553
+ if (!head.length || head[0].type !== csstree.tokenTypes.Ident) return false;
554
+ const type = head.slice(1);
555
+ if (type.length === 0) return true;
556
+ if (type.length === 1) {
557
+ return type[0].type === csstree.tokenTypes.Ident
558
+ || (type[0].type === csstree.tokenTypes.Delim && type[0].text === "%");
559
+ }
560
+ return type[0].type === csstree.tokenTypes.Function
561
+ && decodedFunctionName(type[0]) === "type"
562
+ && matchingFunctionEnd(type, 0) === type.length - 1
563
+ && significantCssTokens(type.slice(1, -1)).length > 0;
564
+ }
565
+
566
+ function dynamicCssSyntax(value) {
567
+ const tokens = cssValueTokens(value);
568
+ let contains = false;
569
+ for (let index = 0; index < tokens.length; index += 1) {
570
+ const token = tokens[index];
571
+ if (token.type !== csstree.tokenTypes.Function) continue;
572
+ const name = decodedFunctionName(token);
573
+ if (!new Set(["var", "env", "attr"]).has(name)) continue;
574
+ contains = true;
575
+ const end = matchingFunctionEnd(tokens, index);
576
+ if (end < 0 || !validDynamicFunction(tokens, index, end, name)) return { contains, valid: false };
577
+ }
578
+ return { contains, valid: true };
579
+ }
580
+
581
+ function inlineDeclarations(style) {
582
+ if (!style) return { display: null, visibility: null, contentVisibility: null };
583
+ let ast;
584
+ try {
585
+ ast = csstree.parse(style, { context: "declarationList", positions: false });
586
+ } catch (error) {
587
+ throw new GateError(`cannot parse inline style: ${JSON.stringify(style)} (${error.message})`);
588
+ }
589
+ const winners = new Map();
590
+ for (const declaration of ast.children.toArray()) {
591
+ if (declaration.type !== "Declaration") continue;
592
+ const property = csstree.ident.decode(declaration.property).toLowerCase();
593
+ if (property !== "all" && !TRACKED_CSS_PROPERTIES.includes(property)) continue;
594
+ const decodedValue = csstree.clone(declaration.value);
595
+ csstree.walk(decodedValue, (node) => {
596
+ if (node.type === "Identifier" || node.type === "Function") node.name = csstree.ident.decode(node.name);
597
+ });
598
+ let valid = false;
599
+ try {
600
+ valid = csstree.lexer.matchProperty(property, decodedValue).matched !== null;
601
+ } catch {
602
+ valid = false;
603
+ }
604
+ const dynamicSyntax = dynamicCssSyntax(csstree.generate(decodedValue));
605
+ if (!dynamicSyntax.valid || (!valid && !dynamicSyntax.contains)) continue;
606
+ const dynamic = dynamicSyntax.contains;
607
+ const targets = property === "all" ? TRACKED_CSS_PROPERTIES : [property];
608
+ for (const target of targets) {
609
+ const prior = winners.get(target);
610
+ if (!prior || declaration.important || !prior.important) {
611
+ winners.set(target, { decodedValue, important: Boolean(declaration.important), dynamic, sourceProperty: property });
612
+ }
613
+ }
614
+ }
615
+ const output = { display: null, visibility: null, contentVisibility: null };
616
+ for (const [property, winner] of winners) {
617
+ if (winner.dynamic) throw new GateError(`cannot resolve dynamic inline ${winner.sourceProperty} declaration affecting ${property}`);
618
+ const nodes = winner.decodedValue.children?.toArray?.() || [];
619
+ if (nodes.length !== 1 || nodes[0].type !== "Identifier") {
620
+ // Multi-keyword display values cannot hide an element. Visibility accepts one keyword only.
621
+ if (property !== "display") throw new GateError(`cannot resolve inline ${property} declaration: ${csstree.generate(winner.decodedValue)}`);
622
+ output.display = csstree.generate(winner.decodedValue).toLowerCase();
623
+ continue;
624
+ }
625
+ const value = csstree.ident.decode(nodes[0].name).toLowerCase();
626
+ if (value === "revert-layer") throw new GateError(`cannot resolve inline ${winner.sourceProperty} declaration using revert-layer`);
627
+ if (property === "content-visibility") output.contentVisibility = value;
628
+ else output[property] = value;
629
+ }
630
+ return output;
631
+ }
632
+
633
+ function initialCssValue(property) {
634
+ if (property === "display") return "inline";
635
+ return "visible";
636
+ }
637
+
638
+ function resolvedCssValue(property, authored, parent, userAgent) {
639
+ if (authored === null || authored === "revert") return userAgent;
640
+ if (authored === "initial") return initialCssValue(property);
641
+ if (authored === "inherit") return parent;
642
+ if (authored === "unset") return property === "visibility" ? parent : initialCssValue(property);
643
+ return authored;
644
+ }
645
+
646
+ function userAgentDefaults(node, attrs, isHtml, parent) {
647
+ const defaults = { display: "inline", visibility: parent.visibility, contentVisibility: "visible" };
648
+ if (!isHtml) return defaults;
649
+ if (attrs.has("hidden")) {
650
+ if ((attrs.get("hidden") || "").toLowerCase() === "until-found") defaults.contentVisibility = "hidden";
651
+ else defaults.display = "none";
652
+ }
653
+ if ((node.tagName === "dialog" && !attrs.has("open")) || attrs.has("popover") || UA_DISPLAY_NONE_TAGS.has(node.tagName)) {
654
+ defaults.display = "none";
655
+ }
656
+ return defaults;
657
+ }
658
+
659
+ function elementState(node, attrs, parent) {
660
+ const isHtml = node.namespaceURI === HTML_NAMESPACE;
661
+ const inline = inlineDeclarations(attrs.get("style") || "");
662
+ const userAgent = userAgentDefaults(node, attrs, isHtml, parent);
663
+ const display = resolvedCssValue("display", inline.display, parent.display, userAgent.display);
664
+ const contentVisibility = resolvedCssValue(
665
+ "content-visibility",
666
+ inline.contentVisibility,
667
+ parent.contentVisibility,
668
+ userAgent.contentVisibility,
669
+ );
670
+ const visibility = resolvedCssValue("visibility", inline.visibility, parent.visibility, userAgent.visibility);
671
+ const hard = parent.hard
672
+ || (isHtml && attrs.has("inert"))
673
+ || (attrs.get("aria-hidden") || "").trim().toLowerCase() === "true"
674
+ || display === "none"
675
+ || contentVisibility === "hidden";
676
+ if (!new Set(["hidden", "collapse", "visible"]).has(visibility)) {
677
+ throw new GateError(`cannot resolve inline visibility declaration: ${visibility}`);
678
+ }
679
+ return { hard, display, visibility, contentVisibility };
680
+ }
681
+
682
+ function htmlNonNegativeInteger(value) {
683
+ if (value === undefined) return null;
684
+ const match = /^[\t\n\f\r ]*\+?([0-9]+)/u.exec(value);
685
+ if (!match) return null;
686
+ const parsed = Number(match[1]);
687
+ return Number.isSafeInteger(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
688
+ }
689
+
690
+ function isTrackingImage(attrs) {
691
+ const width = htmlNonNegativeInteger(attrs.get("width"));
692
+ const height = htmlNonNegativeInteger(attrs.get("height"));
693
+ return width !== null && height !== null && width <= 2 && height <= 2;
694
+ }
695
+
696
+ export function robotsDisallowIndexing(values) {
697
+ const tokens = new Set(values.flatMap((value) => value.trim().toLowerCase().split(/[,\s]+/u).filter(Boolean)));
698
+ return tokens.has("noindex") || tokens.has("none");
699
+ }
700
+
701
+ function normalizedOpenDetails(document) {
702
+ const open = new Set();
703
+ const named = new Set();
704
+ function visit(node) {
705
+ if (node.tagName === "details" && node.namespaceURI === HTML_NAMESPACE) {
706
+ const attrs = attrsOf(node);
707
+ if (attrs.has("open")) {
708
+ const name = attrs.get("name") || "";
709
+ if (!name || !named.has(name)) {
710
+ open.add(node);
711
+ if (name) named.add(name);
712
+ }
713
+ }
714
+ }
715
+ for (const child of node.childNodes || []) visit(child);
716
+ }
717
+ visit(document);
718
+ return open;
719
+ }
720
+
721
+ function firstDirectSummary(details) {
722
+ return (details.childNodes || []).find((node) => node.tagName === "summary" && node.namespaceURI === HTML_NAMESPACE) || null;
723
+ }
724
+
725
+ function originNeutralByteState(payload, siteOrigin) {
726
+ if (siteOrigin === null || siteOrigin === undefined) {
727
+ return { bytes_without_site_origin: payload.length, site_origin_occurrences: 0 };
728
+ }
729
+ const originBytes = Buffer.from(siteOrigin, "utf8");
730
+ let occurrences = 0;
731
+ let offset = 0;
732
+ while (offset <= payload.length - originBytes.length) {
733
+ const found = payload.indexOf(originBytes, offset);
734
+ if (found < 0) break;
735
+ occurrences += 1;
736
+ offset = found + originBytes.length;
737
+ }
738
+ return {
739
+ bytes_without_site_origin: payload.length - occurrences * originBytes.length,
740
+ site_origin_occurrences: occurrences,
741
+ };
742
+ }
743
+
744
+ export function parsePage(route, file, { inSitemap, policyPath = route, siteOrigin = null }) {
745
+ let payload;
746
+ try {
747
+ payload = readFileSync(file);
748
+ } catch (error) {
749
+ throw new GateError(`cannot read built HTML ${file}: ${error.message}`);
750
+ }
751
+ let document;
752
+ try {
753
+ document = parseHtml(payload.toString("utf8"), { scriptingEnabled: true });
754
+ } catch (error) {
755
+ throw new GateError(`cannot parse built HTML ${file}: ${error.message}`);
756
+ }
757
+ const elements = htmlElements(document);
758
+ const titleNode = elements.find((node) => node.tagName === "title");
759
+ const title = titleNode ? normalizedTitleWhitespace(textContent(titleNode)) : null;
760
+ let description = null;
761
+ const robots = [];
762
+ const head = elements.find((node) => node.tagName === "head");
763
+ for (const node of head?.childNodes || []) {
764
+ if (node.namespaceURI !== HTML_NAMESPACE) continue;
765
+ if (node.tagName !== "meta") continue;
766
+ const attrs = attrsOf(node);
767
+ const name = (attrs.get("name") || "").toLowerCase();
768
+ const content = (attrs.get("content") || "").trim();
769
+ if (name === "description" && description === null) description = content;
770
+ if (name === "robots" || name === "googlebot") robots.push(content);
771
+ }
772
+
773
+ const h1Records = [];
774
+ let imagesWithoutAlt = 0;
775
+ const activeH1s = [];
776
+ const openDetails = normalizedOpenDetails(document);
777
+ function visit(node, parentState, inertContext) {
778
+ if (node.nodeName === "#text") {
779
+ if (!inertContext && !parentState.hard && parentState.visibility === "visible") {
780
+ for (const record of activeH1s) record.parts.push(node.value || "");
781
+ }
782
+ return;
783
+ }
784
+ if (!node.tagName) {
785
+ for (const child of node.childNodes || []) visit(child, parentState, inertContext);
786
+ return;
787
+ }
788
+ const attrs = attrsOf(node);
789
+ const isHtml = node.namespaceURI === HTML_NAMESPACE;
790
+ const state = elementState(node, attrs, parentState);
791
+ const nextInert = inertContext || (isHtml && SEMANTICALLY_INERT.has(node.tagName));
792
+ const isH1 = isHtml && node.tagName === "h1" && !nextInert && !state.hard;
793
+ let record = null;
794
+ if (isH1) {
795
+ record = { parts: [], exposed: state.visibility === "visible" };
796
+ h1Records.push(record);
797
+ activeH1s.push(record);
798
+ }
799
+ if (!nextInert && !state.hard && state.visibility === "visible") {
800
+ for (const active of activeH1s) active.exposed = true;
801
+ if (isHtml && node.tagName === "img" && !attrs.has("alt") && !isTrackingImage(attrs)) {
802
+ imagesWithoutAlt += 1;
803
+ }
804
+ }
805
+ const closedDetails = isHtml && node.tagName === "details" && !openDetails.has(node);
806
+ const summary = closedDetails ? firstDirectSummary(node) : null;
807
+ for (const child of node.childNodes || []) {
808
+ const childState = closedDetails && child !== summary ? { ...state, hard: true } : state;
809
+ visit(child, childState, nextInert);
810
+ }
811
+ if (record !== null) {
812
+ activeH1s.pop();
813
+ }
814
+ }
815
+ visit(document, { hard: false, display: "block", visibility: "visible", contentVisibility: "visible" }, false);
816
+ const h1s = h1Records.filter((record) => record.exposed).map((record) => normalizedWhitespace(record.parts.join("")));
817
+ const byteState = originNeutralByteState(payload, siteOrigin);
818
+
819
+ return {
820
+ route,
821
+ policy_path: policyPath,
822
+ file,
823
+ in_sitemap: inSitemap,
824
+ html_bytes: payload.length,
825
+ html_bytes_without_site_origin: byteState.bytes_without_site_origin,
826
+ site_origin_occurrences: byteState.site_origin_occurrences,
827
+ title,
828
+ description,
829
+ noindex: robotsDisallowIndexing(robots),
830
+ h1s,
831
+ images_without_alt: imagesWithoutAlt,
832
+ };
833
+ }
834
+
835
+ export function titleIsOnlyTheSiteSuffix(title) {
836
+ const index = title.lastIndexOf("|");
837
+ return index >= 0 && normalizedTitleWhitespace(title.slice(0, index)) === "";
838
+ }
839
+
840
+ function stableObject(value) {
841
+ if (Array.isArray(value)) return value.map(stableObject);
842
+ if (!isPlainObject(value)) return value;
843
+ return Object.fromEntries(Object.keys(value).sort(compareText).map((key) => [key, stableObject(value[key])]));
844
+ }
845
+
846
+ function stateSignature(state) {
847
+ return JSON.stringify(stableObject(state));
848
+ }
849
+
850
+ function finding(rule, path, message, signature = null) {
851
+ if (!(rule in RULES)) throw new GateError(`unclassified gate rule: ${rule}`);
852
+ let identity = `${rule}|${path}`;
853
+ if (signature !== null) identity += `|${sha256Bytes(Buffer.from(signature, "utf8"))}`;
854
+ return { identity, rule, classification: RULES[rule].classification, path, message };
855
+ }
856
+
857
+ function sortedFindings(findings) {
858
+ return findings.sort((a, b) => compareText(a.rule, b.rule) || compareText(a.path, b.path) || compareText(a.identity, b.identity));
859
+ }
860
+
861
+ function isIntegrationPolicyPath(policyPath, integrationRoutePrefixes) {
862
+ return integrationRoutePrefixes.some((prefix) =>
863
+ policyPath === prefix || policyPath.startsWith(`${prefix}/`),
864
+ );
865
+ }
866
+
867
+ export function scanBuiltHtml(buildDir, siteOrigin, { integrationRoutePrefixes = [] } = {}) {
868
+ const rootInput = resolve(buildDir);
869
+ if (!existsSync(rootInput) || !statSync(rootInput).isDirectory()) throw new GateError(`build directory does not exist: ${buildDir}`);
870
+ const root = realpathSync(rootInput);
871
+ const origin = typeof siteOrigin === "string" ? normalizeSiteOrigin(siteOrigin) : siteOrigin;
872
+ const integrationPrefixes = normalizeIntegrationRoutePrefixes(integrationRoutePrefixes);
873
+ const sitemapRoutes = new Map();
874
+ const sitemapArtifacts = new Map();
875
+ for (const location of sitemapUrls(root, origin)) {
876
+ const artifact = exactBuiltArtifact(root, location, origin);
877
+ if (artifact !== null) {
878
+ if (sitemapArtifacts.has(artifact.file)) {
879
+ throw new GateError(`more than one sitemap URL maps to ${artifact.file}: ${JSON.stringify(sitemapArtifacts.get(artifact.file))} and ${JSON.stringify(location)}`);
880
+ }
881
+ sitemapArtifacts.set(artifact.file, location);
882
+ if (!HTML_SUFFIXES.has(extname(artifact.publicPath).toLowerCase())) continue;
883
+ }
884
+ const mapped = pageFile(root, location, origin);
885
+ if (sitemapRoutes.has(mapped.file)) {
886
+ throw new GateError(`more than one sitemap URL maps to ${mapped.file}: ${JSON.stringify(sitemapRoutes.get(mapped.file))} and ${JSON.stringify(mapped.route)}`);
887
+ }
888
+ sitemapRoutes.set(mapped.file, mapped);
889
+ }
890
+
891
+ const pages = [];
892
+ const seenRoutes = new Set();
893
+ for (const item of allBuiltHtml(root)) {
894
+ const mapped = sitemapRoutes.get(item.file) || inferredRoute(root, item.publicFile);
895
+ if (seenRoutes.has(mapped.route)) throw new GateError(`more than one built HTML file maps to route ${JSON.stringify(mapped.route)}`);
896
+ seenRoutes.add(mapped.route);
897
+ pages.push(parsePage(mapped.route, item.file, {
898
+ inSitemap: sitemapRoutes.has(item.file),
899
+ policyPath: mapped.policyPath,
900
+ siteOrigin: origin,
901
+ }));
902
+ }
903
+
904
+ const findings = [];
905
+ for (const page of pages) {
906
+ if (!page.title || titleIsOnlyTheSiteSuffix(page.title)) findings.push(finding("missing-title", page.route, "title is empty"));
907
+ if (page.images_without_alt) {
908
+ findings.push(finding("images-missing-alt", page.route, `${page.images_without_alt} statically exposed HTML image(s) lack an alt attribute`, stateSignature({ count: page.images_without_alt })));
909
+ }
910
+ if (page.html_bytes > HEAVY_BYTES && !isIntegrationPolicyPath(page.policy_path, integrationPrefixes)) {
911
+ findings.push(finding("heavy-page", page.route, `${page.html_bytes} bytes exceeds the ${HEAVY_BYTES}-byte estate guardrail`, stateSignature({
912
+ bytes_without_site_origin: page.html_bytes_without_site_origin,
913
+ site_origin_occurrences: page.site_origin_occurrences,
914
+ })));
915
+ }
916
+ const guide = page.policy_path === "/guides" || page.policy_path === "/guides/" || page.policy_path.startsWith("/guides/");
917
+ if ((!page.in_sitemap || page.noindex) && !guide) continue;
918
+ if (page.in_sitemap && !page.noindex && !page.description) findings.push(finding("missing-description", page.route, "meta description is empty"));
919
+ if (!page.h1s.length || !page.h1s[0]) {
920
+ const signature = stateSignature({ count: page.h1s.length, first: page.h1s.length ? "empty" : "absent" });
921
+ findings.push(finding("missing-h1", page.route, "first h1 is absent or empty", signature));
922
+ }
923
+ else if (page.h1s.length > 1) findings.push(finding("multiple-h1", page.route, `found ${page.h1s.length} h1 elements`, stateSignature({ count: page.h1s.length })));
924
+ }
925
+
926
+ for (const [field, rule] of [["title", "duplicate-title"], ["description", "duplicate-description"]]) {
927
+ const groups = new Map();
928
+ for (const page of pages) {
929
+ if (!page.in_sitemap || page.noindex) continue;
930
+ const value = (page[field] || "").trim();
931
+ if (!value) continue;
932
+ if (!groups.has(value)) groups.set(value, []);
933
+ groups.get(value).push(page);
934
+ }
935
+ for (const [value, group] of groups) {
936
+ if (group.length < 2) continue;
937
+ const paths = group.map((page) => page.route).sort(compareText);
938
+ const signature = stateSignature({ count: group.length, paths, value });
939
+ for (const page of group) findings.push(finding(rule, page.route, `value is shared by ${group.length} pages`, signature));
940
+ }
941
+ }
942
+ return {
943
+ pages: pages.sort((a, b) => compareText(a.route, b.route)),
944
+ findings: sortedFindings(findings),
945
+ integrationRoutePrefixes: integrationPrefixes,
946
+ };
947
+ }
948
+
949
+ export function loadBaseline(path) {
950
+ if (path === null || path === undefined) return new Map();
951
+ const value = loadJson(path, "gate baseline");
952
+ exactKeys(value, ["schema", "contract", "ruleset_version", "allowed"], "gate baseline");
953
+ if (value.schema !== BASELINE_SCHEMA || value.contract !== CONTRACT_SCHEMA) throw new GateError("gate baseline schema or contract is not supported");
954
+ if (value.ruleset_version !== RULESET_VERSION) throw new GateError(`baseline ruleset ${JSON.stringify(value.ruleset_version)} does not match ${RULESET_VERSION}`);
955
+ if (!Array.isArray(value.allowed)) throw new GateError("gate baseline allowed field must be a list");
956
+ const allowed = new Map();
957
+ for (const entry of value.allowed) {
958
+ exactKeys(entry, ["identity", "reason"], "baseline entry");
959
+ if (typeof entry.identity !== "string" || !entry.identity || typeof entry.reason !== "string" || !entry.reason.trim()) {
960
+ throw new GateError("baseline identity and reason must be non-empty strings");
961
+ }
962
+ if (!(entry.identity.split("|", 1)[0] in RULES)) throw new GateError(`baseline identity names an unknown rule: ${entry.identity}`);
963
+ if (allowed.has(entry.identity)) throw new GateError(`duplicate baseline identity: ${entry.identity}`);
964
+ allowed.set(entry.identity, entry.reason.trim());
965
+ }
966
+ return allowed;
967
+ }
968
+
969
+ export function runGate(buildDir, { siteOrigin, baselinePath = null, integrationRoutePrefixes = [] }) {
970
+ requireNode22();
971
+ const origin = normalizeSiteOrigin(siteOrigin);
972
+ const { pages, findings, integrationRoutePrefixes: normalizedIntegrationPrefixes } =
973
+ scanBuiltHtml(buildDir, origin, { integrationRoutePrefixes });
974
+ const allowed = loadBaseline(baselinePath);
975
+ const identities = new Set(findings.map((item) => item.identity));
976
+ const regressions = findings.filter((item) => !allowed.has(item.identity));
977
+ const accepted = findings.filter((item) => allowed.has(item.identity)).map((item) => ({ ...item, baseline_reason: allowed.get(item.identity) }));
978
+ const stale = [...allowed.entries()].filter(([identity]) => !identities.has(identity)).sort(([a], [b]) => compareText(a, b)).map(([identity, reason]) => ({ identity, reason }));
979
+ const counts = {};
980
+ for (const item of findings) counts[item.rule] = (counts[item.rule] || 0) + 1;
981
+ return {
982
+ schema: REPORT_SCHEMA,
983
+ contract: CONTRACT_SCHEMA,
984
+ ruleset_version: RULESET_VERSION,
985
+ site_origin: origin,
986
+ integration_route_prefixes: normalizedIntegrationPrefixes,
987
+ checked_pages: pages.length,
988
+ checked_index_pages: pages.filter((page) => page.in_sitemap && !page.noindex).length,
989
+ counts: Object.fromEntries(Object.entries(counts).sort(([a], [b]) => compareText(a, b))),
990
+ findings,
991
+ accepted_findings: accepted,
992
+ regressions,
993
+ stale_baseline: stale,
994
+ };
995
+ }
996
+
997
+ function printText(report) {
998
+ console.log(`site build gate v${report.ruleset_version}: ${report.checked_pages} pages, ${report.regressions.length} regression(s), ${report.accepted_findings.length} accepted finding(s)`);
999
+ for (const item of report.regressions) console.log(`ERROR ${item.rule} [${item.classification}] ${item.path}: ${item.message}`);
1000
+ for (const item of report.accepted_findings) console.log(`ACCEPTED ${item.rule} ${item.path}: ${item.baseline_reason}`);
1001
+ for (const item of report.stale_baseline) console.log(`RESOLVED ${item.identity}: baseline entry can be removed`);
1002
+ }
1003
+
1004
+ function parseArgs(argv) {
1005
+ const args = { buildDir: null, siteOrigin: null, baseline: null, format: "text", integrationRoutePrefixes: [] };
1006
+ for (let index = 0; index < argv.length; index += 2) {
1007
+ const name = argv[index];
1008
+ const value = argv[index + 1];
1009
+ if (!value || !new Set(["--build-dir", "--site-origin", "--baseline", "--format", "--integration-route-prefix"]).has(name)) throw new GateError(`invalid or incomplete argument: ${name || "<missing>"}`);
1010
+ if (name === "--build-dir") args.buildDir = value;
1011
+ else if (name === "--site-origin") args.siteOrigin = value;
1012
+ else if (name === "--baseline") args.baseline = value;
1013
+ else if (name === "--format") args.format = value;
1014
+ else args.integrationRoutePrefixes.push(value);
1015
+ }
1016
+ if (!args.buildDir || !args.siteOrigin) throw new GateError("--build-dir and --site-origin are required");
1017
+ if (!new Set(["text", "json"]).has(args.format)) throw new GateError("--format must be text or json");
1018
+ return args;
1019
+ }
1020
+
1021
+ export function main(argv = process.argv.slice(2)) {
1022
+ try {
1023
+ const args = parseArgs(argv);
1024
+ const report = runGate(args.buildDir, {
1025
+ siteOrigin: args.siteOrigin,
1026
+ baselinePath: args.baseline,
1027
+ integrationRoutePrefixes: args.integrationRoutePrefixes,
1028
+ });
1029
+ if (args.format === "json") console.log(JSON.stringify(report, null, 2));
1030
+ else printText(report);
1031
+ return report.regressions.length ? 1 : 0;
1032
+ } catch (error) {
1033
+ console.error(`site build gate contract error: ${error.message}`);
1034
+ return 2;
1035
+ }
1036
+ }
1037
+
1038
+ function isDirectInvocation() {
1039
+ if (!process.argv[1]) return false;
1040
+ try {
1041
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve(process.argv[1]));
1042
+ } catch {
1043
+ return false;
1044
+ }
1045
+ }
1046
+
1047
+ if (isDirectInvocation()) {
1048
+ process.exitCode = main();
1049
+ }