@reddoorla/maintenance 0.54.4 → 0.56.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 (149) hide show
  1. package/README.md +3 -1
  2. package/dist/airtable-2BJX4ATJ.js +9 -0
  3. package/dist/airtable-2BJX4ATJ.js.map +1 -0
  4. package/dist/announce-JEVCDDKL.js +171 -0
  5. package/dist/announce-JEVCDDKL.js.map +1 -0
  6. package/dist/bump-deps-O3SOLQYY.js +57 -0
  7. package/dist/bump-deps-O3SOLQYY.js.map +1 -0
  8. package/dist/chunk-2X322ZES.js +99 -0
  9. package/dist/chunk-2X322ZES.js.map +1 -0
  10. package/dist/chunk-4NAOR44V.js +153 -0
  11. package/dist/chunk-4NAOR44V.js.map +1 -0
  12. package/dist/chunk-4PY4I5OR.js +1167 -0
  13. package/dist/chunk-4PY4I5OR.js.map +1 -0
  14. package/dist/chunk-5WL3ET24.js +203 -0
  15. package/dist/chunk-5WL3ET24.js.map +1 -0
  16. package/dist/chunk-6LIWDIXU.js +38 -0
  17. package/dist/chunk-6LIWDIXU.js.map +1 -0
  18. package/dist/chunk-6VAL7XJT.js +52 -0
  19. package/dist/chunk-6VAL7XJT.js.map +1 -0
  20. package/dist/chunk-BWLUNY7L.js +585 -0
  21. package/dist/chunk-BWLUNY7L.js.map +1 -0
  22. package/dist/chunk-CASCWQQX.js +161 -0
  23. package/dist/chunk-CASCWQQX.js.map +1 -0
  24. package/dist/chunk-CLVTVDOU.js +274 -0
  25. package/dist/chunk-CLVTVDOU.js.map +1 -0
  26. package/dist/chunk-D3DQFC32.js +79 -0
  27. package/dist/chunk-D3DQFC32.js.map +1 -0
  28. package/dist/chunk-DIVB6ZF7.js +217 -0
  29. package/dist/chunk-DIVB6ZF7.js.map +1 -0
  30. package/dist/chunk-EYODPUHV.js +37 -0
  31. package/dist/chunk-EYODPUHV.js.map +1 -0
  32. package/dist/chunk-FDIFTIXC.js +70 -0
  33. package/dist/chunk-FDIFTIXC.js.map +1 -0
  34. package/dist/chunk-FUD4I2ZW.js +35 -0
  35. package/dist/chunk-FUD4I2ZW.js.map +1 -0
  36. package/dist/chunk-HG5DTFNF.js +62 -0
  37. package/dist/chunk-HG5DTFNF.js.map +1 -0
  38. package/dist/chunk-HN6I2XWI.js +65 -0
  39. package/dist/chunk-HN6I2XWI.js.map +1 -0
  40. package/dist/chunk-HVOOCK6L.js +84 -0
  41. package/dist/chunk-HVOOCK6L.js.map +1 -0
  42. package/dist/chunk-IMIR7SC2.js +51 -0
  43. package/dist/chunk-IMIR7SC2.js.map +1 -0
  44. package/dist/chunk-J3OPWFZE.js +64 -0
  45. package/dist/chunk-J3OPWFZE.js.map +1 -0
  46. package/dist/chunk-JNXCDUYB.js +211 -0
  47. package/dist/chunk-JNXCDUYB.js.map +1 -0
  48. package/dist/chunk-JQG222CI.js +49 -0
  49. package/dist/chunk-JQG222CI.js.map +1 -0
  50. package/dist/chunk-LKGVSM2O.js +43 -0
  51. package/dist/chunk-LKGVSM2O.js.map +1 -0
  52. package/dist/chunk-LYCKN7ZR.js +41 -0
  53. package/dist/chunk-LYCKN7ZR.js.map +1 -0
  54. package/dist/chunk-O65I7G7G.js +246 -0
  55. package/dist/chunk-O65I7G7G.js.map +1 -0
  56. package/dist/chunk-OCS7SZUZ.js +243 -0
  57. package/dist/chunk-OCS7SZUZ.js.map +1 -0
  58. package/dist/chunk-OGGRFBL4.js +14 -0
  59. package/dist/chunk-OGGRFBL4.js.map +1 -0
  60. package/dist/chunk-OQPMZ6BM.js +368 -0
  61. package/dist/chunk-OQPMZ6BM.js.map +1 -0
  62. package/dist/chunk-OTGB6YQO.js +52 -0
  63. package/dist/chunk-OTGB6YQO.js.map +1 -0
  64. package/dist/chunk-PF6BNYGE.js +162 -0
  65. package/dist/chunk-PF6BNYGE.js.map +1 -0
  66. package/dist/chunk-QTHKOIFJ.js +13 -0
  67. package/dist/chunk-QTHKOIFJ.js.map +1 -0
  68. package/dist/chunk-U5SZ4FOP.js +26 -0
  69. package/dist/chunk-U5SZ4FOP.js.map +1 -0
  70. package/dist/chunk-VXSX2FHO.js +269 -0
  71. package/dist/chunk-VXSX2FHO.js.map +1 -0
  72. package/dist/chunk-W3G7XFBZ.js +444 -0
  73. package/dist/chunk-W3G7XFBZ.js.map +1 -0
  74. package/dist/chunk-WEKL2HYJ.js +31 -0
  75. package/dist/chunk-WEKL2HYJ.js.map +1 -0
  76. package/dist/chunk-WVRPOXKH.js +74 -0
  77. package/dist/chunk-WVRPOXKH.js.map +1 -0
  78. package/dist/chunk-XKOZUVK2.js +121 -0
  79. package/dist/chunk-XKOZUVK2.js.map +1 -0
  80. package/dist/chunk-XO2RUYVM.js +138 -0
  81. package/dist/chunk-XO2RUYVM.js.map +1 -0
  82. package/dist/chunk-XTK5VIQB.js +97 -0
  83. package/dist/chunk-XTK5VIQB.js.map +1 -0
  84. package/dist/chunk-XXTZBPUY.js +9 -0
  85. package/dist/chunk-XXTZBPUY.js.map +1 -0
  86. package/dist/chunk-ZYBOT5PM.js +153 -0
  87. package/dist/chunk-ZYBOT5PM.js.map +1 -0
  88. package/dist/cli/bin.js +75 -7689
  89. package/dist/cli/bin.js.map +1 -1
  90. package/dist/cli/commands/audit.js +37 -2330
  91. package/dist/cli/commands/audit.js.map +1 -1
  92. package/dist/client-EPVO3FEH.js +10 -0
  93. package/dist/client-EPVO3FEH.js.map +1 -0
  94. package/dist/client-JA6QLEDW.js +48 -0
  95. package/dist/client-JA6QLEDW.js.map +1 -0
  96. package/dist/configs/lighthouse.js +4 -31
  97. package/dist/configs/lighthouse.js.map +1 -1
  98. package/dist/configs/playwright-a11y.js +6 -33
  99. package/dist/configs/playwright-a11y.js.map +1 -1
  100. package/dist/convert-to-pnpm-UR64X3OX.js +51 -0
  101. package/dist/convert-to-pnpm-UR64X3OX.js.map +1 -0
  102. package/dist/db-AXKMRTVA.js +23 -0
  103. package/dist/db-AXKMRTVA.js.map +1 -0
  104. package/dist/digest-YTO73BEO.js +408 -0
  105. package/dist/digest-YTO73BEO.js.map +1 -0
  106. package/dist/forms/index.js +3 -8
  107. package/dist/forms/index.js.map +1 -1
  108. package/dist/github-signals-YD64R2AF.js +122 -0
  109. package/dist/github-signals-YD64R2AF.js.map +1 -0
  110. package/dist/index.d.ts +6 -1
  111. package/dist/index.js +102 -4741
  112. package/dist/index.js.map +1 -1
  113. package/dist/init-JITQXFVL.js +85 -0
  114. package/dist/init-JITQXFVL.js.map +1 -0
  115. package/dist/launch-HXHEL6ID.js +207 -0
  116. package/dist/launch-HXHEL6ID.js.map +1 -0
  117. package/dist/lighthouse-airtable-4FKYKFH6.js +12 -0
  118. package/dist/lighthouse-airtable-4FKYKFH6.js.map +1 -0
  119. package/dist/migrate-P2LS4WXI.js +7 -0
  120. package/dist/migrate-P2LS4WXI.js.map +1 -0
  121. package/dist/onboard-TCQOP2W4.js +69 -0
  122. package/dist/onboard-TCQOP2W4.js.map +1 -0
  123. package/dist/orchestrate-RMSBHLQK.js +19 -0
  124. package/dist/orchestrate-RMSBHLQK.js.map +1 -0
  125. package/dist/recipes/sync-configs.js +9 -505
  126. package/dist/recipes/sync-configs.js.map +1 -1
  127. package/dist/renovate-dispatch-ZGLXNPEK.js +97 -0
  128. package/dist/renovate-dispatch-ZGLXNPEK.js.map +1 -0
  129. package/dist/report-SD3CNBOQ.js +240 -0
  130. package/dist/report-SD3CNBOQ.js.map +1 -0
  131. package/dist/resend-G2BJMAQV.js +7 -0
  132. package/dist/resend-G2BJMAQV.js.map +1 -0
  133. package/dist/self-updating-CIUMOMYI.js +61 -0
  134. package/dist/self-updating-CIUMOMYI.js.map +1 -0
  135. package/dist/svelte-codemods-BYRJAU7I.js +51 -0
  136. package/dist/svelte-codemods-BYRJAU7I.js.map +1 -0
  137. package/dist/sync-configs-JPRLAW5J.js +109 -0
  138. package/dist/sync-configs-JPRLAW5J.js.map +1 -0
  139. package/dist/upgrade-ERA45EVR.js +64 -0
  140. package/dist/upgrade-ERA45EVR.js.map +1 -0
  141. package/dist/util/git.js +19 -76
  142. package/dist/util/git.js.map +1 -1
  143. package/dist/util/pkg.js +5 -45
  144. package/dist/util/pkg.js.map +1 -1
  145. package/dist/websites-X5DLC2PC.js +43 -0
  146. package/dist/websites-X5DLC2PC.js.map +1 -0
  147. package/dist/write-audits-to-airtable-CE4POBAF.js +13 -0
  148. package/dist/write-audits-to-airtable-CE4POBAF.js.map +1 -0
  149. package/package.json +13 -16
