@nextcommerce/campaigns-os 1.55.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +97 -0
  5. package/campaign-spec/dist/checkout-flow.js +245 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  30. package/contracts/commerce-surface-catalog.json +26 -27
  31. package/contracts/effects.v1.json +5 -5
  32. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  35. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  45. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  46. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  47. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  48. package/contracts/release-ledger.json +1137 -0
  49. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  50. package/contracts/supported-surface.json +9 -5
  51. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  52. package/docs/build-packet.md +68 -10
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +124 -22
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/package.json +2 -1
  61. package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
  62. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
  63. package/schemas/campaign-spec.v5.schema.json +586 -0
  64. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  65. package/skills/campaign-readback-classification/SKILL.md +3 -3
  66. package/skills/campaign-run-evidence/SKILL.md +3 -3
  67. package/skills/contribution-intake/SKILL.md +3 -3
  68. package/skills/next-campaigns-build/SKILL.md +6 -4
  69. package/skills/next-campaigns-os/SKILL.md +3 -3
  70. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  71. package/skills/next-campaigns-polish/SKILL.md +21 -6
  72. package/skills/next-campaigns-qa/SKILL.md +4 -3
  73. package/skills.json +10 -10
  74. package/src/adapter-decision-contract.mjs +1 -1
  75. package/src/build-brief.mjs +14 -5
  76. package/src/built-site-scope.mjs +91 -0
  77. package/src/cli-helpers.mjs +129 -0
  78. package/src/cli.mjs +117 -57
  79. package/src/commercial-journey.mjs +25 -2
  80. package/src/design-source-package.mjs +1 -1
  81. package/src/design-source-publication.mjs +7 -7
  82. package/src/doctor/checks.mjs +269 -16
  83. package/src/doctor/inspect.mjs +4 -2
  84. package/src/doctor/next-step.mjs +18 -9
  85. package/src/doctor-sidecar.mjs +5 -3
  86. package/src/gate-actions.mjs +21 -1
  87. package/src/input-refresh.mjs +6 -6
  88. package/src/local-preview-policy.mjs +110 -12
  89. package/src/polish-node.mjs +8 -7
  90. package/src/qa-browser.mjs +363 -36
  91. package/src/qa-cart-entry.mjs +183 -14
  92. package/src/qa-node.mjs +38 -34
  93. package/src/qa-test-order-topology.mjs +118 -1
  94. package/src/qa-verdict-discovery.mjs +21 -4
  95. package/src/qa-verdict.mjs +24 -1
  96. package/src/runtime-state-ignore.mjs +7 -5
  97. package/src/sdk-attribute-index.mjs +8 -7
  98. package/src/source-html-intake.mjs +58 -7
  99. package/src/stage-ledger.mjs +4 -2
  100. package/src/stage-record.mjs +203 -36
@@ -26,7 +26,7 @@ import {
26
26
  loadCampaignBuildBriefFile,
27
27
  } from "./build-brief.mjs";
