@fulldotdev/scan 0.1.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 (55) hide show
  1. package/README.md +79 -0
  2. package/bin/fullscan.js +2 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +239 -0
  5. package/dist/engine/analysis.d.ts +2 -0
  6. package/dist/engine/analysis.js +747 -0
  7. package/dist/engine/browser-inspection.d.ts +143 -0
  8. package/dist/engine/browser-inspection.js +567 -0
  9. package/dist/engine/browser.d.ts +22 -0
  10. package/dist/engine/browser.js +629 -0
  11. package/dist/engine/collect.d.ts +6 -0
  12. package/dist/engine/collect.js +359 -0
  13. package/dist/engine/crawl-scope.d.ts +22 -0
  14. package/dist/engine/crawl-scope.js +145 -0
  15. package/dist/engine/env.d.ts +1 -0
  16. package/dist/engine/env.js +3 -0
  17. package/dist/engine/html.d.ts +307 -0
  18. package/dist/engine/html.js +645 -0
  19. package/dist/engine/language.d.ts +13 -0
  20. package/dist/engine/language.js +75 -0
  21. package/dist/engine/lighthouse-evidence.d.ts +36 -0
  22. package/dist/engine/lighthouse-evidence.js +69 -0
  23. package/dist/engine/lighthouse.d.ts +4 -0
  24. package/dist/engine/lighthouse.js +284 -0
  25. package/dist/engine/log.d.ts +1 -0
  26. package/dist/engine/log.js +4 -0
  27. package/dist/engine/network.d.ts +53 -0
  28. package/dist/engine/network.js +296 -0
  29. package/dist/engine/proxy.d.ts +8 -0
  30. package/dist/engine/proxy.js +95 -0
  31. package/dist/engine/run.d.ts +38 -0
  32. package/dist/engine/run.js +202 -0
  33. package/dist/engine/select.d.ts +6 -0
  34. package/dist/engine/select.js +38 -0
  35. package/dist/engine/site.d.ts +186 -0
  36. package/dist/engine/site.js +758 -0
  37. package/dist/engine/srcset.d.ts +1 -0
  38. package/dist/engine/srcset.js +31 -0
  39. package/dist/engine/state.d.ts +30 -0
  40. package/dist/engine/state.js +198 -0
  41. package/dist/engine/structured-data.d.ts +83 -0
  42. package/dist/engine/structured-data.js +331 -0
  43. package/dist/engine/types.d.ts +128 -0
  44. package/dist/engine/types.js +63 -0
  45. package/dist/engine.d.ts +1 -0
  46. package/dist/engine.js +1 -0
  47. package/dist/index.d.ts +8 -0
  48. package/dist/index.js +7 -0
  49. package/dist/report/build.d.ts +377 -0
  50. package/dist/report/build.js +2263 -0
  51. package/dist/report/evidence.d.ts +58 -0
  52. package/dist/report/evidence.js +192 -0
  53. package/dist/report/rules.d.ts +41 -0
  54. package/dist/report/rules.js +301 -0
  55. package/package.json +52 -0
