@salesforce/storefront-next-dev 1.4.0-alpha.1 → 1.4.1-alpha.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 (136) hide show
  1. package/README.md +13 -2
  2. package/dist/assets.js +25 -0
  3. package/dist/audit/catalog/categories/best-practices/CATEGORY.md +21 -0
  4. package/dist/audit/catalog/categories/best-practices/rules/commerce-api-client.md +20 -0
  5. package/dist/audit/catalog/categories/best-practices/rules/error-boundaries.md +19 -0
  6. package/dist/audit/catalog/categories/best-practices/rules/production-integrations.md +20 -0
  7. package/dist/audit/catalog/categories/best-practices/rules/route-revalidation.md +19 -0
  8. package/dist/audit/catalog/categories/best-practices/rules/server-data-loading.md +19 -0
  9. package/dist/audit/catalog/categories/performance/CATEGORY.md +18 -0
  10. package/dist/audit/catalog/categories/performance/rules/app-config-size.js +15 -0
  11. package/dist/audit/catalog/categories/performance/rules/app-config-size.md +22 -0
  12. package/dist/audit/catalog/categories/performance/rules/cache-control.js +59 -0
  13. package/dist/audit/catalog/categories/performance/rules/cache-control.md +20 -0
  14. package/dist/audit/catalog/categories/performance/rules/cls-budget.js +13 -0
  15. package/dist/audit/catalog/categories/performance/rules/cls-budget.md +20 -0
  16. package/dist/audit/catalog/categories/performance/rules/content-encoding.js +35 -0
  17. package/dist/audit/catalog/categories/performance/rules/content-encoding.md +20 -0
  18. package/dist/audit/catalog/categories/performance/rules/dom-size.js +18 -0
  19. package/dist/audit/catalog/categories/performance/rules/dom-size.md +22 -0
  20. package/dist/audit/catalog/categories/performance/rules/html-document-size.js +15 -0
  21. package/dist/audit/catalog/categories/performance/rules/html-document-size.md +20 -0
  22. package/dist/audit/catalog/categories/performance/rules/image-dimensions.js +141 -0
  23. package/dist/audit/catalog/categories/performance/rules/image-dimensions.md +22 -0
  24. package/dist/audit/catalog/categories/performance/rules/image-transformation.js +117 -0
  25. package/dist/audit/catalog/categories/performance/rules/image-transformation.md +22 -0
  26. package/dist/audit/catalog/categories/performance/rules/javascript-size.js +126 -0
  27. package/dist/audit/catalog/categories/performance/rules/javascript-size.md +20 -0
  28. package/dist/audit/catalog/categories/performance/rules/large-images.js +187 -0
  29. package/dist/audit/catalog/categories/performance/rules/large-images.md +22 -0
  30. package/dist/audit/catalog/categories/performance/rules/lcp-budget.js +13 -0
  31. package/dist/audit/catalog/categories/performance/rules/lcp-budget.md +20 -0
  32. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.js +227 -0
  33. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.md +27 -0
  34. package/dist/audit/catalog/categories/performance/rules/page-data-size.js +15 -0
  35. package/dist/audit/catalog/categories/performance/rules/page-data-size.md +22 -0
  36. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.js +97 -0
  37. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.md +73 -0
  38. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.js +201 -0
  39. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.md +20 -0
  40. package/dist/audit/catalog/categories/performance/rules/server-request-waterfall.md +85 -0
  41. package/dist/audit/catalog/categories/performance/rules/tbt-budget.js +13 -0
  42. package/dist/audit/catalog/categories/performance/rules/tbt-budget.md +20 -0
  43. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.js +265 -0
  44. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.md +20 -0
  45. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.js +17 -0
  46. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.md +20 -0
  47. package/dist/audit/catalog/categories/security/CATEGORY.md +17 -0
  48. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.js +37 -0
  49. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.md +20 -0
  50. package/dist/audit/catalog/categories/security/rules/content-security-policy.js +40 -0
  51. package/dist/audit/catalog/categories/security/rules/content-security-policy.md +20 -0
  52. package/dist/audit/catalog/categories/security/rules/csp-runtime-compatibility.md +24 -0
  53. package/dist/audit/catalog/categories/security/rules/dependency-vulnerabilities.md +19 -0
  54. package/dist/audit/catalog/categories/security/rules/dynamic-code-execution.md +19 -0
  55. package/dist/audit/catalog/categories/security/rules/hardcoded-credentials.md +19 -0
  56. package/dist/audit/catalog/categories/security/rules/html-config-secrets.js +46 -0
  57. package/dist/audit/catalog/categories/security/rules/html-config-secrets.md +22 -0
  58. package/dist/audit/catalog/categories/security/rules/protected-route-authorization.md +19 -0
  59. package/dist/audit/catalog/categories/security/rules/public-client-secrets.md +20 -0
  60. package/dist/audit/catalog/categories/security/rules/raw-html-sinks.md +20 -0
  61. package/dist/audit/catalog/categories/security/rules/strict-transport-security.js +183 -0
  62. package/dist/audit/catalog/categories/security/rules/strict-transport-security.md +24 -0
  63. package/dist/audit/catalog/categories/seo/CATEGORY.md +17 -0
  64. package/dist/audit/catalog/categories/seo/rules/canonical-url.md +20 -0
  65. package/dist/audit/catalog/categories/seo/rules/locale-alternates.md +19 -0
  66. package/dist/audit/catalog/categories/seo/rules/meta-description.md +19 -0
  67. package/dist/audit/catalog/categories/seo/rules/page-title.md +19 -0
  68. package/dist/audit/catalog/categories/seo/rules/robots-indexing.md +20 -0
  69. package/dist/audit/catalog/categories/seo/rules/structured-data.md +19 -0
  70. package/dist/audit/instructions/evaluate-rule.md +42 -0
  71. package/dist/audit/schemas/audit-result.schema.json +1237 -0
  72. package/dist/audit/ui/assets/banner-brand-default-CDRB3_S0.png +0 -0
  73. package/dist/audit/ui/assets/banner-group-public-default-BGRJzVY8.png +0 -0
  74. package/dist/audit/ui/assets/banner-user-default-BW81Ba3z.png +0 -0
  75. package/dist/audit/ui/assets/bg-info@2x-CzpP0vaE.png +0 -0
  76. package/dist/audit/ui/assets/einstein-figure-B1xFOo8O.svg +1 -0
  77. package/dist/audit/ui/assets/group_avatar_160-CrxVHlYA.png +0 -0
  78. package/dist/audit/ui/assets/group_avatar_200-CSLS8I0r.png +0 -0
  79. package/dist/audit/ui/assets/index-BKCWlO9b.css +1 -0
  80. package/dist/audit/ui/assets/index-CUPt8e4A.js +44 -0
  81. package/dist/audit/ui/assets/profile_avatar_160-CtQTUBo0.png +0 -0
  82. package/dist/audit/ui/assets/profile_avatar_200-DAztxshF.png +0 -0
  83. package/dist/audit/ui/assets/symbols-BRmpfDbO.svg +1 -0
  84. package/dist/audit/ui/assets/symbols-D0Ne6690.svg +1 -0
  85. package/dist/audit/ui/einstein.png +0 -0
  86. package/dist/audit/ui/index.html +13 -0
  87. package/dist/audit.js +1896 -0
  88. package/dist/cartridge-services/index.d.ts +5 -0
  89. package/dist/cartridge-services/index.d.ts.map +1 -1
  90. package/dist/cartridge-services/index.js +94 -7
  91. package/dist/cartridge-services/index.js.map +1 -1
  92. package/dist/catalog.js +89 -0
  93. package/dist/commands/audit/catalog.js +27 -0
  94. package/dist/commands/audit/finalize.js +42 -0
  95. package/dist/commands/audit/prepare.js +84 -0
  96. package/dist/commands/audit/progress.js +51 -0
  97. package/dist/commands/audit/serve.js +63 -0
  98. package/dist/commands/config/aggregate-extensions.js +1 -0
  99. package/dist/commands/create-bundle.js +2 -1
  100. package/dist/commands/create-instructions.js +1 -0
  101. package/dist/commands/create-storefront.js +1 -0
  102. package/dist/commands/deploy-cartridge.js +11 -3
  103. package/dist/commands/dev.js +2 -0
  104. package/dist/commands/extensions/create.js +1 -0
  105. package/dist/commands/extensions/install.js +1 -0
  106. package/dist/commands/extensions/list.js +1 -0
  107. package/dist/commands/extensions/remove.js +1 -0
  108. package/dist/commands/generate-cartridge.js +49 -5
  109. package/dist/commands/locales/aggregate-extensions.js +1 -0
  110. package/dist/commands/prepare-local.js +1 -0
  111. package/dist/commands/preview.js +2 -0
  112. package/dist/commands/push.js +3 -1
  113. package/dist/commands/validate-cartridge.js +13 -4
  114. package/dist/config.js +13 -2
  115. package/dist/document-size.js +231 -0
  116. package/dist/events.js +688 -0
  117. package/dist/flags2.js +34 -0
  118. package/dist/generate-cartridge.js +41 -6
  119. package/dist/har.js +205 -0
  120. package/dist/images.js +146 -0
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/live.js +277 -0
  123. package/dist/logger.js +1 -4
  124. package/dist/measured-run.js +91 -0
  125. package/dist/metric-budget.js +125 -0
  126. package/dist/mrt/ssr.mjs +19 -19
  127. package/dist/mrt/ssr.mjs.map +1 -1
  128. package/dist/mrt/streamingHandler.mjs +26 -26
  129. package/dist/mrt/streamingHandler.mjs.map +1 -1
  130. package/dist/package.js +5 -0
  131. package/dist/sanitize.js +185 -0
  132. package/dist/schema.js +121 -0
  133. package/dist/server.js +8 -60
  134. package/dist/server2.js +185 -0
  135. package/dist/ts-import.js +57 -0
  136. package/package.json +21 -4