28
28
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
29
- import { isObject, optionalString, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
29
+ import { isObject, optionalString, portableArtifactPaths, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
30
30
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
31
31
  import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
32
32
  import {
@@ -475,9 +475,9 @@ function saveBriefUnderLock({ packetPath, explicitBrief, sidecars, lockedTarget,
475
475
  if (!dryRun) {
476
476
  // Intake's publication order, the report last: a failure between files
477
477
  // leaves the report bound to the old material, which reads as owed.
478
- writeJsonAtomic(packetPath, nextPacket);
479
- writeJsonAtomic(normalizedPath, buildBrief.artifact);
480
- writeJsonAtomic(contextPath, saved.nextContext);
478
+ writeJsonAtomic(packetPath, portableArtifactPaths(nextPacket, targetRepo, { artifactPath: packetPath }));
479
+ writeJsonAtomic(normalizedPath, portableArtifactPaths(buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
480
+ writeJsonAtomic(contextPath, portableArtifactPaths(saved.nextContext, targetRepo, { artifactPath: contextPath }));
481
481
  commitAssemblyReport(workspace, () => nextReport, {
482
482
  command: "record brief",
483
483
  staleReason: "The Campaign Build Brief was saved after this doctor snapshot. Re-run doctor (or next) for current state.",
@@ -674,8 +674,8 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
674
674
  if (!dryRun) {
675
675
  // Intake's publication order, the report last: a failure between files
676
676
  // leaves the report bound to the old spec material, which reads as owed.
677
- if (briefWritten) writeJsonAtomic(normalizedPath, brief.buildBrief.artifact);
678
- writeJsonAtomic(contextPath, nextContext);
677
+ if (briefWritten) writeJsonAtomic(normalizedPath, portableArtifactPaths(brief.buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
678
+ writeJsonAtomic(contextPath, portableArtifactPaths(nextContext, targetRepo, { artifactPath: contextPath }));
679
679
  commitAssemblyReport(workspace, () => nextReport, {
680
680
  command: "record spec",
681
681
  staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",
@@ -1,5 +1,5 @@
1
- // The local preview policy: one place that decides which missing evidence a
2
- // localhost preview carries forward as a warning instead of a blocker.
1
+ // Preview policy: one place that decides which missing evidence a preview
2
+ // carries forward as a warning instead of a blocker.
3
3
  //
4
4
  // The local preview is a packet whose deploy target is `local-serve`, served
5
5
  // from a loopback host. There a campaign must still prove its commerce (store
@@ -8,16 +8,20 @@
8
8
  // evidence is reported as missing, never as passed: a carried-forward gate is
9
9
  // a warning in doctor, `next` does not stop on it, and QA records it as a
10
10
  // `warn` row, so the verdict is at best `ready_with_exceptions`. A hosted
11
- // preview or production packet never takes this path, and every check not
12
- // named below keeps its meaning on every path.
11
+ // preview carries forward only the all-template no-capturable-routes
12
+ // shape and the missing Polish report/evidence it owns. QA against the
13
+ // packet's production URL stays strict. Every other check keeps its meaning.
13
14
  //
14
15
  // Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
15
16
  // gates. Recording a stage (`record polish`) and the waiver commands keep the
16
17
  // strict gates; `record deploy` follows next past a carried-forward polish.
17
18
  import { isLocalServePacket } from "./local-proof.mjs";
18
19
  import { isLoopbackHostname } from "./remit.mjs";
20
+ import { hostedTemplateQaAction, HOSTED_TEMPLATE_PREVIEW_URL_ACTION } from "./gate-actions.mjs";
21
+ import { normalizePublicRouteSlug } from "./route-identity.mjs";
19
22
 
20
23
  export const LOCAL_PREVIEW_POLICY = "local_preview";
24
+ export const HOSTED_TEMPLATE_PREVIEW_POLICY = "hosted_template_preview";
21
25
  export const CARRIED_FORWARD = "carried_forward";
22
26
 
23
27
  // The checks carried forward, one by one. Each is evidence the campaign does
@@ -30,6 +34,9 @@ const CARRIED_POLISH_CODES = Object.freeze(new Set([
30
34
  "polish.report_missing",
31
35
  "polish.evidence_missing",
32
36
  ]));
37
+ export function polishGateCanCarryPreview(gate) {
38
+ return Boolean(gate?.owned_checkpoint_only || CARRIED_POLISH_CODES.has(gate?.code));
39
+ }
33
40
  export const NO_CAPTURABLE_ROUTES_CODE = "polish.hidden_eager_media.no_capturable_routes";
34
41
  const MISSING_CAPTURE_CODE = "polish.hidden_eager_media.capture_malformed";
35
42
 
@@ -53,21 +60,96 @@ export function isLocalPreview(packet, { baseUrl = null } = {}) {
53
60
  && loopbackOrAbsent(baseUrl);
54
61
  }
55
62
 
63
+ function comparableUrl(value) {
64
+ try {
65
+ const url = new URL(String(value));
66
+ if (!/^https?:$/.test(url.protocol)) return null;
67
+ return `${url.origin}${url.pathname.replace(/\/+$/, "") || "/"}`;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export function ensureUrlTrailingSlash(value) {
74
+ try {
75
+ const url = new URL(value);
76
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
77
+ return url.toString();
78
+ } catch {
79
+ return value.endsWith("/") ? value : `${value}/`;
80
+ }
81
+ }
82
+
83
+ // QA composes a campaign slug onto a site root before testing it. Preview
84
+ // policy compares those same targets, ignoring only query and fragment.
85
+ export function normalizeQaBaseUrl(value, publicRouteSlug) {
86
+ const baseUrl = typeof value === "string" && value.trim() ? value.trim() : null;
87
+ if (!baseUrl) return null;
88
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
89
+ if (!slug) return ensureUrlTrailingSlash(baseUrl);
90
+ try {
91
+ const url = new URL(ensureUrlTrailingSlash(baseUrl));
92
+ const segments = url.pathname.split("/").filter(Boolean);
93
+ if (segments.at(-1) === slug) return ensureUrlTrailingSlash(url.toString());
94
+ return new URL(`${slug}/`, url).toString();
95
+ } catch {
96
+ return ensureUrlTrailingSlash(baseUrl);
97
+ }
98
+ }
99
+
100
+ function comparableQaTarget(value, packet) {
101
+ const slug = packet?.campaign?.public_route_slug || packet?.deploy?.live_url_path;
102
+ return comparableUrl(normalizeQaBaseUrl(value, slug));
103
+ }
104
+
105
+ function isHostedTemplatePreview(packet, { baseUrl = null } = {}) {
106
+ const preview = comparableQaTarget(packet?.deploy?.preview_url, packet);
107
+ const production = comparableQaTarget(packet?.deploy?.production_url, packet);
108
+ const tested = baseUrl == null ? preview : comparableQaTarget(baseUrl, packet);
109
+ return Boolean(packet?.deploy?.target && packet.deploy.target !== "local-serve"
110
+ && preview && tested === preview && preview !== production
111
+ && !isLoopbackHostname(new URL(preview).hostname));
112
+ }
113
+
114
+ export function hostedTemplateNeedsUsablePreview(packet, gate) {
115
+ return gate?.code === NO_CAPTURABLE_ROUTES_CODE
116
+ && packet?.deploy?.target && packet.deploy.target !== "local-serve"
117
+ && !isHostedTemplatePreview(packet);
118
+ }
119
+
120
+ export function hostedTemplateNeedsPreviewUrl(packet, gate) {
121
+ return gate?.code === NO_CAPTURABLE_ROUTES_CODE
122
+ && packet?.deploy?.target && packet.deploy.target !== "local-serve"
123
+ && !packet.deploy.preview_url;
124
+ }
125
+
56
126
  function pageLoadRecorded(report) {
57
127
  return report?.stages?.polish?.evidence?.visual_review?.page_load != null;
58
128
  }
59
129
 
60
- function carried(gate) {
130
+ export function carried(gate, policy = LOCAL_PREVIEW_POLICY, packet = null) {
131
+ const previewUrl = packet?.deploy?.preview_url;
132
+ if (policy === HOSTED_TEMPLATE_PREVIEW_POLICY && !comparableUrl(previewUrl)) return gate;
61
133
  return {
62
134
  ...gate,
63
135
  status: CARRIED_FORWARD,
64
- carried_forward: { policy: LOCAL_PREVIEW_POLICY, from_status: gate.status, evidence: "missing" },
136
+ ...(policy === HOSTED_TEMPLATE_PREVIEW_POLICY
137
+ ? { required_actions: [hostedTemplateQaAction(previewUrl)] }
138
+ : {}),
139
+ carried_forward: { policy, from_status: gate.status, evidence: "missing" },
65
140
  };
66
141
  }
67
142
 
68
143
  // The hidden eager-media checkpoint, evaluated on its own.
69
144
  export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl = null } = {}) {
70
- if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
145
+ if (gate?.status !== "blocked") return gate;
146
+ if (hostedTemplateNeedsUsablePreview(packet, gate)) {
147
+ return { ...gate, required_actions: [HOSTED_TEMPLATE_PREVIEW_URL_ACTION] };
148
+ }
149
+ if (gate.code === NO_CAPTURABLE_ROUTES_CODE && isHostedTemplatePreview(packet, { baseUrl })) {
150
+ return carried(gate, HOSTED_TEMPLATE_PREVIEW_POLICY, packet);
151
+ }
152
+ if (!isLocalPreview(packet, { baseUrl })) return gate;
71
153
  const missing = gate.code === NO_CAPTURABLE_ROUTES_CODE
72
154
  || (gate.code === MISSING_CAPTURE_CODE && !pageLoadRecorded(report));
73
155
  return missing ? carried(gate) : gate;
@@ -82,11 +164,24 @@ export function polishCarriedForwardForLadder(report, gate) {
82
164
  }
83
165
 
84
166
  export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
85
- if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
167
+ if (gate?.status !== "blocked") return gate;
168
+ // A template-stock map has no route that capture can measure, even when
169
+ // the preview is strict. Do not keep an impossible recapture action.
170
+ const actionableGate = checkpointGate?.code === NO_CAPTURABLE_ROUTES_CODE
171
+ ? { ...gate, required_actions: (gate.required_actions || []).filter((action) => action.id !== "polish.hidden_eager_media.capture") }
172
+ : gate;
173
+ if (polishGateCanCarryPreview(gate) && hostedTemplateNeedsUsablePreview(packet, checkpointGate)) {
174
+ return { ...actionableGate, required_actions: [HOSTED_TEMPLATE_PREVIEW_URL_ACTION] };
175
+ }
176
+ const hostedTemplate = checkpointGate?.code === NO_CAPTURABLE_ROUTES_CODE
177
+ && checkpointGate?.carried_forward?.policy === HOSTED_TEMPLATE_PREVIEW_POLICY
178
+ && isHostedTemplatePreview(packet, { baseUrl });
179
+ if (!hostedTemplate && !isLocalPreview(packet, { baseUrl })) return actionableGate;
86
180
  const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
87
- if (gate.owned_checkpoint_status === "blocked" && !checkpointCarried) return gate;
88
- if (gate.owned_checkpoint_only) return checkpointCarried ? carried(gate) : gate;
89
- return CARRIED_POLISH_CODES.has(gate.code) ? carried(gate) : gate;
181
+ if (gate.owned_checkpoint_status === "blocked" && !checkpointCarried) return actionableGate;
182
+ const policy = hostedTemplate ? HOSTED_TEMPLATE_PREVIEW_POLICY : LOCAL_PREVIEW_POLICY;
183
+ if (gate.owned_checkpoint_only) return checkpointCarried ? carried(actionableGate, policy, packet) : actionableGate;
184
+ return CARRIED_POLISH_CODES.has(gate.code) ? carried(actionableGate, policy, packet) : actionableGate;
90
185
  }
91
186
 
92
187
  export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } = {}) {
@@ -94,5 +189,8 @@ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } =
94
189
  }
95
190
 
96
191
  export function carriedForwardMessage(gate) {
97
- return `Carried forward on the local preview: ${gate.reason} This evidence is missing, not passed; a hosted preview or production run requires it.`;
192
+ if (gate?.carried_forward?.policy === HOSTED_TEMPLATE_PREVIEW_POLICY) {
193
+ return `Carried forward on the hosted preview: ${gate.reason} This evidence is missing, not passed; production QA still requires it.`;
194
+ }
195
+ return `Carried forward on the local preview: ${gate.reason} This evidence is missing, not passed; production QA still requires it.`;
98
196
  }
@@ -3,7 +3,7 @@ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
5
5
  import { dirname, join, resolve } from "node:path";
6
- import { computeBuildFingerprint } from "./built-site-scope.mjs";
6
+ import { computeBuildFingerprint, describeBuildOutputDrift } from "./built-site-scope.mjs";
7
7
  import {
8
8
  buildPageLoadCapture,
9
9
  MAX_POLISH_CAPTURE_URL_LENGTH,
@@ -160,7 +160,7 @@ export function planPolishCapture({ packet, baseUrl } = {}) {
160
160
  if (routes.length === 0) {
161
161
  const error = new Error(
162
162
  "polish capture has no mapped non-skipped routes to capture: every mapped page is template stock (skip_reason), so there is no design route to compare."
163
- + " On a local-serve preview, missing polish evidence is carried forward as a warning; run `next` for the next stage.",
163
+ + " On a local or hosted preview, missing polish evidence is carried forward as a warning; run `next` for the next stage.",
164
164
  );
165
165
  error.code = NO_CAPTURABLE_ROUTES_ERROR;
166
166
  throw error;
@@ -226,13 +226,13 @@ function recordedAuthorityBlock({ packet, report, plan = null, now } = {}) {
226
226
 
227
227
  // Every mapped page is template stock: there is nothing for polish capture to
228
228
  // measure, which is missing evidence rather than a malformed packet. It stays
229
- // a non-waivable block; the local preview policy carries it forward.
229
+ // a non-waivable block; the preview policy carries it forward.
230
230
  function noCapturableRoutesBlock({ packet, report, now } = {}) {
231
231
  const block = recordedAuthorityBlock({ packet, report, now });
232
232
  return {
233
233
  ...block,
234
234
  code: NO_CAPTURABLE_ROUTES_CODE,
235
- reason: "Every mapped page is template stock (skip_reason), so polish capture has no design route to capture and this build has no page-load evidence.",
235
+ reason: "Every mapped page is template stock (skip_reason), so this build has no design route to compare and no page-load evidence.",
236
236
  required_actions: [HIDDEN_EAGER_MEDIA_ACTIONS.map_design_route],
237
237
  };
238
238
  }
@@ -402,7 +402,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
402
402
  // an output that has drifted from what build recorded would be evidence
403
403
  // about a build that no longer exists, and an output that changes during the
404
404
  // browser pass fails the unchanged-binding assertion after it.
405
- const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint);
405
+ const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint, report.stages.assembly);
406
406
  const runId = nonemptyString(report.run_id);
407
407
  const reportPacketPath = nonemptyString(report?.inputs?.packet_path);
408
408
  if (!runId || !reportPacketPath) {
@@ -585,7 +585,7 @@ function uncapturedPages(packet, plan) {
585
585
  // (permissions, a file vanishing mid-walk), or an output that no longer
586
586
  // matches what build recorded. None of them may surface as an uncaught
587
587
  // filesystem error, and none may bind as a null.
588
- function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
588
+ function boundOutputFingerprint(outputRoot, slug, buildFingerprint, assembly = null) {
589
589
  let current;
590
590
  try {
591
591
  current = computeBuildFingerprint(outputRoot);
@@ -604,7 +604,8 @@ function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
604
604
  if (current.fingerprint !== buildFingerprint) {
605
605
  throw new Error(
606
606
  `polish capture refuses: built output under _site/${slug}/ no longer matches stages.assembly.build_fingerprint `
607
- + `(recorded ${buildFingerprint}, current ${current.fingerprint}). Re-run build and record the current fingerprint first.`,
607
+ + `(recorded ${buildFingerprint}, current ${current.fingerprint}). ${describeBuildOutputDrift(assembly, current).summary} `
608
+ + "Re-run build and record the current fingerprint first.",
608
609
  );
609
610
  }
610
611
  return current.fingerprint;