@@ -0,0 +1,58 @@
1
+ import type { Observation, UrlRow } from "../engine/types.js";
2
+ import type { StoredFinding } from "./build.js";
3
+ export interface PageRow {
4
+ url: string;
5
+ status: number | null;
6
+ outcome: string | null;
7
+ html: boolean;
8
+ title: string | null;
9
+ type: string | null;
10
+ language: string | null;
11
+ indexable: boolean | null;
12
+ canonical: string | null;
13
+ depth: number | null;
14
+ inSitemap: boolean;
15
+ wordCount: number | null;
16
+ template: string | null;
17
+ browser: string | null;
18
+ lighthouse: {
19
+ mobile: Record<string, number | null> | null;
20
+ desktop: Record<string, number | null> | null;
21
+ };
22
+ findings: {
23
+ critical: number;
24
+ warning: number;
25
+ hint: number;
26
+ };
27
+ }
28
+ export declare function pageRows(records: Observation[], urls: UrlRow[], findings: StoredFinding[], templateOf?: (page: Observation) => string): PageRow[];
29
+ export type AssetType = "image" | "script" | "style" | "font" | "document" | "page" | "feed" | "other";
30
+ export interface AssetRow {
31
+ url: string;
32
+ type: AssetType;
33
+ external: boolean;
34
+ status: number | null;
35
+ outcome: string | null;
36
+ contentType: string | null;
37
+ bytes: number | null;
38
+ sizeEvidence: "complete-response" | "response-prefix" | "content-length" | "unknown";
39
+ checked: boolean;
40
+ skipReason: string | null;
41
+ redirects: number;
42
+ usage: {
43
+ kind: string;
44
+ page: string;
45
+ }[];
46
+ pages: number;
47
+ }
48
+ export declare function assetType(url: string, contentType: string | null, sniffed: string | null): AssetType;
49
+ export declare function assetInventory(records: Observation[], urls: UrlRow[], siteUrl: string): AssetRow[];
50
+ export interface IncomingLink {
51
+ source: string;
52
+ target: string;
53
+ text: string | null;
54
+ location: string | null;
55
+ rel: string | null;
56
+ representation: "original" | "rendered";
57
+ }
58
+ export declare function incomingLinks(records: Observation[], target: string): IncomingLink[];
@@ -0,0 +1,192 @@
1
+ import { assetExtension } from "../engine/crawl-scope.js";
2
+ import { sameSite } from "../engine/network.js";
3
+ export function pageRows(records, urls, findings, templateOf) {
4
+ const graph = new Map((records.find((r) => r.kind === "link-graph")?.data?.pages ?? []).map((p) => [p.url, p]));
5
+ const browser = new Map(records.filter((r) => r.kind === "browser").map((r) => [r.key, r.data]));
6
+ const lighthouse = records.filter((r) => r.kind === "lighthouse");
7
+ const sitemapped = new Set(urls
8
+ .filter((u) => u.sources.some((s) => s.startsWith("sitemap:")))
9
+ .map((u) => u.url));
10
+ const counts = new Map();
11
+ for (const finding of findings)
12
+ for (const url of new Set(finding.items.map((i) => i.url).filter(Boolean)))
13
+ if (url) {
14
+ const row = counts.get(url) ?? { critical: 0, warning: 0, hint: 0 };
15
+ row[finding.severity]++;
16
+ counts.set(url, row);
17
+ }
18
+ const scores = (url, device) => {
19
+ const run = lighthouse.find((l) => l.data.url === url && l.data.device === device && l.data.scores);
20
+ if (!run)
21
+ return null;
22
+ const s = run.data.scores;
23
+ return {
24
+ performance: numberOrNull(s.performance),
25
+ accessibility: numberOrNull(s.accessibility),
26
+ bestPractices: numberOrNull(s["best-practices"]),
27
+ seo: numberOrNull(s.seo),
28
+ };
29
+ };
30
+ return records
31
+ .filter((r) => r.kind === "page")
32
+ .sort((a, b) => a.key.localeCompare(b.key))
33
+ .map((page) => {
34
+ const d = page.data ?? {};
35
+ const html = d.html !== false;
36
+ const directives = [
37
+ d.xRobotsTag,
38
+ ...(d.robots ?? [])
39
+ .filter((r) => /^(robots|googlebot)$/i.test(r.name ?? ""))
40
+ .map((r) => r.content),
41
+ ].join(",");
42
+ return {
43
+ url: page.key,
44
+ status: d.http?.status ?? null,
45
+ outcome: d.http?.outcome ?? null,
46
+ html,
47
+ title: html ? (d.title ?? null) : null,
48
+ type: graph.get(page.key)?.type ?? null,
49
+ language: html ? (d.language ?? null) : null,
50
+ indexable: html ? !/\b(noindex|none)\b/i.test(directives) : null,
51
+ canonical: d.canonicals?.[0]?.href ?? null,
52
+ depth: graph.get(page.key)?.clickDepth ?? null,
53
+ inSitemap: sitemapped.has(page.key),
54
+ wordCount: html ? (d.wordCount ?? null) : null,
55
+ template: html && templateOf ? templateOf(page) : null,
56
+ browser: browser.get(page.key)?.status ?? null,
57
+ lighthouse: {
58
+ mobile: scores(page.key, "mobile"),
59
+ desktop: scores(page.key, "desktop"),
60
+ },
61
+ findings: counts.get(page.key) ?? { critical: 0, warning: 0, hint: 0 },
62
+ };
63
+ });
64
+ }
65
+ const numberOrNull = (value) => typeof value === "number" ? Math.round(value * 100) : null;
66
+ export function assetType(url, contentType, sniffed) {
67
+ const type = (contentType ?? "").toLowerCase();
68
+ if (/^image\//.test(type) ||
69
+ sniffed === "png" ||
70
+ sniffed === "jpeg" ||
71
+ sniffed === "webp" ||
72
+ sniffed === "gif")
73
+ return "image";
74
+ if (/javascript|ecmascript/.test(type))
75
+ return "script";
76
+ if (/text\/css/.test(type))
77
+ return "style";
78
+ if (/font\/|application\/font|application\/x-font/.test(type))
79
+ return "font";
80
+ if (/text\/html|xhtml/.test(type))
81
+ return "page";
82
+ if (/rss|atom/.test(type))
83
+ return "feed";
84
+ if (/pdf|msword|officedocument|zip|csv|text\/plain|markdown/.test(type) ||
85
+ sniffed === "pdf")
86
+ return "document";
87
+ const path = (() => {
88
+ try {
89
+ return new URL(url).pathname;
90
+ }
91
+ catch {
92
+ return "";
93
+ }
94
+ })();
95
+ const extension = assetExtension.exec(path)?.[1]?.toLowerCase();
96
+ if (!extension)
97
+ return contentType ? "other" : "page";
98
+ if (/^(jpe?g|png|gif|svg|webp|avif|ico)$/.test(extension))
99
+ return "image";
100
+ if (/^(js|mjs)$/.test(extension))
101
+ return "script";
102
+ if (extension === "css")
103
+ return "style";
104
+ if (/^(woff2?|ttf|otf)$/.test(extension))
105
+ return "font";
106
+ if (/^(rss|atom)$/.test(extension))
107
+ return "feed";
108
+ if (/^(pdf|zip|docx?|xlsx?|pptx?|csv|txt|md)$/.test(extension))
109
+ return "document";
110
+ return "other";
111
+ }
112
+ // Every discovered file, external destination and linked page with where
113
+ // it is used, its HTTP answer and measured size.
114
+ export function assetInventory(records, urls, siteUrl) {
115
+ const resources = new Map(records.filter((r) => r.kind === "resource").map((r) => [r.key, r.data]));
116
+ return urls
117
+ .filter((u) => u.kind === "resource")
118
+ .sort((a, b) => a.url.localeCompare(b.url))
119
+ .map((u) => {
120
+ const data = resources.get(u.url);
121
+ const http = data?.http;
122
+ const status = http?.status ?? null;
123
+ const usage = u.sources.map((s) => {
124
+ const index = s.indexOf(":");
125
+ return index > 0 && /^https?:/.test(s.slice(index + 1))
126
+ ? { kind: s.slice(0, index), page: s.slice(index + 1) }
127
+ : { kind: s, page: "" };
128
+ });
129
+ let bytes = http?.bytes ?? null;
130
+ let sizeEvidence = "unknown";
131
+ if (http?.outcome === "ok" && bytes !== null) {
132
+ sizeEvidence = http.truncated ? "response-prefix" : "complete-response";
133
+ const length = Number(http.headers?.["content-length"]);
134
+ if (http.truncated &&
135
+ Number.isSafeInteger(length) &&
136
+ length >= bytes &&
137
+ !http.headers?.["content-encoding"] &&
138
+ status === 200) {
139
+ bytes = length;
140
+ sizeEvidence = "content-length";
141
+ }
142
+ }
143
+ return {
144
+ url: u.url,
145
+ type: assetType(u.url, http?.headers?.["content-type"] ?? null, data?.sniffedType ?? null),
146
+ external: !sameSiteSafe(u.url, siteUrl),
147
+ status,
148
+ outcome: http?.outcome ?? null,
149
+ contentType: http?.headers?.["content-type"] ?? null,
150
+ bytes,
151
+ sizeEvidence,
152
+ checked: !!http,
153
+ skipReason: u.skipReason,
154
+ redirects: http?.redirects?.length ?? 0,
155
+ usage,
156
+ pages: new Set(usage.map((x) => x.page).filter(Boolean)).size,
157
+ };
158
+ });
159
+ }
160
+ // Links to one page from original HTML and, where the browser pass ran,
161
+ // links that JavaScript added.
162
+ export function incomingLinks(records, target) {
163
+ const rows = [];
164
+ const seen = new Set();
165
+ for (const kind of ["page", "rendered"])
166
+ for (const record of records)
167
+ if (record.kind === kind)
168
+ for (const link of record.data?.links ?? [])
169
+ if (link.href === target) {
170
+ const id = `${record.key}|${link.fragment ?? ""}`;
171
+ if (kind === "rendered" && seen.has(id))
172
+ continue;
173
+ seen.add(id);
174
+ rows.push({
175
+ source: record.key,
176
+ target,
177
+ text: link.name ?? link.text ?? null,
178
+ location: link.location ?? null,
179
+ rel: link.rel ?? null,
180
+ representation: kind === "page" ? "original" : "rendered",
181
+ });
182
+ }
183
+ return rows;
184
+ }
185
+ function sameSiteSafe(a, b) {
186
+ try {
187
+ return sameSite(a, b);
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
@@ -0,0 +1,41 @@
1
+ export type Topic = "availability-security" | "crawling-indexability" | "content-structure" | "links-navigation" | "structured-data" | "performance" | "accessibility-usability" | "business-profiles" | "ai-agent-access";
2
+ export type Severity = "critical" | "warning" | "hint";
3
+ export type Confidence = "confirmed" | "probable" | "review";
4
+ export type Requirement = "pages" | "browser" | "lighthouse" | "rendered" | "dns" | "tls" | "domain" | "url-variant" | "robots" | "sitemap" | "analysis" | "structured-data" | "field-data" | "safe-browsing" | "ssl-labs" | "blocklists" | "certificates" | "ai-discovery" | "options";
5
+ export declare const topicLabels: Record<Topic, string>;
6
+ export declare const topicOrder: Topic[];
7
+ export interface Rule {
8
+ topic: Topic;
9
+ title: string;
10
+ severity: Severity;
11
+ confidence: Confidence;
12
+ requires: Requirement;
13
+ problem: string;
14
+ impact: string;
15
+ }
16
+ export declare const rules: Record<string, Rule>;
17
+ export declare const areaTopics: Record<string, Topic>;
18
+ export declare function findingGuidance(code: string, area?: string): {
19
+ topic: Topic;
20
+ problem: string;
21
+ impact: string;
22
+ confidence: Confidence;
23
+ };
24
+ export declare const auditCondition: (id: string) => string;
25
+ export declare const accessibilityCondition: (id: string, manual?: boolean) => string;
26
+ export declare function diagnosticEvidence<T>(value: T): T;
27
+ export declare function publicFinding<T extends {
28
+ code: string;
29
+ title: string;
30
+ group?: string;
31
+ id?: string;
32
+ area?: string;
33
+ problem?: string;
34
+ impact?: string;
35
+ }>(finding: T): T;
36
+ export declare function unverifiedChanges<T>(changes: {
37
+ resolvedFindings?: T[];
38
+ fixedFindings?: T[];
39
+ unverifiedFindings?: T[];
40
+ }): T[];
41
+ export declare function publicReport<T>(report: T): T;
@@ -0,0 +1,301 @@
1
+ // Every rule the report can raise: where it lives, how severe it is by
2
+ // default, how sure the scanner is, which evidence it needs, what the
3
+ // problem is and why it matters. No remediation instructions anywhere:
4
+ // the report describes conditions, not fixes.
5
+ export const topicLabels = {
6
+ "availability-security": "Availability and security",
7
+ "crawling-indexability": "Crawling and indexability",
8
+ "content-structure": "Content and structure",
9
+ "links-navigation": "Links and navigation",
10
+ "structured-data": "Structured data",
11
+ performance: "Performance",
12
+ "accessibility-usability": "Accessibility and usability",
13
+ "business-profiles": "Business and profiles",
14
+ "ai-agent-access": "AI and agent access",
15
+ };
16
+ export const topicOrder = Object.keys(topicLabels);
17
+ const rule = (topic, severity, confidence, requires, title, problem, impact) => ({ topic, severity, confidence, requires, title, problem, impact });
18
+ export const rules = {
19
+ // Availability and security
20
+ "homepage-unreachable": rule("availability-security", "critical", "confirmed", "pages", "Homepage not reachable", "The homepage did not return a successful response to the scanner.", "Visitors, search engines and agents cannot reach the site's entry point."),
21
+ "tls-invalid": rule("availability-security", "critical", "confirmed", "tls", "TLS certificate not valid", "The HTTPS certificate failed validation for this hostname.", "Browsers show a security warning and most visitors leave; automated clients refuse the connection."),
22
+ "tls-expiring": rule("availability-security", "critical", "confirmed", "tls", "TLS certificate expires soon", "The certificate's validity ends within two weeks.", "Once it lapses every HTTPS visit fails with a browser error."),
23
+ "tls-grade-low": rule("availability-security", "warning", "confirmed", "ssl-labs", "Weak TLS configuration", "SSL Labs grades the TLS setup C or lower.", "Outdated protocols or ciphers weaken transport security and can fail compliance checks."),
24
+ "domain-expiring": rule("availability-security", "critical", "confirmed", "domain", "Domain registration expires soon", "The public registration record shows an approaching expiry date.", "An expired registration takes the whole site and its email offline."),
25
+ "insecure-forms": rule("availability-security", "critical", "confirmed", "pages", "Forms submit over plain HTTP", "A form's action is an http:// address.", "Submitted data travels unencrypted and browsers warn or block the submission."),
26
+ "http-not-redirected": rule("availability-security", "warning", "confirmed", "url-variant", "HTTP served without redirect to HTTPS", "The plain-HTTP address returns content instead of redirecting.", "Visitors can use the site unencrypted and two versions of every URL exist."),
27
+ "security-headers": rule("availability-security", "warning", "confirmed", "pages", "Missing security headers on the homepage", "One or more browser protection headers were absent from the homepage response.", "Browsers cannot enforce transport security, framing or content-type protections the headers would enable."),
28
+ "email-authentication": rule("availability-security", "warning", "confirmed", "dns", "Email authentication incomplete", "The domain's SPF or DMARC records are missing or DMARC has no enforcing policy.", "Receiving servers cannot verify mail from the domain, which makes spoofing easier and delivery less reliable."),
29
+ "email-transport-security": rule("availability-security", "hint", "confirmed", "dns", "No MTA-STS policy for mail transport", "The domain receives mail but publishes no MTA-STS record.", "Mail in transit to the domain can be downgraded to an unencrypted connection."),
30
+ "mixed-content": rule("availability-security", "warning", "confirmed", "browser", "HTTPS pages load HTTP resources", "A secure page requested resources over plain HTTP.", "Browsers block or flag the insecure requests, so parts of the page fail or the padlock disappears."),
31
+ "trackers-before-consent": rule("availability-security", "warning", "probable", "browser", "Trackers loaded before any consent", "Known tracking hosts were contacted on first load, before any interaction.", "Where consent is required, tracking before the choice is made carries legal exposure. The purpose of each request was not established."),
32
+ "cookies-before-consent": rule("availability-security", "warning", "probable", "browser", "Third-party cookies set before any consent", "Cookies from other domains existed after the first load without interaction.", "Where consent is required this may count as tracking before consent."),
33
+ "safe-browsing-flagged": rule("availability-security", "critical", "confirmed", "safe-browsing", "Google Safe Browsing flags this site", "The site is listed as unsafe by Google Safe Browsing.", "Chrome and other browsers show a full-page warning before the site."),
34
+ blocklisted: rule("availability-security", "critical", "confirmed", "blocklists", "Domain on a spam blocklist", "A public blocklist lists the domain.", "Mail from the domain is rejected or marked as spam and some filters block links to the site."),
35
+ "staging-hostnames": rule("availability-security", "hint", "review", "certificates", "Staging-like hostnames have certificates", "Certificate transparency logs show hostnames that look like test or staging environments.", "If those hosts are reachable, unfinished or duplicate content is publicly exposed. Logs also list hosts that no longer exist."),
36
+ // Crawling and indexability
37
+ "broken-pages": rule("crawling-indexability", "critical", "confirmed", "pages", "Broken pages", "Internal pages return an error status or no response.", "Visitors following these links hit an error and search engines drop the pages."),
38
+ "homepage-noindex": rule("crawling-indexability", "critical", "confirmed", "pages", "Homepage excluded from search", "The homepage carries a noindex directive without a configured exception.", "The site's main page cannot appear in search results."),
39
+ "unexpected-noindex": rule("crawling-indexability", "critical", "confirmed", "pages", "Pages excluded from search", "Pages carry a noindex directive without a configured exception.", "These pages cannot appear in search results."),
40
+ "search-blocked": rule("crawling-indexability", "critical", "confirmed", "robots", "Googlebot blocked by robots.txt", "The robots policy disallows Googlebot for the homepage.", "Google cannot crawl the site, so its pages drop out of or never reach the index."),
41
+ "robots-unavailable": rule("crawling-indexability", "warning", "confirmed", "robots", "robots.txt could not be retrieved", "The robots file returned an error or was unreadable.", "Crawlers cannot tell what they may fetch; many stop crawling when robots.txt returns a server error."),
42
+ "canonical-broken": rule("crawling-indexability", "warning", "confirmed", "analysis", "Canonical targets do not respond OK", "A canonical link points to a URL that returned an error.", "Search engines ignore a canonical to a broken page and choose a URL themselves."),
43
+ "canonical-rendered-differs": rule("crawling-indexability", "warning", "probable", "rendered", "Canonical differs after JavaScript", "The canonical in the rendered page is not the one in the original HTML.", "Crawlers that read the HTML and crawlers that render see different preferred URLs."),
44
+ "robots-rendered-differs": rule("crawling-indexability", "warning", "probable", "rendered", "Indexing directive differs after JavaScript", "The robots meta directive changes once scripts run.", "Whether a page is indexed depends on whether the crawler executes JavaScript."),
45
+ "sitemap-missing": rule("crawling-indexability", "warning", "confirmed", "sitemap", "No valid XML sitemap found", "Neither robots.txt nor the common locations served a valid sitemap.", "Search engines rely on links alone to discover pages, so deep or new pages are found later or not at all."),
46
+ "sitemap-invalid": rule("crawling-indexability", "warning", "confirmed", "sitemap", "Sitemap could not be parsed", "A declared or linked sitemap returned an error or invalid XML.", "Its URLs are not submitted to search engines."),
47
+ "sitemap-problems": rule("crawling-indexability", "warning", "confirmed", "sitemap", "Sitemap lists pages that are broken, redirected or excluded", "Sitemap entries point to error pages, redirects or noindex pages.", "Search engines treat such entries as noise and trust the sitemap less."),
48
+ "sitemap-dates": rule("crawling-indexability", "warning", "confirmed", "sitemap", "Sitemap lastmod dates that cannot be right", "Modification dates are unreadable, in the future or several years old.", "Search engines ignore unreliable dates, losing the freshness signal for every entry."),
49
+ "sitemap-not-listed": rule("crawling-indexability", "hint", "review", "sitemap", "Indexable pages missing from the sitemap", "Crawled indexable pages do not appear in any sitemap.", "These pages depend on links alone for discovery; some may be intentionally left out."),
50
+ "orphan-pages": rule("crawling-indexability", "warning", "review", "analysis", "Sitemap pages that no page links to", "The sitemap lists a page with no incoming link in the observed crawl.", "Without links the page gets little crawl attention and no internal relevance; unseen links may still exist."),
51
+ "trailing-slash-variants": rule("crawling-indexability", "warning", "confirmed", "pages", "Slash variants serve duplicates or diverge", "The URL with and without a trailing slash both return content without a shared canonical, or redirect to different pages.", "Two addresses compete for the same page and links split between them."),
52
+ "canonicalized-slash-variants": rule("crawling-indexability", "hint", "confirmed", "pages", "Both slash variants return content with a shared canonical", "Both URL forms respond 200 and declare the same canonical.", "Crawlers fetch both forms; the canonical resolves the duplicate but doubles crawl work."),
53
+ "different-slash-content": rule("crawling-indexability", "hint", "review", "pages", "Slash variants serve different content", "The URL with and without a trailing slash return different pages.", "Links and shares that differ only by the slash reach different content."),
54
+ "redirect-chains": rule("crawling-indexability", "warning", "confirmed", "pages", "Internal redirects through several hops", "An internal destination needed two or more redirects.", "Each hop adds latency and crawlers may stop following after a few."),
55
+ "hreflang-not-reciprocal": rule("crawling-indexability", "warning", "confirmed", "analysis", "hreflang links without a return link", "A language alternate does not link back to the source page.", "Search engines ignore hreflang pairs that are not confirmed from both sides."),
56
+ "hreflang-unreachable": rule("crawling-indexability", "warning", "confirmed", "analysis", "hreflang targets that do not respond OK", "A declared language alternate returned an error or could not be fetched.", "The alternate cannot be shown to visitors of that language."),
57
+ "hreflang-invalid": rule("crawling-indexability", "warning", "confirmed", "analysis", "Invalid hreflang language tags", "A hreflang value is not a valid language or region tag.", "Search engines drop the invalid alternate."),
58
+ "pagination-broken": rule("crawling-indexability", "warning", "confirmed", "analysis", "Pagination links to broken pages", "A next or numbered pagination link returned an error.", "Items beyond the broken page are unreachable through the listing."),
59
+ "pagination-canonical-first": rule("crawling-indexability", "hint", "review", "analysis", "Paginated pages canonical to the first page", "Pages 2 and beyond declare the first page as canonical.", "Items only listed on later pages lose their listing signal; this can be intentional."),
60
+ "pagination-noindex": rule("crawling-indexability", "hint", "review", "analysis", "Paginated pages carry noindex", "Listing pages beyond the first are excluded from indexing.", "Search engines may still follow the links, but the listing pages themselves do not rank; often intentional."),
61
+ "rendered-only-pages": rule("crawling-indexability", "hint", "probable", "rendered", "Pages linked only through JavaScript", "These pages are reachable only through links that scripts add after load.", "Crawlers and agents that do not run JavaScript cannot discover them from other pages."),
62
+ // Content and structure
63
+ "missing-title": rule("content-structure", "warning", "confirmed", "pages", "Pages without a title", "The page has no title element.", "Search results and browser tabs show the URL or a guessed label instead."),
64
+ "title-length": rule("content-structure", "hint", "review", "pages", "Titles outside 30 to 60 characters", "The title is shorter or longer than Scan's editorial range.", "Very short titles say little; long ones are cut off in search results. Search engines set no requirement."),
65
+ "missing-description": rule("content-structure", "warning", "confirmed", "pages", "Pages without a meta description", "The page has no meta description.", "Search engines and sharing previews compose a snippet themselves."),
66
+ "description-length": rule("content-structure", "hint", "review", "pages", "Meta descriptions outside 70 to 160 characters", "The description is shorter or longer than Scan's editorial range.", "Short descriptions waste the snippet; long ones are cut off."),
67
+ "duplicate-titles": rule("content-structure", "warning", "confirmed", "analysis", "Duplicate titles", "Several pages use the same title.", "The pages are hard to tell apart in search results and browser history, and search engines may pick one."),
68
+ "duplicate-descriptions": rule("content-structure", "hint", "confirmed", "analysis", "Duplicate meta descriptions", "Several pages share one meta description.", "Snippets do not describe the individual page."),
69
+ "duplicate-content": rule("content-structure", "hint", "review", "analysis", "Identical page text", "The extracted main text of several pages is identical.", "Search engines fold identical pages into one; shared navigation can also cause this on very short pages."),
70
+ "similar-content": rule("content-structure", "hint", "review", "analysis", "Pages with similar text", "Pages share at least 90% of their sampled word sets.", "Near-identical pages compete with each other; the heuristic ignores word order and meaning."),
71
+ "substituted-content": rule("content-structure", "hint", "review", "analysis", "Template text with a few words swapped", "Pairs of pages are identical except for a handful of words, typically a place or product name.", "Search engines may treat such pages as one; whether that matters depends on their purpose. No claim of search cannibalization is made."),
72
+ "overlapping-collections": rule("content-structure", "hint", "review", "analysis", "Listings with the same items", "Collection pages link to almost the same set of products.", "Near-identical listings dilute each other's signals; filters and sort variants often cause this."),
73
+ "thin-pages": rule("content-structure", "hint", "review", "pages", "Pages under 100 words", "The main content holds fewer than 100 words.", "Short pages can be exactly right (contact, portfolio); very thin pages give visitors and search engines little to work with. Not a minimum requirement."),
74
+ "h1-missing": rule("content-structure", "warning", "confirmed", "pages", "Pages without an H1 heading", "The page has no first-level heading.", "Readers and assistive technology lack the page's main topic marker."),
75
+ "h1-multiple": rule("content-structure", "hint", "review", "pages", "Pages with several H1 headings", "More than one first-level heading exists.", "The page's main topic is ambiguous for readers and tools; sectioned layouts sometimes do this on purpose."),
76
+ "heading-skips": rule("content-structure", "hint", "confirmed", "pages", "Heading levels that skip", "Heading levels jump, for example from H2 to H4.", "Screen reader users navigating by heading lose the outline."),
77
+ "long-paragraphs": rule("content-structure", "hint", "review", "pages", "Paragraphs over 150 words", "Some paragraphs exceed 150 words.", "Long blocks are harder to scan on small screens."),
78
+ "long-sentences": rule("content-structure", "hint", "review", "pages", "Many sentences over 20 words", "More than a quarter of paragraph sentences exceed 20 words.", "Long sentences reduce readability; the count is a heuristic."),
79
+ "language-mismatch": rule("content-structure", "warning", "probable", "pages", "Declared language differs from the text", "The lang attribute does not match the language the text appears to be written in.", "Screen readers pronounce the page with the wrong voice and search engines may target the wrong audience."),
80
+ "language-switch-mismatch": rule("content-structure", "warning", "probable", "analysis", "Language switch leads to another language", "A language-switch link leads to a page whose declared or detected language is different.", "Visitors choosing a language land on content in another one."),
81
+ "content-requires-javascript": rule("content-structure", "hint", "probable", "rendered", "Most text only appears after JavaScript", "Less than half of the rendered text exists in the original HTML.", "Crawlers, agents and readers without JavaScript see a mostly empty page."),
82
+ "title-rendered-differs": rule("content-structure", "hint", "probable", "rendered", "Title or headings change after JavaScript", "The title or headings in the rendered page differ from the original HTML.", "Tools reading only the HTML index a different title or outline than visitors see."),
83
+ // Links and navigation
84
+ "broken-resources": rule("links-navigation", "warning", "confirmed", "pages", "Broken images, scripts or files on this site", "Internal files linked or embedded by pages return an error.", "Images do not show, scripts do not run or downloads fail."),
85
+ "broken-external-resources": rule("links-navigation", "warning", "confirmed", "pages", "Broken links and files on other sites", "External destinations returned an error or no response.", "Visitors following the link reach an error page on another site."),
86
+ "unverifiable-external": rule("links-navigation", "hint", "review", "pages", "External destinations that could not be verified", "External destinations answered with a block, login or rate limit.", "The link may work for visitors; automated checks cannot tell."),
87
+ "broken-anchors": rule("links-navigation", "warning", "confirmed", "analysis", "Links to missing page anchors", "A link's fragment identifier does not exist on the destination.", "The visitor lands at the top of the page instead of the intended section."),
88
+ "external-redirect-chains": rule("links-navigation", "hint", "confirmed", "pages", "External links reached through redirects", "Successful external destinations needed two or more redirects, including consent or regional routing.", "Each hop adds latency; the destination still works."),
89
+ "vague-anchors": rule("links-navigation", "hint", "review", "analysis", "Links with vague or empty text", "Content links read 'click here', 'more' or have no text or image alternative.", "Screen reader users hearing the link out of context cannot tell where it leads, and search engines get no topic signal."),
90
+ "few-contextual-links": rule("links-navigation", "hint", "review", "analysis", "Pages with no contextual links compared with peers", "Within a group of similar pages, some receive links only from navigation while their peers are also linked from content.", "These pages get less internal relevance than comparable pages. Which pages matter most was not judged."),
91
+ "deep-pages": rule("links-navigation", "hint", "review", "analysis", "Pages four or more clicks from the homepage", "The shortest observed link path from the homepage needs four or more clicks.", "Deep pages are crawled less often and are harder for visitors to reach; the depth reflects observed links only."),
92
+ "language-switch-broken": rule("links-navigation", "warning", "confirmed", "analysis", "Language switch leads to an error", "A language-switch link returned an error or could not be fetched.", "Visitors cannot reach that language version."),
93
+ "pagination-rendered-only": rule("links-navigation", "hint", "probable", "rendered", "Pagination only exists after JavaScript", "Next or numbered links appear only in the rendered page.", "Crawlers and agents without JavaScript cannot reach later listing pages."),
94
+ "map-link-broken": rule("links-navigation", "warning", "confirmed", "pages", "Map links that do not respond OK", "A link to a map service returned an error.", "Visitors cannot open the location."),
95
+ // Structured data
96
+ "structured-data-invalid": rule("structured-data", "warning", "confirmed", "pages", "Structured data that does not parse", "A JSON-LD block is not valid JSON.", "Search engines and agents ignore the whole block."),
97
+ "structured-data-structural": rule("structured-data", "warning", "confirmed", "pages", "Structured data with invalid @type or @id shapes", "A JSON-LD node uses a non-string @type or @id.", "Processors reject or misread the node."),
98
+ "structured-data-missing": rule("structured-data", "hint", "confirmed", "pages", "No structured data on any page", "No page carries JSON-LD.", "Search engines and agents get no machine-readable description of the site's entities; optional, not a requirement."),
99
+ "homepage-business-schema": rule("structured-data", "hint", "review", "pages", "Homepage has no organization or business data", "No Organization, LocalBusiness or Person node was found on the homepage.", "Search engines and agents cannot read the site owner's identity from the page; whether it is wanted depends on the site."),
100
+ "rich-result-required-missing": rule("structured-data", "warning", "confirmed", "analysis", "Rich-result properties missing", "A structured-data node lacks properties Google documents as required for that result type.", "The item is unlikely to qualify for the corresponding rich result; presence of the properties does not guarantee eligibility."),
101
+ "offer-price-mismatch": rule("structured-data", "warning", "probable", "analysis", "Declared price differs from the visible price", "The JSON-LD offer price does not appear among the prices shown in the page text.", "Search results and agents may quote a price the visitor does not see; a discount or variant display can explain it."),
102
+ "offer-currency-mismatch": rule("structured-data", "warning", "probable", "analysis", "Declared currency differs from the visible currency", "The JSON-LD price currency is not the currency shown on the page.", "Listings can show the wrong currency."),
103
+ "offer-availability-mismatch": rule("structured-data", "warning", "probable", "analysis", "Declared availability contradicts the page", "The JSON-LD availability says in stock while the page says sold out, or the reverse.", "Listings show items as available or unavailable against what the page says."),
104
+ "offer-comparison-review": rule("structured-data", "hint", "review", "analysis", "Offer comparison ambiguous", "Several products, price ranges or many visible prices made a declared-versus-visible comparison inconclusive.", "Mismatches could not be confirmed or ruled out."),
105
+ "structured-data-rendered-only": rule("structured-data", "hint", "probable", "rendered", "Structured data only present after JavaScript", "JSON-LD appears in the rendered page but not in the original HTML.", "Processors that do not run scripts do not see it."),
106
+ // Performance
107
+ "slow-mobile": rule("performance", "warning", "confirmed", "lighthouse", "Mobile performance score below 50", "Lighthouse's simulated mobile performance score is below 50.", "On a mid-range phone with a slow connection the page loads noticeably slowly."),
108
+ "performance-opportunities": rule("performance", "warning", "confirmed", "lighthouse", "Substantial load-time waste measured by Lighthouse", "A Lighthouse audit estimates at least 500 ms or 100 KB of avoidable loading.", "The estimated savings show how much faster the page could load under the same conditions."),
109
+ "small-performance-opportunities": rule("performance", "hint", "confirmed", "lighthouse", "Smaller load-time waste measured by Lighthouse", "A Lighthouse audit estimates a small amount of avoidable loading.", "Minor on its own; listed for completeness."),
110
+ "heavy-pages": rule("performance", "warning", "confirmed", "lighthouse", "Pages over 3 MB on mobile", "The page transfers more than 3 MB on a mobile load.", "Slow connections take many seconds and data plans are consumed."),
111
+ "slow-responses": rule("performance", "warning", "confirmed", "pages", "Slow server responses", "The HTML response took over 1.5 seconds.", "Everything else waits for the HTML, so the whole page is late."),
112
+ "slow-lcp-own-browser": rule("performance", "warning", "confirmed", "browser", "Largest paint over 4 seconds on a fast connection", "In the unthrottled browser pass the largest content element rendered after 4 seconds.", "Real visitors on slower connections wait even longer."),
113
+ "field-vitals-poor": rule("performance", "warning", "confirmed", "field-data", "Real visitors experience poor Core Web Vitals", "Chrome UX Report data for the origin exceeds Google's poor thresholds.", "Real visitors experience slow loading, sluggish interaction or shifting layout; Google uses these thresholds in ranking."),
114
+ "field-page-vitals-poor": rule("performance", "warning", "confirmed", "field-data", "Real visitors experience poor vitals on specific pages", "Page-level Chrome UX Report data exceeds the poor thresholds.", "Visitors of this page experience slow loading, sluggish interaction or shifting layout."),
115
+ "unsized-images": rule("performance", "warning", "confirmed", "lighthouse", "Images without dimensions shift the layout", "Images lack width and height so the browser cannot reserve their space.", "Content jumps while images load, which counts against layout stability."),
116
+ // Accessibility and usability
117
+ "accessibility-violations": rule("accessibility-usability", "warning", "confirmed", "browser", "Accessibility rule violated", "axe-core found elements failing this WCAG or best-practice rule.", "Users of assistive technology or keyboards hit a barrier at these elements. Automated checks cover part of WCAG only."),
118
+ "accessibility-review": rule("accessibility-usability", "hint", "review", "browser", "Accessibility rule needs manual review", "axe-core could not decide this rule automatically for some elements.", "A person needs to check these elements; they may or may not be barriers."),
119
+ "missing-alt": rule("accessibility-usability", "warning", "confirmed", "pages", "Images without alt text", "Images have no alt attribute at all.", "Screen readers announce the file name or nothing; decorative images need an empty alt to be skipped."),
120
+ "forms-without-labels": rule("accessibility-usability", "warning", "confirmed", "pages", "Form fields without a label", "Fields have no label, aria-label or referenced label text.", "Screen reader users cannot tell what to enter."),
121
+ "mobile-overflow": rule("accessibility-usability", "warning", "confirmed", "browser", "Content wider than a phone screen", "The document is wider than the viewport at phone widths.", "Visitors scroll sideways or miss content at the edge."),
122
+ "focus-indicator": rule("accessibility-usability", "hint", "review", "browser", "Focused elements without a visible indicator", "Tab stops showed no outline or box shadow in their computed style.", "Keyboard users cannot see where they are; custom focus styles using other techniques are not detected."),
123
+ "javascript-errors": rule("accessibility-usability", "warning", "confirmed", "browser", "JavaScript errors from this site's own code", "The browser logged uncaught errors from the site's scripts.", "Features depending on the failing script may not work for visitors."),
124
+ "external-errors": rule("accessibility-usability", "hint", "confirmed", "browser", "Errors from third-party scripts and blocked requests", "The browser logged errors from other domains or blocked requests.", "Third-party features may fail; many such errors come from ad or tracking blockers."),
125
+ // Business and profiles
126
+ "missing-social-image": rule("business-profiles", "warning", "confirmed", "pages", "Homepage has no sharing image", "No og:image or twitter:image is declared.", "Shares on social networks and chat apps show no preview image."),
127
+ "social-image-broken": rule("business-profiles", "warning", "confirmed", "pages", "Sharing image does not load", "The declared sharing image returned an error or is not an image.", "Shares show a broken or missing preview."),
128
+ "missing-favicon": rule("business-profiles", "hint", "confirmed", "pages", "No favicon found", "No icon declaration and no /favicon.ico were observed.", "Browser tabs and bookmarks show a generic icon."),
129
+ "profile-broken": rule("business-profiles", "warning", "confirmed", "pages", "Linked profiles that do not respond OK", "A linked social or video profile on a platform that answers automated requests returned an error.", "Visitors following the link reach a missing profile."),
130
+ "profile-unverifiable": rule("business-profiles", "hint", "review", "pages", "Linked profiles that could not be verified", "Profiles on platforms that block automated requests could not be checked.", "The links may work for visitors; this scan cannot tell."),
131
+ "profile-variants": rule("business-profiles", "hint", "review", "analysis", "Several profile URLs for one platform", "Pages link to more than one profile address on the same platform.", "Visitors and search engines may see different profiles as the official one; some may be intentional (regional or personal profiles)."),
132
+ "sameas-not-linked": rule("business-profiles", "hint", "review", "analysis", "sameAs profiles not linked on the site", "Structured data lists profiles that no page links to.", "The declared identity and the visible identity differ."),
133
+ "linked-not-sameas": rule("business-profiles", "hint", "review", "analysis", "Linked profiles missing from sameAs", "Pages link to profiles that the organization's structured data does not list.", "Search engines cannot connect these profiles to the organization from the structured data."),
134
+ "business-name-variants": rule("business-profiles", "hint", "review", "analysis", "Business name written in several ways", "Structured data, site name and title use different names.", "Search engines and agents may not recognise them as one business; abbreviations and legal names often differ on purpose."),
135
+ "phone-mismatch": rule("business-profiles", "hint", "review", "analysis", "Phone numbers differ between structured data and pages", "Declared telephone numbers and visible phone numbers do not match.", "Search results may show a number visitors cannot find on the site, or the reverse."),
136
+ // AI and agent access
137
+ "llms-missing": rule("ai-agent-access", "hint", "confirmed", "ai-discovery", "No llms.txt (optional)", "The site serves no /llms.txt.", "Agents that look for it fall back to the HTML; the file is optional and has no ranking effect."),
138
+ "llms-links-broken": rule("ai-agent-access", "warning", "confirmed", "ai-discovery", "llms.txt links that do not respond OK", "Links listed in llms.txt returned an error.", "Agents following the file's guidance reach errors."),
139
+ "llms-title-mismatch": rule("ai-agent-access", "hint", "review", "ai-discovery", "llms.txt title differs from the site name", "The first heading in llms.txt does not match the site's declared names.", "Agents may not connect the file with the site; naming differences can be intentional."),
140
+ "markdown-unavailable": rule("ai-agent-access", "hint", "confirmed", "pages", "No Markdown version of pages found", "Neither content negotiation nor common .md locations returned Markdown.", "Agents read the HTML instead; optional and increasingly offered by documentation sites."),
141
+ };
142
+ export const areaTopics = {
143
+ availability: "availability-security",
144
+ search: "crawling-indexability",
145
+ performance: "performance",
146
+ accessibility: "accessibility-usability",
147
+ quality: "links-navigation",
148
+ };
149
+ // Problem and impact for a finding; historical findings keep their stored
150
+ // title and fall back to their area when the code is unknown.
151
+ export function findingGuidance(code, area) {
152
+ const known = rules[code];
153
+ if (known)
154
+ return {
155
+ topic: known.topic,
156
+ problem: known.problem,
157
+ impact: known.impact,
158
+ confidence: known.confidence,
159
+ };
160
+ return {
161
+ topic: areaTopics[area ?? ""] ?? "links-navigation",
162
+ problem: "The scan recorded this condition; the evidence lists what was observed.",
163
+ impact: "Impact depends on the affected pages.",
164
+ confidence: "confirmed",
165
+ };
166
+ }
167
+ // Lighthouse audit titles are phrased as advice; the report names the
168
+ // measured condition instead.
169
+ const auditConditions = {
170
+ "render-blocking-resources": "Render-blocking resources delay the first paint",
171
+ "render-blocking-insight": "Render-blocking resources delay the first paint",
172
+ "unused-javascript": "Unused JavaScript is downloaded",
173
+ "unused-css-rules": "Unused CSS is downloaded",
174
+ "modern-image-formats": "Images use formats with larger transfer sizes",
175
+ "uses-optimized-images": "Images are larger than an efficient encoding",
176
+ "uses-responsive-images": "Images are larger than their rendered size",
177
+ "image-delivery-insight": "Image delivery transfers more bytes than needed",
178
+ "offscreen-images": "Offscreen images load before they are needed",
179
+ "unminified-javascript": "JavaScript is served unminified",
180
+ "unminified-css": "CSS is served unminified",
181
+ "uses-text-compression": "Text resources are served without compression",
182
+ "server-response-time": "The server responds slowly to the document request",
183
+ "document-latency-insight": "The document request is slow, redirected or uncompressed",
184
+ redirects: "The page redirects before loading",
185
+ "uses-rel-preconnect": "Third-party origins connect late",
186
+ "efficient-animated-content": "Animated content is large",
187
+ "legacy-javascript": "Legacy JavaScript polyfills are shipped to modern browsers",
188
+ "legacy-javascript-insight": "Legacy JavaScript polyfills are shipped to modern browsers",
189
+ "duplicated-javascript-insight": "The same JavaScript is bundled more than once",
190
+ "uses-long-cache-ttl": "Static resources have short cache lifetimes",
191
+ "cache-insight": "Static resources have short cache lifetimes",
192
+ "third-parties-insight": "Third-party code takes main-thread time",
193
+ "third-party-summary": "Third-party code takes main-thread time",
194
+ "font-display-insight": "Web fonts block text rendering while loading",
195
+ "lcp-discovery-insight": "The largest content element is discovered late",
196
+ "lcp-breakdown-insight": "The largest content element renders late",
197
+ "network-dependency-tree-insight": "A long chain of requests precedes rendering",
198
+ "forced-reflow-insight": "Scripts force layout recalculation",
199
+ "dom-size-insight": "The page has a very large DOM",
200
+ "modern-http-insight": "Resources are served over HTTP/1.1",
201
+ "viewport-insight": "The viewport meta tag delays interaction",
202
+ "cls-culprits-insight": "Elements shift the layout after load",
203
+ "total-byte-weight": "The page transfers a very large number of bytes",
204
+ "bootup-time": "JavaScript execution takes a long time",
205
+ "mainthread-work-breakdown": "The main thread is busy for a long time",
206
+ "dom-size": "The page has a very large DOM",
207
+ "prioritize-lcp-image": "The largest image is requested late",
208
+ "largest-contentful-paint-element": "The largest content element renders late",
209
+ "uses-passive-event-listeners": "Scroll listeners are not passive",
210
+ };
211
+ export const auditCondition = (id) => auditConditions[id] ?? `Lighthouse measurement: ${id}`;
212
+ const accessibilityConditions = {
213
+ "color-contrast": "Text contrast is below the measured threshold",
214
+ "landmark-one-main": "A main landmark is absent or repeated",
215
+ region: "Content is outside page landmarks",
216
+ "landmark-unique": "Landmark roles and labels are repeated",
217
+ "image-alt": "Images lack an accessible text alternative",
218
+ "button-name": "Buttons lack an accessible name",
219
+ "link-name": "Links lack an accessible name",
220
+ label: "Form controls lack an accessible label",
221
+ "html-has-lang": "The document language is absent",
222
+ "html-lang-valid": "The document language value is invalid",
223
+ "document-title": "The document title is absent",
224
+ "heading-order": "Heading levels skip a level",
225
+ };
226
+ export const accessibilityCondition = (id, manual = false) => manual
227
+ ? `Accessibility check needs manual review (${id})`
228
+ : `${accessibilityConditions[id] ?? "Accessibility rule violation"} (${id})`;
229
+ // Normalize scanner-generated prose only. Site text, URLs, selectors and
230
+ // measured values remain evidence, even when the site itself uses advice.
231
+ export function diagnosticEvidence(value) {
232
+ if (Array.isArray(value))
233
+ return value.map(diagnosticEvidence);
234
+ if (!value || typeof value !== "object")
235
+ return value;
236
+ const source = value;
237
+ const result = {};
238
+ for (const [key, item] of Object.entries(source)) {
239
+ if ([
240
+ "description",
241
+ "action",
242
+ "help",
243
+ "helpUrl",
244
+ "failureSummary",
245
+ "guidance",
246
+ ].includes(key))
247
+ continue;
248
+ if (key === "title") {
249
+ if (typeof source.id === "string")
250
+ result.title = auditCondition(source.id);
251
+ continue;
252
+ }
253
+ result[key] = diagnosticEvidence(item);
254
+ }
255
+ return result;
256
+ }
257
+ export function publicFinding(finding) {
258
+ const { description: _description, action: _action, ...rest } = finding;
259
+ const group = finding.group ?? finding.id?.slice(finding.code.length + 1);
260
+ let title = finding.title;
261
+ if (finding.code === "accessibility-violations" ||
262
+ finding.code === "accessibility-review")
263
+ title = group
264
+ ? accessibilityCondition(group, finding.code === "accessibility-review")
265
+ : rules[finding.code].title;
266
+ else if (["performance-opportunities", "small-performance-opportunities"].includes(finding.code))
267
+ title = group ? auditCondition(group) : rules[finding.code].title;
268
+ else if (!finding.problem)
269
+ title = rules[finding.code]?.title ?? `Observed condition: ${finding.code}`;
270
+ return { ...rest, title };
271
+ }
272
+ export function unverifiedChanges(changes) {
273
+ return [
274
+ ...(changes.unverifiedFindings ?? []),
275
+ ...(changes.resolvedFindings === undefined
276
+ ? (changes.fixedFindings ?? [])
277
+ : []),
278
+ ];
279
+ }
280
+ export function publicReport(report) {
281
+ if (!report || typeof report !== "object")
282
+ return report;
283
+ const source = report;
284
+ const changes = source.changes ? { ...source.changes } : source.changes;
285
+ if (changes)
286
+ for (const key of [
287
+ "newFindings",
288
+ "resolvedFindings",
289
+ "fixedFindings",
290
+ "unverifiedFindings",
291
+ "recurringFindings",
292
+ "worsenedFindings",
293
+ ])
294
+ if (Array.isArray(changes[key]))
295
+ changes[key] = changes[key].map(publicFinding);
296
+ return {
297
+ ...source,
298
+ findings: (source.findings ?? []).map(publicFinding),
299
+ changes,
300
+ };
301
+ }