package/dist/events.js ADDED
@@ -0,0 +1,688 @@
1
+ import { n as sanitizeText, r as sanitizeUrl, t as sanitizeJsonValue } from "./sanitize.js";
2
+ import { a as MIN_RECOMMENDED_BYTES, o as bundleAnalysisSchema } from "./schema.js";
3
+ import path from "node:path";
4
+ import { appendFile, readFile, readdir } from "node:fs/promises";
5
+ import { z } from "zod";
6
+
7
+ //#region src/audit/guidance.ts
8
+ /**
9
+ * Copyright 2026 Salesforce, Inc.
10
+ *
11
+ * Licensed under the Apache License, Version 2.0 (the "License");
12
+ * you may not use this file except in compliance with the License.
13
+ * You may obtain a copy of the License at
14
+ *
15
+ * http://www.apache.org/licenses/LICENSE-2.0
16
+ *
17
+ * Unless required by applicable law or agreed to in writing, software
18
+ * distributed under the License is distributed on an "AS IS" BASIS,
19
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20
+ * See the License for the specific language governing permissions and
21
+ * limitations under the License.
22
+ */
23
+ const GUIDANCE = [
24
+ "go",
25
+ "go-with-fixes",
26
+ "no-go"
27
+ ];
28
+ /** Launch guidance from the known findings: critical blocks launch, high or medium needs fixes. */
29
+ function guidanceFor(evaluations) {
30
+ const severities = new Set(evaluations.filter((item) => item.disposition === "finding").map((item) => item.severity));
31
+ if (severities.has("critical")) return "no-go";
32
+ return severities.has("high") || severities.has("medium") ? "go-with-fixes" : "go";
33
+ }
34
+
35
+ //#endregion
36
+ //#region src/audit/runs.ts
37
+ const RUNS_DIR = "runs";
38
+ const RUN_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/;
39
+ /** `2026-10-14T10:26:00.123Z` → `2026-10-14T10-26-00Z`, safe as a folder name. */
40
+ function runIdFor(startedAt) {
41
+ return startedAt.replace(/\.\d+Z$/, "Z").replaceAll(":", "-");
42
+ }
43
+ function runDir(workspace, id) {
44
+ if (!RUN_ID.test(id)) throw new Error(`Invalid run id: ${id}`);
45
+ return path.join(workspace, RUNS_DIR, id);
46
+ }
47
+ /** Run ids in the workspace, newest first. */
48
+ async function listRuns(workspace) {
49
+ let entries;
50
+ try {
51
+ entries = await readdir(path.join(workspace, RUNS_DIR), { withFileTypes: true });
52
+ } catch (error) {
53
+ if (error.code === "ENOENT") return [];
54
+ throw error;
55
+ }
56
+ return entries.filter((entry) => entry.isDirectory() && RUN_ID.test(entry.name)).map((entry) => entry.name).sort().reverse();
57
+ }
58
+ /** The latest run's folder; throws when the workspace has no runs yet. */
59
+ async function latestRunDir(workspace) {
60
+ const [latest] = await listRuns(workspace);
61
+ if (!latest) throw new Error(`${workspace} has no audit runs; run prepare first`);
62
+ return runDir(workspace, latest);
63
+ }
64
+
65
+ //#endregion
66
+ //#region src/audit/results.ts
67
+ /** Archive file name of the per-page Lighthouse HTML report. When present it is
68
+ * recorded in `artifacts` as `{ available: true }` and served by the dashboard
69
+ * server rather than embedded. ui/src/panels/Performance.tsx repeats this name. */
70
+ const LIGHTHOUSE_REPORT = "report-mobile-cold.html";
71
+ /** The workspace's prepared.json, written by `prepare` and read by `finalize` and the dashboard server. */
72
+ const preparedSchema = z.object({
73
+ schemaVersion: z.literal("1.0"),
74
+ run: z.object({
75
+ id: z.string().regex(RUN_ID),
76
+ generatedAt: z.string().datetime(),
77
+ toolVersion: z.string(),
78
+ playhouseVersion: z.string().optional()
79
+ }).strict(),
80
+ project: z.object({
81
+ root: z.string(),
82
+ packageName: z.string().optional()
83
+ }).strict(),
84
+ instructions: z.string().optional(),
85
+ pages: z.array(z.object({
86
+ label: z.string(),
87
+ url: z.string(),
88
+ archiveDir: z.string()
89
+ }).strict()),
90
+ pageFailures: z.array(z.object({
91
+ label: z.string(),
92
+ url: z.string(),
93
+ error: z.string()
94
+ }).strict()).optional(),
95
+ expectedPages: z.array(z.string()).optional(),
96
+ coverage: z.array(z.object({
97
+ page: z.string(),
98
+ status: z.literal("missing"),
99
+ note: z.string()
100
+ }).strict()).optional().default([]),
101
+ rules: z.array(z.object({
102
+ ruleId: z.string(),
103
+ title: z.string(),
104
+ file: z.string(),
105
+ data: z.string().optional(),
106
+ output: z.string()
107
+ }).strict()),
108
+ decidedFindings: z.array(z.object({
109
+ ruleId: z.string(),
110
+ title: z.string(),
111
+ severity: z.string(),
112
+ summary: z.string()
113
+ }).strict())
114
+ }).strict();
115
+ function parsePrepared(value) {
116
+ const parsed = preparedSchema.safeParse(value);
117
+ if (!parsed.success) throw new Error(`prepared.json is invalid: ${parsed.error.message}`);
118
+ return parsed.data;
119
+ }
120
+ const evidenceSchema = z.discriminatedUnion("type", [
121
+ z.object({
122
+ type: z.literal("source"),
123
+ file: z.string(),
124
+ line: z.number().int().positive().optional(),
125
+ excerpt: z.string().optional()
126
+ }),
127
+ z.object({
128
+ type: z.literal("playhouse"),
129
+ page: z.string(),
130
+ artifact: z.string(),
131
+ pointer: z.string().optional(),
132
+ value: z.union([
133
+ z.string(),
134
+ z.number(),
135
+ z.boolean()
136
+ ]).optional()
137
+ }),
138
+ z.object({
139
+ type: z.literal("manual"),
140
+ description: z.string(),
141
+ location: z.string().optional()
142
+ })
143
+ ]);
144
+ /** The one rule whose evaluation may carry a `waterfall`: the per-page server request graph drawn on the Performance tab. */
145
+ const WATERFALL_RULE_ID = "performance/server-request-waterfall";
146
+ const waterfallStepSchema = z.object({
147
+ id: z.string().regex(/^[a-z0-9-]+$/),
148
+ kind: z.enum([
149
+ "middleware",
150
+ "loader",
151
+ "api",
152
+ "render"
153
+ ]),
154
+ label: z.string().min(1),
155
+ service: z.enum([
156
+ "scapi",
157
+ "slas",
158
+ "ocapi",
159
+ "einstein",
160
+ "data-store",
161
+ "other"
162
+ ]).optional(),
163
+ source: z.object({
164
+ file: z.string(),
165
+ line: z.number().int().positive().optional()
166
+ }).strict(),
167
+ after: z.array(z.string()).default([]),
168
+ blocking: z.boolean(),
169
+ conditional: z.string().optional(),
170
+ note: z.string().optional(),
171
+ critical: z.boolean().optional(),
172
+ opportunity: z.enum([
173
+ "parallelize",
174
+ "scapi-hook",
175
+ "stream"
176
+ ]).optional()
177
+ }).strict();
178
+ const waterfallSchema = z.object({ pages: z.array(z.object({
179
+ page: z.string(),
180
+ route: z.string(),
181
+ summary: z.string(),
182
+ steps: z.array(waterfallStepSchema).min(1).max(60)
183
+ }).strict()) }).strict();
184
+ /** The one rule whose evaluation may carry `recommendations`: the AI's suggestions in the Bundle size view. */
185
+ const RECOMMENDATIONS_RULE_ID = "performance/reduce-javascript-on-load";
186
+ const RECOMMENDATION_CHANGES = [
187
+ "lazy-component",
188
+ "dynamic-import",
189
+ "narrower-import",
190
+ "replace",
191
+ "remove"
192
+ ];
193
+ /** Changes that keep the code but load it later, so they need a trigger. */
194
+ const DEFERRING_CHANGES = new Set(["lazy-component", "dynamic-import"]);
195
+ const recommendationSchema = z.object({
196
+ pages: z.array(z.string()).min(1),
197
+ title: z.string().min(1).max(140),
198
+ change: z.enum(RECOMMENDATION_CHANGES),
199
+ target: z.string().min(1),
200
+ trigger: z.enum([
201
+ "interaction",
202
+ "visible",
203
+ "navigation",
204
+ "idle"
205
+ ]).optional(),
206
+ how: z.string().min(1).max(1200),
207
+ files: z.array(z.object({
208
+ file: z.string(),
209
+ line: z.number().int().positive().optional()
210
+ }).strict()).min(1).max(10),
211
+ savings: z.array(z.object({
212
+ page: z.string(),
213
+ bytes: z.number().int().nonnegative(),
214
+ upTo: z.boolean()
215
+ }).strict()).optional()
216
+ }).strict();
217
+ const recommendationsSchema = z.array(recommendationSchema).max(30);
218
+ const SEVERITY_ORDER = [
219
+ "critical",
220
+ "high",
221
+ "medium",
222
+ "low",
223
+ "info"
224
+ ];
225
+ const evaluationSchema = z.object({
226
+ ruleId: z.string(),
227
+ disposition: z.enum([
228
+ "pass",
229
+ "finding",
230
+ "not-applicable",
231
+ "not-verified"
232
+ ]),
233
+ title: z.string(),
234
+ description: z.string().optional(),
235
+ severity: z.enum(SEVERITY_ORDER).optional(),
236
+ summary: z.string(),
237
+ remediation: z.string().optional(),
238
+ evidence: z.array(evidenceSchema),
239
+ method: z.enum(["script", "ai"]).optional(),
240
+ waterfall: waterfallSchema.optional(),
241
+ recommendations: recommendationsSchema.optional()
242
+ }).superRefine((value, context) => {
243
+ if (value.disposition === "finding" && value.evidence.length === 0) context.addIssue({
244
+ code: "custom",
245
+ message: "finding requires evidence",
246
+ path: ["evidence"]
247
+ });
248
+ });
249
+ const guidanceSchema = z.enum(GUIDANCE);
250
+ const auditResultSchema = z.object({
251
+ schemaVersion: z.literal("1.0"),
252
+ run: z.object({
253
+ id: z.string(),
254
+ generatedAt: z.string(),
255
+ toolVersion: z.string()
256
+ }),
257
+ project: z.object({ name: z.string().optional() }),
258
+ pages: z.array(z.object({
259
+ label: z.string(),
260
+ url: z.string(),
261
+ playhouse: z.object({
262
+ archiveDir: z.string(),
263
+ version: z.string(),
264
+ lighthouseVersion: z.string(),
265
+ artifacts: z.record(z.string(), z.json())
266
+ })
267
+ })),
268
+ catalog: z.object({
269
+ categories: z.array(z.object({
270
+ id: z.string(),
271
+ title: z.string(),
272
+ ruleIds: z.array(z.string())
273
+ })),
274
+ ruleIds: z.array(z.string())
275
+ }),
276
+ evaluations: z.array(evaluationSchema),
277
+ observations: z.array(z.object({
278
+ title: z.string(),
279
+ category: z.string().optional(),
280
+ summary: z.string(),
281
+ evidence: z.array(evidenceSchema)
282
+ })).default([]),
283
+ coverage: z.array(z.object({
284
+ category: z.string().optional(),
285
+ ruleId: z.string().optional(),
286
+ page: z.string().optional(),
287
+ status: z.enum([
288
+ "covered",
289
+ "partial",
290
+ "missing"
291
+ ]),
292
+ note: z.string()
293
+ })),
294
+ summary: z.object({
295
+ guidance: guidanceSchema,
296
+ narrative: z.string(),
297
+ highlights: z.array(z.string()).max(6).default([])
298
+ }).optional(),
299
+ bundle: bundleAnalysisSchema.optional()
300
+ });
301
+ function validateEvidence(evidence, archiveFiles) {
302
+ if (evidence.type === "source" && (evidence.file.split(/[\\/]/).includes("..") || pathIsAbsolute(evidence.file))) throw new Error(`Unsafe source evidence file: ${evidence.file}`);
303
+ if (evidence.type === "playhouse") {
304
+ const files = archiveFiles.get(evidence.page);
305
+ if (!files) throw new Error(`Unknown Playhouse evidence page: ${evidence.page}`);
306
+ if (!files.has(evidence.artifact)) throw new Error(`Unknown Playhouse evidence artifact for ${evidence.page}: ${evidence.artifact}`);
307
+ }
308
+ }
309
+ /** A waterfall belongs only to its rule, names captured pages once each, and is an acyclic graph of known steps. */
310
+ function validateWaterfall(evaluation, pageLabels) {
311
+ if (!evaluation.waterfall) return;
312
+ if (evaluation.ruleId !== WATERFALL_RULE_ID) throw new Error(`waterfall is only supported on ${WATERFALL_RULE_ID}`);
313
+ const seenPages = /* @__PURE__ */ new Set();
314
+ for (const entry of evaluation.waterfall.pages) {
315
+ if (!pageLabels.has(entry.page)) throw new Error(`Unknown waterfall page: ${entry.page}`);
316
+ if (seenPages.has(entry.page)) throw new Error(`Duplicate waterfall page: ${entry.page}`);
317
+ seenPages.add(entry.page);
318
+ const steps = /* @__PURE__ */ new Map();
319
+ for (const step of entry.steps) {
320
+ if (steps.has(step.id)) throw new Error(`Duplicate waterfall step on ${entry.page}: ${step.id}`);
321
+ steps.set(step.id, step);
322
+ validateEvidence({
323
+ type: "source",
324
+ file: step.source.file
325
+ }, /* @__PURE__ */ new Map());
326
+ }
327
+ for (const step of entry.steps) {
328
+ for (const id of step.after) if (!steps.has(id)) throw new Error(`Waterfall step ${step.id} on ${entry.page} waits on unknown step: ${id}`);
329
+ if (step.opportunity === "stream" && (!step.blocking || step.critical)) throw new Error(`Waterfall step ${step.id} on ${entry.page} can only suggest streaming when it is awaited and not critical`);
330
+ if ((step.opportunity === "parallelize" || step.opportunity === "scapi-hook") && !step.after.length) throw new Error(`Waterfall step ${step.id} on ${entry.page} suggests ${step.opportunity} but waits on nothing`);
331
+ }
332
+ const done = /* @__PURE__ */ new Set();
333
+ const visiting = /* @__PURE__ */ new Set();
334
+ const visit = (id) => {
335
+ if (done.has(id)) return;
336
+ if (visiting.has(id)) throw new Error(`Waterfall on ${entry.page} has a cycle through step: ${id}`);
337
+ visiting.add(id);
338
+ for (const next of steps.get(id)?.after ?? []) visit(next);
339
+ visiting.delete(id);
340
+ done.add(id);
341
+ };
342
+ for (const id of steps.keys()) visit(id);
343
+ }
344
+ }
345
+ /**
346
+ * Recommendations belong only to their rule, and each names a candidate or a package of every page it lists in the
347
+ * bundle analysis, whose bytes it takes. Without `bundle` (re-validating a finished result) only the rule, pages and
348
+ * files are checked.
349
+ */
350
+ function validateRecommendations(evaluation, pageLabels, bundle) {
351
+ if (!evaluation.recommendations) return;
352
+ if (evaluation.ruleId !== RECOMMENDATIONS_RULE_ID) throw new Error(`recommendations is only supported on ${RECOMMENDATIONS_RULE_ID}`);
353
+ const seen = /* @__PURE__ */ new Set();
354
+ for (const item of evaluation.recommendations) {
355
+ const label = `${item.change} ${item.target}`;
356
+ if (seen.has(label)) throw new Error(`Duplicate recommendation: ${label}`);
357
+ seen.add(label);
358
+ if (new Set(item.pages).size !== item.pages.length) throw new Error(`Recommendation ${label} lists a page twice`);
359
+ for (const page of item.pages) if (!pageLabels.has(page)) throw new Error(`Unknown recommendation page: ${page}`);
360
+ for (const file of item.files) validateEvidence({
361
+ type: "source",
362
+ file: file.file
363
+ }, /* @__PURE__ */ new Map());
364
+ const deferring = DEFERRING_CHANGES.has(item.change);
365
+ if (deferring !== Boolean(item.trigger)) throw new Error(`Recommendation ${label} ${deferring ? "needs a trigger" : "only defers code with lazy-component or dynamic-import, so it takes no trigger"}`);
366
+ if (!bundle) continue;
367
+ item.savings = item.pages.map((page) => {
368
+ const local = bundle.pages.find((entry) => entry.page === page)?.local;
369
+ const candidate = local?.candidates.find((entry) => entry.target === item.target);
370
+ const owner = local?.packages.find((entry) => entry.name === item.target && entry.kind !== "build");
371
+ if (candidate) {
372
+ const file = item.files.find((entry) => entry.file === candidate.importer.file);
373
+ if (file && candidate.importer.line) file.line ??= candidate.importer.line;
374
+ return {
375
+ page,
376
+ bytes: candidate.savedBytes,
377
+ upTo: !deferring
378
+ };
379
+ }
380
+ if (owner) return deferring && owner.deferrableBytes !== void 0 ? {
381
+ page,
382
+ bytes: owner.deferrableBytes,
383
+ upTo: false
384
+ } : {
385
+ page,
386
+ bytes: owner.bytes,
387
+ upTo: true
388
+ };
389
+ throw new Error(`Recommendation ${label}: ${item.target} is not a candidate or package of ${page} in bundle/analysis.json`);
390
+ });
391
+ const most = Math.max(...item.savings.map((entry) => entry.bytes));
392
+ if (most < MIN_RECOMMENDED_BYTES) throw new Error(`Recommendation ${label} saves at most ${Math.round(most / 1024)} KiB on its pages, below the ${MIN_RECOMMENDED_BYTES / 1024} KiB worth recommending; leave it out`);
393
+ }
394
+ }
395
+ /** `archiveFiles` defaults to the artifacts embedded in `input.pages`. */
396
+ function validateAuditResult(input, catalog, archiveFiles) {
397
+ const result = auditResultSchema.parse(input);
398
+ assertUniquePageLabels(result.pages);
399
+ if (!catalog && result.evaluations.some((evaluation) => evaluation.disposition === "finding" && !evaluation.severity)) throw new Error("finding requires severity");
400
+ const declaredRules = result.catalog.categories.flatMap((category) => category.ruleIds);
401
+ if (new Set(declaredRules).size !== declaredRules.length || declaredRules.slice().sort().join("\n") !== result.catalog.ruleIds.slice().sort().join("\n")) throw new Error("Catalog categories and ruleIds are inconsistent");
402
+ for (const category of result.catalog.categories) for (const ruleId of category.ruleIds) if (!ruleId.startsWith(`${category.id}/`)) throw new Error(`Catalog rule ${ruleId} is not in category ${category.id}`);
403
+ if (catalog) {
404
+ const known = new Set(catalog.ruleIds);
405
+ const categories = new Set(catalog.categories.map((category) => category.id));
406
+ const pages = archiveFiles ?? new Map(result.pages.map((page) => [page.label, new Set(Object.keys(page.playhouse.artifacts))]));
407
+ const severityByRule = new Map(catalog.categories.flatMap((category) => category.rules.map((rule) => [rule.id, rule.severity])));
408
+ const evaluated = /* @__PURE__ */ new Set();
409
+ for (const evaluation of result.evaluations) {
410
+ if (!known.has(evaluation.ruleId)) throw new Error(`Unknown rule id: ${evaluation.ruleId}`);
411
+ if (evaluated.has(evaluation.ruleId)) throw new Error(`Duplicate evaluation: ${evaluation.ruleId}`);
412
+ if (evaluation.disposition === "finding") {
413
+ const ceiling = severityByRule.get(evaluation.ruleId);
414
+ if (!ceiling) throw new Error(`Rule ${evaluation.ruleId} has no category in the catalog`);
415
+ const supplied = evaluation.severity;
416
+ evaluation.severity = supplied && SEVERITY_ORDER.indexOf(supplied) > SEVERITY_ORDER.indexOf(ceiling) ? supplied : ceiling;
417
+ }
418
+ evaluated.add(evaluation.ruleId);
419
+ }
420
+ for (const ruleId of catalog.ruleIds) if (!evaluated.has(ruleId)) result.evaluations.push({
421
+ ruleId,
422
+ disposition: "not-verified",
423
+ title: ruleId,
424
+ summary: "No evaluation was supplied.",
425
+ evidence: []
426
+ });
427
+ for (const evaluation of result.evaluations) for (const evidence of evaluation.evidence) validateEvidence(evidence, pages);
428
+ for (const observation of result.observations) for (const evidence of observation.evidence) validateEvidence(evidence, pages);
429
+ for (const item of result.coverage) {
430
+ if (item.ruleId && !known.has(item.ruleId)) throw new Error(`Unknown coverage rule id: ${item.ruleId}`);
431
+ if (item.category && !categories.has(item.category)) throw new Error(`Unknown coverage category: ${item.category}`);
432
+ }
433
+ result.summary = {
434
+ guidance: guidanceFor(result.evaluations),
435
+ narrative: result.summary?.narrative ?? "",
436
+ highlights: result.summary?.highlights ?? []
437
+ };
438
+ result.evaluations.sort((a, b) => a.ruleId.localeCompare(b.ruleId));
439
+ result.coverage.sort((a, b) => `${a.category ?? ""}/${a.ruleId ?? ""}/${a.page ?? ""}`.localeCompare(`${b.category ?? ""}/${b.ruleId ?? ""}/${b.page ?? ""}`));
440
+ }
441
+ const pageLabels = new Set(archiveFiles ? archiveFiles.keys() : result.pages.map((page) => page.label));
442
+ for (const evaluation of result.evaluations) {
443
+ validateWaterfall(evaluation, pageLabels);
444
+ validateRecommendations(evaluation, pageLabels);
445
+ }
446
+ for (const page of result.pages) {
447
+ page.url = sanitizeUrl(page.url);
448
+ page.playhouse.archiveDir = "[PLAYHOUSE_ARCHIVE]";
449
+ page.playhouse.artifacts = sanitizeJson(page.playhouse.artifacts);
450
+ }
451
+ for (const evaluation of result.evaluations) {
452
+ evaluation.title = sanitizeText(evaluation.title);
453
+ evaluation.summary = sanitizeText(evaluation.summary);
454
+ if (evaluation.remediation) evaluation.remediation = sanitizeText(evaluation.remediation);
455
+ for (const evidence of evaluation.evidence) sanitizeEvidence(evidence);
456
+ for (const entry of evaluation.waterfall?.pages ?? []) {
457
+ entry.route = sanitizeText(entry.route);
458
+ entry.summary = sanitizeText(entry.summary);
459
+ for (const step of entry.steps) {
460
+ step.label = sanitizeText(step.label);
461
+ if (step.conditional) step.conditional = sanitizeText(step.conditional);
462
+ if (step.note) step.note = sanitizeText(step.note);
463
+ }
464
+ }
465
+ for (const item of evaluation.recommendations ?? []) {
466
+ item.title = sanitizeText(item.title);
467
+ item.how = sanitizeText(item.how);
468
+ }
469
+ }
470
+ if (result.bundle) result.bundle = bundleAnalysisSchema.parse(sanitizeJson(result.bundle));
471
+ for (const observation of result.observations) {
472
+ observation.title = sanitizeText(observation.title);
473
+ observation.summary = sanitizeText(observation.summary);
474
+ for (const evidence of observation.evidence) sanitizeEvidence(evidence);
475
+ }
476
+ for (const item of result.coverage) item.note = sanitizeText(item.note);
477
+ if (result.summary) {
478
+ result.summary.narrative = sanitizeText(result.summary.narrative);
479
+ result.summary.highlights = result.summary.highlights.map(sanitizeText);
480
+ }
481
+ return result;
482
+ }
483
+ function assertUniquePageLabels(pages) {
484
+ const labels = /* @__PURE__ */ new Set();
485
+ for (const { label } of pages) {
486
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(label)) throw new Error(`Invalid page label: ${label}`);
487
+ if (labels.has(label)) throw new Error(`Duplicate page label: ${label}`);
488
+ labels.add(label);
489
+ }
490
+ }
491
+ function pathIsAbsolute(value) {
492
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
493
+ }
494
+ function sanitizeJson(value) {
495
+ return sanitizeJsonValue(value);
496
+ }
497
+ function sanitizeJsonPointer(pointer) {
498
+ if (!pointer.startsWith("/")) return sanitizeText(pointer);
499
+ return pointer.split("/").map((token, index) => index === 0 ? "" : sanitizeText(token)).join("/");
500
+ }
501
+ function sanitizeEvidence(evidence) {
502
+ if (evidence.type === "source" && evidence.excerpt) evidence.excerpt = sanitizeText(evidence.excerpt);
503
+ else if (evidence.type === "manual") {
504
+ evidence.description = sanitizeText(evidence.description);
505
+ if (evidence.location) evidence.location = sanitizeText(evidence.location);
506
+ } else if (evidence.type === "playhouse") {
507
+ if (evidence.pointer) evidence.pointer = sanitizeJsonPointer(evidence.pointer);
508
+ if (typeof evidence.value === "string") evidence.value = sanitizeText(evidence.value);
509
+ }
510
+ }
511
+
512
+ //#endregion
513
+ //#region src/audit/events.ts
514
+ const severity = z.enum([
515
+ "critical",
516
+ "high",
517
+ "medium",
518
+ "low",
519
+ "info"
520
+ ]);
521
+ const guidance = z.enum(GUIDANCE);
522
+ const metric = z.object({
523
+ key: z.string(),
524
+ value: z.string(),
525
+ bad: z.boolean().optional()
526
+ }).strict();
527
+ const planStage = z.object({
528
+ id: z.string(),
529
+ title: z.string(),
530
+ detail: z.string().optional()
531
+ }).strict();
532
+ const progressEventSchema = z.discriminatedUnion("type", [
533
+ z.object({
534
+ type: z.literal("run.start"),
535
+ runId: z.string(),
536
+ startedAt: z.string(),
537
+ target: z.object({
538
+ url: z.string().optional(),
539
+ commit: z.string().optional(),
540
+ env: z.string().optional()
541
+ }).strict().optional(),
542
+ plan: z.array(planStage).default([])
543
+ }).strict(),
544
+ z.object({
545
+ type: z.literal("stage.start"),
546
+ stageId: z.string()
547
+ }).strict(),
548
+ z.object({
549
+ type: z.literal("stage.complete"),
550
+ stageId: z.string(),
551
+ summary: z.string().optional(),
552
+ durationMs: z.number().int().nonnegative().optional()
553
+ }).strict(),
554
+ z.object({
555
+ type: z.literal("page.start"),
556
+ pageId: z.string(),
557
+ label: z.string(),
558
+ url: z.string()
559
+ }).strict(),
560
+ z.object({
561
+ type: z.literal("page.run"),
562
+ pageId: z.string(),
563
+ runIndex: z.number().int().positive(),
564
+ total: z.number().int().positive(),
565
+ status: z.enum([
566
+ "running",
567
+ "ok",
568
+ "failed",
569
+ "retry"
570
+ ])
571
+ }).strict(),
572
+ z.object({
573
+ type: z.literal("page.complete"),
574
+ pageId: z.string(),
575
+ score: z.number().optional(),
576
+ metrics: z.array(metric).default([])
577
+ }).strict(),
578
+ z.object({
579
+ type: z.literal("rules.progress"),
580
+ evaluated: z.number().int().nonnegative(),
581
+ total: z.number().int().nonnegative(),
582
+ category: z.string().optional(),
583
+ categoryEvaluated: z.number().int().nonnegative().optional(),
584
+ categoryTotal: z.number().int().nonnegative().optional()
585
+ }).strict(),
586
+ z.object({
587
+ type: z.literal("finding"),
588
+ id: z.string(),
589
+ severity,
590
+ title: z.string(),
591
+ category: z.string().optional(),
592
+ source: z.string().optional(),
593
+ ruleId: z.string().optional(),
594
+ preliminary: z.boolean().default(true)
595
+ }).strict(),
596
+ z.object({
597
+ type: z.literal("log"),
598
+ level: z.enum([
599
+ "info",
600
+ "ok",
601
+ "warn",
602
+ "find"
603
+ ]),
604
+ message: z.string()
605
+ }).strict(),
606
+ z.object({
607
+ type: z.literal("run.complete"),
608
+ guidance: guidance.optional(),
609
+ scores: z.array(z.object({
610
+ label: z.string(),
611
+ score: z.number()
612
+ }).strict()).default([])
613
+ }).strict(),
614
+ z.object({
615
+ type: z.literal("run.error"),
616
+ message: z.string(),
617
+ stageId: z.string().optional()
618
+ }).strict()
619
+ ]);
620
+ /** Validate, sanitize, stamp with an arrival time, and append one event. */
621
+ async function appendEvent(file, event) {
622
+ const validated = progressEventSchema.parse(event);
623
+ const stored = {
624
+ ...progressEventSchema.parse(sanitizeJsonValue(validated)),
625
+ at: (/* @__PURE__ */ new Date()).toISOString()
626
+ };
627
+ await appendFile(file, `${JSON.stringify(stored)}\n`);
628
+ return stored;
629
+ }
630
+ /** Read and validate every event in the log. Tolerates a partially written
631
+ * trailing line (a concurrent append) and skips anything that fails to parse. */
632
+ async function readEvents(file) {
633
+ let text;
634
+ try {
635
+ text = await readFile(file, "utf8");
636
+ } catch (error) {
637
+ if (error.code === "ENOENT") return [];
638
+ throw error;
639
+ }
640
+ const events = [];
641
+ for (const line of text.split("\n")) {
642
+ const trimmed = line.trim();
643
+ if (!trimmed) continue;
644
+ let parsed;
645
+ try {
646
+ parsed = JSON.parse(trimmed);
647
+ } catch {
648
+ continue;
649
+ }
650
+ if (typeof parsed !== "object" || parsed === null) continue;
651
+ const { at,...rest } = parsed;
652
+ const result = progressEventSchema.safeParse(rest);
653
+ if (!result.success) continue;
654
+ events.push({
655
+ ...result.data,
656
+ at: typeof at === "string" ? at : (/* @__PURE__ */ new Date()).toISOString()
657
+ });
658
+ }
659
+ return events;
660
+ }
661
+ /** The events of the latest run: a log passed with `--events` can hold several runs, each from its run.start. */
662
+ function latestRunEvents(events) {
663
+ const start = events.map((event) => event.type).lastIndexOf("run.start");
664
+ return start === -1 ? events : events.slice(start);
665
+ }
666
+ /** Derive the coarse run status from the event log for /api/status. The full
667
+ * view-model reduction lives in the client (the single reducer). */
668
+ function deriveStatus(events) {
669
+ let status = "preparing";
670
+ let resumable = false;
671
+ for (const event of latestRunEvents(events)) {
672
+ if (event.type === "run.complete") return "complete";
673
+ if (event.type === "run.error") {
674
+ status = "error";
675
+ resumable = event.stageId !== void 0;
676
+ } else if (status === "error") status = resumable ? "running" : "error";
677
+ else if (event.type === "stage.start" || event.type === "page.start" || event.type === "run.start") status = status === "preparing" ? "running" : status;
678
+ }
679
+ return status;
680
+ }
681
+ /** Whether the run is over at `event`: it completed, or prepare failed. A failed finalize is not over, since it
682
+ * can be fixed and run again. */
683
+ function endsRun(event) {
684
+ return event.type === "run.complete" || event.type === "run.error" && event.stageId === void 0;
685
+ }
686
+
687
+ //#endregion
688
+ export { runIdFor as S, validateWaterfall as _, progressEventSchema as a, listRuns as b, SEVERITY_ORDER as c, evidenceSchema as d, parsePrepared as f, validateRecommendations as g, validateEvidence as h, latestRunEvents as i, auditResultSchema as l, validateAuditResult as m, deriveStatus as n, readEvents as o, preparedSchema as p, endsRun as r, LIGHTHOUSE_REPORT as s, appendEvent as t, evaluationSchema as u, RUN_ID as v, runDir as x, latestRunDir as y };