@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/dist/events.js CHANGED
@@ -1,515 +1,7 @@
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";
1
+ import { _ as GUIDANCE, v as sanitizeJsonValue } from "./results.js";
2
+ import { appendFile, readFile } from "node:fs/promises";
5
3
  import { z } from "zod";
6
4
 
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
5
  //#region src/audit/events.ts
514
6
  const severity = z.enum([
515
7
  "critical",
@@ -685,4 +177,4 @@ function endsRun(event) {
685
177
  }
686
178
 
687
179
  //#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 };
180
+ export { progressEventSchema as a, latestRunEvents as i, deriveStatus as n, readEvents as o, endsRun as r, appendEvent as t };
package/dist/flags2.js CHANGED
@@ -1,15 +1,162 @@
1
1
  import { n as PROJECT_DIRECTORY_FLAG } from "./flags.js";
2
- import { n as sanitizeText } from "./sanitize.js";
2
+ import { S as VERSION, _ as GUIDANCE, f as RUN_ID, n as SEVERITY_ORDER, y as sanitizeText } from "./results.js";
3
3
  import { Command, Errors, Flags } from "@oclif/core";
4
4
  import path from "node:path";
5
+ import { readFile } from "node:fs/promises";
5
6
 
7
+ //#region src/audit/telemetry.ts
8
+ /**
9
+ * The b2c CLI's Application Insights connection string: a public, write-only ingestion key that `@salesforce/b2c-cli`
10
+ * already ships in its package.json, not a credential. It is the fallback for `pnpm exec sfnext audit`, where no host
11
+ * CLI supplies one. It is deliberately not in this package's package.json: the SDK base classes of `push`, `scapi` and
12
+ * the other commands read `oclif.telemetry` there and would start reporting too.
13
+ */
14
+ const AUDIT_CONNECTION_STRING = "InstrumentationKey=6fcc215f-0b11-4864-ad5c-3945ae19e2f3;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=a60f17ec-265a-4dfc-b8df-03a64695697d";
15
+ /** Events are sent every 30 s, so an audit that is killed mid-run loses at most that much. */
16
+ const FLUSH_INTERVAL_MS = 3e4;
17
+ /**
18
+ * The event name prefix (`<project>/COMMAND_START`) and `origin`, fixed so the audit's events keep one name whether
19
+ * they run under `pnpm exec sfnext` or a host CLI such as `b2c`, and never mix with the host CLI's own command events.
20
+ * The running CLI goes in `hostCli`.
21
+ */
22
+ const TELEMETRY_PROJECT = "@salesforce/storefront-next-dev";
23
+ /**
24
+ * Starts usage telemetry for an audit command, with the host CLI's connection string (the b2c CLI has one) or
25
+ * {@link AUDIT_CONNECTION_STRING}. The B2C CLI's opt-outs apply (`SF_DISABLE_TELEMETRY`, `SFCC_DISABLE_TELEMETRY`).
26
+ * Never throws.
27
+ */
28
+ async function startAuditTelemetry(config, command) {
29
+ try {
30
+ const { Telemetry, createTelemetry } = await import("@salesforce/b2c-tooling-sdk/telemetry");
31
+ const pjson = config.pjson;
32
+ const appInsightsKey = Telemetry.getConnectionString(pjson.oclif?.telemetry?.connectionString ?? AUDIT_CONNECTION_STRING);
33
+ if (!appInsightsKey) return void 0;
34
+ const telemetry = createTelemetry({
35
+ project: TELEMETRY_PROJECT,
36
+ appInsightsKey,
37
+ version: config.version,
38
+ dataDir: config.dataDir,
39
+ flushIntervalMs: FLUSH_INTERVAL_MS,
40
+ initialAttributes: {
41
+ command,
42
+ surface: "sfnext-audit",
43
+ storefrontNextDevVersion: VERSION,
44
+ hostCli: config.name
45
+ }
46
+ });
47
+ await telemetry.start();
48
+ return telemetry;
49
+ } catch {
50
+ return;
51
+ }
52
+ }
53
+ const count = (value) => Array.isArray(value) ? value.length : 0;
54
+ /** Strings come from files in the workspace, so only values of the expected shape are sent. */
55
+ const runId = (value) => typeof value === "string" && RUN_ID.test(value) ? value : void 0;
56
+ const guidance = (value) => typeof value === "string" && GUIDANCE.includes(value) ? value : void 0;
57
+ const statusToken = (value) => typeof value === "string" && /^[a-z][a-z-]{0,23}$/.test(value) ? value : void 0;
58
+ /**
59
+ * The `command` dimension: the oclif id under the `sfnext` topic (`sfnext:audit:prepare`), the id the b2c CLI gives
60
+ * these commands, so usage segments the same way whether `pnpm exec sfnext` or `b2c sfnext` ran them.
61
+ */
62
+ const usageCommand = (id) => `sfnext:${(id ?? "").replace(/^sfnext:/, "")}`;
63
+ /** An error's class name (`Error`, `CLIError`, `TypeError`), or `Error` when `name` holds anything else. */
64
+ const errorName = (error) => typeof error.name === "string" && /^[A-Za-z_$][\w$]{0,63}$/.test(error.name) ? error.name : "Error";
65
+ async function readJson(file) {
66
+ try {
67
+ return JSON.parse(await readFile(file, "utf8"));
68
+ } catch {
69
+ return;
70
+ }
71
+ }
72
+ /**
73
+ * Counts from a run's prepared.json and bundle/analysis.json. Only counts, enums and the run id (a timestamp
74
+ * that joins a run's prepare and finalize): never URLs, page labels, paths, rule text or findings.
75
+ */
76
+ async function preparedAttributes(preparedPath, options) {
77
+ const [preparedFile, bundle] = await Promise.all([readJson(preparedPath), readJson(path.join(path.dirname(preparedPath), "bundle", "analysis.json"))]);
78
+ const prepared = preparedFile ?? {};
79
+ const run = prepared.run;
80
+ return {
81
+ auditRunId: runId(run?.id),
82
+ pagesCollected: count(prepared.pages),
83
+ pagesFailed: count(prepared.pageFailures),
84
+ pagesMissing: count(prepared.coverage),
85
+ rulesForAi: count(prepared.rules),
86
+ scriptFindings: count(prepared.decidedFindings),
87
+ runs: options.runs,
88
+ build: options.build,
89
+ bundleStatus: statusToken(bundle?.status)
90
+ };
91
+ }
92
+ /** Counts and the launch guidance from a run's audit-result.json, under the same rules as {@link preparedAttributes}. */
93
+ async function finalizedAttributes(resultPath) {
94
+ const result = await readJson(resultPath) ?? {};
95
+ const run = result.run;
96
+ const summary = result.summary;
97
+ const evaluations = Array.isArray(result.evaluations) ? result.evaluations : [];
98
+ const findings = evaluations.filter((evaluation) => evaluation.disposition === "finding");
99
+ return {
100
+ auditRunId: runId(run?.id),
101
+ guidance: guidance(summary?.guidance),
102
+ pages: count(result.pages),
103
+ rules: evaluations.length,
104
+ rulesByScript: evaluations.filter((evaluation) => evaluation.method === "script").length,
105
+ rulesNotVerified: evaluations.filter((evaluation) => evaluation.disposition === "not-verified").length,
106
+ findings: findings.length,
107
+ ...Object.fromEntries(SEVERITY_ORDER.map((severity) => [`findings${severity[0].toUpperCase()}${severity.slice(1)}`, findings.filter((finding) => finding.severity === severity).length]))
108
+ };
109
+ }
110
+
111
+ //#endregion
6
112
  //#region src/audit/command.ts
7
- /** Base for the audit commands: an error's message is sanitized before oclif prints it, so a token in a `--url` stays out of the terminal. */
113
+ /**
114
+ * Base for the audit commands: an error's message is sanitized before oclif prints it, so a token in a `--url` stays
115
+ * out of the terminal. Each command also reports COMMAND_START, COMMAND_SUCCESS and COMMAND_ERROR
116
+ * like the B2C CLI's own commands, but without the error message, which can hold a storefront URL.
117
+ */
8
118
  var AuditCommand = class extends Command {
119
+ /** Whether the command reports COMMAND_* events. Off for the ones an agent calls repeatedly or that keep running. */
120
+ static reportsUsage = true;
121
+ telemetry;
122
+ startedAt = Date.now();
123
+ async init() {
124
+ await super.init();
125
+ this.startedAt = Date.now();
126
+ if (!this.constructor.reportsUsage) return;
127
+ this.telemetry = await startAuditTelemetry(this.config, usageCommand(this.id));
128
+ this.telemetry?.sendEvent("COMMAND_START", { command: usageCommand(this.id) });
129
+ this.telemetry?.flush().catch(() => void 0);
130
+ }
131
+ /** Sends a usage event when telemetry is on. Building its attributes or sending it never fails the command. */
132
+ async track(event, attributes) {
133
+ if (!this.telemetry) return;
134
+ try {
135
+ this.telemetry.sendEvent(event, {
136
+ command: usageCommand(this.id),
137
+ ...await attributes()
138
+ });
139
+ } catch {}
140
+ }
9
141
  async catch(error) {
10
142
  error.message = sanitizeText(error.message);
143
+ this.telemetry?.sendEvent("COMMAND_ERROR", {
144
+ command: usageCommand(this.id),
145
+ exitCode: error.oclif?.exit ?? error.exitCode ?? 1,
146
+ duration: Date.now() - this.startedAt,
147
+ errorName: errorName(error)
148
+ });
11
149
  return super.catch(error);
12
150
  }
151
+ async finally(error) {
152
+ if (!error) this.telemetry?.sendEvent("COMMAND_SUCCESS", {
153
+ command: usageCommand(this.id),
154
+ duration: Date.now() - this.startedAt
155
+ });
156
+ await this.telemetry?.stop().catch(() => void 0);
157
+ this.telemetry = void 0;
158
+ return super.finally(error);
159
+ }
13
160
  };
14
161
 
15
162
  //#endregion
@@ -31,4 +178,4 @@ function workspaceFor(flags) {
31
178
  }
32
179
 
33
180
  //#endregion
34
- export { AuditCommand as i, workspaceFlag as n, workspaceFor as r, text as t };
181
+ export { finalizedAttributes as a, AuditCommand as i, workspaceFlag as n, preparedAttributes as o, workspaceFor as r, text as t };