spiderlint 0.0.0 → 0.143.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.
Files changed (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +233 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +358 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +56 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +16 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +353 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
@@ -0,0 +1,131 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { log } from "../logger.js";
5
+ import { resolve } from "./builtin.js";
6
+ import { said } from "./message.js";
7
+ // The fragment-free hreflang targets of a page other than itself, `x-default` included.
8
+ function alternatesOf(page) {
9
+ const here = selves(page);
10
+ return [...new Set((page.html?.hreflang ?? []).map((alternate) => resolve(alternate.href, page.url.href)))].filter((href) => !here.has(href));
11
+ }
12
+ // Every URL a page answers to: its own, the one it was requested as and its twin on the canonical origin.
13
+ function selves(page) {
14
+ return new Set([page.url.href, page.crawl.requested, page.url.twin].flatMap((href) => (href ? [resolve(href, href)] : [])));
15
+ }
16
+ // Whether a page answered 2xx.
17
+ function isOk(page) {
18
+ return page.http.status >= 200 && page.http.status < 300;
19
+ }
20
+ // The crawled 2xx page behind a URL, through its twin and through a redirect.
21
+ function pageIndex(pages, site) {
22
+ const index = new Map();
23
+ for (const page of pages)
24
+ if (isOk(page))
25
+ for (const href of selves(page))
26
+ index.set(href, page);
27
+ return (href) => index.get(href) ?? index.get(site?.redirects?.[href] ?? "");
28
+ }
29
+ // The primary language subtag, lower-cased.
30
+ function primary(tag) {
31
+ return tag.trim().split("-", 1)[0]?.toLowerCase() ?? "";
32
+ }
33
+ // Every crawled 2xx alternate that does not name back the 2xx pages listing it, once per alternate.
34
+ const hreflangReciprocal = (severity) => ({
35
+ meta: { id: "i18n/hreflang-reciprocal", severity, scope: "site", facts: ["html.hreflang", "site.redirects"], docs: "https://developers.google.com/search/docs/specialty/international/localized-versions#html", fix: "List every language version, itself included, in the hreflang links of each version." },
36
+ check(pages, _group, site) {
37
+ const find = pageIndex(pages, site);
38
+ const unanswered = new Map();
39
+ for (const page of pages) {
40
+ if (!isOk(page))
41
+ continue;
42
+ for (const href of alternatesOf(page)) {
43
+ const alternate = find(href);
44
+ const isNamedBack = alternate === undefined || alternatesOf(alternate).some((back) => selves(page).has(back));
45
+ log.debug({ rule: "i18n/hreflang-reciprocal", url: page.url.href, alternate: href, isCrawled: alternate !== undefined, isNamedBack }, "hreflang alternate judged");
46
+ if (!isNamedBack)
47
+ unanswered.set(alternate.url.href, [...(unanswered.get(alternate.url.href) ?? []), page.url.href]);
48
+ }
49
+ }
50
+ return unanswered
51
+ .entries()
52
+ .map(([url, urls]) => ({ rule: "i18n/hreflang-reciprocal", severity, scope: "site", url, ...said("the page does not name back the pages listing it as an hreflang alternate"), value: urls, urls }))
53
+ .toArray();
54
+ },
55
+ });
56
+ // Every crawled hreflang target answering outside 2xx or redirecting, once per target, with the pages naming it.
57
+ const hreflangStatus = (severity) => ({
58
+ meta: { id: "i18n/hreflang-status", severity, scope: "site", facts: ["html.hreflang", "http.status", "site.redirects"], docs: "https://developers.google.com/search/docs/specialty/international/localized-versions#html", fix: "Point each hreflang link at the final URL of a page that answers 200." },
59
+ check(pages, _group, site) {
60
+ const status = new Map(pages.flatMap((page) => [...selves(page)].map((href) => [href, page.http.status])));
61
+ const naming = new Map();
62
+ for (const page of pages)
63
+ for (const href of alternatesOf(page))
64
+ naming.set(href, [...(naming.get(href) ?? []), page.url.href]);
65
+ const findings = [];
66
+ for (const [url, urls] of naming) {
67
+ const [answer, landing] = [status.get(url), site?.redirects?.[url]];
68
+ log.debug({ rule: "i18n/hreflang-status", url, status: answer, landing, pages: urls.length }, "hreflang target judged");
69
+ const verdict = landing ? { ...said("the hreflang target redirects; named by these pages"), data: { [url]: { landing } } } : answer !== undefined && (answer < 200 || answer > 299) ? said("the hreflang target answers {status}; named by these pages", { status: String(answer) }) : undefined;
70
+ if (verdict)
71
+ findings.push({ rule: "i18n/hreflang-status", severity, scope: "site", url, ...verdict, value: landing ?? answer, urls });
72
+ }
73
+ return findings;
74
+ },
75
+ });
76
+ // A page whose `lang` names a language its `Content-Language` header leaves out; a page lacking either is skipped.
77
+ const contentLanguage = (severity) => ({
78
+ meta: { id: "i18n/content-language", severity, scope: "page", facts: ["html.lang", "http.headers.content-language"], docs: "https://www.w3.org/International/questions/qa-http-and-lang", fix: "Make the Content-Language header name the language the html lang attribute declares, or drop the header." },
79
+ check(page) {
80
+ const [lang, header] = [page.html?.lang, [page.http.headers["content-language"] ?? []].flat().join(",")];
81
+ if (!lang || header === "")
82
+ return;
83
+ const languages = header.split(",").map((tag) => primary(tag));
84
+ const isListed = languages.includes(primary(lang));
85
+ log.debug({ rule: "i18n/content-language", url: page.url.href, lang, languages, isListed }, "content language compared");
86
+ return isListed ? [] : [{ rule: "i18n/content-language", severity, scope: "page", url: page.url.href, group: page.group, ...said("lang and Content-Language name different languages"), data: { [page.url.href]: { lang, "content-language": header } }, value: header }];
87
+ },
88
+ });
89
+ // `lang href` pairs of a hreflang list, resolved against the page and lower-cased.
90
+ function pairs(alternates, base) {
91
+ return new Set(alternates.map((alternate) => `${alternate.lang.toLowerCase()} ${resolve(alternate.href, base)}`));
92
+ }
93
+ // A page whose sitemap alternates and hreflang links both exist and disagree.
94
+ const sitemapHreflang = (severity) => ({
95
+ meta: { id: "sitemap/hreflang", severity, scope: "page", facts: ["sitemap.alternates", "html.hreflang"], docs: "https://developers.google.com/search/docs/specialty/international/localized-versions#sitemap", fix: "Generate the sitemap alternates and the page’s hreflang links from one list." },
96
+ check(page) {
97
+ const [listed, linked] = [page.sitemap?.alternates ?? [], page.html?.hreflang ?? []];
98
+ if (listed.length === 0 || linked.length === 0)
99
+ return;
100
+ const [inSitemap, onPage] = [pairs(listed, page.url.href), pairs(linked, page.url.href)];
101
+ const differ = [...inSitemap.symmetricDifference(onPage)];
102
+ log.debug({ rule: "sitemap/hreflang", url: page.url.href, sitemap: inSitemap.size, page: onPage.size, differ: differ.length }, "sitemap alternates compared");
103
+ if (differ.length === 0)
104
+ return [];
105
+ const only = (side) => differ.filter((pair) => side.has(pair)).join(", ");
106
+ return [{ rule: "sitemap/hreflang", severity, scope: "page", url: page.url.href, group: page.group, ...said("sitemap alternates differ from the page’s hreflang"), data: { [page.url.href]: { "sitemap only": only(inSitemap), "page only": only(onPage) } }, value: differ }];
107
+ },
108
+ });
109
+ // A title or description confidently identified as a language other than the primary subtag of `lang`.
110
+ const metadataLanguage = (severity) => ({
111
+ meta: { id: "i18n/metadata-language", severity, scope: "page", facts: ["html.detected", "html.lang"], docs: "https://www.w3.org/International/questions/qa-html-language-declarations", fix: "Translate the title and meta description into the language lang declares, or correct lang." },
112
+ check(page) {
113
+ const [lang, detected] = [page.html?.lang, page.html?.detected];
114
+ if (!lang || !detected)
115
+ return;
116
+ const declared = primary(lang);
117
+ const off = ["title", "description"].flatMap((field) => {
118
+ const guess = detected[field];
119
+ return guess?.reliable && guess.language !== declared ? [`${field} reads as ${guess.language} (${guess.confidence})`] : [];
120
+ });
121
+ log.debug({ rule: "i18n/metadata-language", url: page.url.href, lang, declared, title: detected.title?.language, description: detected.description?.language, off: off.length }, "metadata language compared");
122
+ return off.length === 0 ? [] : [{ rule: "i18n/metadata-language", severity, scope: "page", url: page.url.href, group: page.group, ...said("the title or description reads as another language than lang declares"), data: { [page.url.href]: { lang } }, locations: off, value: detected }];
123
+ },
124
+ });
125
+ export const i18nRules = {
126
+ "i18n/metadata-language": metadataLanguage,
127
+ "sitemap/hreflang": sitemapHreflang,
128
+ "i18n/hreflang-reciprocal": hreflangReciprocal,
129
+ "i18n/hreflang-status": hreflangStatus,
130
+ "i18n/content-language": contentLanguage,
131
+ };
@@ -0,0 +1,100 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { log } from "../logger.js";
5
+ import { median } from "../report/stats.js";
6
+ import { get } from "./declarative.js";
7
+ import { said } from "./message.js";
8
+ // Iglewicz and Hoaglin’s modified z-score scale and cut-off.
9
+ const Z_SCALE = 0.6745;
10
+ // Their mean absolute deviation fallback when over half the values are equal.
11
+ const MEAN_SCALE = 0.797885;
12
+ // Pages the dominance test weighs the largest value against.
13
+ const NEXT = 10;
14
+ const DOCS = "https://www.itl.nist.gov/div898/handbook/eda/section3/eda35h.htm";
15
+ const NUMERIC = { facts: ["co2.grams", "http.size.body", "resources.length", "http.timing.total"], z: 3.5, ratio: 3, "min-pages": 20 };
16
+ const MINORITY = { facts: ["http.version", "http.headers.content-encoding", "http.headers.cache-control"], share: 0.1, dominant: 0.8, "min-pages": 20 };
17
+ // Pages carrying a value for `read`, grouped by it in first-seen order.
18
+ export function partition(pages, read) {
19
+ return Map.groupBy(pages.filter((page) => read(page) !== undefined), (page) => read(page));
20
+ }
21
+ // Pages that answered 2xx, the only ones an insight compares.
22
+ function served(pages) {
23
+ return pages.filter((page) => page.http.status >= 200 && page.http.status < 300);
24
+ }
25
+ // A fact as one comparable string, repeated headers joined; objects and arrays are not categories.
26
+ function category(page, path) {
27
+ const value = get(page, path);
28
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
29
+ return value.join(", ");
30
+ return ["string", "number", "boolean"].includes(typeof value) ? String(value) : undefined;
31
+ }
32
+ // The message sentences; each listed page’s own value rides in the finding’s `data`.
33
+ const SENTENCES = {
34
+ outlier: "{label} is far above the median {median} of {count} pages",
35
+ dominates: "{label} is far above the median {median} of {count} pages; the first alone outweighs the next {next} pages combined",
36
+ minority: "{label} is {common} on {count} of {total} pages, but not on these",
37
+ };
38
+ // The pages whose `path` lies far above the rest by modified z-score and by `ratio` times the median.
39
+ function outliers(pages, path, settings, severity) {
40
+ const measured = pages.flatMap((page) => {
41
+ const value = get(page, path);
42
+ return typeof value === "number" && Number.isFinite(value) ? [{ url: page.url.href, value }] : [];
43
+ });
44
+ if (measured.length < settings["min-pages"])
45
+ return undefined;
46
+ const sorted = measured.map((entry) => entry.value).toSorted((a, b) => a - b);
47
+ const middle = median(sorted);
48
+ const deviations = sorted.map((value) => Math.abs(value - middle));
49
+ const mad = median(deviations.toSorted((a, b) => a - b));
50
+ const scale = mad > 0 ? mad / Z_SCALE : deviations.reduce((sum, value) => sum + value, 0) / deviations.length / MEAN_SCALE;
51
+ const far = scale > 0 ? measured.filter((entry) => (entry.value - middle) / scale > settings.z && entry.value >= middle * settings.ratio).toSorted((a, b) => b.value - a.value) : [];
52
+ log.debug({ rule: "insight/numeric-outlier", fact: path, pages: measured.length, median: middle, mad, scale, outliers: far.length }, "numeric fact compared");
53
+ const [top] = far;
54
+ if (!top)
55
+ return undefined;
56
+ const next = sorted.slice(-NEXT - 1, -1).reduce((sum, value) => sum + value, 0);
57
+ const isDominant = sorted.length > NEXT && top.value > next;
58
+ const data = Object.fromEntries(far.map((entry) => [entry.url, { value: { fact: path, value: entry.value }, ...(middle > 0 && { ratio: { ratio: entry.value / middle } }) }]));
59
+ return {
60
+ rule: "insight/numeric-outlier",
61
+ severity,
62
+ scope: "site",
63
+ url: top.url,
64
+ ...said(isDominant ? SENTENCES.dominates : SENTENCES.outlier, { label: { name: path }, median: { fact: path, value: middle }, count: measured.length, ...(isDominant && { next: NEXT }) }),
65
+ data,
66
+ value: { median: middle, ...Object.fromEntries(far.map((entry) => [entry.url, entry.value])) },
67
+ urls: far.map((entry) => entry.url),
68
+ };
69
+ }
70
+ // The pages holding a rare value of `path` while most pages share one other value.
71
+ function minority(pages, path, settings, severity) {
72
+ const byValue = [...partition(pages, (page) => category(page, path))].toSorted(([, a], [, b]) => b.length - a.length);
73
+ const total = byValue.reduce((sum, [, members]) => sum + members.length, 0);
74
+ const [top, ...rest] = byValue;
75
+ const rare = rest.filter(([, members]) => members.length / total <= settings.share);
76
+ const isDominant = top !== undefined && top[1].length / total >= settings.dominant;
77
+ log.debug({ rule: "insight/minority-value", fact: path, pages: total, values: byValue.length, top: top?.[0], rare: rare.length, isDominant }, "categorical fact compared");
78
+ if (!top || !isDominant || total < settings["min-pages"] || rare.length === 0)
79
+ return undefined;
80
+ const urls = rare.flatMap(([, members]) => members.map((page) => page.url.href));
81
+ const data = Object.fromEntries(rare.flatMap(([value, members]) => members.map((page) => [page.url.href, { value }])));
82
+ return { rule: "insight/minority-value", severity, scope: "site", url: urls[0], ...said(SENTENCES.minority, { label: { name: path }, common: top[0], count: top[1].length, total }), data, value: Object.fromEntries(byValue.map(([value, members]) => [value, members.length])), urls };
83
+ }
84
+ // One finding per numeric fact whose pages hold robust outliers above the median.
85
+ const numericOutlier = (severity, expect) => {
86
+ const settings = { ...NUMERIC, ...expect };
87
+ return {
88
+ meta: { id: "insight/numeric-outlier", severity, scope: "site", facts: settings.facts, docs: DOCS, fix: "Find what makes the listed pages cost more than the rest, an uncached query or an oversized image, and bring them near the median." },
89
+ check: (pages) => settings.facts.map((path) => outliers(served(pages), path, settings, severity)).filter((finding) => finding !== undefined),
90
+ };
91
+ };
92
+ // One finding per categorical fact where a few pages break the value nearly every page shares.
93
+ const minorityValue = (severity, expect) => {
94
+ const settings = { ...MINORITY, ...expect };
95
+ return {
96
+ meta: { id: "insight/minority-value", severity, scope: "site", facts: settings.facts, fix: "Serve the listed pages the way the rest of the site serves them, or keep the difference only where it is deliberate." },
97
+ check: (pages) => settings.facts.map((path) => minority(served(pages), path, settings, severity)).filter((finding) => finding !== undefined),
98
+ };
99
+ };
100
+ export const insightRules = { "insight/numeric-outlier": numericOutlier, "insight/minority-value": minorityValue };
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { MISMATCH } from "../crawl/fetch.js";
5
+ import { log } from "../logger.js";
6
+ import { said } from "./message.js";
7
+ // What a resource finding names first.
8
+ const SUBJECT = "{kind}:";
9
+ // What is wrong with a response’s framing headers, or undefined when nothing is.
10
+ export function framingFault(status, headers, error) {
11
+ const hasLength = headers["content-length"] !== undefined;
12
+ const detail = error?.startsWith(MISMATCH) ? error.slice(MISMATCH.length + 2) : undefined;
13
+ if (detail)
14
+ return { text: "the response body ended short of the bytes Content-Length declares", detail };
15
+ if (detail !== undefined)
16
+ return { text: "the response body does not match Content-Length, or Content-Length is sent twice or beside chunked" };
17
+ if (hasLength && headers["transfer-encoding"] !== undefined)
18
+ return { text: "the response sends Content-Length beside Transfer-Encoding" };
19
+ return hasLength && status === 204 ? { text: "a 204 response carries Content-Length" } : undefined;
20
+ }
21
+ // One finding per host whose pages, and per resource whose response, frame the body wrongly.
22
+ const contentLength = (severity) => ({
23
+ meta: { id: "http/content-length", severity, scope: "site", facts: ["http.headers", "resources"], docs: "https://www.rfc-editor.org/rfc/rfc9112#section-6.3", fix: "Send one Content-Length equal to the body, none beside Transfer-Encoding and none on a 204." },
24
+ check(pages) {
25
+ const byFault = new Map();
26
+ const note = (key, subject, fault, url, kind) => byFault.set(key, { subject, ...(kind && { kind }), fault, urls: (byFault.get(key)?.urls ?? new Set()).add(url) });
27
+ for (const page of pages) {
28
+ const fault = framingFault(page.http.status, page.http.headers, page.http.error);
29
+ log.debug({ rule: "http/content-length", url: page.url.href, fault }, "page framing judged");
30
+ if (fault)
31
+ note(`${page.url.host}\t${fault.text}\t${fault.detail}`, page.url.host, fault, page.url.href);
32
+ const resources = page.resources ?? [];
33
+ for (const resource of resources) {
34
+ const http = resource.http;
35
+ const found = http && framingFault(http.status, http.headers, http.error);
36
+ if (found)
37
+ note(resource.url, resource.url, found, page.url.href, resource.kind);
38
+ }
39
+ }
40
+ const findings = [];
41
+ for (const [key, { subject, kind, fault, urls }] of byFault) {
42
+ const listed = [...urls].toSorted((a, b) => a.localeCompare(b));
43
+ const sentence = kind === undefined ? said(`${fault.text}; on these pages`) : said(`${SUBJECT} ${fault.text}; used by these pages`, { kind });
44
+ log.debug({ rule: "http/content-length", subject, key, pages: listed.length }, "framing fault grouped");
45
+ findings.push({ rule: "http/content-length", severity, scope: "site", url: subject, ...sentence, ...(fault.detail && { data: { [subject]: { detail: fault.detail } } }), urls: listed });
46
+ }
47
+ return findings;
48
+ },
49
+ });
50
+ export const lengthRules = {
51
+ "http/content-length": contentLength,
52
+ };
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { label, withUnit } from "../facts/labels.js";
5
+ import { utc } from "../facts/read-note.js";
6
+ import { translator } from "../i18n.js";
7
+ const english = translator("en");
8
+ // Numbers as English writes them, one decimal.
9
+ export const inEnglish = (value, options) => english.number(value, options);
10
+ // A datum as text, numbers in `format`, a fact’s label through `translate`.
11
+ export function written(datum, format, translate = (text) => text) {
12
+ if (typeof datum === "string")
13
+ return datum;
14
+ if (typeof datum === "number")
15
+ return format(datum);
16
+ if ("fact" in datum)
17
+ return withUnit(datum.fact, datum.value, format);
18
+ if ("name" in datum)
19
+ return translate(label(datum.name) ?? datum.name);
20
+ return "ratio" in datum ? `${format(datum.ratio)}×` : utc(datum.at);
21
+ }
22
+ // Every datum of a record as text, numbers in `format`, labels through `translate`.
23
+ export function writtenAll(data = {}, format, translate) {
24
+ return Object.fromEntries(Object.entries(data).map(([key, datum]) => [key, written(datum, format, translate)]));
25
+ }
26
+ // A finding’s sentence in `t`’s language and locale; a finding with no template keeps its English message.
27
+ export function sentence(finding, t = english) {
28
+ return finding.text === undefined
29
+ ? finding.message
30
+ : t._(finding.text, writtenAll(finding.variables, t.number, (text) => t._(text)));
31
+ }
32
+ // The message fields of a finding from its template and shared values.
33
+ export function said(text, variables) {
34
+ return { message: sentence({ message: "", text, variables }), text, ...(variables && { variables }) };
35
+ }
36
+ // The values measured at `url`, as text in `format`, in the order the rule wrote them.
37
+ export function valuesAt(finding, url, format) {
38
+ return Object.values(writtenAll(finding.data?.[url], format));
39
+ }
40
+ // Observations the findings rest on that this run took from a cache rather than the origin, once each.
41
+ export function cachedReads(findings) {
42
+ return new Map(findings
43
+ .flatMap((finding) => finding.evidence ?? [])
44
+ .filter((read) => read.via === "cache")
45
+ .map((read) => [`${read.bucket}:${read.key}`, read]))
46
+ .values()
47
+ .toArray();
48
+ }
@@ -0,0 +1,88 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import picomatch from "picomatch";
5
+ import { ConfigError } from "../config/index.js";
6
+ import { REGIONS } from "../facts/html.js";
7
+ import { log } from "../logger.js";
8
+ import { said } from "./message.js";
9
+ // Internal links carrying `nofollow`, which withholds the site’s own ranking from its pages.
10
+ const internalNofollow = (severity) => ({
11
+ meta: {
12
+ id: "links/internal-nofollow",
13
+ severity,
14
+ scope: "page",
15
+ facts: ["html.links.internal", "html.links.nofollow"],
16
+ docs: "https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links",
17
+ fix: "Drop rel=nofollow from links to the site’s own pages; keep robots.txt or noindex for pages crawlers should skip.",
18
+ },
19
+ check(page) {
20
+ if (!page.html)
21
+ return;
22
+ const internal = new Set(page.html.links.internal);
23
+ const found = page.html.links.nofollow.filter((href) => internal.has(href));
24
+ log.debug({ rule: "links/internal-nofollow", url: page.url.href, found: found.length }, "internal nofollow checked");
25
+ return found.length === 0 ? [] : [{ rule: "links/internal-nofollow", severity, scope: "page", url: page.url.href, group: page.group, ...said("internal links carry rel=nofollow"), data: { [page.url.href]: { links: found.length } }, locations: found, value: found }];
26
+ },
27
+ });
28
+ // The `expect` of `links/external-rel`: `[region:]host glob` → the rel tokens a link to a matching host, inside that region when named, carries.
29
+ function policyOf(expect) {
30
+ const entries = Object.entries((expect ?? {}));
31
+ return entries.map(([key, tokens]) => {
32
+ const list = [tokens].flat();
33
+ const [region, glob] = key.includes(":") ? key.split(":", 2) : [undefined, key];
34
+ if (region !== undefined && !Object.hasOwn(REGIONS, region))
35
+ throw new ConfigError(`rule links/external-rel: expect ${key} names region ${region}, not one of ${Object.keys(REGIONS).join(", ")}`);
36
+ if (glob.length === 0 || list.length === 0 || list.some((token) => typeof token !== "string" || !/^[a-z-]+$/.test(token)))
37
+ throw new ConfigError(`rule links/external-rel: expect ${key || '""'} must name lower-case rel tokens, found ${JSON.stringify(tokens)}`);
38
+ return { ...(region !== undefined && { region }), isMatch: picomatch(glob, { nocase: true }), tokens: list };
39
+ });
40
+ }
41
+ // External links whose host a declared glob matches and that lack a token it requires.
42
+ const externalRelation = (severity, expect) => {
43
+ const policy = policyOf(expect);
44
+ return {
45
+ meta: {
46
+ id: "links/external-rel",
47
+ severity,
48
+ scope: "page",
49
+ facts: ["html.links.external", "html.links.rel"],
50
+ docs: "https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links",
51
+ fix: "Add the rel tokens the policy names, as sponsored on paid links and ugc on user content, rather than nofollow on every external link.",
52
+ expect: expect,
53
+ },
54
+ check(page) {
55
+ if (!page.html || policy.length === 0)
56
+ return;
57
+ const findings = [];
58
+ const links = page.html.links;
59
+ const scopes = [
60
+ [undefined, links.external.map((href) => [href, links.rel?.[href] ?? []])],
61
+ ...new Set(policy.flatMap(({ region }) => region ?? [])).values().map((region) => [region, Object.entries(links.regions?.[region] ?? {})]),
62
+ ];
63
+ for (const [region, hrefs] of scopes) {
64
+ for (const [href, carried] of hrefs) {
65
+ const missing = [...new Set(policy.filter((entry) => entry.region === region && entry.isMatch(new URL(href).hostname)).flatMap(({ tokens }) => tokens))].filter((token) => !carried.includes(token));
66
+ if (missing.length === 0)
67
+ continue;
68
+ log.debug({ rule: "links/external-rel", url: page.url.href, href, region, carried, missing }, "external link lacks declared rel");
69
+ findings.push({
70
+ rule: "links/external-rel",
71
+ severity,
72
+ scope: "page",
73
+ url: page.url.href,
74
+ group: page.group,
75
+ ...said("a link lacks the rel tokens declared for its host"),
76
+ data: { [page.url.href]: { href, missing: missing.join(" "), ...(carried.length > 0 && { carried: carried.join(" ") }), ...(region !== undefined && { region }) } },
77
+ value: { href, missing, ...(region !== undefined && { region }) },
78
+ });
79
+ }
80
+ }
81
+ return findings;
82
+ },
83
+ };
84
+ };
85
+ export const relationRules = {
86
+ "links/internal-nofollow": internalNofollow,
87
+ "links/external-rel": externalRelation,
88
+ };
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { log } from "../logger.js";
5
+ import { said } from "./message.js";
6
+ // AI crawler product tokens by purpose; `retired` ones no vendor still sends.
7
+ export const AI_CRAWLERS = {
8
+ gptbot: "training",
9
+ claudebot: "training",
10
+ ccbot: "training",
11
+ "google-extended": "training",
12
+ "applebot-extended": "training",
13
+ bytespider: "training",
14
+ "meta-externalagent": "training",
15
+ "cohere-training-data-crawler": "training",
16
+ ai2bot: "training",
17
+ "oai-searchbot": "search",
18
+ "claude-searchbot": "search",
19
+ perplexitybot: "search",
20
+ "chatgpt-user": "user",
21
+ "claude-user": "user",
22
+ "perplexity-user": "user",
23
+ "anthropic-ai": "retired",
24
+ "claude-web": "retired",
25
+ "cohere-ai": "retired",
26
+ };
27
+ // Content Signals keys and the values each takes.
28
+ const SIGNALS = new Set(["search", "ai-input", "ai-train"]);
29
+ const VERDICTS = new Set(["yes", "no"]);
30
+ // A group shutting its agents out of every path.
31
+ function isBlanket(group) {
32
+ return group.disallow.includes("/") && group.allow.length === 0;
33
+ }
34
+ // One site rule per robots.txt file: `judge` returns the finding’s sentence and value, or nothing.
35
+ function robotsRule(id, documentation, judge, fix) {
36
+ return (severity) => ({
37
+ meta: { id, severity, scope: "site", facts: ["site.robots"], docs: documentation, ...(fix && { fix }) },
38
+ check(_pages, _group, site) {
39
+ const findings = [];
40
+ const files = site?.robots ?? [];
41
+ for (const file of files) {
42
+ const verdict = judge(file);
43
+ log.debug({ rule: id, url: file.url, isFinding: verdict !== undefined }, "robots.txt judged");
44
+ if (verdict)
45
+ findings.push({ rule: id, severity, scope: "site", url: file.url, ...verdict });
46
+ }
47
+ return findings;
48
+ },
49
+ });
50
+ }
51
+ // Every group naming `*` that disallows `/` and allows nothing back.
52
+ const disallowAll = robotsRule("robots/disallow-all", "https://www.rfc-editor.org/rfc/rfc9309#section-2.2.2", (file) => {
53
+ const blanket = file.groups.filter((group) => group.agents.includes("*") && isBlanket(group));
54
+ return blanket.length > 0 ? { ...said("User-agent: * is disallowed from every path, so no crawler indexes the site"), value: "/" } : undefined;
55
+ }, "Remove the Disallow: / rule from the User-agent: * group, or add Allow rules for the paths you want crawled.");
56
+ // The AI crawlers the file names, each with its purpose and whether it is shut out.
57
+ const aiCrawlers = robotsRule("robots/ai-crawlers", "https://www.rfc-editor.org/rfc/rfc9309#section-2.2.1", (file) => {
58
+ const named = file.groups.flatMap((group) => group.agents.flatMap((agent) => (Object.hasOwn(AI_CRAWLERS, agent) ? [{ agent, purpose: AI_CRAWLERS[agent], blocked: isBlanket(group) }] : [])));
59
+ if (named.length === 0)
60
+ return;
61
+ return { ...said("the file names AI crawlers"), locations: named.map(({ agent, purpose, blocked }) => `${agent} (${purpose}${blocked ? ", disallowed" : ""})`), value: named };
62
+ }, "Confirm each named AI crawler is allowed or disallowed as intended.");
63
+ // Every `Content-Signal` line naming an unknown signal, a value other than yes or no, or nothing.
64
+ const contentSignal = robotsRule("robots/content-signal", "https://contentsignals.org/", (file) => {
65
+ const malformed = file["content-signals"].filter(({ signals }) => {
66
+ const entries = Object.entries(signals);
67
+ return entries.length === 0 || entries.some(([key, value]) => !SIGNALS.has(key) || !VERDICTS.has(value));
68
+ });
69
+ return malformed.length > 0 ? { ...said("Content-Signal should be search, ai-input or ai-train set to yes or no"), data: { [file.url]: { found: malformed.map((line) => line.value).join(", ") } }, value: malformed.map((line) => line.value) } : undefined;
70
+ }, "Write each Content-Signal line as comma-separated signal=yes|no pairs, e.g. Content-Signal: search=yes, ai-input=no, ai-train=no.");
71
+ export const robotsRules = {
72
+ "robots/disallow-all": disallowAll,
73
+ "robots/ai-crawlers": aiCrawlers,
74
+ "robots/content-signal": contentSignal,
75
+ };
@@ -0,0 +1,134 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { readdirSync, readFileSync } from "node:fs";
5
+ import picomatch from "picomatch";
6
+ import { parse } from "yaml";
7
+ import { ConfigError } from "../config/index.js";
8
+ import { log } from "../logger.js";
9
+ import { pageReader, pluginPreset, pluginPresetNames, ruleMaker } from "../plugins/index.js";
10
+ import { compileRule } from "./declarative.js";
11
+ const PRESETS = new URL("../../presets/", import.meta.url);
12
+ const PREFIX = "spiderlint:";
13
+ const ALL = "all";
14
+ const SERVER = "server";
15
+ const presetCache = new Map();
16
+ // A plugin’s preset, else presets/<name>.yaml read once; undefined when no such preset ships.
17
+ function preset(name) {
18
+ if (name === ALL)
19
+ return {
20
+ description: "Every rule that ships or a loaded plugin adds",
21
+ extends: presetNames()
22
+ .filter((other) => other !== ALL && other !== SERVER)
23
+ .map((other) => `${PREFIX}${other}`),
24
+ };
25
+ if (name === SERVER)
26
+ return serverPreset();
27
+ const plugged = pluginPreset(name);
28
+ if (plugged)
29
+ return plugged;
30
+ if (!presetCache.has(name)) {
31
+ try {
32
+ const text = readFileSync(new URL(`${name}.yaml`, PRESETS), "utf8");
33
+ presetCache.set(name, parse(text));
34
+ }
35
+ catch (error) {
36
+ if (error.code !== "ENOENT")
37
+ throw new ConfigError(`preset ${PREFIX}${name}: ${error instanceof Error ? error.message : String(error)}`);
38
+ }
39
+ }
40
+ return presetCache.get(name);
41
+ }
42
+ // The `recommended` rules that judge only the seeds’ origins and hosts, so running them alone fetches no page past a seed.
43
+ function serverPreset() {
44
+ const cached = presetCache.get(SERVER);
45
+ if (cached)
46
+ return cached;
47
+ const specs = Object.entries(resolveRuleset(`${PREFIX}recommended`, {}));
48
+ const rules = Object.fromEntries(specs.filter(([id, spec]) => spec.severity !== "off" && !pageReader([compileRule(id, spec)])));
49
+ log.debug({ rules: Object.keys(rules).length, of: specs.length }, "server preset derived");
50
+ const derived = { description: "The recommended rules about the server, DNS and domain, which fetch only the seed pages", rules };
51
+ presetCache.set(SERVER, derived);
52
+ return derived;
53
+ }
54
+ // A bare name is the user's ruleset, else the bundled preset; the prefix forces the preset, and `all` is always every preset.
55
+ export function lookup(name, rulesets) {
56
+ if (name === ALL && Object.hasOwn(rulesets, ALL))
57
+ throw new ConfigError(`ruleset ${ALL}: reserved for every shipped rule; rename it`);
58
+ return name.startsWith(PREFIX) ? preset(name.slice(PREFIX.length)) : (rulesets[name] ?? preset(name));
59
+ }
60
+ // Every preset that ships or a plugin adds, by bare name.
61
+ export function presetNames() {
62
+ const files = readdirSync(PRESETS)
63
+ .filter((file) => file.endsWith(".yaml"))
64
+ .map((file) => file.slice(0, -".yaml".length));
65
+ return [...files, ALL, SERVER, ...pluginPresetNames()].toSorted((a, b) => a.localeCompare(b));
66
+ }
67
+ // A level pins the rule’s score to its band; a number replaces the level.
68
+ function entryOf(entry, isUser) {
69
+ if (typeof entry === "number")
70
+ return { score: entry, severity: undefined, pinned: undefined };
71
+ const spec = typeof entry === "string" ? { severity: entry } : entry;
72
+ const isPinned = spec.severity !== undefined && spec.score === undefined && (typeof entry === "string" || isUser);
73
+ return isPinned ? { ...spec, pinned: true } : spec;
74
+ }
75
+ // Flattens `extends` depth-first; later entries override earlier ones per rule ID, `expect` keyword by keyword.
76
+ export function resolveRuleset(name, rulesets, seen = []) {
77
+ if (seen.includes(name))
78
+ throw new ConfigError(`ruleset ${name}: extends itself through ${seen.join(" → ")}`);
79
+ const config = lookup(name, rulesets);
80
+ if (!config)
81
+ return selectRules(name, rulesets, seen);
82
+ const merged = {};
83
+ const parents = config.extends ?? [];
84
+ const isUser = !name.startsWith(PREFIX) && Object.hasOwn(rulesets, name);
85
+ for (const parent of parents)
86
+ Object.assign(merged, resolveRuleset(parent, rulesets, [...seen, name]));
87
+ const own = Object.entries(config.rules ?? {});
88
+ for (const [id, entry] of own) {
89
+ if (typeof entry !== "object" && merged[id] === undefined && ruleMaker(id) === undefined)
90
+ throw new ConfigError(`ruleset ${name}: rule ${id} sets ${entry} but is not defined`);
91
+ const spec = entryOf(entry, isUser);
92
+ // A new `expect` drops the inherited sentence, which may state the old bounds.
93
+ const expect = spec.expect && { expect: { ...merged[id]?.expect, ...spec.expect }, message: spec.message };
94
+ merged[id] = { ...merged[id], ...spec, ...expect, ...(config.when && { when: { ...config.when, ...merged[id]?.when, ...spec.when } }) };
95
+ }
96
+ log.debug({ ruleset: name, rules: Object.keys(merged).length }, "ruleset resolved");
97
+ return merged;
98
+ }
99
+ // A name no ruleset carries, read as a rule ID or glob over every shipped rule.
100
+ function selectRules(pattern, rulesets, seen) {
101
+ const shipped = Object.entries(resolveRuleset(`${PREFIX}${ALL}`, rulesets, [...seen, pattern]));
102
+ const picked = Object.fromEntries(shipped.filter(([id]) => isRuleMatch(id, pattern)));
103
+ log.debug({ pattern, rules: Object.keys(picked) }, "ruleset name read as rule IDs");
104
+ if (Object.keys(picked).length === 0)
105
+ throw new ConfigError(`ruleset ${pattern}: not defined, and no rule ID matches it`);
106
+ return picked;
107
+ }
108
+ // Whether a rule ID is `pattern` or matches it as a glob (`lighthouse/*`).
109
+ export function isRuleMatch(id, pattern) {
110
+ return id === pattern || picomatch.isMatch(id, pattern);
111
+ }
112
+ // Every rule ID the named rulesets define, `off` ones included.
113
+ export function ruleIds(names, rulesets) {
114
+ return new Set(names.flatMap((name) => Object.keys(resolveRuleset(name, rulesets))));
115
+ }
116
+ // Union of the named rulesets, compiled; `off` rules — including `--exclude-rules` and a
117
+ // `--error`/`--warning`/`--info` override landing on `off` — are dropped (AGENTS.md ## Rules).
118
+ export function compileRulesets(names, rulesets, excludeRules = new Set(), overrides = {}) {
119
+ const specs = {};
120
+ for (const name of names)
121
+ Object.assign(specs, resolveRuleset(name, rulesets));
122
+ const rules = [];
123
+ for (const [id, spec] of Object.entries(specs)) {
124
+ const isExcluded = [...excludeRules].some((pattern) => isRuleMatch(id, pattern));
125
+ const override = overrides[id] ?? Object.entries(overrides).find(([pattern]) => isRuleMatch(id, pattern))?.[1];
126
+ const severity = isExcluded ? "off" : (override ?? spec.severity);
127
+ if (severity === "off") {
128
+ log.debug({ rule: id }, "rule off");
129
+ continue;
130
+ }
131
+ rules.push(compileRule(id, severity === spec.severity ? spec : { ...spec, severity, pinned: true }));
132
+ }
133
+ return rules;
134
+ }