@@ -0,0 +1,1167 @@
1
+ import {
2
+ baselineVersions
3
+ } from "./chunk-LKGVSM2O.js";
4
+ import {
5
+ defaultSpawn
6
+ } from "./chunk-HVOOCK6L.js";
7
+ import {
8
+ siteLabel
9
+ } from "./chunk-XXTZBPUY.js";
10
+ import {
11
+ lighthouseConfig
12
+ } from "./chunk-EYODPUHV.js";
13
+ import {
14
+ a11yRoutes,
15
+ smokeRoutes
16
+ } from "./chunk-LYCKN7ZR.js";
17
+
18
+ // src/audits/deps.ts
19
+ import { readFile } from "fs/promises";
20
+ import { join as join2 } from "path";
21
+
22
+ // src/audits/deps-outdated.ts
23
+ import { stat } from "fs/promises";
24
+ import { join } from "path";
25
+ async function exists(path) {
26
+ try {
27
+ await stat(path);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function majorOf(version) {
34
+ const head = version.replace(/^[\^~]/, "").split(".")[0] ?? "0";
35
+ const n = Number.parseInt(head, 10);
36
+ return Number.isNaN(n) ? 0 : n;
37
+ }
38
+ async function scanOutdated(sitePath, spawn) {
39
+ if (!await exists(join(sitePath, "pnpm-lock.yaml"))) return null;
40
+ try {
41
+ if (!await exists(join(sitePath, "node_modules"))) {
42
+ const install = await spawn("pnpm", ["install", "--frozen-lockfile"], {
43
+ cwd: sitePath,
44
+ timeoutMs: 18e4
45
+ });
46
+ if (install.code !== 0) return null;
47
+ }
48
+ const res = await spawn("pnpm", ["outdated", "--json"], {
49
+ cwd: sitePath,
50
+ timeoutMs: 6e4
51
+ });
52
+ const parsed = JSON.parse(res.stdout || "{}");
53
+ const entries = Object.values(parsed);
54
+ return {
55
+ outdated: entries.length,
56
+ major: entries.filter((e) => e.current && e.latest && majorOf(e.latest) > majorOf(e.current)).length
57
+ };
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // src/audits/deps.ts
64
+ function stripCaret(range) {
65
+ return range.replace(/^[\^~]/, "");
66
+ }
67
+ function isComparableRange(spec) {
68
+ return /^[\^~]?\d/.test(spec.trim());
69
+ }
70
+ function parseSemver(v) {
71
+ const cleaned = stripCaret(v).split("-")[0] ?? "0.0.0";
72
+ const parts = cleaned.split(".").map((n) => Number.parseInt(n, 10));
73
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
74
+ }
75
+ function compareSemver(actual, baseline) {
76
+ const [aMajor, aMinor, aPatch] = parseSemver(actual);
77
+ const [bMajor, bMinor, bPatch] = parseSemver(baseline);
78
+ if (aMajor > bMajor) return "newer";
79
+ if (aMajor < bMajor) return "major";
80
+ if (aMinor > bMinor) return "newer";
81
+ if (aMinor < bMinor) return "minor";
82
+ if (aPatch > bPatch) return "newer";
83
+ if (aPatch < bPatch) return "patch";
84
+ return "same";
85
+ }
86
+ async function depsAudit(ctx) {
87
+ const pkgPath = join2(ctx.site.path, "package.json");
88
+ let pkgRaw;
89
+ try {
90
+ pkgRaw = await readFile(pkgPath, "utf-8");
91
+ } catch (err) {
92
+ return {
93
+ audit: "deps",
94
+ site: siteLabel(ctx.site),
95
+ status: "skip",
96
+ summary: `no package.json at ${pkgPath}`,
97
+ details: { error: String(err) }
98
+ };
99
+ }
100
+ let pkg;
101
+ try {
102
+ pkg = JSON.parse(pkgRaw);
103
+ } catch (err) {
104
+ return {
105
+ audit: "deps",
106
+ site: siteLabel(ctx.site),
107
+ status: "fail",
108
+ summary: `package.json is not valid JSON: ${err.message}`,
109
+ details: { error: String(err) }
110
+ };
111
+ }
112
+ const installed = {
113
+ ...pkg.dependencies ?? {},
114
+ ...pkg.devDependencies ?? {}
115
+ };
116
+ const entries = [];
117
+ for (const [name, baseline] of Object.entries(baselineVersions)) {
118
+ const actual = installed[name];
119
+ if (!actual) continue;
120
+ if (!isComparableRange(actual)) continue;
121
+ entries.push({
122
+ pkg: name,
123
+ baseline,
124
+ actual,
125
+ drift: compareSemver(actual, baseline)
126
+ });
127
+ }
128
+ const anyMajor = entries.some((d) => d.drift === "major");
129
+ const anyMinor = entries.some((d) => d.drift === "minor");
130
+ const anyNewer = entries.some((d) => d.drift === "newer");
131
+ const status = anyMajor ? "fail" : anyMinor || anyNewer ? "warn" : "pass";
132
+ const driftSummary = status === "pass" ? `all ${entries.length} tracked deps in line with baseline` : status === "warn" ? `${entries.filter((d) => d.drift !== "same").length} of ${entries.length} tracked deps drifted` : `${entries.filter((d) => d.drift === "major").length} deps lagging by a major version`;
133
+ const outdated = await scanOutdated(ctx.site.path, ctx.spawn ?? defaultSpawn);
134
+ const summary = outdated ? `${driftSummary}; ${outdated.outdated} outdated install(s) (${outdated.major} major)` : driftSummary;
135
+ return {
136
+ audit: "deps",
137
+ site: siteLabel(ctx.site),
138
+ status,
139
+ summary,
140
+ details: { entries, outdated }
141
+ };
142
+ }
143
+
144
+ // src/audits/lint.ts
145
+ import { existsSync } from "fs";
146
+ import { readFile as readFile2 } from "fs/promises";
147
+ import { join as join3 } from "path";
148
+ import { ESLint } from "eslint";
149
+ import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "prettier";
150
+ import { glob } from "tinyglobby";
151
+ var TARGET_GLOBS = ["**/*.{ts,js,svelte}"];
152
+ var IGNORE = ["node_modules/**", "dist/**", ".svelte-kit/**", "build/**", ".netlify/**"];
153
+ async function listFiles(cwd) {
154
+ return glob(TARGET_GLOBS, { cwd, ignore: IGNORE, absolute: false });
155
+ }
156
+ async function lintAudit(ctx) {
157
+ const { site } = ctx;
158
+ const configPath = join3(site.path, "eslint.config.js");
159
+ if (!existsSync(configPath)) {
160
+ return {
161
+ audit: "lint",
162
+ site: siteLabel(site),
163
+ status: "skip",
164
+ summary: "no eslint config at site root"
165
+ };
166
+ }
167
+ const eslint = new ESLint({
168
+ cwd: site.path,
169
+ overrideConfigFile: configPath,
170
+ errorOnUnmatchedPattern: false
171
+ });
172
+ const relFiles = await listFiles(site.path);
173
+ const eslintResults = await eslint.lintFiles(relFiles);
174
+ const eslintErrors = eslintResults.reduce((n, r) => n + r.errorCount, 0);
175
+ const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);
176
+ const prettierUnformatted = [];
177
+ for (const rel of relFiles) {
178
+ const absForResolve = join3(site.path, rel);
179
+ const source = await readFile2(absForResolve, "utf-8");
180
+ const options = await prettierResolveConfig(absForResolve) ?? {};
181
+ const ok = await prettierCheck(source, { ...options, filepath: absForResolve });
182
+ if (!ok) prettierUnformatted.push(rel);
183
+ }
184
+ const status = eslintErrors > 0 || prettierUnformatted.length > 0 ? "fail" : eslintWarnings > 0 ? "warn" : "pass";
185
+ const summary = status === "pass" ? `lint clean across ${relFiles.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
186
+ return {
187
+ audit: "lint",
188
+ site: siteLabel(site),
189
+ status,
190
+ summary,
191
+ details: {
192
+ eslintErrors,
193
+ eslintWarnings,
194
+ prettierUnformatted,
195
+ files: relFiles.length
196
+ }
197
+ };
198
+ }
199
+
200
+ // src/audits/security.ts
201
+ function classify(v) {
202
+ if (v.critical > 0 || v.high > 0) return "fail";
203
+ if (v.moderate > 0 || v.low > 0) return "warn";
204
+ return "pass";
205
+ }
206
+ function normalizeSeverity(s) {
207
+ if (s === "low" || s === "moderate" || s === "high" || s === "critical") return s;
208
+ return "low";
209
+ }
210
+ function extractAdvisoriesFromPnpm(parsed) {
211
+ const out = [];
212
+ for (const a of Object.values(parsed.advisories ?? {})) {
213
+ if (!a) continue;
214
+ out.push({
215
+ module: a.module_name ?? "unknown",
216
+ severity: normalizeSeverity(a.severity),
217
+ title: a.title ?? "(no title)",
218
+ ...a.cves ? { cves: a.cves } : {},
219
+ ...a.url ? { url: a.url } : {}
220
+ });
221
+ }
222
+ return out;
223
+ }
224
+ function resolveNpmAdvisoryRoot(startName, vulnerabilities) {
225
+ const seen = /* @__PURE__ */ new Set();
226
+ let current = startName;
227
+ while (!seen.has(current)) {
228
+ seen.add(current);
229
+ const entry = vulnerabilities[current];
230
+ if (!entry || !Array.isArray(entry.via)) return { rootName: current };
231
+ const detailed = entry.via.find(
232
+ (e) => typeof e === "object" && e !== null
233
+ );
234
+ if (detailed) return { rootName: current, detail: detailed };
235
+ const next = entry.via.find((e) => typeof e === "string");
236
+ if (!next || next === current) return { rootName: current };
237
+ current = next;
238
+ }
239
+ return { rootName: current };
240
+ }
241
+ function extractAdvisoriesFromNpm(parsed) {
242
+ const vulnerabilities = parsed.vulnerabilities ?? {};
243
+ const roots = /* @__PURE__ */ new Map();
244
+ for (const [name, v] of Object.entries(vulnerabilities)) {
245
+ if (!v) continue;
246
+ const { rootName, detail } = resolveNpmAdvisoryRoot(name, vulnerabilities);
247
+ if (roots.has(rootName)) continue;
248
+ const rootEntry = vulnerabilities[rootName];
249
+ const severity = normalizeSeverity(rootEntry?.severity ?? v.severity);
250
+ const title = detail?.title ?? rootName;
251
+ const url = detail?.url;
252
+ roots.set(rootName, {
253
+ module: rootEntry?.name ?? rootName,
254
+ severity,
255
+ title,
256
+ ...url ? { url } : {}
257
+ });
258
+ }
259
+ return [...roots.values()];
260
+ }
261
+ async function runAuditTool(spawn, cmd, args, cwd) {
262
+ let raw;
263
+ try {
264
+ raw = await spawn(cmd, args, { cwd });
265
+ } catch (err) {
266
+ const e = err;
267
+ if (e.code === "ENOENT" || /ENOENT/.test(String(err))) return { kind: "missing" };
268
+ return { kind: "error", reason: `spawn failed: ${String(err).slice(0, 200)}` };
269
+ }
270
+ if (raw.code !== 0 && raw.code !== 1) {
271
+ return {
272
+ kind: "error",
273
+ reason: `exit ${raw.code}${raw.stderr ? `: ${raw.stderr.slice(0, 150)}` : ""}`
274
+ };
275
+ }
276
+ let parsed;
277
+ try {
278
+ parsed = JSON.parse(raw.stdout || "{}");
279
+ } catch (err) {
280
+ return { kind: "error", reason: `unparseable JSON: ${String(err).slice(0, 100)}` };
281
+ }
282
+ const errEnvelope = parsed.error;
283
+ if (errEnvelope && typeof errEnvelope === "object") {
284
+ return { kind: "error", reason: errEnvelope.code ?? "error envelope returned" };
285
+ }
286
+ const vulnsMeta = parsed.metadata?.vulnerabilities;
287
+ if (!vulnsMeta || Object.keys(vulnsMeta).length === 0) {
288
+ return { kind: "error", reason: "no metadata.vulnerabilities in output" };
289
+ }
290
+ return { kind: "ok", parsed };
291
+ }
292
+ async function securityAudit(ctx) {
293
+ const spawn = ctx.spawn ?? defaultSpawn;
294
+ const site = ctx.site;
295
+ const label = siteLabel(site);
296
+ let used = "pnpm audit";
297
+ let result = await runAuditTool(spawn, "pnpm", ["audit", "--json", "--prod"], site.path);
298
+ if (result.kind !== "ok") {
299
+ const pnpmReason = result.kind === "missing" ? "not installed" : result.reason;
300
+ const npmResult = await runAuditTool(
301
+ spawn,
302
+ "npm",
303
+ ["audit", "--json", "--omit=dev"],
304
+ site.path
305
+ );
306
+ if (npmResult.kind === "ok") {
307
+ result = npmResult;
308
+ used = "npm audit";
309
+ } else {
310
+ const npmReason = npmResult.kind === "missing" ? "not installed" : npmResult.reason;
311
+ return {
312
+ audit: "security",
313
+ site: label,
314
+ status: "skip",
315
+ summary: `cannot run audit \u2014 pnpm: ${pnpmReason}; npm: ${npmReason}`
316
+ };
317
+ }
318
+ }
319
+ const parsed = result.parsed;
320
+ const counts = {
321
+ low: parsed.metadata?.vulnerabilities?.low ?? 0,
322
+ moderate: parsed.metadata?.vulnerabilities?.moderate ?? 0,
323
+ high: parsed.metadata?.vulnerabilities?.high ?? 0,
324
+ critical: parsed.metadata?.vulnerabilities?.critical ?? 0
325
+ };
326
+ const advisories = used === "pnpm audit" ? extractAdvisoriesFromPnpm(parsed) : extractAdvisoriesFromNpm(parsed);
327
+ const status = classify(counts);
328
+ const total = counts.low + counts.moderate + counts.high + counts.critical;
329
+ const summary = status === "pass" ? `${used}: 0 vulnerabilities` : `${used}: ${total} vulnerabilities (${counts.critical}C/${counts.high}H/${counts.moderate}M/${counts.low}L)`;
330
+ return {
331
+ audit: "security",
332
+ site: label,
333
+ status,
334
+ summary,
335
+ details: { counts, advisories }
336
+ };
337
+ }
338
+
339
+ // src/audits/lighthouse.ts
340
+ import { readFile as readFile4, writeFile, mkdtemp, rm, readdir } from "fs/promises";
341
+ import { tmpdir } from "os";
342
+ import { join as join5 } from "path";
343
+
344
+ // src/audits/util/site-config.ts
345
+ import { readFile as readFile3 } from "fs/promises";
346
+ import { join as join4 } from "path";
347
+ async function readSiteConfig(sitePath) {
348
+ let raw;
349
+ try {
350
+ raw = await readFile3(join4(sitePath, "package.json"), "utf-8");
351
+ } catch {
352
+ return {};
353
+ }
354
+ let pkg;
355
+ try {
356
+ pkg = JSON.parse(raw);
357
+ } catch {
358
+ return {};
359
+ }
360
+ if (!pkg || typeof pkg !== "object") return {};
361
+ const cfg = pkg.reddoor;
362
+ if (!cfg || typeof cfg !== "object") return {};
363
+ const out = {};
364
+ const url = cfg.lighthouseUrl;
365
+ if (typeof url === "string" && url.length > 0) {
366
+ out.lighthouseUrl = url;
367
+ }
368
+ return out;
369
+ }
370
+
371
+ // src/util/free-port.ts
372
+ import { createServer } from "net";
373
+ async function findFreePort() {
374
+ return new Promise((resolve, reject) => {
375
+ const server = createServer();
376
+ server.unref();
377
+ server.on("error", reject);
378
+ server.listen(0, "127.0.0.1", () => {
379
+ const addr = server.address();
380
+ if (typeof addr === "object" && addr) {
381
+ const port = addr.port;
382
+ server.close(() => resolve(port));
383
+ } else {
384
+ server.close();
385
+ reject(new Error("findFreePort: could not determine assigned port from socket"));
386
+ }
387
+ });
388
+ });
389
+ }
390
+ function withFreePort(url, port) {
391
+ const u = new URL(url);
392
+ u.hostname = "localhost";
393
+ u.port = String(port);
394
+ return u.toString();
395
+ }
396
+
397
+ // src/audits/lighthouse.ts
398
+ async function readJsonMaybe(path) {
399
+ try {
400
+ const raw = await readFile4(path, "utf-8");
401
+ return JSON.parse(raw);
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+ async function readLhrEntries(resultsDir) {
407
+ const files = await readdir(resultsDir).catch(() => []);
408
+ const entries = [];
409
+ for (const f of files) {
410
+ if (!f.startsWith("lhr-") || !f.endsWith(".json")) continue;
411
+ const lhr = await readJsonMaybe(join5(resultsDir, f));
412
+ if (!lhr || !lhr.categories) continue;
413
+ const summary = {};
414
+ for (const [k, v] of Object.entries(lhr.categories)) {
415
+ if (typeof v?.score === "number") summary[k] = v.score;
416
+ }
417
+ entries.push({ url: lhr.requestedUrl, summary });
418
+ }
419
+ return entries;
420
+ }
421
+ function averageSummaries(entries) {
422
+ if (entries.length === 0) return {};
423
+ const sums = {};
424
+ const counts = {};
425
+ for (const e of entries) {
426
+ for (const [k, v] of Object.entries(e.summary ?? {})) {
427
+ if (typeof v !== "number") continue;
428
+ sums[k] = (sums[k] ?? 0) + v;
429
+ counts[k] = (counts[k] ?? 0) + 1;
430
+ }
431
+ }
432
+ const out = {};
433
+ for (const k of Object.keys(sums)) {
434
+ const total = sums[k] ?? 0;
435
+ const count = counts[k] ?? 1;
436
+ out[k] = total / count;
437
+ }
438
+ return out;
439
+ }
440
+ function categoryFromAssertion(a) {
441
+ const colonIdx = a.name.indexOf(":");
442
+ return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;
443
+ }
444
+ function messageForAssertion(a) {
445
+ const actual = typeof a.actual === "number" ? a.actual.toFixed(2) : "n/a";
446
+ return `${a.name} ${a.operator} ${a.expected} (actual: ${actual})`;
447
+ }
448
+ async function parseLhciResults(resultsDir, label, raw) {
449
+ const manifest = await readLhrEntries(resultsDir);
450
+ if (manifest.length === 0) {
451
+ return {
452
+ audit: "lighthouse",
453
+ site: label,
454
+ status: "fail",
455
+ summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
456
+ };
457
+ }
458
+ const assertionResults = await readJsonMaybe(join5(resultsDir, "assertion-results.json")) ?? [];
459
+ const failed = assertionResults.filter((a) => !a.passed);
460
+ const assertions = failed.map((a) => ({
461
+ category: categoryFromAssertion(a),
462
+ level: a.level,
463
+ message: messageForAssertion(a)
464
+ }));
465
+ const anyError = assertions.some((a) => a.level === "error");
466
+ const anyWarn = assertions.some((a) => a.level === "warn");
467
+ const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
468
+ const normalized = {
469
+ summary: averageSummaries(manifest),
470
+ assertionsFailed: failed.length,
471
+ assertions
472
+ };
473
+ const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
474
+ return { audit: "lighthouse", site: label, status, summary, details: normalized };
475
+ }
476
+ async function checkoutLighthouse(spawn, site, label) {
477
+ const siteCfg = await readSiteConfig(site.path);
478
+ const port = await findFreePort();
479
+ const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];
480
+ const resolvedConfig = {
481
+ ...lighthouseConfig,
482
+ ci: {
483
+ ...lighthouseConfig.ci,
484
+ collect: {
485
+ ...lighthouseConfig.ci.collect,
486
+ url: [withFreePort(baseUrl, port)],
487
+ startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`
488
+ }
489
+ }
490
+ };
491
+ const configDir = await mkdtemp(join5(tmpdir(), "reddoor-lhci-"));
492
+ const configPath = join5(configDir, "lighthouserc.json");
493
+ await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
494
+ const resultsDir = join5(site.path, ".lighthouseci");
495
+ await rm(resultsDir, { recursive: true, force: true });
496
+ let raw;
497
+ try {
498
+ raw = await spawn("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
499
+ cwd: site.path,
500
+ timeoutMs: 5 * 6e4
501
+ });
502
+ } catch (err) {
503
+ await rm(configDir, { recursive: true, force: true });
504
+ const e = err;
505
+ if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
506
+ return {
507
+ audit: "lighthouse",
508
+ site: label,
509
+ status: "skip",
510
+ summary: "npx/@lhci/cli not available"
511
+ };
512
+ }
513
+ throw err;
514
+ }
515
+ await rm(configDir, { recursive: true, force: true });
516
+ return parseLhciResults(resultsDir, label, raw);
517
+ }
518
+ async function deployedLighthouse(spawn, deployedUrl, label) {
519
+ const workDir = await mkdtemp(join5(tmpdir(), "reddoor-lh-deployed-"));
520
+ const resolvedConfig = {
521
+ ci: {
522
+ // Deliberately NOT spread from lighthouseConfig.ci.collect: deployed mode
523
+ // must omit startServerCommand and the dev-server settings entirely.
524
+ collect: {
525
+ url: [deployedUrl],
526
+ // 3 runs to damp Lighthouse's run-to-run variance; parseLhciResults
527
+ // averages the lhr files. (Median is a tracked future refinement.)
528
+ numberOfRuns: 3,
529
+ settings: { preset: "desktop", skipAudits: ["uses-http2"] }
530
+ },
531
+ assert: lighthouseConfig.ci.assert,
532
+ upload: { target: "filesystem", outputDir: join5(workDir, "lhci-report") }
533
+ }
534
+ };
535
+ const configPath = join5(workDir, "lighthouserc.json");
536
+ await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
537
+ const resultsDir = join5(workDir, ".lighthouseci");
538
+ let raw;
539
+ try {
540
+ raw = await spawn("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
541
+ cwd: workDir,
542
+ // 3 serial cold runs of a slow deployed site (lhci's own maxWaitForLoad
543
+ // ~45-60s each) + first-use Chrome download can plausibly exceed 3 min →
544
+ // SIGTERM → no lhr-*.json → spurious "no scores". Match the 5-min budget
545
+ // the checkout path already gives (erp-industrials nightly flake,
546
+ // morning-brief 2026-06-10 MEDIUM-F).
547
+ timeoutMs: 5 * 6e4
548
+ });
549
+ } catch (err) {
550
+ await rm(workDir, { recursive: true, force: true });
551
+ const e = err;
552
+ if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
553
+ return {
554
+ audit: "lighthouse",
555
+ site: label,
556
+ status: "skip",
557
+ summary: "npx/@lhci/cli not available"
558
+ };
559
+ }
560
+ throw err;
561
+ }
562
+ try {
563
+ return await parseLhciResults(resultsDir, label, raw);
564
+ } finally {
565
+ await rm(workDir, { recursive: true, force: true });
566
+ }
567
+ }
568
+ async function lighthouseAudit(ctx) {
569
+ const spawn = ctx.spawn ?? defaultSpawn;
570
+ const site = ctx.site;
571
+ const label = siteLabel(site);
572
+ return site.deployedUrl ? deployedLighthouse(spawn, site.deployedUrl, label) : checkoutLighthouse(spawn, site, label);
573
+ }
574
+
575
+ // src/audits/a11y.ts
576
+ import { readFile as readFile5, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
577
+ import { join as join6 } from "path";
578
+ var RESULTS_REL = ".reddoor-a11y/results.json";
579
+ async function readJsonMaybe2(path) {
580
+ try {
581
+ const raw = await readFile5(path, "utf-8");
582
+ return JSON.parse(raw);
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ function buildPlaywrightConfig(port, sitePath) {
588
+ return `import { defineConfig } from "@playwright/test";
589
+
590
+ export default defineConfig({
591
+ testDir: ".",
592
+ testMatch: /.*\\.spec\\.ts$/,
593
+ fullyParallel: true,
594
+ forbidOnly: !!process.env.CI,
595
+ retries: process.env.CI ? 2 : 0,
596
+ reporter: process.env.CI ? "github" : "list",
597
+ use: {
598
+ baseURL: "http://localhost:${port}",
599
+ trace: "on-first-retry",
600
+ },
601
+ webServer: {
602
+ // --strictPort: refuse to bump to a different port if ours is taken,
603
+ // so the audit fails loudly instead of probing a zombie.
604
+ // reuseExistingServer:false: never reuse \u2014 we control the lifecycle.
605
+ // cwd: playwright's default webServer.cwd is the config file's
606
+ // directory. Our config lives in /tmp so without this override,
607
+ // "npm run vite:dev" tries to read /tmp/.../package.json and
608
+ // ENOENTs before vite ever starts. Caltex 2026-05-28 (0.10.5).
609
+ command: "npm run vite:dev -- --port ${port} --strictPort",
610
+ url: "http://localhost:${port}/dev/a11y-fixtures",
611
+ cwd: ${JSON.stringify(sitePath)},
612
+ reuseExistingServer: false,
613
+ timeout: 120_000,
614
+ },
615
+ });
616
+ `;
617
+ }
618
+ function buildSpec() {
619
+ return `import { test, expect } from "@playwright/test";
620
+ import AxeBuilder from "@axe-core/playwright";
621
+ import { mkdir, writeFile } from "node:fs/promises";
622
+ import { dirname } from "node:path";
623
+
624
+ const pages = ${JSON.stringify(a11yRoutes)};
625
+ const smokePages = ${JSON.stringify(smokeRoutes)};
626
+ const OUTPUT = process.env.REDDOOR_A11Y_OUTPUT;
627
+
628
+ // Playwright's default per-test timeout is 30s. We loop through every
629
+ // configured route in a single test, so the budget needs to scale.
630
+ test.setTimeout(5 * 60_000);
631
+
632
+ test("a11y + hydration across configured routes", async ({ page }) => {
633
+ const violations = [];
634
+
635
+ // Capture uncaught client-side exceptions across every route we visit. A page
636
+ // that builds + SSRs cleanly can still throw on hydrate and blank itself
637
+ // (data-dynamiq: a Svelte 4->5 run() referenced a $state declared after it) --
638
+ // axe never sees that, so we listen for it directly and tag the route in scope.
639
+ let currentRoute = "";
640
+ page.on("pageerror", (err) => {
641
+ violations.push({
642
+ id: "client-error",
643
+ impact: "critical",
644
+ route: currentRoute,
645
+ help: String(err && err.message ? err.message : err),
646
+ });
647
+ });
648
+
649
+ for (const { path, name } of pages) {
650
+ currentRoute = name;
651
+ await page.goto(path);
652
+ // Snap CSS transitions/animations to their resting state before axe runs.
653
+ // AnimateIn-style fixtures transition opacity 0->1; sampling mid-transition
654
+ // makes axe compute color-contrast against semi-transparent text, yielding a
655
+ // flaky "serious" color-contrast violation (~1/3 of runs on /dev/animate-in).
656
+ // Disabling transitions/animations forces the final, rendered state
657
+ // deterministically -- which is also what users (and prefers-reduced-motion
658
+ // users) actually see, so it's the correct thing to assert.
659
+ await page.addStyleTag({
660
+ content: "*,*::before,*::after{transition:none!important;animation:none!important;}",
661
+ });
662
+ const results = await new AxeBuilder({ page })
663
+ .withTags(["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"])
664
+ .analyze();
665
+ for (const v of results.violations) {
666
+ violations.push({
667
+ id: v.id,
668
+ impact: v.impact ?? "moderate",
669
+ route: name,
670
+ help: v.help,
671
+ helpUrl: v.helpUrl,
672
+ nodes: v.nodes.map((n) => ({ html: n.html, target: n.target })),
673
+ });
674
+ }
675
+ }
676
+
677
+ // Hydration smoke check: load real routes (the homepage) and fail on any
678
+ // uncaught client-side error. No axe here -- real routes carry pre-existing
679
+ // a11y debt we don't gate on; we only assert they don't crash on hydrate.
680
+ // HTTP/SSR errors don't fire 'pageerror', so a data-less CI homepage that
681
+ // renders empty-but-valid won't false-fail -- only a real client crash does.
682
+ for (const { path, name } of smokePages) {
683
+ currentRoute = name;
684
+ await page.goto(path);
685
+ // Let hydration + first effects run so a TDZ/ReferenceError surfaces.
686
+ await page.waitForTimeout(2000);
687
+ }
688
+
689
+ const byImpact = {};
690
+ for (const v of violations) {
691
+ byImpact[v.impact] = (byImpact[v.impact] ?? 0) + 1;
692
+ }
693
+ if (OUTPUT) {
694
+ await mkdir(dirname(OUTPUT), { recursive: true });
695
+ await writeFile(
696
+ OUTPUT,
697
+ JSON.stringify({ totalViolations: violations.length, byImpact, violations }, null, 2),
698
+ );
699
+ }
700
+ expect(violations).toEqual([]);
701
+ });
702
+ `;
703
+ }
704
+ async function a11yAudit(ctx) {
705
+ const spawn = ctx.spawn ?? defaultSpawn;
706
+ const site = ctx.site;
707
+ const label = siteLabel(site);
708
+ const specDir = await mkdtemp2(join6(site.path, ".reddoor-a11y-spec-"));
709
+ try {
710
+ const specPath = join6(specDir, "a11y.spec.ts");
711
+ await writeFile2(specPath, buildSpec(), "utf-8");
712
+ const port = await findFreePort();
713
+ const configPath = join6(specDir, "playwright.config.ts");
714
+ await writeFile2(configPath, buildPlaywrightConfig(port, site.path), "utf-8");
715
+ const resultsPath = join6(site.path, RESULTS_REL);
716
+ await rm2(join6(site.path, ".reddoor-a11y"), { recursive: true, force: true });
717
+ let raw;
718
+ try {
719
+ raw = await spawn(
720
+ "npx",
721
+ ["--yes", "playwright", "test", `--config=${configPath}`, "--reporter=line", specPath],
722
+ {
723
+ cwd: site.path,
724
+ env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },
725
+ // playwright on a cold tree downloads Chrome, boots the site's dev
726
+ // server, and runs axe over every configured route. The shared 30 s
727
+ // default in runAudits is fine for deps/lint/security but starves
728
+ // playwright (mirrors the lighthouse fix shipped earlier).
729
+ timeoutMs: 5 * 6e4
730
+ }
731
+ );
732
+ } catch (err) {
733
+ const e = err;
734
+ if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
735
+ return {
736
+ audit: "a11y",
737
+ site: label,
738
+ status: "skip",
739
+ summary: "npx/playwright not available"
740
+ };
741
+ }
742
+ throw err;
743
+ }
744
+ const artifact = await readJsonMaybe2(resultsPath);
745
+ if (!artifact) {
746
+ return {
747
+ audit: "a11y",
748
+ site: label,
749
+ status: "fail",
750
+ summary: `a11y: no results written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
751
+ };
752
+ }
753
+ const hasSerious = (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;
754
+ const hasAny = artifact.totalViolations > 0;
755
+ const status = hasSerious ? "fail" : hasAny ? "warn" : "pass";
756
+ const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes (+${smokeRoutes.length} hydration smoke)` : `a11y: ${artifact.totalViolations} violations`;
757
+ return {
758
+ audit: "a11y",
759
+ site: label,
760
+ status,
761
+ summary,
762
+ details: artifact
763
+ };
764
+ } finally {
765
+ await rm2(specDir, { recursive: true, force: true });
766
+ }
767
+ }
768
+
769
+ // src/audits/domain.ts
770
+ import { promises as dnsPromises } from "dns";
771
+ import tls from "tls";
772
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
773
+ async function checkDomain(url, deps) {
774
+ let host;
775
+ try {
776
+ host = new URL(url).hostname;
777
+ } catch {
778
+ return { resolved: false, certDaysRemaining: null };
779
+ }
780
+ try {
781
+ await deps.lookup(host);
782
+ } catch {
783
+ return { resolved: false, certDaysRemaining: null };
784
+ }
785
+ let validTo;
786
+ try {
787
+ validTo = await deps.certValidTo(host);
788
+ } catch {
789
+ validTo = null;
790
+ }
791
+ if (!validTo || Number.isNaN(validTo.getTime()))
792
+ return { resolved: true, certDaysRemaining: null };
793
+ return {
794
+ resolved: true,
795
+ certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
796
+ };
797
+ }
798
+ function defaultDomainDeps(now) {
799
+ return {
800
+ lookup: async (host) => {
801
+ await dnsPromises.lookup(host);
802
+ },
803
+ certValidTo: (host) => new Promise((resolvePromise) => {
804
+ const socket = tls.connect(
805
+ { host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
806
+ () => {
807
+ const cert = socket.authorized ? socket.getPeerCertificate() : null;
808
+ socket.end();
809
+ const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
810
+ resolvePromise(validTo);
811
+ }
812
+ );
813
+ socket.on("error", () => resolvePromise(null));
814
+ socket.on("timeout", () => {
815
+ socket.destroy();
816
+ resolvePromise(null);
817
+ });
818
+ }),
819
+ now
820
+ };
821
+ }
822
+ async function domainAudit(ctx) {
823
+ const { site } = ctx;
824
+ const label = siteLabel(site);
825
+ if (!site.deployedUrl) {
826
+ return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
827
+ }
828
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
829
+ const deps = ctx.domainDeps ?? defaultDomainDeps(now);
830
+ const check = await checkDomain(site.deployedUrl, deps);
831
+ const checkedAt = now.toISOString();
832
+ const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
833
+ const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
834
+ return {
835
+ audit: "domain",
836
+ site: label,
837
+ status,
838
+ summary,
839
+ details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
840
+ };
841
+ }
842
+
843
+ // src/audits/route-discovery.ts
844
+ var DEFAULT_CAP = 15;
845
+ function parseSitemapUrls(xml) {
846
+ const out = [];
847
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
848
+ let m;
849
+ while ((m = re.exec(xml)) !== null) {
850
+ const url = m[1];
851
+ if (url) out.push(url.trim());
852
+ }
853
+ return out;
854
+ }
855
+ function parseHtmlLinks(html, baseUrl) {
856
+ const out = /* @__PURE__ */ new Set();
857
+ const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
858
+ let m;
859
+ while ((m = re.exec(html)) !== null) {
860
+ const href = m[1];
861
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
862
+ try {
863
+ const u = new URL(href, baseUrl);
864
+ if (u.origin !== new URL(baseUrl).origin) continue;
865
+ out.add(u.pathname);
866
+ } catch {
867
+ }
868
+ }
869
+ return [...out];
870
+ }
871
+ function family(pathname) {
872
+ return pathname.split("/").filter(Boolean)[0] ?? "";
873
+ }
874
+ function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
875
+ const seen = /* @__PURE__ */ new Set(["/"]);
876
+ const buckets = /* @__PURE__ */ new Map();
877
+ for (const raw of urlsOrPaths) {
878
+ let pathname;
879
+ try {
880
+ pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
881
+ } catch {
882
+ continue;
883
+ }
884
+ if (pathname === "/") continue;
885
+ if (seen.has(pathname)) continue;
886
+ seen.add(pathname);
887
+ const fam = family(pathname);
888
+ const arr = buckets.get(fam) ?? [];
889
+ arr.push(pathname);
890
+ buckets.set(fam, arr);
891
+ }
892
+ const result = ["/"];
893
+ const families = [...buckets.values()];
894
+ let guard = 0;
895
+ while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
896
+ for (const fam of families) {
897
+ if (result.length >= cap) break;
898
+ const next = fam.shift();
899
+ if (next) result.push(next);
900
+ }
901
+ }
902
+ return result;
903
+ }
904
+ function familyCountsOf(paths) {
905
+ const counts = {};
906
+ for (const p of paths) {
907
+ const key = p === "/" ? "/" : `/${family(p)}`;
908
+ counts[key] = (counts[key] ?? 0) + 1;
909
+ }
910
+ return counts;
911
+ }
912
+ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
913
+ const origin = new URL(deployedUrl).origin;
914
+ const abs = (paths) => paths.map((p) => new URL(p, origin).href);
915
+ const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
916
+ if (sitemapXml) {
917
+ const urls = parseSitemapUrls(sitemapXml);
918
+ if (urls.length > 0) {
919
+ const paths = sampleRoutePaths(urls, cap);
920
+ return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
921
+ }
922
+ }
923
+ const homeHtml = await deps.fetchText(origin);
924
+ if (homeHtml) {
925
+ const links = parseHtmlLinks(homeHtml, origin);
926
+ if (links.length > 0) {
927
+ const paths = sampleRoutePaths(links, cap);
928
+ return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
929
+ }
930
+ }
931
+ return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
932
+ }
933
+
934
+ // src/audits/browser.ts
935
+ function isBroken(status) {
936
+ return status === null || status >= 400;
937
+ }
938
+ function summarizeBrowser(routes, links, familyCounts) {
939
+ const desktopChecks = routes.flatMap((r) => r.desktop);
940
+ const mobileChecks = routes.flatMap((r) => r.mobile);
941
+ const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
942
+ const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
943
+ const brokenLinks = links.filter((l) => isBroken(l.status)).length;
944
+ const linksOk = links.length > 0 && brokenLinks === 0;
945
+ const engines = [...new Set(desktopChecks.map((d) => d.engine))];
946
+ const devices = [...new Set(mobileChecks.map((m) => m.device))];
947
+ const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
948
+ const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
949
+ return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
950
+ }
951
+ async function browserAudit(ctx) {
952
+ const { site } = ctx;
953
+ const label = siteLabel(site);
954
+ if (!site.deployedUrl) {
955
+ return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
956
+ }
957
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
958
+ const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
959
+ const runner = ctx.browserRunner ?? await defaultBrowserRunner();
960
+ try {
961
+ const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
962
+ const routeResults = await runner.probe(discovered.routes);
963
+ const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
964
+ const linkResults = await runner.checkLinks(internalLinks);
965
+ const summary = summarizeBrowser(
966
+ routeResults,
967
+ linkResults,
968
+ discovered.familyCounts ?? familyCountsOf(discovered.routes)
969
+ );
970
+ const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
971
+ return {
972
+ audit: "browser",
973
+ site: label,
974
+ status,
975
+ summary: summary.note,
976
+ details: { ...summary, checkedAt: now.toISOString() }
977
+ };
978
+ } finally {
979
+ await runner.close?.();
980
+ }
981
+ }
982
+ var FETCH_TIMEOUT_MS = 1e4;
983
+ function defaultDiscoverDeps() {
984
+ return {
985
+ fetchText: async (url) => {
986
+ try {
987
+ const res = await fetch(url, {
988
+ redirect: "follow",
989
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
990
+ });
991
+ if (!res.ok) return null;
992
+ return await res.text();
993
+ } catch {
994
+ return null;
995
+ }
996
+ }
997
+ };
998
+ }
999
+ var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
1000
+ var PAGE_TIMEOUT_MS = 3e4;
1001
+ async function defaultBrowserRunner() {
1002
+ const { chromium, firefox, webkit, devices } = await import("@playwright/test");
1003
+ const desktopEngines = [
1004
+ { engine: "chromium", type: chromium },
1005
+ { engine: "firefox", type: firefox },
1006
+ { engine: "webkit", type: webkit }
1007
+ ];
1008
+ const mobileTargets = [
1009
+ { device: "Pixel 7", descriptor: devices["Pixel 7"] },
1010
+ { device: "iPhone 14", descriptor: devices["iPhone 14"] }
1011
+ ];
1012
+ return {
1013
+ async probe(urls) {
1014
+ const results = [];
1015
+ const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
1016
+ const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
1017
+ try {
1018
+ for (const url of urls) {
1019
+ const desktop = [];
1020
+ const linkSet = /* @__PURE__ */ new Set();
1021
+ for (let i = 0; i < desktopEngines.length; i++) {
1022
+ const engine = desktopEngines[i].engine;
1023
+ const browser = browsers[i];
1024
+ const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
1025
+ const page = await ctx.newPage();
1026
+ const errors = [];
1027
+ page.on("pageerror", (e) => errors.push(String(e)));
1028
+ let ok = false;
1029
+ try {
1030
+ const resp = await page.goto(url, {
1031
+ waitUntil: "domcontentloaded",
1032
+ timeout: PAGE_TIMEOUT_MS
1033
+ });
1034
+ const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
1035
+ ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
1036
+ if (engine === "chromium") {
1037
+ const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
1038
+ const origin = new URL(url).origin;
1039
+ for (const h of hrefs) {
1040
+ try {
1041
+ if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
1042
+ } catch {
1043
+ }
1044
+ }
1045
+ }
1046
+ } catch {
1047
+ ok = false;
1048
+ } finally {
1049
+ await ctx.close().catch(() => {
1050
+ });
1051
+ }
1052
+ desktop.push({ engine, ok });
1053
+ }
1054
+ const mobile = [];
1055
+ for (let i = 0; i < mobileTargets.length; i++) {
1056
+ const { device, descriptor } = mobileTargets[i];
1057
+ const browser = mobileBrowsers[i];
1058
+ const ctx = await browser.newContext({ ...descriptor });
1059
+ const page = await ctx.newPage();
1060
+ const errors = [];
1061
+ page.on("pageerror", (e) => errors.push(String(e)));
1062
+ let ok = false;
1063
+ try {
1064
+ const resp = await page.goto(url, {
1065
+ waitUntil: "domcontentloaded",
1066
+ timeout: PAGE_TIMEOUT_MS
1067
+ });
1068
+ const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
1069
+ ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
1070
+ } catch {
1071
+ ok = false;
1072
+ } finally {
1073
+ await ctx.close().catch(() => {
1074
+ });
1075
+ }
1076
+ mobile.push({ device, ok });
1077
+ }
1078
+ results.push({ url, desktop, mobile, links: [...linkSet] });
1079
+ }
1080
+ } finally {
1081
+ await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
1082
+ })));
1083
+ }
1084
+ return results;
1085
+ },
1086
+ async checkLinks(urls) {
1087
+ const out = [];
1088
+ for (const url of urls) {
1089
+ let status;
1090
+ try {
1091
+ let res = await fetch(url, {
1092
+ method: "HEAD",
1093
+ redirect: "follow",
1094
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
1095
+ });
1096
+ if (res.status === 405 || res.status === 501) {
1097
+ res = await fetch(url, {
1098
+ method: "GET",
1099
+ redirect: "follow",
1100
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
1101
+ });
1102
+ }
1103
+ status = res.status;
1104
+ } catch {
1105
+ status = null;
1106
+ }
1107
+ out.push({ url, status });
1108
+ }
1109
+ return out;
1110
+ }
1111
+ };
1112
+ }
1113
+
1114
+ // src/audits/index.ts
1115
+ var REGISTRY = {
1116
+ deps: depsAudit,
1117
+ lint: lintAudit,
1118
+ security: securityAudit,
1119
+ lighthouse: lighthouseAudit,
1120
+ a11y: a11yAudit,
1121
+ domain: domainAudit,
1122
+ browser: browserAudit
1123
+ };
1124
+ var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
1125
+ var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
1126
+ function timedSpawn(timeoutMs) {
1127
+ return (cmd, args, opts = {}) => defaultSpawn(cmd, args, { ...opts, timeoutMs: opts.timeoutMs ?? timeoutMs });
1128
+ }
1129
+ async function runOneAudit(site, name) {
1130
+ if (!(name in REGISTRY)) throw new Error(`unknown audit: ${name}`);
1131
+ const spawn = timedSpawn(DEFAULT_AUDIT_TIMEOUT_MS);
1132
+ const label = site.name || site.path;
1133
+ try {
1134
+ return await REGISTRY[name]({ site, spawn });
1135
+ } catch (err) {
1136
+ return {
1137
+ audit: name,
1138
+ site: label,
1139
+ status: "fail",
1140
+ summary: `${name}: unexpected error \u2014 ${String(err)}`
1141
+ };
1142
+ }
1143
+ }
1144
+ async function runAudits(site, which) {
1145
+ const names = which ?? ALL_AUDIT_NAMES;
1146
+ for (const n of names) {
1147
+ if (!(n in REGISTRY)) throw new Error(`unknown audit: ${n}`);
1148
+ }
1149
+ return Promise.all(names.map((n) => runOneAudit(site, n)));
1150
+ }
1151
+ async function runAuditsAcross(sites, which) {
1152
+ const all = await Promise.all(sites.map((s) => runAudits(s, which)));
1153
+ return all.flat();
1154
+ }
1155
+
1156
+ export {
1157
+ depsAudit,
1158
+ lintAudit,
1159
+ securityAudit,
1160
+ lighthouseAudit,
1161
+ a11yAudit,
1162
+ ALL_AUDIT_NAMES,
1163
+ runOneAudit,
1164
+ runAudits,
1165
+ runAuditsAcross
1166
+ };
1167
+ //# sourceMappingURL=chunk-4PY4I5OR.js.map