@salesforce/storefront-next-dev 1.5.0-alpha.0 → 1.5.0-alpha.1
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.
- package/README.md +6 -0
- package/dist/audit/ui/assets/{index-aSdbHIeq.js → index-DM8l6IcE.js} +1 -1
- package/dist/audit/ui/index.html +1 -1
- package/dist/audit.js +2 -3
- package/dist/catalog.js +1 -1
- package/dist/commands/audit/catalog.js +3 -2
- package/dist/commands/audit/finalize.js +6 -5
- package/dist/commands/audit/prepare.js +8 -4
- package/dist/commands/audit/progress.js +3 -3
- package/dist/commands/audit/serve.js +4 -1
- package/dist/events.js +3 -511
- package/dist/flags2.js +150 -3
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/mrt/ssr.mjs +50 -50
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +56 -56
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/package.js +1 -1
- package/dist/results.js +718 -0
- package/dist/server.js +30 -1
- package/dist/server2.js +2 -3
- package/package.json +2 -2
- package/dist/assets.js +0 -25
- package/dist/sanitize.js +0 -185
package/dist/results.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import { t as version } from "./package.js";
|
|
2
|
+
import { a as MIN_RECOMMENDED_BYTES, o as bundleAnalysisSchema } from "./schema.js";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { readdir } from "node:fs/promises";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
//#region src/audit/assets.ts
|
|
10
|
+
/** The storefront-next-dev version, not the host CLI's: under `b2c sfnext`, oclif's config version is b2c's. */
|
|
11
|
+
const VERSION = version;
|
|
12
|
+
function packageRoot(from) {
|
|
13
|
+
for (let dir = from;; dir = path.dirname(dir)) {
|
|
14
|
+
if (existsSync(path.join(dir, "package.json"))) return dir;
|
|
15
|
+
if (path.dirname(dir) === dir) throw new Error(`No package.json above ${from}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const root = packageRoot(here);
|
|
20
|
+
/**
|
|
21
|
+
* The directory holding the audit's runtime assets (catalog, instructions, UI). tsdown moves shared code into
|
|
22
|
+
* chunks at the top of `dist`, so paths relative to a module break after bundling. This resolves to `src/audit`
|
|
23
|
+
* when running from source (tests) and `dist/audit` when running the build.
|
|
24
|
+
*/
|
|
25
|
+
const AUDIT_ROOT = path.join(root, path.relative(root, here).split(path.sep)[0] === "src" ? "src" : "dist", "audit");
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/audit/sanitize.ts
|
|
29
|
+
/**
|
|
30
|
+
* Copyright 2026 Salesforce, Inc.
|
|
31
|
+
*
|
|
32
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
33
|
+
* you may not use this file except in compliance with the License.
|
|
34
|
+
* You may obtain a copy of the License at
|
|
35
|
+
*
|
|
36
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
37
|
+
*
|
|
38
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
39
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
40
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
41
|
+
* See the License for the specific language governing permissions and
|
|
42
|
+
* limitations under the License.
|
|
43
|
+
*/
|
|
44
|
+
const bearer = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
|
|
45
|
+
const secretKey = new RegExp(`\\b([\\w-]{0,64}?)(?:api[_-]?key|api[_-]?signature|access[_-]?token|token|secret|password|passwd|authorization|set-cookie|cookie|session[_-]?id|sessionid|credentials?|csrf[_-]?token|private[_-]?key)(?:[_-]?(?:access[_-]?)?(?:key|value|hash|id|token))?\\\\?["']?\\s*[:=]\\s*`, "gi");
|
|
46
|
+
const knownToken = /(?<![\w-])eyJ[\w-]{1,1024}\.eyJ[\w-]{1,16384}\.[\w-]*|\b[rs]k_(?:live|test)_[A-Za-z0-9]{10,}|\bwhsec_[A-Za-z0-9]{20,}|\bgh[opsru]_[A-Za-z0-9]{20,}|\bgithub_pat_\w{20,}|\bnpm_[A-Za-z0-9]{36}\b|\bx(?:ox[abeprs]|app)-(?![\w-]{0,253}\.[A-Za-z])[A-Za-z0-9-]{10,}|\bsk-(?:ant|proj)-(?![\w-]{0,253}\.[A-Za-z])[\w-]{20,}|\b(?:AKIA|ASIA)[0-9A-Z]{16}\b|(?<=hooks\.slack\.com\/services\/)[A-Za-z0-9/]+/g;
|
|
47
|
+
const pemBegin = /-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/;
|
|
48
|
+
const pemEnd = /-----END [A-Z ]{0,40}PRIVATE KEY-----/;
|
|
49
|
+
const pemLine = /(?:[ \t]*(?:\r?\n|(?:\\r)?\\n))*[ \t]*(?:(?:Proc-Type|DEK-Info):[^\r\n\\]*|[A-Za-z0-9+/=]{16,})/y;
|
|
50
|
+
const maxKeyLength = 16384;
|
|
51
|
+
/** Not after `@` or `~`: `@/components/header` and `~/lib` are import aliases, not files on disk. */
|
|
52
|
+
const absolutePath = /(?<![\w.@~-])(?:[A-Za-z]:[\\/]|\/)(?:[^\s<>'"`]+[\\/])+[^\s<>'"`,.;:]*/g;
|
|
53
|
+
const webUrl = /https?:\/\/[^\s<>'"`]+/gi;
|
|
54
|
+
/** Returns the first match at or after an index. Indexes must not decrease; reusing the last match keeps repeated searches linear. */
|
|
55
|
+
function finder(pattern, text) {
|
|
56
|
+
const global = new RegExp(pattern.source, "g");
|
|
57
|
+
let found;
|
|
58
|
+
return (from) => {
|
|
59
|
+
if (found === void 0 || found && found.index < from) {
|
|
60
|
+
global.lastIndex = from;
|
|
61
|
+
found = global.exec(text);
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Redacts each private key from its BEGIN line to its END line. A key without an END line before the next key
|
|
67
|
+
* (or within maxKeyLength) is redacted up to its last key-like line, so prose after a lone header stays. */
|
|
68
|
+
function redactPrivateKeys(text) {
|
|
69
|
+
const nextBegin = finder(pemBegin, text);
|
|
70
|
+
const nextEnd = finder(pemEnd, text);
|
|
71
|
+
let out = "";
|
|
72
|
+
let last = 0;
|
|
73
|
+
for (let begin = nextBegin(0); begin; begin = nextBegin(last)) {
|
|
74
|
+
const start = begin.index + begin[0].length;
|
|
75
|
+
const end = nextEnd(start);
|
|
76
|
+
const following = nextBegin(start);
|
|
77
|
+
let stop = start;
|
|
78
|
+
if (end && end.index - start <= maxKeyLength && (!following || following.index > end.index)) stop = end.index + end[0].length;
|
|
79
|
+
else for (pemLine.lastIndex = stop; pemLine.exec(text); pemLine.lastIndex = stop) stop = pemLine.lastIndex;
|
|
80
|
+
out += `${text.slice(last, begin.index)}[REDACTED]`;
|
|
81
|
+
last = stop;
|
|
82
|
+
}
|
|
83
|
+
return out + text.slice(last);
|
|
84
|
+
}
|
|
85
|
+
const quoteAt = (text, index) => {
|
|
86
|
+
if (text[index] === "\"" || text[index] === "'") return text[index];
|
|
87
|
+
return text[index] === "\\" && (text[index + 1] === "\"" || text[index + 1] === "'") ? text.slice(index, index + 2) : "";
|
|
88
|
+
};
|
|
89
|
+
const lineEnd = (text, index) => {
|
|
90
|
+
const end = text.indexOf("\n", index);
|
|
91
|
+
return end === -1 ? text.length : end;
|
|
92
|
+
};
|
|
93
|
+
const skip = (text, index, pattern) => {
|
|
94
|
+
while (index < text.length && pattern.test(text.charAt(index))) index++;
|
|
95
|
+
return index;
|
|
96
|
+
};
|
|
97
|
+
const wordChar = /[^\s"',;}&\\]/;
|
|
98
|
+
const tokenChar = /[\w.~+/=-]/;
|
|
99
|
+
const schemeCredential = /[ \t]+[^\s"',;}&\\]+/y;
|
|
100
|
+
/**
|
|
101
|
+
* Reads the value that starts at `start`: a quoted string up to its unescaped closing quote, a list or object up
|
|
102
|
+
* to its matching bracket (plus any token glued to it), an auth scheme with its credential, or one word. An
|
|
103
|
+
* unclosed quote or bracket takes the rest of the line. `core` is the value without its quotes.
|
|
104
|
+
*/
|
|
105
|
+
function scanValue(text, start) {
|
|
106
|
+
const quote = quoteAt(text, start);
|
|
107
|
+
if (quote) {
|
|
108
|
+
for (let index = start + quote.length; index < text.length && text[index] !== "\n"; index += text[index] === "\\" && text[index + 1] !== "\n" ? 2 : 1) if (text.startsWith(quote, index)) return {
|
|
109
|
+
end: index + quote.length,
|
|
110
|
+
quote,
|
|
111
|
+
core: text.slice(start + quote.length, index)
|
|
112
|
+
};
|
|
113
|
+
} else if (text[start] === "[" || text[start] === "{") {
|
|
114
|
+
let depth = 0;
|
|
115
|
+
let inQuote = "";
|
|
116
|
+
for (let index = start; index < text.length && text[index] !== "\n"; index++) {
|
|
117
|
+
if (inQuote) {
|
|
118
|
+
if (text.startsWith(inQuote, index)) {
|
|
119
|
+
index += inQuote.length - 1;
|
|
120
|
+
inQuote = "";
|
|
121
|
+
} else if (text[index] === "\\") index++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
inQuote = quoteAt(text, index);
|
|
125
|
+
if (inQuote) index += inQuote.length - 1;
|
|
126
|
+
else if (text[index] === "[" || text[index] === "{") depth++;
|
|
127
|
+
else if ((text[index] === "]" || text[index] === "}") && --depth === 0) {
|
|
128
|
+
const end$1 = skip(text, index + 1, tokenChar);
|
|
129
|
+
return {
|
|
130
|
+
end: end$1,
|
|
131
|
+
quote: "",
|
|
132
|
+
core: text.slice(start, end$1)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
let end$1 = skip(text, start, wordChar);
|
|
138
|
+
if (/^(?:Basic|Bearer|Digest|Token)$/i.test(text.slice(start, end$1))) {
|
|
139
|
+
schemeCredential.lastIndex = end$1;
|
|
140
|
+
if (schemeCredential.exec(text)) end$1 = schemeCredential.lastIndex;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
end: end$1,
|
|
144
|
+
quote: "",
|
|
145
|
+
core: text.slice(start, end$1)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const end = lineEnd(text, start);
|
|
149
|
+
return {
|
|
150
|
+
end,
|
|
151
|
+
quote: "",
|
|
152
|
+
core: text.slice(start, end)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Values that can't be secrets: an exact placeholder, booleans and null under any name (`hasPassword: true`),
|
|
156
|
+
* and numbers under a page, count or limit name (`pageToken=2`). */
|
|
157
|
+
function keepValue(prefix, value) {
|
|
158
|
+
return /^(?:\[REDACTED\]|(?:Basic|Bearer|Digest|Token) \[REDACTED\]|true|false|null|undefined)$/i.test(value) || /^-?\d+(?:\.\d+)?$/.test(value) && /page|max|min|count|limit|total|num/i.test(prefix);
|
|
159
|
+
}
|
|
160
|
+
/** Redacts the value of every secret-named assignment. Each value is read and decided on once, and the search
|
|
161
|
+
* resumes after it, so a value is never rewritten twice and the work stays linear. */
|
|
162
|
+
function redactAssignments(text) {
|
|
163
|
+
let out = "";
|
|
164
|
+
let last = 0;
|
|
165
|
+
secretKey.lastIndex = 0;
|
|
166
|
+
for (let key = secretKey.exec(text); key; key = secretKey.exec(text)) {
|
|
167
|
+
const start = key.index + key[0].length;
|
|
168
|
+
const { end, quote, core } = scanValue(text, start);
|
|
169
|
+
if (end === start) continue;
|
|
170
|
+
if (!keepValue(key[1] ?? "", core)) {
|
|
171
|
+
const scheme = /^(Basic|Bearer|Digest|Token)\s+\S/i.exec(core)?.[1];
|
|
172
|
+
out += `${text.slice(last, start)}${quote}${scheme ? `${scheme} ` : ""}[REDACTED]${quote}`;
|
|
173
|
+
last = end;
|
|
174
|
+
}
|
|
175
|
+
secretKey.lastIndex = end;
|
|
176
|
+
}
|
|
177
|
+
return out + text.slice(last);
|
|
178
|
+
}
|
|
179
|
+
function sanitizeUrl(value) {
|
|
180
|
+
const parsed = new URL(value);
|
|
181
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("URL must use https (or http with --allow-http)");
|
|
182
|
+
parsed.username = "";
|
|
183
|
+
parsed.password = "";
|
|
184
|
+
for (const key of [...parsed.searchParams.keys()]) parsed.searchParams.set(key, "[REDACTED]");
|
|
185
|
+
parsed.hash = "";
|
|
186
|
+
return parsed.toString();
|
|
187
|
+
}
|
|
188
|
+
function sanitizeText(value) {
|
|
189
|
+
const urls = [];
|
|
190
|
+
let sanitized = redactPrivateKeys(value.replaceAll("\0", "")).replace(knownToken, "[REDACTED]").replace(webUrl, (url) => {
|
|
191
|
+
try {
|
|
192
|
+
urls.push(sanitizeUrl(url));
|
|
193
|
+
return `\u0000URL${urls.length - 1}\u0000`;
|
|
194
|
+
} catch {
|
|
195
|
+
return "[REDACTED_URL]";
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
sanitized = redactAssignments(sanitized.replace(bearer, "Bearer [REDACTED]")).replace(absolutePath, "[REDACTED_PATH]");
|
|
199
|
+
return sanitized.replace(/\u0000URL(\d+)\u0000/g, (match, index) => urls[Number(index)] ?? match);
|
|
200
|
+
}
|
|
201
|
+
function sanitizeJsonValue(value, key) {
|
|
202
|
+
if (typeof value === "string") {
|
|
203
|
+
if (key === "pointer" && value.startsWith("/")) return value.split("/").map((token, index) => index === 0 ? "" : sanitizeText(token)).join("/");
|
|
204
|
+
return sanitizeText(value);
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeJsonValue(item));
|
|
207
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, sanitizeJsonValue(child, childKey)]));
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/audit/guidance.ts
|
|
213
|
+
/**
|
|
214
|
+
* Copyright 2026 Salesforce, Inc.
|
|
215
|
+
*
|
|
216
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
217
|
+
* you may not use this file except in compliance with the License.
|
|
218
|
+
* You may obtain a copy of the License at
|
|
219
|
+
*
|
|
220
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
221
|
+
*
|
|
222
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
223
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
224
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
225
|
+
* See the License for the specific language governing permissions and
|
|
226
|
+
* limitations under the License.
|
|
227
|
+
*/
|
|
228
|
+
const GUIDANCE = [
|
|
229
|
+
"go",
|
|
230
|
+
"go-with-fixes",
|
|
231
|
+
"no-go"
|
|
232
|
+
];
|
|
233
|
+
/** Launch guidance from the known findings: critical blocks launch, high or medium needs fixes. */
|
|
234
|
+
function guidanceFor(evaluations) {
|
|
235
|
+
const severities = new Set(evaluations.filter((item) => item.disposition === "finding").map((item) => item.severity));
|
|
236
|
+
if (severities.has("critical")) return "no-go";
|
|
237
|
+
return severities.has("high") || severities.has("medium") ? "go-with-fixes" : "go";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/audit/runs.ts
|
|
242
|
+
const RUNS_DIR = "runs";
|
|
243
|
+
const RUN_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/;
|
|
244
|
+
/** `2026-10-14T10:26:00.123Z` → `2026-10-14T10-26-00Z`, safe as a folder name. */
|
|
245
|
+
function runIdFor(startedAt) {
|
|
246
|
+
return startedAt.replace(/\.\d+Z$/, "Z").replaceAll(":", "-");
|
|
247
|
+
}
|
|
248
|
+
function runDir(workspace, id) {
|
|
249
|
+
if (!RUN_ID.test(id)) throw new Error(`Invalid run id: ${id}`);
|
|
250
|
+
return path.join(workspace, RUNS_DIR, id);
|
|
251
|
+
}
|
|
252
|
+
/** Run ids in the workspace, newest first. */
|
|
253
|
+
async function listRuns(workspace) {
|
|
254
|
+
let entries;
|
|
255
|
+
try {
|
|
256
|
+
entries = await readdir(path.join(workspace, RUNS_DIR), { withFileTypes: true });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error.code === "ENOENT") return [];
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
return entries.filter((entry) => entry.isDirectory() && RUN_ID.test(entry.name)).map((entry) => entry.name).sort().reverse();
|
|
262
|
+
}
|
|
263
|
+
/** The latest run's folder; throws when the workspace has no runs yet. */
|
|
264
|
+
async function latestRunDir(workspace) {
|
|
265
|
+
const [latest] = await listRuns(workspace);
|
|
266
|
+
if (!latest) throw new Error(`${workspace} has no audit runs; run prepare first`);
|
|
267
|
+
return runDir(workspace, latest);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/audit/results.ts
|
|
272
|
+
/** Archive file name of the per-page Lighthouse HTML report. When present it is
|
|
273
|
+
* recorded in `artifacts` as `{ available: true }` and served by the dashboard
|
|
274
|
+
* server rather than embedded. ui/src/panels/Performance.tsx repeats this name. */
|
|
275
|
+
const LIGHTHOUSE_REPORT = "report-mobile-cold.html";
|
|
276
|
+
/** The workspace's prepared.json, written by `prepare` and read by `finalize` and the dashboard server. */
|
|
277
|
+
const preparedSchema = z.object({
|
|
278
|
+
schemaVersion: z.literal("1.0"),
|
|
279
|
+
run: z.object({
|
|
280
|
+
id: z.string().regex(RUN_ID),
|
|
281
|
+
generatedAt: z.string().datetime(),
|
|
282
|
+
toolVersion: z.string(),
|
|
283
|
+
playhouseVersion: z.string().optional()
|
|
284
|
+
}).strict(),
|
|
285
|
+
project: z.object({
|
|
286
|
+
root: z.string(),
|
|
287
|
+
packageName: z.string().optional()
|
|
288
|
+
}).strict(),
|
|
289
|
+
instructions: z.string().optional(),
|
|
290
|
+
pages: z.array(z.object({
|
|
291
|
+
label: z.string(),
|
|
292
|
+
url: z.string(),
|
|
293
|
+
archiveDir: z.string()
|
|
294
|
+
}).strict()),
|
|
295
|
+
pageFailures: z.array(z.object({
|
|
296
|
+
label: z.string(),
|
|
297
|
+
url: z.string(),
|
|
298
|
+
error: z.string()
|
|
299
|
+
}).strict()).optional(),
|
|
300
|
+
expectedPages: z.array(z.string()).optional(),
|
|
301
|
+
coverage: z.array(z.object({
|
|
302
|
+
page: z.string(),
|
|
303
|
+
status: z.literal("missing"),
|
|
304
|
+
note: z.string()
|
|
305
|
+
}).strict()).optional().default([]),
|
|
306
|
+
rules: z.array(z.object({
|
|
307
|
+
ruleId: z.string(),
|
|
308
|
+
title: z.string(),
|
|
309
|
+
file: z.string(),
|
|
310
|
+
data: z.string().optional(),
|
|
311
|
+
output: z.string()
|
|
312
|
+
}).strict()),
|
|
313
|
+
decidedFindings: z.array(z.object({
|
|
314
|
+
ruleId: z.string(),
|
|
315
|
+
title: z.string(),
|
|
316
|
+
severity: z.string(),
|
|
317
|
+
summary: z.string()
|
|
318
|
+
}).strict())
|
|
319
|
+
}).strict();
|
|
320
|
+
function parsePrepared(value) {
|
|
321
|
+
const parsed = preparedSchema.safeParse(value);
|
|
322
|
+
if (!parsed.success) throw new Error(`prepared.json is invalid: ${parsed.error.message}`);
|
|
323
|
+
return parsed.data;
|
|
324
|
+
}
|
|
325
|
+
const evidenceSchema = z.discriminatedUnion("type", [
|
|
326
|
+
z.object({
|
|
327
|
+
type: z.literal("source"),
|
|
328
|
+
file: z.string(),
|
|
329
|
+
line: z.number().int().positive().optional(),
|
|
330
|
+
excerpt: z.string().optional()
|
|
331
|
+
}),
|
|
332
|
+
z.object({
|
|
333
|
+
type: z.literal("playhouse"),
|
|
334
|
+
page: z.string(),
|
|
335
|
+
artifact: z.string(),
|
|
336
|
+
pointer: z.string().optional(),
|
|
337
|
+
value: z.union([
|
|
338
|
+
z.string(),
|
|
339
|
+
z.number(),
|
|
340
|
+
z.boolean()
|
|
341
|
+
]).optional()
|
|
342
|
+
}),
|
|
343
|
+
z.object({
|
|
344
|
+
type: z.literal("manual"),
|
|
345
|
+
description: z.string(),
|
|
346
|
+
location: z.string().optional()
|
|
347
|
+
})
|
|
348
|
+
]);
|
|
349
|
+
/** The one rule whose evaluation may carry a `waterfall`: the per-page server request graph drawn on the Performance tab. */
|
|
350
|
+
const WATERFALL_RULE_ID = "performance/server-request-waterfall";
|
|
351
|
+
const waterfallStepSchema = z.object({
|
|
352
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
353
|
+
kind: z.enum([
|
|
354
|
+
"middleware",
|
|
355
|
+
"loader",
|
|
356
|
+
"api",
|
|
357
|
+
"render"
|
|
358
|
+
]),
|
|
359
|
+
label: z.string().min(1),
|
|
360
|
+
service: z.enum([
|
|
361
|
+
"scapi",
|
|
362
|
+
"slas",
|
|
363
|
+
"ocapi",
|
|
364
|
+
"einstein",
|
|
365
|
+
"data-store",
|
|
366
|
+
"other"
|
|
367
|
+
]).optional(),
|
|
368
|
+
source: z.object({
|
|
369
|
+
file: z.string(),
|
|
370
|
+
line: z.number().int().positive().optional()
|
|
371
|
+
}).strict(),
|
|
372
|
+
after: z.array(z.string()).default([]),
|
|
373
|
+
blocking: z.boolean(),
|
|
374
|
+
conditional: z.string().optional(),
|
|
375
|
+
note: z.string().optional(),
|
|
376
|
+
critical: z.boolean().optional(),
|
|
377
|
+
opportunity: z.enum([
|
|
378
|
+
"parallelize",
|
|
379
|
+
"scapi-hook",
|
|
380
|
+
"stream"
|
|
381
|
+
]).optional()
|
|
382
|
+
}).strict();
|
|
383
|
+
const waterfallSchema = z.object({ pages: z.array(z.object({
|
|
384
|
+
page: z.string(),
|
|
385
|
+
route: z.string(),
|
|
386
|
+
summary: z.string(),
|
|
387
|
+
steps: z.array(waterfallStepSchema).min(1).max(60)
|
|
388
|
+
}).strict()) }).strict();
|
|
389
|
+
/** The one rule whose evaluation may carry `recommendations`: the AI's suggestions in the Bundle size view. */
|
|
390
|
+
const RECOMMENDATIONS_RULE_ID = "performance/reduce-javascript-on-load";
|
|
391
|
+
const RECOMMENDATION_CHANGES = [
|
|
392
|
+
"lazy-component",
|
|
393
|
+
"dynamic-import",
|
|
394
|
+
"narrower-import",
|
|
395
|
+
"replace",
|
|
396
|
+
"remove"
|
|
397
|
+
];
|
|
398
|
+
/** Changes that keep the code but load it later, so they need a trigger. */
|
|
399
|
+
const DEFERRING_CHANGES = new Set(["lazy-component", "dynamic-import"]);
|
|
400
|
+
const recommendationSchema = z.object({
|
|
401
|
+
pages: z.array(z.string()).min(1),
|
|
402
|
+
title: z.string().min(1).max(140),
|
|
403
|
+
change: z.enum(RECOMMENDATION_CHANGES),
|
|
404
|
+
target: z.string().min(1),
|
|
405
|
+
trigger: z.enum([
|
|
406
|
+
"interaction",
|
|
407
|
+
"visible",
|
|
408
|
+
"navigation",
|
|
409
|
+
"idle"
|
|
410
|
+
]).optional(),
|
|
411
|
+
how: z.string().min(1).max(1200),
|
|
412
|
+
files: z.array(z.object({
|
|
413
|
+
file: z.string(),
|
|
414
|
+
line: z.number().int().positive().optional()
|
|
415
|
+
}).strict()).min(1).max(10),
|
|
416
|
+
savings: z.array(z.object({
|
|
417
|
+
page: z.string(),
|
|
418
|
+
bytes: z.number().int().nonnegative(),
|
|
419
|
+
upTo: z.boolean()
|
|
420
|
+
}).strict()).optional()
|
|
421
|
+
}).strict();
|
|
422
|
+
const recommendationsSchema = z.array(recommendationSchema).max(30);
|
|
423
|
+
const SEVERITY_ORDER = [
|
|
424
|
+
"critical",
|
|
425
|
+
"high",
|
|
426
|
+
"medium",
|
|
427
|
+
"low",
|
|
428
|
+
"info"
|
|
429
|
+
];
|
|
430
|
+
const evaluationSchema = z.object({
|
|
431
|
+
ruleId: z.string(),
|
|
432
|
+
disposition: z.enum([
|
|
433
|
+
"pass",
|
|
434
|
+
"finding",
|
|
435
|
+
"not-applicable",
|
|
436
|
+
"not-verified"
|
|
437
|
+
]),
|
|
438
|
+
title: z.string(),
|
|
439
|
+
description: z.string().optional(),
|
|
440
|
+
severity: z.enum(SEVERITY_ORDER).optional(),
|
|
441
|
+
summary: z.string(),
|
|
442
|
+
remediation: z.string().optional(),
|
|
443
|
+
evidence: z.array(evidenceSchema),
|
|
444
|
+
method: z.enum(["script", "ai"]).optional(),
|
|
445
|
+
waterfall: waterfallSchema.optional(),
|
|
446
|
+
recommendations: recommendationsSchema.optional()
|
|
447
|
+
}).superRefine((value, context) => {
|
|
448
|
+
if (value.disposition === "finding" && value.evidence.length === 0) context.addIssue({
|
|
449
|
+
code: "custom",
|
|
450
|
+
message: "finding requires evidence",
|
|
451
|
+
path: ["evidence"]
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
const guidanceSchema = z.enum(GUIDANCE);
|
|
455
|
+
const auditResultSchema = z.object({
|
|
456
|
+
schemaVersion: z.literal("1.0"),
|
|
457
|
+
run: z.object({
|
|
458
|
+
id: z.string(),
|
|
459
|
+
generatedAt: z.string(),
|
|
460
|
+
toolVersion: z.string()
|
|
461
|
+
}),
|
|
462
|
+
project: z.object({ name: z.string().optional() }),
|
|
463
|
+
pages: z.array(z.object({
|
|
464
|
+
label: z.string(),
|
|
465
|
+
url: z.string(),
|
|
466
|
+
playhouse: z.object({
|
|
467
|
+
archiveDir: z.string(),
|
|
468
|
+
version: z.string(),
|
|
469
|
+
lighthouseVersion: z.string(),
|
|
470
|
+
artifacts: z.record(z.string(), z.json())
|
|
471
|
+
})
|
|
472
|
+
})),
|
|
473
|
+
catalog: z.object({
|
|
474
|
+
categories: z.array(z.object({
|
|
475
|
+
id: z.string(),
|
|
476
|
+
title: z.string(),
|
|
477
|
+
ruleIds: z.array(z.string())
|
|
478
|
+
})),
|
|
479
|
+
ruleIds: z.array(z.string())
|
|
480
|
+
}),
|
|
481
|
+
evaluations: z.array(evaluationSchema),
|
|
482
|
+
observations: z.array(z.object({
|
|
483
|
+
title: z.string(),
|
|
484
|
+
category: z.string().optional(),
|
|
485
|
+
summary: z.string(),
|
|
486
|
+
evidence: z.array(evidenceSchema)
|
|
487
|
+
})).default([]),
|
|
488
|
+
coverage: z.array(z.object({
|
|
489
|
+
category: z.string().optional(),
|
|
490
|
+
ruleId: z.string().optional(),
|
|
491
|
+
page: z.string().optional(),
|
|
492
|
+
status: z.enum([
|
|
493
|
+
"covered",
|
|
494
|
+
"partial",
|
|
495
|
+
"missing"
|
|
496
|
+
]),
|
|
497
|
+
note: z.string()
|
|
498
|
+
})),
|
|
499
|
+
summary: z.object({
|
|
500
|
+
guidance: guidanceSchema,
|
|
501
|
+
narrative: z.string(),
|
|
502
|
+
highlights: z.array(z.string()).max(6).default([])
|
|
503
|
+
}).optional(),
|
|
504
|
+
bundle: bundleAnalysisSchema.optional()
|
|
505
|
+
});
|
|
506
|
+
function validateEvidence(evidence, archiveFiles) {
|
|
507
|
+
if (evidence.type === "source" && (evidence.file.split(/[\\/]/).includes("..") || pathIsAbsolute(evidence.file))) throw new Error(`Unsafe source evidence file: ${evidence.file}`);
|
|
508
|
+
if (evidence.type === "playhouse") {
|
|
509
|
+
const files = archiveFiles.get(evidence.page);
|
|
510
|
+
if (!files) throw new Error(`Unknown Playhouse evidence page: ${evidence.page}`);
|
|
511
|
+
if (!files.has(evidence.artifact)) throw new Error(`Unknown Playhouse evidence artifact for ${evidence.page}: ${evidence.artifact}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** A waterfall belongs only to its rule, names captured pages once each, and is an acyclic graph of known steps. */
|
|
515
|
+
function validateWaterfall(evaluation, pageLabels) {
|
|
516
|
+
if (!evaluation.waterfall) return;
|
|
517
|
+
if (evaluation.ruleId !== WATERFALL_RULE_ID) throw new Error(`waterfall is only supported on ${WATERFALL_RULE_ID}`);
|
|
518
|
+
const seenPages = /* @__PURE__ */ new Set();
|
|
519
|
+
for (const entry of evaluation.waterfall.pages) {
|
|
520
|
+
if (!pageLabels.has(entry.page)) throw new Error(`Unknown waterfall page: ${entry.page}`);
|
|
521
|
+
if (seenPages.has(entry.page)) throw new Error(`Duplicate waterfall page: ${entry.page}`);
|
|
522
|
+
seenPages.add(entry.page);
|
|
523
|
+
const steps = /* @__PURE__ */ new Map();
|
|
524
|
+
for (const step of entry.steps) {
|
|
525
|
+
if (steps.has(step.id)) throw new Error(`Duplicate waterfall step on ${entry.page}: ${step.id}`);
|
|
526
|
+
steps.set(step.id, step);
|
|
527
|
+
validateEvidence({
|
|
528
|
+
type: "source",
|
|
529
|
+
file: step.source.file
|
|
530
|
+
}, /* @__PURE__ */ new Map());
|
|
531
|
+
}
|
|
532
|
+
for (const step of entry.steps) {
|
|
533
|
+
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}`);
|
|
534
|
+
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`);
|
|
535
|
+
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`);
|
|
536
|
+
}
|
|
537
|
+
const done = /* @__PURE__ */ new Set();
|
|
538
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
539
|
+
const visit = (id) => {
|
|
540
|
+
if (done.has(id)) return;
|
|
541
|
+
if (visiting.has(id)) throw new Error(`Waterfall on ${entry.page} has a cycle through step: ${id}`);
|
|
542
|
+
visiting.add(id);
|
|
543
|
+
for (const next of steps.get(id)?.after ?? []) visit(next);
|
|
544
|
+
visiting.delete(id);
|
|
545
|
+
done.add(id);
|
|
546
|
+
};
|
|
547
|
+
for (const id of steps.keys()) visit(id);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Recommendations belong only to their rule, and each names a candidate or a package of every page it lists in the
|
|
552
|
+
* bundle analysis, whose bytes it takes. Without `bundle` (re-validating a finished result) only the rule, pages and
|
|
553
|
+
* files are checked.
|
|
554
|
+
*/
|
|
555
|
+
function validateRecommendations(evaluation, pageLabels, bundle) {
|
|
556
|
+
if (!evaluation.recommendations) return;
|
|
557
|
+
if (evaluation.ruleId !== RECOMMENDATIONS_RULE_ID) throw new Error(`recommendations is only supported on ${RECOMMENDATIONS_RULE_ID}`);
|
|
558
|
+
const seen = /* @__PURE__ */ new Set();
|
|
559
|
+
for (const item of evaluation.recommendations) {
|
|
560
|
+
const label = `${item.change} ${item.target}`;
|
|
561
|
+
if (seen.has(label)) throw new Error(`Duplicate recommendation: ${label}`);
|
|
562
|
+
seen.add(label);
|
|
563
|
+
if (new Set(item.pages).size !== item.pages.length) throw new Error(`Recommendation ${label} lists a page twice`);
|
|
564
|
+
for (const page of item.pages) if (!pageLabels.has(page)) throw new Error(`Unknown recommendation page: ${page}`);
|
|
565
|
+
for (const file of item.files) validateEvidence({
|
|
566
|
+
type: "source",
|
|
567
|
+
file: file.file
|
|
568
|
+
}, /* @__PURE__ */ new Map());
|
|
569
|
+
const deferring = DEFERRING_CHANGES.has(item.change);
|
|
570
|
+
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"}`);
|
|
571
|
+
if (!bundle) continue;
|
|
572
|
+
item.savings = item.pages.map((page) => {
|
|
573
|
+
const local = bundle.pages.find((entry) => entry.page === page)?.local;
|
|
574
|
+
const candidate = local?.candidates.find((entry) => entry.target === item.target);
|
|
575
|
+
const owner = local?.packages.find((entry) => entry.name === item.target && entry.kind !== "build");
|
|
576
|
+
if (candidate) {
|
|
577
|
+
const file = item.files.find((entry) => entry.file === candidate.importer.file);
|
|
578
|
+
if (file && candidate.importer.line) file.line ??= candidate.importer.line;
|
|
579
|
+
return {
|
|
580
|
+
page,
|
|
581
|
+
bytes: candidate.savedBytes,
|
|
582
|
+
upTo: !deferring
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
if (owner) return deferring && owner.deferrableBytes !== void 0 ? {
|
|
586
|
+
page,
|
|
587
|
+
bytes: owner.deferrableBytes,
|
|
588
|
+
upTo: false
|
|
589
|
+
} : {
|
|
590
|
+
page,
|
|
591
|
+
bytes: owner.bytes,
|
|
592
|
+
upTo: true
|
|
593
|
+
};
|
|
594
|
+
throw new Error(`Recommendation ${label}: ${item.target} is not a candidate or package of ${page} in bundle/analysis.json`);
|
|
595
|
+
});
|
|
596
|
+
const most = Math.max(...item.savings.map((entry) => entry.bytes));
|
|
597
|
+
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`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** `archiveFiles` defaults to the artifacts embedded in `input.pages`. */
|
|
601
|
+
function validateAuditResult(input, catalog, archiveFiles) {
|
|
602
|
+
const result = auditResultSchema.parse(input);
|
|
603
|
+
assertUniquePageLabels(result.pages);
|
|
604
|
+
if (!catalog && result.evaluations.some((evaluation) => evaluation.disposition === "finding" && !evaluation.severity)) throw new Error("finding requires severity");
|
|
605
|
+
const declaredRules = result.catalog.categories.flatMap((category) => category.ruleIds);
|
|
606
|
+
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");
|
|
607
|
+
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}`);
|
|
608
|
+
if (catalog) {
|
|
609
|
+
const known = new Set(catalog.ruleIds);
|
|
610
|
+
const categories = new Set(catalog.categories.map((category) => category.id));
|
|
611
|
+
const pages = archiveFiles ?? new Map(result.pages.map((page) => [page.label, new Set(Object.keys(page.playhouse.artifacts))]));
|
|
612
|
+
const severityByRule = new Map(catalog.categories.flatMap((category) => category.rules.map((rule) => [rule.id, rule.severity])));
|
|
613
|
+
const evaluated = /* @__PURE__ */ new Set();
|
|
614
|
+
for (const evaluation of result.evaluations) {
|
|
615
|
+
if (!known.has(evaluation.ruleId)) throw new Error(`Unknown rule id: ${evaluation.ruleId}`);
|
|
616
|
+
if (evaluated.has(evaluation.ruleId)) throw new Error(`Duplicate evaluation: ${evaluation.ruleId}`);
|
|
617
|
+
if (evaluation.disposition === "finding") {
|
|
618
|
+
const ceiling = severityByRule.get(evaluation.ruleId);
|
|
619
|
+
if (!ceiling) throw new Error(`Rule ${evaluation.ruleId} has no category in the catalog`);
|
|
620
|
+
const supplied = evaluation.severity;
|
|
621
|
+
evaluation.severity = supplied && SEVERITY_ORDER.indexOf(supplied) > SEVERITY_ORDER.indexOf(ceiling) ? supplied : ceiling;
|
|
622
|
+
}
|
|
623
|
+
evaluated.add(evaluation.ruleId);
|
|
624
|
+
}
|
|
625
|
+
for (const ruleId of catalog.ruleIds) if (!evaluated.has(ruleId)) result.evaluations.push({
|
|
626
|
+
ruleId,
|
|
627
|
+
disposition: "not-verified",
|
|
628
|
+
title: ruleId,
|
|
629
|
+
summary: "No evaluation was supplied.",
|
|
630
|
+
evidence: []
|
|
631
|
+
});
|
|
632
|
+
for (const evaluation of result.evaluations) for (const evidence of evaluation.evidence) validateEvidence(evidence, pages);
|
|
633
|
+
for (const observation of result.observations) for (const evidence of observation.evidence) validateEvidence(evidence, pages);
|
|
634
|
+
for (const item of result.coverage) {
|
|
635
|
+
if (item.ruleId && !known.has(item.ruleId)) throw new Error(`Unknown coverage rule id: ${item.ruleId}`);
|
|
636
|
+
if (item.category && !categories.has(item.category)) throw new Error(`Unknown coverage category: ${item.category}`);
|
|
637
|
+
}
|
|
638
|
+
result.summary = {
|
|
639
|
+
guidance: guidanceFor(result.evaluations),
|
|
640
|
+
narrative: result.summary?.narrative ?? "",
|
|
641
|
+
highlights: result.summary?.highlights ?? []
|
|
642
|
+
};
|
|
643
|
+
result.evaluations.sort((a, b) => a.ruleId.localeCompare(b.ruleId));
|
|
644
|
+
result.coverage.sort((a, b) => `${a.category ?? ""}/${a.ruleId ?? ""}/${a.page ?? ""}`.localeCompare(`${b.category ?? ""}/${b.ruleId ?? ""}/${b.page ?? ""}`));
|
|
645
|
+
}
|
|
646
|
+
const pageLabels = new Set(archiveFiles ? archiveFiles.keys() : result.pages.map((page) => page.label));
|
|
647
|
+
for (const evaluation of result.evaluations) {
|
|
648
|
+
validateWaterfall(evaluation, pageLabels);
|
|
649
|
+
validateRecommendations(evaluation, pageLabels);
|
|
650
|
+
}
|
|
651
|
+
for (const page of result.pages) {
|
|
652
|
+
page.url = sanitizeUrl(page.url);
|
|
653
|
+
page.playhouse.archiveDir = "[PLAYHOUSE_ARCHIVE]";
|
|
654
|
+
page.playhouse.artifacts = sanitizeJson(page.playhouse.artifacts);
|
|
655
|
+
}
|
|
656
|
+
for (const evaluation of result.evaluations) {
|
|
657
|
+
evaluation.title = sanitizeText(evaluation.title);
|
|
658
|
+
evaluation.summary = sanitizeText(evaluation.summary);
|
|
659
|
+
if (evaluation.remediation) evaluation.remediation = sanitizeText(evaluation.remediation);
|
|
660
|
+
for (const evidence of evaluation.evidence) sanitizeEvidence(evidence);
|
|
661
|
+
for (const entry of evaluation.waterfall?.pages ?? []) {
|
|
662
|
+
entry.route = sanitizeText(entry.route);
|
|
663
|
+
entry.summary = sanitizeText(entry.summary);
|
|
664
|
+
for (const step of entry.steps) {
|
|
665
|
+
step.label = sanitizeText(step.label);
|
|
666
|
+
if (step.conditional) step.conditional = sanitizeText(step.conditional);
|
|
667
|
+
if (step.note) step.note = sanitizeText(step.note);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
for (const item of evaluation.recommendations ?? []) {
|
|
671
|
+
item.title = sanitizeText(item.title);
|
|
672
|
+
item.how = sanitizeText(item.how);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (result.bundle) result.bundle = bundleAnalysisSchema.parse(sanitizeJson(result.bundle));
|
|
676
|
+
for (const observation of result.observations) {
|
|
677
|
+
observation.title = sanitizeText(observation.title);
|
|
678
|
+
observation.summary = sanitizeText(observation.summary);
|
|
679
|
+
for (const evidence of observation.evidence) sanitizeEvidence(evidence);
|
|
680
|
+
}
|
|
681
|
+
for (const item of result.coverage) item.note = sanitizeText(item.note);
|
|
682
|
+
if (result.summary) {
|
|
683
|
+
result.summary.narrative = sanitizeText(result.summary.narrative);
|
|
684
|
+
result.summary.highlights = result.summary.highlights.map(sanitizeText);
|
|
685
|
+
}
|
|
686
|
+
return result;
|
|
687
|
+
}
|
|
688
|
+
function assertUniquePageLabels(pages) {
|
|
689
|
+
const labels = /* @__PURE__ */ new Set();
|
|
690
|
+
for (const { label } of pages) {
|
|
691
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(label)) throw new Error(`Invalid page label: ${label}`);
|
|
692
|
+
if (labels.has(label)) throw new Error(`Duplicate page label: ${label}`);
|
|
693
|
+
labels.add(label);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
function pathIsAbsolute(value) {
|
|
697
|
+
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
|
698
|
+
}
|
|
699
|
+
function sanitizeJson(value) {
|
|
700
|
+
return sanitizeJsonValue(value);
|
|
701
|
+
}
|
|
702
|
+
function sanitizeJsonPointer(pointer) {
|
|
703
|
+
if (!pointer.startsWith("/")) return sanitizeText(pointer);
|
|
704
|
+
return pointer.split("/").map((token, index) => index === 0 ? "" : sanitizeText(token)).join("/");
|
|
705
|
+
}
|
|
706
|
+
function sanitizeEvidence(evidence) {
|
|
707
|
+
if (evidence.type === "source" && evidence.excerpt) evidence.excerpt = sanitizeText(evidence.excerpt);
|
|
708
|
+
else if (evidence.type === "manual") {
|
|
709
|
+
evidence.description = sanitizeText(evidence.description);
|
|
710
|
+
if (evidence.location) evidence.location = sanitizeText(evidence.location);
|
|
711
|
+
} else if (evidence.type === "playhouse") {
|
|
712
|
+
if (evidence.pointer) evidence.pointer = sanitizeJsonPointer(evidence.pointer);
|
|
713
|
+
if (typeof evidence.value === "string") evidence.value = sanitizeText(evidence.value);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
//#endregion
|
|
718
|
+
export { VERSION as S, GUIDANCE as _, evidenceSchema as a, sanitizeUrl as b, validateAuditResult as c, validateWaterfall as d, RUN_ID as f, runIdFor as g, runDir as h, evaluationSchema as i, validateEvidence as l, listRuns as m, SEVERITY_ORDER as n, parsePrepared as o, latestRunDir as p, auditResultSchema as r, preparedSchema as s, LIGHTHOUSE_REPORT as t, validateRecommendations as u, sanitizeJsonValue as v, AUDIT_ROOT as x, sanitizeText as y };
|