@tokenoftrust/cli 2.0.28 → 2.1.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.
@@ -0,0 +1,1064 @@
1
+ /**
2
+ * Pure report builder for a store's go-live readiness report (`tot report go-live`).
3
+ *
4
+ * This is the artifact a MERCHANT reads before cutover, so it answers three questions in
5
+ * their terms: what was done, what is proven, and what still needs a person. It is generated
6
+ * from measurement rather than authored, because a hand-written status page drifts from the
7
+ * gates and the drift is invisible — the same failure the parity gate exists to prevent.
8
+ *
9
+ * Three framing rules, all deliberate:
10
+ *
11
+ * 1. HOT-STANDBY MEANS RISKS, NOT SHOW-STOPPERS. While the lifecycle is hot-standby nothing
12
+ * here blocks anyone: the live site is untouched and the migration is a parallel build. So
13
+ * an open human gate is reported as an AREA OF RISK carrying a decision, never as a
14
+ * failure. Calling a pending signature a blocker at this stage is both false and the kind
15
+ * of thing that stalls a migration nobody was actually blocked on.
16
+ *
17
+ * 2. THE WINS ARE THE POINT AND THEY MUST BE QUANTIFIED. A merchant deciding whether to go
18
+ * live is buying an outcome. `41 → 87` on their own homepage is the argument; "performance
19
+ * improved" is not. Every number here is measured against the source site captured before
20
+ * cutover, so the comparison is real and survives scrutiny.
21
+ *
22
+ * 3. NOTHING BELOW THE BAR IS HIDDEN. A report that only sells is not trusted twice. Anything
23
+ * that misses the standard is stated with what it would take to close, so the merchant
24
+ * makes the call rather than discovering it later.
25
+ */
26
+
27
+ /**
28
+ * Lighthouse dimensions in the order a reader cares about them.
29
+ *
30
+ * All FIVE categories Lighthouse 13 scores. `agentic-browsing` is the one that is easy to miss —
31
+ * it is new in 13, and an older captured baseline will not have it. That is handled, not
32
+ * special-cased: judgeParity already treats a dimension absent from the baseline as `unmeasured`,
33
+ * which never fails a run.
34
+ */
35
+ const DIMENSIONS = ["performance", "accessibility", "best-practices", "seo", "agentic-browsing"];
36
+
37
+ const DIMENSION_LABEL = {
38
+ performance: "Speed",
39
+ "agentic-browsing": "Agent readiness",
40
+ accessibility: "Accessibility",
41
+ "best-practices": "Best practices",
42
+ seo: "SEO",
43
+ };
44
+
45
+ /**
46
+ * What each add-on stage would buy, in merchant terms. Keyed to the register in
47
+ * docs/architecture/migration-add-on-stages.md — core is the parity obligation and is never
48
+ * an add-on, so it is absent here by construction.
49
+ */
50
+ export const ADD_ON_CATALOG = {
51
+ seo: {
52
+ label: "SEO enhancement",
53
+ buys: "Generated titles, descriptions and structured data where the source had none — "
54
+ + "the migration copies what exists; this adds what never did.",
55
+ },
56
+ "visual-fidelity": {
57
+ label: "Visual fidelity review",
58
+ buys: "A human review that the migrated pages LOOK like the brand, not merely that they "
59
+ + "carry the same content. Deliberately not automated.",
60
+ },
61
+ lookAndFeel: {
62
+ label: "Look and feel",
63
+ buys: "Chrome variants, header/footer treatment and theme tokens tuned beyond the "
64
+ + "reconstruction.",
65
+ },
66
+ branding: {
67
+ label: "Branding adoption",
68
+ buys: "Logo, palette and brand voice carried through the whole system rather than "
69
+ + "page by page.",
70
+ },
71
+ analytics: {
72
+ label: "Analytics adapter",
73
+ buys: "A reviewed measurement adapter and destination id, so the new site reports into "
74
+ + "the same analytics the merchant already trusts.",
75
+ },
76
+ storefront: {
77
+ label: "Storefront (the platform itself)",
78
+ buys: "Going live on Storefront is what this migration delivers: hosted regulated commerce "
79
+ + "with checkout, the product catalog, compliance notices, legacy URL redirects, theme "
80
+ + "tokens and product reviews included. Checkout activation is the cutover step that turns "
81
+ + "the standby site into the live store.",
82
+ },
83
+ "storefront-verification": {
84
+ label: "Age and identity verification",
85
+ buys: "Token of Trust age and identity verification wired into the storefront — the "
86
+ + "compliance layer a regulated merchant needs at the door and at checkout, rather than a "
87
+ + "third-party gate bolted on.",
88
+ },
89
+ "storefront-excise": {
90
+ label: "Excise tax engine",
91
+ buys: "Per-SKU excise tax calculated correctly at checkout, fed by per-SKU tax metadata. "
92
+ + "Regulated categories get taxed right instead of approximately.",
93
+ },
94
+ "storefront-reporting": {
95
+ label: "Reporting",
96
+ buys: "Reporting across your orders and catalog.",
97
+ },
98
+ "custom-theme": {
99
+ label: "Custom theme",
100
+ buys: "A theme designed for your brand rather than rebuilt from the old site. The migration "
101
+ + "carries your current look across; this is the chance to improve on it.",
102
+ },
103
+ "custom-apps": {
104
+ label: "Custom apps and plugins",
105
+ buys: "Custom storefront behaviour built as a reviewed app or plugin, so it keeps working "
106
+ + "through future platform updates.",
107
+ },
108
+ "additional-stores": {
109
+ label: "Additional store migrations",
110
+ buys: "The same migration for your other brands or regional stores, at lower cost now that the "
111
+ + "groundwork is done.",
112
+ },
113
+ "managed-support": {
114
+ label: "Managed support and SLA",
115
+ buys: "A guaranteed response time for a store your revenue now depends on.",
116
+ },
117
+ "own-your-exit": {
118
+ label: "Own Your Exit (single-tenant extraction)",
119
+ buys: "Your site, handed over as a repository you own outright — so staying with us is always "
120
+ + "your choice rather than a constraint.",
121
+ },
122
+ custom: {
123
+ label: "Custom migration work",
124
+ buys: "Anything your store needs that the standard migration does not cover — a bespoke page "
125
+ + "type, an integration you want kept, an unusual URL scheme. Quoted per item so you "
126
+ + "always see what it costs before it is built.",
127
+ },
128
+ };
129
+
130
+
131
+ /**
132
+ * The commerce checklist a storefront must clear to become a live store.
133
+ *
134
+ * Sourced from this platform's own cutover scope (checkout activation, catalog graduation,
135
+ * public publication/DNS, monitoring, rollback, source retirement — docs/recipes/
136
+ * migration-overview.md:96-98 and site-port.md Step 9) plus the items every ecommerce launch
137
+ * checklist in this category carries: a payment gateway that has actually taken a transaction,
138
+ * a confirmed end-to-end checkout, tax and shipping configured, and order email that arrives.
139
+ *
140
+ * NONE of these is verifiable from a site validation run — they are integrations, credentials
141
+ * and human confirmations. So each is recorded, absent means NOT CONFIRMED, and the report says
142
+ * so rather than implying a clean sheet. `key` maps to `goLive.<key>` in the tenant's
143
+ * migration-state.
144
+ */
145
+ export const GO_LIVE_CHECKLIST = [
146
+ { key: "paymentGateway", label: "Payment gateway connected",
147
+ why: "Live payments have to run through your own gateway account, with credentials in place." },
148
+ { key: "checkoutConfirmed", label: "Checkout confirmed end to end",
149
+ why: "A real order placed and received — the single proof that the store can take money." },
150
+ { key: "taxConfigured", label: "Tax and excise configured",
151
+ why: "Regulated products are taxed per item; wrong tax at checkout is a compliance exposure, not a rounding error." },
152
+ { key: "shippingConfigured", label: "Shipping rates and zones",
153
+ why: "Rates, zones and any shipping restrictions have to match what you actually ship." },
154
+ { key: "orderEmail", label: "Order confirmation email",
155
+ why: "Your customers need the receipt and you need the notification — an email that does not arrive is a broken store." },
156
+ { key: "domainReady", label: "Domain, SSL and DNS cutover plan",
157
+ why: "The hostname move is the cutover; it needs a scheduled plan and a certificate ready before it happens." },
158
+ { key: "redirectsVerified", label: "Legacy URL redirects verified",
159
+ why: "Your old links keep their search value only if every retired URL lands somewhere correct." },
160
+ { key: "analyticsLive", label: "Analytics reporting",
161
+ why: "Launch day is when you most need to see traffic and orders." },
162
+ { key: "monitoringReady", label: "Monitoring and alerting",
163
+ why: "Somebody has to be told when a live store stops taking orders." },
164
+ { key: "rollbackPlan", label: "Rollback plan agreed",
165
+ why: "Cutover is reversible by reverting the hostname mapping — agreeing that in advance is what makes it fast." },
166
+ { key: "sourceRetirement", label: "Old store retirement plan",
167
+ why: "Your previous store needs a decision: kept read-only, redirected, or switched off." },
168
+ ];
169
+
170
+ /**
171
+ * Add-ons move through three states and only the last one means anything on launch day:
172
+ * SELECTED (the merchant bought it), CONFIGURED (it is set up), LIVE (it is confirmed working).
173
+ * Reporting a selected-but-unconfigured add-on as ready is how a merchant discovers at cutover
174
+ * that something they paid for was never switched on.
175
+ */
176
+ export function addOnLifecycle(addOns = {}) {
177
+ const chosen = Object.entries(addOns).filter(([, v]) => v === "selected" || v === "configured"
178
+ || v === "live" || v === "done");
179
+ return chosen.map(([id, state]) => ({
180
+ id,
181
+ label: ADD_ON_CATALOG[id]?.label ?? id,
182
+ state: state === "done" ? "live" : state,
183
+ live: state === "live" || state === "done",
184
+ }));
185
+ }
186
+
187
+ /**
188
+ * Which milestone an open item actually blocks.
189
+ *
190
+ * These are different questions with different urgency, and merging them is how a merchant ends
191
+ * up believing their standby site is broken when it is fine. HOT STANDBY asks whether the
192
+ * parallel build is faithful, serving, AND able to take over: routes resolve, the commerce
193
+ * boundary holds, no secrets leak, the reconstruction is complete, and order sync and product
194
+ * sync are proven. GO-LIVE asks whether it is ready to become the public store with checkout
195
+ * on: polish, sign-offs, merchant-supplied evidence.
196
+ *
197
+ * COMMERCE SYNC BELONGS TO HOT STANDBY, not to go-live. A standby store that cannot move orders
198
+ * or products is not standing by for anything — it is a website. Proving it at cutover is
199
+ * proving it at the one moment the answer cannot be acted on, so an unproven sync is a BLOCKER
200
+ * here rather than a decision deferred to later.
201
+ *
202
+ * Anything unrecognised is treated as blocking HOT STANDBY on purpose — the conservative
203
+ * default, because the claim this report exists to support is "standby is solid", and an
204
+ * unclassified failure must never quietly strengthen that claim.
205
+ */
206
+ const GO_LIVE_ONLY_AREAS = new Set([
207
+ "Style guide", "Ecommerce style-guide bar", "Performance", "Mobile and UX", "Accessibility",
208
+ ]);
209
+
210
+ /** @param {string} area */
211
+ export function blocksHotStandby(area) {
212
+ return !GO_LIVE_ONLY_AREAS.has(String(area ?? "").trim());
213
+ }
214
+
215
+ /**
216
+ * What buying a Lighthouse dimension actually delivers, in merchant terms.
217
+ *
218
+ * Every dimension is offerable, ALWAYS — including the ones this run could not measure. The
219
+ * catalogue used to carry `seo` alone, which is why performance, the dimension merchants care
220
+ * about most and the one Google ranks on, was the single thing they could not see they could buy.
221
+ * A hand-written catalogue omits by accident; deriving one per dimension cannot.
222
+ */
223
+ export const LIGHTHOUSE_ADDON = {
224
+ performance: "Faster pages: deferred third-party scripts, tuned images, fewer render-blocking "
225
+ + "resources. Google ranks on real-visit Core Web Vitals, so this one is revenue-bearing "
226
+ + "rather than cosmetic.",
227
+ accessibility: "Contrast, focus order, labels, landmarks and tap targets taken past the "
228
+ + "baseline — reaching more customers, and reducing ADA exposure on a regulated storefront.",
229
+ "best-practices": "Modern APIs, clean console, correct image sizing and no deprecated "
230
+ + "platform calls — the hygiene layer auditors and enterprise buyers check first.",
231
+ seo: "Generated titles, descriptions and structured data where the source had none. The "
232
+ + "migration copies the SEO that exists; this adds the SEO that never did.",
233
+ "agentic-browsing": "Whether an AI agent can actually shop this store: a clean agent "
234
+ + "accessibility tree, WebMCP tools registered with valid schemas, forms an agent can "
235
+ + "complete, and an llms.txt. This is the SEO of agent-mediated commerce — the merchants who "
236
+ + "are legible to agents early will be the ones agents transact with.",
237
+ };
238
+
239
+ /**
240
+ * Turn the run's own results into the offer list.
241
+ *
242
+ * Three families, because they are bought differently: a dimension lift is measured and
243
+ * quantified, an open item is work someone already knows is outstanding, and the standing
244
+ * catalogue is what we offer every tenant. Derived rather than hand-listed so a gap found at
245
+ * Step 8 becomes offerable in the same pass instead of waiting for someone to remember it.
246
+ */
247
+ /**
248
+ * @param {{
249
+ * parity?: {dimensions?: {dimension: string, migrated: number}[], measured?: number},
250
+ * unsourcedComponents?: string[],
251
+ * styleGuideApproved?: boolean,
252
+ * syncRows?: {label: string, confirmed: boolean, notApplicable: boolean}[],
253
+ * addOns?: Record<string, string>,
254
+ * opportunities?: {categories?: string[]}[],
255
+ * }} input
256
+ */
257
+ export function deriveAddOns(input) {
258
+ const {
259
+ parity = { dimensions: [] }, unsourcedComponents = [], styleGuideApproved = false,
260
+ syncRows = [], addOns = {}, opportunities = [],
261
+ } = input;
262
+ const byDim = new Map((parity.dimensions ?? []).map((d) => [d.dimension, d]));
263
+ const oppCount = (dim) => opportunities.filter((o) => (o.categories ?? []).includes(dim)).length;
264
+
265
+ const dimensionRows = DIMENSIONS.map((dim) => {
266
+ const measured = byDim.get(dim);
267
+ // Headroom is to 100 because that is the only ceiling Lighthouse has. It is deliberately NOT
268
+ // the parity band: parity is what the migration OWED, and this section is about what can be
269
+ // bought beyond it. A dimension already delivering free value still has room above it.
270
+ const headroom = measured ? Math.max(0, 100 - measured.migrated) : null;
271
+ const found = oppCount(dim);
272
+ return {
273
+ dimension: dim,
274
+ label: DIMENSION_LABEL[dim] ?? dim,
275
+ today: measured ? measured.migrated : null,
276
+ headroom,
277
+ buys: LIGHTHOUSE_ADDON[dim],
278
+ opportunities: found,
279
+ // An unmeasured dimension is offerable but honest about it: we cannot quote a lift we
280
+ // never measured, and saying "not measured" is the difference between an estimate and a
281
+ // guess dressed as one.
282
+ state: measured == null
283
+ ? "not measured this run"
284
+ : (headroom === 0 ? "already at 100" : `+${headroom} available`),
285
+ };
286
+ });
287
+
288
+ const openItems = [];
289
+ for (const row of syncRows.filter((r) => !r.confirmed && !r.notApplicable)) {
290
+ openItems.push({
291
+ item: `${row.label} activation`,
292
+ why: "Builder-facing integration that site validation cannot see, with no check recorded.",
293
+ delivers: `Wire and exercise ${row.label.toLowerCase()} end to end against the merchant's `
294
+ + "store, and record the evidence so cutover is not where it gets tested.",
295
+ });
296
+ }
297
+ if (!styleGuideApproved) {
298
+ openItems.push({
299
+ item: "Style-guide sign-off",
300
+ why: "No approval recorded for the visual standard the site is built to.",
301
+ delivers: "A guided review session ending in a recorded approval — or the visual-fidelity "
302
+ + "review below if the merchant wants the standard raised first, not merely approved.",
303
+ });
304
+ }
305
+ for (const component of unsourcedComponents) {
306
+ openItems.push({
307
+ item: `Source the ${component}`,
308
+ why: "Ships as a placeholder because it makes a claim to customers that we will not invent.",
309
+ delivers: "Collect the real evidence from the merchant, verify it is substantiated, and "
310
+ + "publish it — on a regulated storefront a fabricated claim is a compliance exposure.",
311
+ });
312
+ }
313
+
314
+ const standing = Object.entries(ADD_ON_CATALOG)
315
+ .filter(([id]) => addOns[id] !== "done" && addOns[id] !== "selected")
316
+ .map(([id, a]) => ({
317
+ id,
318
+ label: a.label,
319
+ buys: a.buys,
320
+ status: addOns[id] === "not-selected" ? "not selected" : (addOns[id] ?? "available"),
321
+ }));
322
+
323
+ return { dimensionRows, openItems, standing };
324
+ }
325
+
326
+ /** A Lighthouse finding is an add-on opportunity only when it is OURS to fix and not a metric. */
327
+ const OPPORTUNITY_EXCLUDE = new Set([
328
+ // Pure metrics: outcomes, not actions. Reporting "Largest Contentful Paint" as an
329
+ // opportunity tells a merchant nothing they can buy.
330
+ "first-contentful-paint", "largest-contentful-paint", "speed-index",
331
+ "total-blocking-time", "cumulative-layout-shift", "max-potential-fid",
332
+ "interactive", "server-response-time-insight",
333
+ ]);
334
+
335
+ /** Effort guess per finding id. Absent = unknown, which prints as "—" rather than a made-up size. */
336
+ const OPPORTUNITY_EFFORT = {
337
+ "unused-css-rules": "S",
338
+ "render-blocking-insight": "S",
339
+ "bf-cache": "S",
340
+ "unused-javascript": "S",
341
+ deprecations: "M",
342
+ "errors-in-console": "S",
343
+ "network-dependency-tree-insight": "M",
344
+ "document-latency-insight": "M",
345
+ "cache-insight": "S",
346
+ "server-response-time": "M",
347
+ "landmark-one-main": "S",
348
+ "page-has-heading-one": "S",
349
+ };
350
+
351
+ /**
352
+ * Collapse the per-page Lighthouse runs into one proof-of-value view.
353
+ *
354
+ * Averaging is deliberate over picking a headline page: a single flattering page is the kind
355
+ * of number that does not survive a merchant checking a second URL themselves.
356
+ */
357
+ export function summariseParity(pages) {
358
+ const measured = pages.filter((p) => p.dimensions && Object.keys(p.dimensions).length > 0);
359
+ // The bands the RUN used, never a constant here: the report must not describe a standard
360
+ // the gate is not enforcing. Defaults match lighthouse-parity.mjs only as a last resort.
361
+ const tolerance = measured.find((p) => p.tolerance != null)?.tolerance ?? 3;
362
+ const ceiling = measured.find((p) => p.ceiling != null)?.ceiling ?? 5;
363
+ if (measured.length === 0) {
364
+ return { measured: 0, dimensions: [], verdicts: {}, tolerance, ceiling };
365
+ }
366
+ /** @type {{dimension: string, label: string, source: number, migrated: number, delta: number, improved: boolean, pages: number, verdict?: string}[]} */
367
+ const dimensions = [];
368
+ for (const dim of DIMENSIONS) {
369
+ const rows = measured.map((p) => p.dimensions[dim]).filter(Boolean);
370
+ if (rows.length === 0) continue;
371
+ const avg = (key) => Math.round(rows.reduce((t, r) => t + (r[key] ?? 0), 0) / rows.length);
372
+ const source = avg("source");
373
+ const migrated = avg("migrated");
374
+ dimensions.push({
375
+ dimension: dim,
376
+ label: DIMENSION_LABEL[dim] ?? dim,
377
+ source,
378
+ migrated,
379
+ delta: migrated - source,
380
+ // A dimension is only "improved" if it beat the source on the AVERAGE; one good page
381
+ // does not earn the claim.
382
+ improved: migrated > source,
383
+ pages: rows.length,
384
+ });
385
+ }
386
+ const verdicts = {};
387
+ for (const p of measured) {
388
+ for (const [dim, d] of Object.entries(p.dimensions)) {
389
+ verdicts[dim] ??= { giveaway: 0, parity: 0, regression: 0 };
390
+ if (d.verdict && verdicts[dim][d.verdict] !== undefined) verdicts[dim][d.verdict] += 1;
391
+ }
392
+ }
393
+ // Roll each dimension's per-page verdicts into one. A REGRESSION anywhere outranks
394
+ // everything — a merchant losing something on one page has lost it, and averaging that
395
+ // away is exactly the dishonesty this report must not commit. Giveaway only when it beat
396
+ // the ceiling on balance.
397
+ for (const d of dimensions) {
398
+ const v = verdicts[d.dimension] ?? {};
399
+ d.verdict = v.regression > 0 ? "regression"
400
+ : (d.delta > ceiling ? "giveaway" : "parity");
401
+ }
402
+ return { measured: measured.length, dimensions, verdicts, tolerance, ceiling };
403
+ }
404
+
405
+ /**
406
+ * Lighthouse findings → add-on opportunities, deduped across pages and ranked by reach.
407
+ *
408
+ * Only `owner: platform` findings become opportunities: a provider-owned one (a CDN's own
409
+ * challenge script, say) is not something the merchant can buy from us, and listing it as an
410
+ * opportunity would be selling work we cannot do.
411
+ */
412
+ export function opportunitiesFromFindings(pages) {
413
+ const byId = new Map();
414
+ for (const page of pages) {
415
+ for (const f of page.findings ?? []) {
416
+ if (f.owner !== "platform") continue;
417
+ if (OPPORTUNITY_EXCLUDE.has(f.id)) continue;
418
+ // A channel-exemption or harness note describes the MEASUREMENT, not the site.
419
+ if (String(f.kind ?? "").startsWith("lighthouse-")) continue;
420
+ const entry = byId.get(f.id) ?? {
421
+ id: f.id,
422
+ title: f.title ?? f.id,
423
+ categories: f.categories ?? [],
424
+ pages: new Set(),
425
+ effort: OPPORTUNITY_EFFORT[f.id] ?? null,
426
+ };
427
+ entry.pages.add(page.slug);
428
+ byId.set(f.id, entry);
429
+ }
430
+ }
431
+ return [...byId.values()]
432
+ .map((e) => ({ ...e, pageCount: e.pages.size, pages: [...e.pages].sort() }))
433
+ .sort((a, b) => b.pageCount - a.pageCount || a.id.localeCompare(b.id));
434
+ }
435
+
436
+ /**
437
+ * Split the readiness checks into what is proven and what needs a person.
438
+ *
439
+ * The lifecycle decides the LANGUAGE, not the contents: in hot-standby an unmet check is an
440
+ * area of risk carrying a decision, because nothing is live and nobody is blocked. At cutover
441
+ * the same check is a blocker. Same measurement, honest framing for the stage.
442
+ */
443
+ export function classifyChecks(checks, { lifecycle }) {
444
+ const hotStandby = lifecycle === "hot-standby";
445
+ const confirmed = [];
446
+ const risks = [];
447
+ for (const c of checks ?? []) {
448
+ if (c.status === "PASS") confirmed.push(c);
449
+ else if (c.status !== "UNKNOWN" || !hotStandby) risks.push(c);
450
+ }
451
+ return { confirmed, risks, hotStandby };
452
+ }
453
+
454
+ function table(rows, header, align = []) {
455
+ const sep = header.map((_, i) => (align[i] === "r" ? "---:" : "---"));
456
+ return [`| ${header.join(" | ")} |`, `|${sep.join("|")}|`, ...rows.map((r) => `| ${r.join(" | ")} |`)]
457
+ .join("\n");
458
+ }
459
+
460
+
461
+ /**
462
+ * Areas only the MERCHANT can close, because they need evidence or a decision we cannot supply.
463
+ * Everything else is our work, and saying so is the difference between a report that reads as a
464
+ * status update and one that reads as a bill of work landing on the reader's desk.
465
+ */
466
+ const MERCHANT_CLOSES = new Set(["Ecommerce style-guide bar", "Style guide"]);
467
+
468
+ /** What the check protects, in the reader's terms — the check's own declared scope. */
469
+ function whyItMatters(check) {
470
+ return String(check?.why ?? "").replace(/\|/g, "/")
471
+ || "Part of the pre-launch quality bar.";
472
+ }
473
+
474
+ /**
475
+ * Who closes it, in outcome terms. The gates' own `fix` text is written for whoever runs the
476
+ * migration — it names gate counts, CSS structure and tooling — so it belongs in the implementor
477
+ * report, not in front of a merchant deciding whether to launch.
478
+ */
479
+ function whoCloses(check) {
480
+ return MERCHANT_CLOSES.has(String(check?.area ?? "").trim())
481
+ ? "Needs input from you — see **Turning the store on** below."
482
+ : "Our team closes this before go-live; no action needed from you.";
483
+ }
484
+
485
+
486
+ /**
487
+ * The same comparison, split by device class.
488
+ *
489
+ * Phone and desktop are judged against baselines captured on their own kind of device, because a
490
+ * desktop run compared to a phone number is not a comparison at all — different viewport,
491
+ * throttling and CPU multiplier. Averaging them would hide exactly the difference a merchant
492
+ * cares about: their customers are mostly on phones, and the two numbers can diverge sharply.
493
+ */
494
+ export function summariseByProfile(pages) {
495
+ const names = [...new Set(pages.flatMap((p) => Object.keys(p.profiles ?? {})))];
496
+ const LABEL = { mobile: "Phone", desktop: "Desktop" };
497
+ return names.map((name) => {
498
+ const runs = pages.map((p) => p.profiles?.[name]).filter(Boolean);
499
+ const dimensions = [];
500
+ for (const dim of DIMENSIONS) {
501
+ const rows = runs.map((r) => r.dimensions?.[dim]).filter((d) => d && typeof d.source === "number"
502
+ && typeof d.migrated === "number");
503
+ if (rows.length === 0) continue;
504
+ const avg = (key) => Math.round(rows.reduce((t, r) => t + r[key], 0) / rows.length);
505
+ const source = avg("source");
506
+ const migrated = avg("migrated");
507
+ dimensions.push({ dimension: dim, label: DIMENSION_LABEL[dim] ?? dim, source, migrated,
508
+ delta: migrated - source });
509
+ }
510
+ return { name, label: LABEL[name] ?? name, dimensions };
511
+ }).filter((p) => p.dimensions.length > 0);
512
+ }
513
+
514
+ /** The whole report. Pure: every input is data, so the output is testable byte-for-byte. */
515
+ export function buildGoLiveReport(input) {
516
+ const {
517
+ tenant, generatedAt, lifecycle = "hot-standby", pages = [], checks = [],
518
+ unsourcedComponents = [], styleGuideApproved = false, addOns = {}, totals = {},
519
+ baselineRoutes = 0, baselineCapturedAt = null,
520
+ // Commerce plumbing. Step 8 CANNOT verify these — order-sync and product-sync are
521
+ // builder-facing jobs on the public MCP, not storefront gates — so they arrive as recorded
522
+ // confirmations or not at all. Absent means UNCONFIRMED, which is reported as unconfirmed:
523
+ // never as passing, and never by silent omission.
524
+ commerceSync = {},
525
+ // Recorded go-live checklist confirmations, keyed by GO_LIVE_CHECKLIST.key.
526
+ goLive = {},
527
+ } = input;
528
+
529
+ const parity = summariseParity(pages);
530
+ const { confirmed, risks, hotStandby } = classifyChecks(checks, { lifecycle });
531
+ const opportunities = opportunitiesFromFindings(pages);
532
+ const wins = parity.dimensions.filter((d) => d.improved);
533
+
534
+ const out = [];
535
+ // The title states the LIFECYCLE, not the gate number. "Step 8" is an internal position in a
536
+ // playbook and means nothing to the merchant reading this; what they need to know is that the
537
+ // standby site is built and running, and that this is the readiness case for cutting over.
538
+ out.push(hotStandby
539
+ ? `# Hot standby active — go-live readiness for ${tenant}`
540
+ : `# Go-live readiness — ${tenant}`);
541
+ out.push("");
542
+ out.push(`_Prepared ${generatedAt} from an automated validation run of your new site. Every `
543
+ + `number below is measured on your real pages, not estimated._`);
544
+ out.push("");
545
+
546
+ if (hotStandby) {
547
+ out.push("> **This migration is in HOT STANDBY.** The merchant's live site is untouched and "
548
+ + "serving normally. Everything here is a parallel build, so nothing in this report blocks "
549
+ + "anyone today — the open items are **decisions to make before cutover**, not faults to "
550
+ + "clear before work continues.");
551
+ out.push("");
552
+ }
553
+
554
+ // ---- The headline. Lead with the outcome, in their numbers.
555
+ out.push("## What the merchant gets");
556
+ out.push("");
557
+ if (wins.length > 0) {
558
+ out.push(`Measured against **their own site**, captured before any change, across `
559
+ + `${parity.measured} page(s):`);
560
+ out.push("");
561
+ out.push(table(
562
+ parity.dimensions.map((d) => [
563
+ d.label,
564
+ String(d.source),
565
+ `**${d.migrated}**`,
566
+ d.delta > 0 ? `**+${d.delta}**` : String(d.delta),
567
+ d.improved ? "better" : (d.delta === 0 ? "held" : "below source"),
568
+ ]),
569
+ ["", "Their site today", "Migrated", "Change", ""],
570
+ ["", "r", "r", "r", ""],
571
+ ));
572
+ out.push("");
573
+ const best = [...wins].sort((a, b) => b.delta - a.delta)[0];
574
+ out.push(`The headline: **${best.label.toLowerCase()} ${best.source} → ${best.migrated}** `
575
+ + `on the same pages, same tests. That gap is the argument for cutting over.`);
576
+ out.push("");
577
+ const byProfile = summariseByProfile(pages);
578
+ if (byProfile.length > 0) {
579
+ out.push("#### Phone and desktop, measured separately");
580
+ out.push("");
581
+ out.push("_Each is compared against a baseline captured on the same kind of device. They "
582
+ + "are different measurements and are never averaged together._");
583
+ out.push("");
584
+ out.push(table(
585
+ byProfile.flatMap((p) => p.dimensions.map((d, i) => [
586
+ i === 0 ? `**${p.label}**` : "",
587
+ d.label, String(d.source), `**${d.migrated}**`,
588
+ d.delta > 0 ? `**+${d.delta}**` : String(d.delta),
589
+ ])),
590
+ ["", "", "Their site", "Migrated", "Change"],
591
+ ["", "", "r", "r", "r"],
592
+ ));
593
+ }
594
+ out.push("");
595
+ // The STANDARD, stated from the run's own bands rather than a constant here — otherwise the
596
+ // report can describe a bar the gate is not enforcing. The obligation is parity with their
597
+ // own site; anything above it is value they did not have.
598
+ out.push(`<sub>The bar is their own site: the migration owed parity within `
599
+ + `${parity.tolerance} point(s), and anything more than ${parity.ceiling} above it is value `
600
+ + `they did not have before.</sub>`);
601
+ } else if (parity.measured === 0 && baselineRoutes > 0) {
602
+ // The baseline EXISTS; this run just did not measure the migrated side (a --no-lighthouse
603
+ // fast pass). Saying "no baseline" here would send someone to capture one, and re-capturing
604
+ // after cutover overwrites a real source reading with a post-cutover one — destroying the
605
+ // only unrecoverable artifact in the whole migration to fix a problem that was never there.
606
+ out.push(`_This run did not measure the migrated side, so no before/after can be shown. The `
607
+ + `source baseline is intact (${baselineRoutes} route(s)) — do NOT re-capture it. Re-run `
608
+ + `validation with Lighthouse enabled to fill this section._`);
609
+ } else if (parity.measured === 0) {
610
+ out.push("_No source baseline was available for comparison, so the before/after claim cannot "
611
+ + "be made. Capture it BEFORE cutover — once the domain moves, the original is gone and "
612
+ + "this becomes unanswerable._");
613
+ } else {
614
+ out.push("_The migration holds parity with the source; no dimension improved on average._");
615
+ }
616
+ out.push("");
617
+
618
+ // THE BAR IS THEIR OWN MEASURED SITE, not a number we invented. The migration's obligation
619
+ // is parity: the merchant keeps what they had, within a tolerance. Anything further is value
620
+ // they never had. An absolute floor would judge them against a standard they may never have
621
+ // met and would mislabel free value as a shortfall — measured here, best-practices 73 -> 81
622
+ // is EIGHT POINTS DELIVERED FREE, and an invented 90 floor reported it as "9 short".
623
+ //
624
+ // So the bands come from the run's own verdicts (see lighthouse-parity.mjs): below
625
+ // source-tolerance is a regression and the only real failure; within the band is the
626
+ // obligation met; above source+ceiling is a giveaway, which is the upsell proof.
627
+ const byVerdict = (v) => parity.dimensions.filter((d) => d.verdict === v);
628
+ const giveaways = byVerdict("giveaway");
629
+ // LOST means the number went DOWN. A dimension that tied is not a loss and must never be
630
+ // listed as one — a merchant reading "below what they have today" against 94 -> 94 stops
631
+ // believing the rest of the page, and rightly. The per-page verdict can be `regression` while
632
+ // the site-wide average ties, so the average is what this section reports on.
633
+ const regressions = byVerdict("regression").filter((d) => d.migrated < d.source);
634
+
635
+ // No separate "delivered free" table: it restated the same rows the comparison above already
636
+ // shows, and a second pass over identical numbers reads as selling rather than reporting.
637
+ if (regressions.length > 0) {
638
+ out.push("### Below what they have today — must be decided");
639
+ out.push("");
640
+ out.push("_These are the only numbers that represent something LOST. Everything else either "
641
+ + "held or improved._");
642
+ out.push("");
643
+ out.push(table(
644
+ regressions.map((d) => [
645
+ d.label, String(d.source), `**${d.migrated}**`, String(d.migrated - d.source),
646
+ ]),
647
+ ["", "Their site today", "Migrated", "Change"],
648
+ ["", "r", "r", "r"],
649
+ ));
650
+ out.push("");
651
+ }
652
+
653
+ if (baselineRoutes > 0) {
654
+ out.push(`<sub>Source baseline: ${baselineRoutes} route(s)`
655
+ + `${baselineCapturedAt ? `, captured ${baselineCapturedAt}` : ""}. `
656
+ + `Not re-derivable after cutover.</sub>`);
657
+ out.push("");
658
+ }
659
+
660
+ // ---- What is proven.
661
+ out.push("## What is confirmed");
662
+ out.push("");
663
+ if (confirmed.length > 0) {
664
+ out.push(table(
665
+ confirmed
666
+ // "No open LOW-risk issues" is not a confirmation, it is the absence of the least
667
+ // important thing. The severity table below says what is actually open.
668
+ .filter((c) => !/low-risk/i.test(String(c.area ?? "")))
669
+ // "All 8 page checks passed" says nothing about WHAT passed. Every check already carries
670
+ // a `why` describing its scope; printing the result without it makes a real audit read
671
+ // like a progress bar.
672
+ .map((c) => [
673
+ c.area,
674
+ String(c.why ?? "").replace(/\|/g, "/") || "—",
675
+ String(c.problem ?? "").replace(/\|/g, "/"),
676
+ ]),
677
+ ["Checked", "What it covers", "Result"],
678
+ ));
679
+ } else {
680
+ out.push("_This validation run did not complete. Treat the report as incomplete — we will "
681
+ + "re-run it before anything is decided._");
682
+ }
683
+ out.push("");
684
+
685
+ // SEVERITY, NOT ABSENCE-OF-TRIVIA. This used to report "no open low-risk issues" — an
686
+ // absence at the bottom of the severity scale, printed as reassurance, while 8 HIGH issues
687
+ // sat unmentioned in the same totals object. A reader takes "nothing open" from it and is
688
+ // wrong in the most expensive direction, so the counts that matter are stated first and the
689
+ // clean case has to EARN its sentence.
690
+ if (totals.pages) {
691
+ out.push(`<sub>${totals.pages} page(s) validated end to end: routing, SEO, security headers, `
692
+ + `accessibility, mobile layout and the commerce boundary.</sub>`);
693
+ out.push("");
694
+ }
695
+
696
+ // ---- What is open, split by WHICH MILESTONE IT BLOCKS.
697
+ //
698
+ // A single list forces the merchant to work out for themselves whether their standby site is
699
+ // in trouble or merely unpolished. Those are opposite conclusions, and the severity counts
700
+ // belong with the split rather than floating above it: "8 high" means one thing if it blocks
701
+ // the parallel build and something much smaller if it is go-live polish.
702
+ // PROVENANCE IS PART OF THE STATUS, not a footnote. "A machine checked this five minutes ago"
703
+ // and "someone typed yes last month" are different claims with different decay, and collapsing
704
+ // them is how a stale hand-entered tick ends up justifying a cutover. `detected` is reserved
705
+ // for a real automated check; until one exists, every record here is `recorded` and says so.
706
+ const syncRow = (key, label, required) => {
707
+ const rec = commerceSync[key] ?? {};
708
+ const confirmed = rec.status === "confirmed";
709
+ const notApplicable = rec.status === "not-selected";
710
+ const via = rec.detectedAt ? "detected" : (confirmed ? "recorded by a person" : null);
711
+ return {
712
+ key, label, required, confirmed, notApplicable, via,
713
+ state: confirmed ? "Confirmed" : (notApplicable ? "Not selected" : "NOT CONFIRMED"),
714
+ evidence: rec.evidence ?? null,
715
+ at: rec.detectedAt ?? rec.confirmedAt ?? null,
716
+ };
717
+ };
718
+ const syncRows = [
719
+ syncRow("orderSync", "Order sync", true),
720
+ syncRow("productSync", "Product sync", true),
721
+ ];
722
+ // Required sync that is not proven. Unproven is not a soft state here: hot standby's
723
+ // whole claim is that this store could take over, and it cannot if commerce does not move.
724
+ const unprovenSync = syncRows.filter((r) => r.required && !r.confirmed);
725
+
726
+ // Sync is reported under HOT STANDBY, where it blocks. It is deliberately absent here:
727
+ // listing it in both places is the duplication this merge existed to remove.
728
+ const gates = [];
729
+ for (const row of syncRows.filter((r) => r.confirmed || r.notApplicable)) {
730
+ const status = row.confirmed
731
+ ? `**${row.state}**${row.via ? ` (${row.via}${row.at ? `, ${String(row.at).slice(0, 10)}` : ""})` : ""}`
732
+ : row.state;
733
+ gates.push([
734
+ `${row.label}${row.required ? " — **required**" : " — optional"}`,
735
+ status,
736
+ row.confirmed
737
+ ? (row.evidence ? String(row.evidence).replace(/\|/g, "/") : "Confirmed; nothing outstanding.")
738
+ : (row.notApplicable
739
+ ? "Not selected for your store."
740
+ : `This connects to your existing store rather than the new site, so our automated site `
741
+ + `checks cannot confirm it. It needs ${row.label.toLowerCase()} run end to end — test `
742
+ + `mode is fine at this stage — and the result recorded, so launch day is not where it `
743
+ + `first gets tested.`),
744
+ ]);
745
+ }
746
+ if (!styleGuideApproved) {
747
+ gates.push([
748
+ "Style-guide sign-off",
749
+ "NOT APPROVED",
750
+ "Someone needs to approve the visual standard your site is built to. Nothing is wrong "
751
+ + "with it — no approval has been recorded yet. One reviewer closes this.",
752
+ ]);
753
+ }
754
+ if (unsourcedComponents.length > 0) {
755
+ gates.push([
756
+ "Merchant-supplied trust content",
757
+ `${unsourcedComponents.length} awaiting your content`,
758
+ `${unsourcedComponents.join(", ")} currently show as placeholders. These make claims to `
759
+ + "your customers, so they need real evidence behind them and we will not invent it. "
760
+ + "Send us the genuine rating, badge, guarantee terms, testimonial or logos and we will "
761
+ + "publish them.",
762
+ ]);
763
+ }
764
+
765
+ const issues = totals.issues ?? {};
766
+ // Unproven required sync IS a blocker, so it must appear in the blocker count. Printing
767
+ // "0 blocker" directly above two rows labelled BLOCKER is the kind of self-contradiction that
768
+ // costs a reader their trust in every other number on the page.
769
+ const blocker = (issues.blocker ?? 0) + unprovenSync.length;
770
+ const high = issues.high ?? 0;
771
+ const medium = issues.medium ?? 0;
772
+ const standbyRisks = risks.filter((c) => blocksHotStandby(c.area));
773
+ const goLiveRisks = risks.filter((c) => !blocksHotStandby(c.area));
774
+
775
+ out.push("## What is open");
776
+ out.push("");
777
+ if (blocker + high + medium > 0 || issues.total) {
778
+ out.push(`Across ${totals.pages ?? 0} page(s): **${blocker} blocker**, **${high} high**, `
779
+ + `${medium} medium. `
780
+ + (blocker === 0 && high === 0
781
+ ? "Nothing at blocker or high severity is open."
782
+ : "The high-severity items are the ones worth a person's time; the specific audits behind "
783
+ + "them are itemised under **Raise a score**."));
784
+ out.push("");
785
+ }
786
+
787
+ out.push("### Blocks hot standby");
788
+ out.push("");
789
+ const standbyRows = [
790
+ ...unprovenSync.map((r) => [
791
+ `**${r.label} — BLOCKER**`,
792
+ `${r.state}. A standby store that cannot move ${r.label.toLowerCase().replace(" sync", "s")} `
793
+ + "is not standing by for anything.",
794
+ "Run it end to end against your existing store and record the result. Our automated "
795
+ + "checks cannot confirm this for you today, so it has to be demonstrated and signed off "
796
+ + "by a person before the standby store can be considered ready.",
797
+ ]),
798
+ ...standbyRisks.map((c) => [c.area, whyItMatters(c), whoCloses(c)]),
799
+ ];
800
+ if (standbyRows.length === 0) {
801
+ out.push("**Nothing.** The parallel site is built, faithful and serving: routes resolve, the "
802
+ + "commerce boundary holds, no secrets leak, SEO and sitemap are intact, and commerce sync "
803
+ + "is proven. Hot standby is not at risk from anything in this run.");
804
+ } else {
805
+ out.push(`**Hot standby is NOT cleared.** ${standbyRows.length} item(s) below must be closed `
806
+ + "first — these affect whether the standby site is a faithful, serving copy that could "
807
+ + "actually take over, which is the whole claim hot standby makes.");
808
+ out.push("");
809
+ out.push(table(standbyRows, ["Item", "What we found", "What closes it"]));
810
+ }
811
+ out.push("");
812
+
813
+ // WHAT IS ALREADY WORKING. A list of only the broken things is not a status report — it is a
814
+ // complaint, and it leaves the merchant with no sense of how much of their store already
815
+ // stands up. Every row here is something this run actually confirmed.
816
+ const workingRows = confirmed
817
+ .filter((c) => !/low-risk/i.test(String(c.area ?? "")))
818
+ .map((c) => [c.area, whyItMatters(c)]);
819
+ if (workingRows.length > 0) {
820
+ out.push("### Already working on the standby store");
821
+ out.push("");
822
+ out.push("_Live and confirmed on your standby site right now — measured in this run, not "
823
+ + "assumed._");
824
+ out.push("");
825
+ out.push(table(workingRows, ["Working", "What that means for you"]));
826
+ out.push("");
827
+ }
828
+
829
+ out.push("### Blocks Storefront go-live");
830
+ out.push("");
831
+ if (goLiveRisks.length === 0) {
832
+ out.push("**Nothing.** Every go-live check passed.");
833
+ } else {
834
+ out.push(hotStandby
835
+ ? "_None of these stop work today — the live site is untouched. Each is a decision to "
836
+ + "settle before the domain moves and checkout is switched on, and each is cheaper to "
837
+ + "settle now than after._"
838
+ : "_These must clear before the domain moves._");
839
+ out.push("");
840
+ out.push(table(
841
+ goLiveRisks.map((c) => [c.area, whyItMatters(c), whoCloses(c)]),
842
+ ["Area", "Why it matters", "What closes it"],
843
+ ));
844
+ }
845
+ out.push("");
846
+
847
+ // COMMERCE READINESS IS PART OF GO-LIVE, not a section of its own. It was a separate heading
848
+ // listing the same kind of thing — work that must be closed before the store can open — so a
849
+ // reader had to hold two lists and de-duplicate them mentally. One list, one place.
850
+ out.push("#### Turning the store on");
851
+ out.push("");
852
+ out.push("_None of this can be confirmed by inspecting the site: these are integrations, "
853
+ + "credentials and decisions. NOT CONFIRMED means no one has recorded a check yet — it does "
854
+ + "not mean broken, and it does not mean working._");
855
+ out.push("");
856
+ const goLiveRows = [
857
+ ...gates.map((g) => [g[0], g[1], g[2]]),
858
+ ...GO_LIVE_CHECKLIST.map((item) => {
859
+ const rec = goLive[item.key] ?? {};
860
+ const done = rec.status === "confirmed" || rec.status === "live";
861
+ const na = rec.status === "not-applicable";
862
+ return [
863
+ item.label,
864
+ done ? "**Confirmed**" : (na ? "Not applicable" : "NOT CONFIRMED"),
865
+ done
866
+ ? (rec.evidence ? String(rec.evidence).replace(/\|/g, "/") : "Confirmed; nothing outstanding.")
867
+ : (na ? "Not applicable to your store." : item.why),
868
+ ];
869
+ }),
870
+ ];
871
+ out.push(table(goLiveRows, ["Item", "Status", "What closes it"]));
872
+ out.push("");
873
+
874
+ const lifecycle_rows = addOnLifecycle(addOns);
875
+ if (lifecycle_rows.length > 0) {
876
+ out.push("#### Add-ons you have selected");
877
+ out.push("");
878
+ out.push("_Selected is not the same as live. Each has to be configured and then confirmed "
879
+ + "working, or launch day is when you find out something you paid for was never switched "
880
+ + "on._");
881
+ out.push("");
882
+ out.push(table(
883
+ lifecycle_rows.map((a) => [
884
+ a.label,
885
+ a.live ? "**Live**" : (a.state === "configured" ? "Configured — awaiting confirmation" : "Selected — not yet configured"),
886
+ a.live ? "Confirmed working." : "We complete this before go-live and confirm it back to you.",
887
+ ]),
888
+ ["Add-on", "Status", "What happens next"],
889
+ ));
890
+ out.push("");
891
+ }
892
+ out.push("");
893
+
894
+ // ---- Commerce plumbing. The site being fast is not the business question; whether ORDERS
895
+ // FLOW is. A hot-standby tenant is non-public and processes no live transactions by contract,
896
+ // so the question here is never "is it live" — it is "is it PROVEN before we flip", because
897
+ // cutover is the worst possible moment to discover order sync was never exercised.
898
+
899
+ // ONE table. The sync rows and the human gates were two tables in which "order sync" appeared
900
+ // twice — once as a status and again as a task — so the reader had to work out it was a single
901
+ // thing. Status, why it is open, and what closes it are three columns of one row, not two
902
+ // tables joined by the reader.
903
+
904
+
905
+ // ---- Add-ons: what we could do next, priced in effort.
906
+ out.push("## Additional options");
907
+ out.push("");
908
+ out.push("_The migration's obligation is parity: the merchant keeps everything they had. "
909
+ + "Everything below is value they never had, so it is optional and separately scoped._");
910
+ out.push("");
911
+ const offers = deriveAddOns({
912
+ parity, unsourcedComponents, styleGuideApproved, syncRows, addOns, opportunities,
913
+ });
914
+
915
+ // The measured audit items ARE the scope of a score lift, so they belong inside the dimension
916
+ // they would raise — not in a separate list a reader has to cross-reference. "Speed +8
917
+ // available" is a claim; "+8 available, and here are the six audits that get you there" is a
918
+ // quote someone can act on.
919
+ const byCategory = (dim) => opportunities.filter((o) => (o.categories ?? []).includes(dim));
920
+ out.push("### Raise a score");
921
+ out.push("");
922
+ out.push("_Every dimension is listed, measured or not. Headroom is the distance to 100 on this "
923
+ + "run — what is still buyable above what the migration already delivered. The work named is "
924
+ + "what this site's own audit found, not a generic list._");
925
+ out.push("");
926
+ out.push(table(
927
+ offers.dimensionRows.map((d) => {
928
+ const found = byCategory(d.dimension);
929
+ const work = found.length === 0
930
+ ? "—"
931
+ : found.slice(0, 4).map((o) => `${o.title.replace(/\|/g, "/")} (${o.pageCount}p`
932
+ + `${o.effort ? `, ${o.effort}` : ""})`).join("; ")
933
+ + (found.length > 4 ? `; +${found.length - 4} more` : "");
934
+ return [d.label, d.today == null ? "—" : String(d.today), d.state, d.buys, work];
935
+ }),
936
+ ["Dimension", "Today", "Headroom", "What buying it delivers", "The work this site needs"],
937
+ ["", "r", "", "", ""],
938
+ ));
939
+ out.push("");
940
+ if (opportunities.length > 0) {
941
+ out.push("<sub>Work items are this run's own audit findings, with page reach and a rough "
942
+ + "size. Projections are deliberately absent: a rough number that reads as a commitment is "
943
+ + "worse than no number.</sub>");
944
+ out.push("");
945
+ }
946
+
947
+ if (offers.standing.length > 0) {
948
+ out.push("### Platform and services");
949
+ out.push("");
950
+ out.push(table(
951
+ offers.standing.map((a) => [a.label, a.buys, a.status]),
952
+ ["Option", "What it buys", "Status"],
953
+ ));
954
+ out.push("");
955
+ }
956
+
957
+ out.push("---");
958
+ out.push("");
959
+ out.push("<sub>Generated automatically from a full validation run of the standby site. "
960
+ + "Figures refresh on every run.</sub>");
961
+ return `${out.join("\n")}\n`;
962
+ }
963
+
964
+ /**
965
+ * The IMPLEMENTOR's companion report.
966
+ *
967
+ * The merchant-facing report answers "should we launch, and what do you need from me". This
968
+ * answers "what exactly failed and what do I run" — gate counts, CSS structure, tooling names,
969
+ * verbatim remediation. Both are generated from the same run so they cannot disagree; splitting
970
+ * them is what lets the merchant document speak plainly without deleting the detail somebody
971
+ * actually needs to close the work.
972
+ */
973
+ export function buildImplementorReport(input) {
974
+ const {
975
+ tenant, generatedAt, lifecycle = "hot-standby", checks = [], totals = {},
976
+ pages = [], commerceSync = {},
977
+ } = input;
978
+ const { confirmed, risks } = classifyChecks(checks, { lifecycle });
979
+ const issues = totals.issues ?? {};
980
+ const out = [];
981
+
982
+ out.push(`# Implementor detail — ${tenant}`);
983
+ out.push("");
984
+ out.push(`_Generated ${generatedAt}. Companion to \`go-live-report.md\`, which is the `
985
+ + "merchant-facing document — this one carries the verbatim gate output. Both come from the "
986
+ + "same run; regenerate rather than hand-editing either._");
987
+ out.push("");
988
+ out.push(`Lifecycle: **${lifecycle}** · pages ${totals.pages ?? 0} `
989
+ + `(${totals.passing ?? 0} passing, ${totals.failing ?? 0} failing) · issues `
990
+ + `${issues.total ?? 0} (blocker ${issues.blocker ?? 0}, high ${issues.high ?? 0}, `
991
+ + `medium ${issues.medium ?? 0}, low ${issues.low ?? 0})`);
992
+ out.push("");
993
+
994
+ out.push("## Open gates — verbatim");
995
+ out.push("");
996
+ if (risks.length === 0) {
997
+ out.push("_No open gates._");
998
+ } else {
999
+ out.push(table(
1000
+ risks.map((c) => [
1001
+ c.area,
1002
+ blocksHotStandby(c.area) ? "**hot standby**" : "go-live",
1003
+ String(c.status ?? "").replace(/\|/g, "/"),
1004
+ String(c.problem ?? "").replace(/\|/g, "/"),
1005
+ String(c.fix ?? "").replace(/\|/g, "/"),
1006
+ ]),
1007
+ ["Area", "Blocks", "Status", "Problem", "Fix"],
1008
+ ));
1009
+ }
1010
+ out.push("");
1011
+
1012
+ out.push("## Commerce sync records");
1013
+ out.push("");
1014
+ out.push(table(
1015
+ ["orderSync", "productSync"].map((key) => {
1016
+ const rec = commerceSync[key] ?? {};
1017
+ return [
1018
+ key,
1019
+ rec.status ?? "(absent)",
1020
+ rec.detectedAt ? `detected ${rec.detectedAt}` : (rec.confirmedAt ? `recorded ${rec.confirmedAt}` : "—"),
1021
+ rec.evidence ? String(rec.evidence).replace(/\|/g, "/") : "—",
1022
+ ];
1023
+ }),
1024
+ ["Key", "status", "provenance", "evidence"],
1025
+ ));
1026
+ out.push("");
1027
+ out.push("_Written to the store checkout's `.tot/migration-state.json` under `commerceSync`. Both are "
1028
+ + "REQUIRED for hot standby. No automated status read exists yet — tracked as a critical "
1029
+ + "platform gap, so today this is a recorded human confirmation._");
1030
+ out.push("");
1031
+
1032
+ out.push("## Per-page measurement");
1033
+ out.push("");
1034
+ const measured = pages.filter((p) => p && p.slug);
1035
+ if (measured.length === 0) {
1036
+ out.push("_No per-page Lighthouse artifacts in this run._");
1037
+ } else {
1038
+ out.push(table(
1039
+ measured.map((p) => {
1040
+ const dims = p.dimensions ?? {};
1041
+ const verdicts = Object.entries(dims)
1042
+ .filter(([, d]) => d?.verdict && d.verdict !== "parity")
1043
+ .map(([k, d]) => `${k}:${d.verdict}`);
1044
+ return [
1045
+ p.slug,
1046
+ Object.entries(p.scores ?? {}).map(([k, v]) => `${k.slice(0, 4)} ${v}`).join(", ") || "—",
1047
+ verdicts.join(", ") || "all parity",
1048
+ p.unmeasured ? `**${p.unmeasured}**` : "—",
1049
+ ];
1050
+ }),
1051
+ ["Slug", "Scores", "Non-parity verdicts", "Unmeasured"],
1052
+ ));
1053
+ }
1054
+ out.push("");
1055
+
1056
+ out.push("## Passing gates");
1057
+ out.push("");
1058
+ out.push(confirmed.length > 0
1059
+ ? table(confirmed.map((c) => [c.area, String(c.problem ?? "").replace(/\|/g, "/")]),
1060
+ ["Area", "Result"])
1061
+ : "_None passed._");
1062
+ out.push("");
1063
+ return `${out.join("\n")}\n`;
1064
+ }