@nextcommerce/campaigns-os 1.59.0 → 1.61.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 (96) hide show
  1. package/CHANGELOG.md +242 -0
  2. package/README.md +11 -10
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +1311 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +42 -13
  17. package/docs/campaign-build-brief.md +17 -1
  18. package/docs/campaigns-os-build-flow.md +62 -3
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +41 -3
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +5 -5
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +12 -16
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
  46. package/skills.json +18 -10
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +7 -14
  52. package/src/campaign-identity.mjs +6 -4
  53. package/src/cli.mjs +89 -73
  54. package/src/design-file-reference.mjs +93 -0
  55. package/src/design-source-package.mjs +55 -1
  56. package/src/doctor/checks.mjs +14 -77
  57. package/src/doctor/ci.mjs +112 -0
  58. package/src/doctor/inspect.mjs +20 -8
  59. package/src/doctor/next-step.mjs +6 -3
  60. package/src/doctor/source-provenance.mjs +74 -0
  61. package/src/evidence-debt.mjs +119 -0
  62. package/src/orchestration-stage-contract.mjs +6 -3
  63. package/src/page-kit-build-summary.mjs +21 -1
  64. package/src/page-kit-campaign-config.mjs +19 -0
  65. package/src/parity/compare.mjs +316 -0
  66. package/src/parity/design-access.mjs +54 -0
  67. package/src/parity/element-parity-record.mjs +569 -0
  68. package/src/parity/probe.mjs +205 -0
  69. package/src/parity/snapshot.mjs +110 -0
  70. package/src/png-header.mjs +87 -0
  71. package/src/polish-browser.mjs +186 -9
  72. package/src/polish-captures-record.mjs +52 -0
  73. package/src/polish-deadline.mjs +14 -0
  74. package/src/polish-gate.mjs +26 -11
  75. package/src/polish-node.mjs +81 -7
  76. package/src/polish-visual-fixtures.mjs +74 -0
  77. package/src/polish-visual.mjs +606 -0
  78. package/src/progress-node.mjs +2 -1
  79. package/src/qa-analytics-correctness.mjs +14 -2
  80. package/src/qa-analytics-parity.mjs +94 -5
  81. package/src/qa-node.mjs +39 -6
  82. package/src/qc-accept.mjs +148 -6
  83. package/src/qc-check-registry.mjs +37 -1
  84. package/src/qc-results.mjs +404 -16
  85. package/src/readback.mjs +57 -13
  86. package/src/sdk-attribute-index.mjs +11 -11
  87. package/src/sdk-markup.mjs +77 -2
  88. package/src/sdk-storage-compatibility.mjs +94 -4
  89. package/src/source-html-manifest.mjs +7 -0
  90. package/src/source-html-server.mjs +129 -0
  91. package/src/spec-derive.mjs +24 -9
  92. package/src/stage-ledger.mjs +27 -1
  93. package/src/stage-record.mjs +63 -9
  94. package/src/theme-gate.mjs +2 -2
  95. package/src/visual-diff.mjs +296 -0
  96. package/src/visual-reference.mjs +126 -0
@@ -114,13 +114,25 @@ export function assessAnalyticsInventory(capture = {}, contract = {}, options =
114
114
  if (providers.facebook && providers.facebook.enabled !== false) {
115
115
  const id = providers.facebook.pixelId;
116
116
  const present = inventoryHas(inventory, "meta", id);
117
+ // #686: page code replaced window.fbq, so in-page calls after that were
118
+ // not observed. Without a matching pixel request the row is unmeasured,
119
+ // not a clean absence.
120
+ const unmeasured = !present && capture.metaObserver === "disarmed";
117
121
  assertions.push(emit({
118
122
  id: "analytics-correctness:tag:meta",
119
123
  status: present ? STATUS.PASS : STATUS.FAIL,
120
124
  severity: SEVERITY.BLOCKER,
121
125
  expected: `Meta pixel ${id || ""} fires on the page`.trim(),
122
- actual: present ? "present" : `absent (${(inventory.meta || []).length} meta pixel(s) fired, none matching)`,
123
- evidence: { declared: id || null, observed_count: (inventory.meta || []).length },
126
+ actual: present
127
+ ? "present"
128
+ : unmeasured
129
+ ? `unmeasured: the page replaced window.fbq, so in-page pixel calls could not be observed (${(inventory.meta || []).length} meta pixel(s) seen, none matching)`
130
+ : `absent (${(inventory.meta || []).length} meta pixel(s) fired, none matching)`,
131
+ evidence: {
132
+ declared: id || null,
133
+ observed_count: (inventory.meta || []).length,
134
+ ...(unmeasured ? { reason: "fbq_observer_disarmed" } : {}),
135
+ },
124
136
  }));
125
137
  }
126
138
 
@@ -107,6 +107,53 @@ export function analyticsInitScript(layers = HOOKED_DATA_LAYERS) {
107
107
  set(next) { backing = wrap(name, next || []); },
108
108
  });
109
109
  }
110
+ // Meta pixel calls (#686). Headless Chromium can run fbq init/track without
111
+ // fbevents.js ever requesting facebook.com/tr, so the pixel is also observed
112
+ // in-page: a pixel id counts once it is initialised and a track call follows
113
+ // in the same document. Only the id and event name leave the page.
114
+ const metaInits = [];
115
+ // Mirrored into the in-page store as well, like record(), for pages where
116
+ // the binding could not attach.
117
+ store.metaCalls = [];
118
+ const metaFire = (id, ev) => {
119
+ const call = { id: String(id), ev: ev ? String(ev) : null };
120
+ store.metaCalls.push(call);
121
+ emit({ type: "meta_call", call, document: documentState });
122
+ };
123
+ const pixelId = (value) => (typeof value === "string" || typeof value === "number") && String(value) ? String(value) : null;
124
+ const recordFbq = (args) => {
125
+ try {
126
+ const method = String(args[0] || "");
127
+ // fbq('init', pixelId[, userData]): the pixel id is always args[1].
128
+ if (method === "init" && pixelId(args[1])) {
129
+ const id = pixelId(args[1]);
130
+ if (!metaInits.includes(id)) metaInits.push(id);
131
+ } else if (method === "track" || method === "trackCustom") {
132
+ for (const id of metaInits) metaFire(id, args[1]);
133
+ } else if ((method === "trackSingle" || method === "trackSingleCustom") && metaInits.includes(pixelId(args[1]))) {
134
+ metaFire(args[1], args[2]);
135
+ }
136
+ } catch (_) {}
137
+ };
138
+ const wrapFbq = (fn) => {
139
+ if (typeof fn !== "function" || fn.__nextQaWrapped || typeof Proxy !== "function") return fn;
140
+ // A Proxy keeps fbq's own properties (queue, callMethod, loaded) on the
141
+ // real function, so fbevents.js sees the object it installed.
142
+ return new Proxy(fn, {
143
+ apply(target, self, args) { recordFbq(args); return Reflect.apply(target, self, args); },
144
+ get(target, key, receiver) { return key === "__nextQaWrapped" ? true : Reflect.get(target, key, receiver); },
145
+ });
146
+ };
147
+ let fbqBacking = wrapFbq(window.fbq);
148
+ // The getter is marked so a read can tell this accessor from one page code
149
+ // installed, or from a deleted property.
150
+ const fbqGet = function () { return fbqBacking; };
151
+ Object.defineProperty(fbqGet, "__nextQaFbqAccessor", { value: true });
152
+ Object.defineProperty(window, "fbq", {
153
+ configurable: true,
154
+ get: fbqGet,
155
+ set(next) { fbqBacking = wrapFbq(next); },
156
+ });
110
157
  })();`;
111
158
  }
112
159
 
@@ -209,6 +256,18 @@ export async function attachAnalyticsCapture(page, options = {}) {
209
256
  }
210
257
  return;
211
258
  }
259
+ if (payload?.type === "meta_call") {
260
+ // An in-page fbq call is pixel inventory only. Its event name stays
261
+ // out of `params`, so it never counts as a Meta Purchase fire or
262
+ // supplies a CAPI dedup key; those still need the /tr request.
263
+ const id = payload.call?.id;
264
+ if (!id) return;
265
+ const document = payload.document
266
+ ? lookupDocument(payload.document, { fallbackUrl: sourceUrl, mainFrame })
267
+ : (mainFrame ? activeMainDocument : null) || lookupDocument({}, { fallbackUrl: sourceUrl, mainFrame });
268
+ tagFires.push({ kind: "meta", id: String(id), host: null, params: {}, source: "fbq", event: payload.call?.ev || null, document });
269
+ return;
270
+ }
212
271
  const entry = payload?.type === "event" ? payload.entry : payload;
213
272
  const attributed = attributeEvent(entry, { mainFrame, fallbackUrl: sourceUrl });
214
273
  if (attributed) accumulatedEvents.push(attributed);
@@ -243,6 +302,19 @@ export async function attachAnalyticsCapture(page, options = {}) {
243
302
  try {
244
303
  const raw = await page.evaluate(() => ({
245
304
  events: window.__nextQaAnalytics?.events || [],
305
+ metaCalls: window.__nextQaAnalytics?.metaCalls || [],
306
+ // Whether in-page fbq calls were still observable when read: page code
307
+ // that redefines window.fbq removes the wrapper (#686).
308
+ // Deleting or redefining the property disarms it too; "absent" means
309
+ // the QA accessor is still installed and no pixel was ever set.
310
+ metaObserver: (() => {
311
+ if (!window.__nextQaAnalytics) return null;
312
+ const descriptor = Object.getOwnPropertyDescriptor(window, "fbq");
313
+ if (!descriptor || !descriptor.get || descriptor.get.__nextQaFbqAccessor !== true) return "disarmed";
314
+ const fbq = window.fbq;
315
+ if (fbq === undefined || fbq === null) return "absent";
316
+ return fbq.__nextQaWrapped === true ? "armed" : "disarmed";
317
+ })(),
246
318
  document: window.__nextQaAnalytics?.document || { url: String(location.href || "") },
247
319
  }));
248
320
  snapshot = Array.isArray(raw) ? { events: raw, document: null } : (raw || snapshot);
@@ -272,18 +344,28 @@ export async function attachAnalyticsCapture(page, options = {}) {
272
344
  const journeyEvents = bindingAttached
273
345
  ? [...accumulatedEvents, ...snapshotEvents]
274
346
  : snapshotEvents;
347
+ // Without the binding, in-page fbq calls come only from this document's
348
+ // store; with it, they are already in tagFires.
349
+ const snapshotMetaFires = !bindingAttached && Array.isArray(snapshot.metaCalls)
350
+ ? snapshot.metaCalls.filter((call) => call?.id).map((call) => ({
351
+ kind: "meta", id: String(call.id), host: null, params: {}, source: "fbq", event: call.ev || null, document: snapshotDocument,
352
+ }))
353
+ : [];
354
+ const journeyTagFires = [...tagFires, ...snapshotMetaFires];
275
355
  const currentEvents = journeyEvents.filter(
276
356
  (entry) => entry.document?.mainFrame === true
277
357
  && entry.document.generation === currentDocument.generation,
278
358
  );
279
- const currentTagFires = tagFires.filter(
359
+ const currentTagFires = journeyTagFires.filter(
280
360
  (fire) => fire.document?.mainFrame === true
281
361
  && fire.document.generation === currentDocument.generation,
282
362
  );
363
+ const metaObserver = typeof snapshot.metaObserver === "string" ? snapshot.metaObserver : null;
283
364
  return {
284
- journey: normalizeCapture({ events: journeyEvents, tagFires }),
365
+ journey: { ...normalizeCapture({ events: journeyEvents, tagFires: journeyTagFires }), metaObserver },
285
366
  currentDocument: {
286
367
  ...normalizeCapture({ events: currentEvents, tagFires: currentTagFires }),
368
+ metaObserver,
287
369
  document: publicDocument(currentDocument),
288
370
  },
289
371
  };
@@ -457,13 +539,16 @@ export function normalizeCapture({ events = [], tagFires = [] } = {}) {
457
539
  }
458
540
 
459
541
  const inventory = { gtm: [], ga4: [], google_ads: [], meta: [], tiktok: [], everflow: [], other: [] };
542
+ // Pixel ids seen on a facebook.com request, apart from in-page fbq calls.
543
+ const metaRequestIds = [];
460
544
  let metaPurchaseEventId = null;
461
545
  let metaPurchaseFired = false;
462
546
  let ga4PurchaseFired = false;
463
547
  for (const fire of tagFires) {
464
548
  if (!fire || !inventory[fire.kind]) continue;
465
549
  if (fire.id && !inventory[fire.kind].includes(fire.id)) inventory[fire.kind].push(fire.id);
466
- if (fire.kind === "meta") {
550
+ if (fire.kind === "meta" && fire.source !== "fbq") {
551
+ if (fire.id && !metaRequestIds.includes(fire.id)) metaRequestIds.push(fire.id);
467
552
  const ev = fire.params?.ev || fire.params?.event;
468
553
  if (ev && String(ev).toLowerCase() === "purchase") {
469
554
  metaPurchaseFired = true;
@@ -483,6 +568,7 @@ export function normalizeCapture({ events = [], tagFires = [] } = {}) {
483
568
  purchase: purchase ? { present: true, ...purchase } : { present: false },
484
569
  purchasesByEvent,
485
570
  inventory,
571
+ metaRequestIds,
486
572
  metaPurchaseEventId,
487
573
  // Purchase can fire from any of three sources (dataLayer event, Meta pixel,
488
574
  // GA4) — campaigns that block the SDK event and fire Meta/GA4 manually still
@@ -680,8 +766,11 @@ export function diffAnalyticsParity(baseline, candidate, options = {}) {
680
766
 
681
767
  // 5. Meta CAPI dedup — candidate's Meta Purchase fire carries an eventID,
682
768
  // consistent with the order id so client + server events dedup.
683
- const baselineHasMeta = (b.inventory?.meta || []).length > 0;
684
- const candidateHasMeta = (c.inventory?.meta || []).length > 0;
769
+ // Keyed on pixel requests: an in-page fbq call with no /tr request has
770
+ // no eventID to compare (#686).
771
+ const metaRequests = (cap) => cap.metaRequestIds || cap.inventory?.meta || [];
772
+ const baselineHasMeta = metaRequests(b).length > 0;
773
+ const candidateHasMeta = metaRequests(c).length > 0;
685
774
  if (baselineHasMeta || candidateHasMeta) {
686
775
  const eid = c.metaPurchaseEventId;
687
776
  const consistent = eid && cp.transactionId && String(eid) === String(cp.transactionId);
package/src/qa-node.mjs CHANGED
@@ -138,7 +138,7 @@ Usage:
138
138
  campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
139
139
  campaigns-os qa resolve <map-id> --spec <campaign-spec.json> [--base-url <url>]
140
140
  campaigns-os qa run <map-id> --spec <campaign-spec.json> --base-url <url>
141
- campaigns-os qa run --site <page-kit-target-repo> --base-url <url> --family <family> [--slug <slug>] [--browser] # L7: QA a built _site/ with no packet/spec
141
+ campaigns-os qa run --site <page-kit-target-repo> --base-url <url> --family <family> [--slug <slug>] [--currency <code>] [--browser] # L7: QA a built _site/ with no packet/spec
142
142
  campaigns-os qa install-browser [--json] # one-time: install the package-owned Playwright Chromium (same as npm run qa:install-browser from a checkout)
143
143
 
144
144
  Options:
@@ -249,6 +249,19 @@ Options:
249
249
  --test-exp-year <yyyy> Test card expiration year. Default: 2030.
250
250
  --test-email <email> Customer email for browser test orders. Env CAMPAIGNS_OS_QA_TEST_EMAIL is also recognized.
251
251
  --test-email-prefix <prefix> Legacy unique email prefix override.
252
+ --test-first-name <name> Test customer first name. Default: QA.
253
+ --test-last-name <name> Test customer last name. Default: Playwright.
254
+ --test-phone <phone> Test customer phone, filled when the checkout has the field. Default: empty.
255
+ --test-country <code> Shipping (and billing) country, as the checkout's country select option value
256
+ or label (e.g. GB). Default: US. Changing it does not change the run's currency:
257
+ pair it with --currency <code>, and pass the four address flags below for that
258
+ country, since their defaults are a US address. A non-USD --currency on an
259
+ ordering run with no --test-country prints a warning; the run still goes ahead.
260
+ --test-address1 <line> Street address. Default: 1600 Amphitheatre Pkwy.
261
+ --test-city <city> City. Default: Mountain View.
262
+ --test-province <code> State, province or region, as the province select's option value or label.
263
+ Default: CA. The checkout must show a province select for the country.
264
+ --test-postal <code> Postal code. Default: 94043.
252
265
  --legacy-api-test-order <off|accept|decline|both>
253
266
  Diagnostic-only direct Campaigns API order creation; bypasses deployed checkout.
254
267
  --api-key <key> Campaigns API key for legacy direct API diagnostics. Env QA_CAMPAIGNS_API_KEY is also recognized.
@@ -765,8 +778,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
765
778
  // yields "not_applicable" rather than blocking, so browser QA still runs the
766
779
  // residue/placeholder/demo gates. Test orders are not attempted (no policy).
767
780
  export function resolveQaInputsFromSite(args) {
768
- const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
769
- if (!baseUrl) {
781
+ const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
782
+ if (!inputBaseUrl) {
770
783
  throw refused("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
771
784
  }
772
785
  const templateFamily = stringArg(args.family);
@@ -778,6 +791,10 @@ export function resolveQaInputsFromSite(args) {
778
791
  if (!scope.ok) {
779
792
  throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
780
793
  }
794
+ // The built pages live under _site/<slug>/, so compose the slug onto a site
795
+ // origin the way packet QA does; a base URL already ending in the slug is
796
+ // left alone (#683).
797
+ const baseUrl = normalizeQaBaseUrl(inputBaseUrl, scope.slug);
781
798
  const brandContract = loadBrandContract(templateFamily);
782
799
  if (brandContract.status !== "loaded" || !brandContract.contract) {
783
800
  throw new Error(`Non-packet site QA requires a loadable template brand contract for --family "${templateFamily}" (got ${brandContract.status}).`);
@@ -800,8 +817,8 @@ export function resolveQaInputsFromSite(args) {
800
817
  polishGate,
801
818
  // Non-packet site QA has no Assembly Report, so no recorded waivers.
802
819
  qaWaivers: {},
803
- // Non-packet site QA: --base-url IS the served campaign root by this
804
- // mode's contract (no spec identity exists to compose from).
820
+ // Non-packet site QA: the served campaign root is --base-url with the
821
+ // built slug composed on (no spec identity exists to compose from).
805
822
  analyticsCaptureTarget: buildAnalyticsCaptureTarget({
806
823
  url: ensureUrlTrailingSlash(baseUrl),
807
824
  publicRouteSlug: scope.slug || null,
@@ -2175,6 +2192,21 @@ function qaRunCurrency(args) {
2175
2192
  return value.toUpperCase();
2176
2193
  }
2177
2194
 
2195
+ // `qa run --currency <code>` pins the market the pages and orders run in, but
2196
+ // the order runner's shipping address comes from the --test-* flags and
2197
+ // defaults to a US one. A non-USD currency with no --test-country is the
2198
+ // likely mismatch, so it is named on stderr. Only the missing flag is judged:
2199
+ // a currency such as EUR has many markets, so an explicit --test-country is
2200
+ // taken as the operator's choice. The run is never refused.
2201
+ function warnCurrencyWithoutTestCountry(args, write = (message) => process.stderr.write(message)) {
2202
+ const currency = qaRunCurrency(args);
2203
+ if (!currency || currency === "USD") return false;
2204
+ if (String(args["test-order"] || "off").toLowerCase() === "off") return false;
2205
+ if (stringArg(args["test-country"])) return false;
2206
+ write(`[qa:test-order] --currency ${currency} with no --test-country: test orders use the default US shipping address. Pass --test-country, --test-address1, --test-city, --test-province and --test-postal for the ${currency} market, or ignore this if a US address is intended.\n`);
2207
+ return true;
2208
+ }
2209
+
2178
2210
  async function runParityQa(args) {
2179
2211
  // Checked here as well as on the budget itself: the budget is built after a
2180
2212
  // browser has launched, and a flag the operator typed wrong should cost them
@@ -2253,7 +2285,7 @@ async function runQa(args, options = {}) {
2253
2285
  // Fail-fast before anything resolves or launches. The authoritative check
2254
2286
  // lives on the creation budget itself, which every browser path builds.
2255
2287
  refuseBadOrderCreationLimit(args);
2256
- qaRunCurrency(args);
2288
+ warnCurrencyWithoutTestCountry(args);
2257
2289
  // Match dispatch: an active browser mode takes precedence over legacy API
2258
2290
  // diagnostics. Only the selected legacy path requires a cart and API mode.
2259
2291
  const browserMode = String(args["test-order"] || "off").toLowerCase();
@@ -4260,6 +4292,7 @@ export const __qaNodeTestHooks = Object.freeze({
4260
4292
  templateBrandContractAssertion,
4261
4293
  deriveEntryUrls,
4262
4294
  qaRunCurrency,
4295
+ warnCurrencyWithoutTestCountry,
4263
4296
  withEntryCurrency,
4264
4297
  derivePageUrls,
4265
4298
  deriveTestedUrlsFromAssertions,
package/src/qc-accept.mjs CHANGED
@@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
13
13
  import { isNamedHuman, validateWaiverAttribution } from "./checkpoint-waiver.mjs";
14
14
  import { DOCTOR_SIDECAR_SCHEMA } from "./doctor-sidecar.mjs";
15
15
  import { cmd } from "./install-invocation.mjs";
16
+ import { PARITY_TOLERANCES } from "./parity/compare.mjs";
16
17
  import { canonicalJson } from "./polish-capture.mjs";
17
18
  import { QC_CHECK_REGISTRY } from "./qc-check-registry.mjs";
18
19
  import { QC_LEGS, QC_REASON, fingerprint12, qcResultRef } from "./qc-results.mjs";
@@ -27,7 +28,117 @@ export const QC_DISPOSITION = Object.freeze({ OPEN: "open", OPERATOR_ACCEPTED: "
27
28
  // full QA verdict, or, on the Polish leg, the package-owned record the
28
29
  // accepted check is read from (QC_CHECK_REGISTRY `record`).
29
30
  const QC_LEG_SOURCES = Object.freeze({ doctor: "doctor_sidecar", qa: "qa_full_verdict" });
30
- export const QC_POLISH_RECORD_SOURCES = Object.freeze({ media_weight: "polish_media_weight", readability: "polish_readability" });
31
+ export const QC_POLISH_RECORD_SOURCES = Object.freeze({ media_weight: "polish_media_weight", readability: "polish_readability", captures: "polish_captures", element_parity: "polish_element_parity" });
32
+
33
+ const DESIGN_ACCESS_TEXT = Object.freeze({
34
+ design_access_missing: "A design file is recorded for this page, but this version of Campaigns OS cannot read design files. Element parity compares the built page with the page's source HTML export instead, so differences between the export and the design file are not checked. Nothing needs to change to continue. Where it matters, compare the export with the design file by hand.",
35
+ design_reference_not_recorded: "This page names a design file that the Design Source Package does not record. Rerun intake with `--force` to record it (this resets stage evidence), or continue: element parity still runs against the source HTML.",
36
+ design_reference_missing: "The Design Source Package records a design file, but this page no longer has a design-file declaration. Restore the declaration, or rerun intake with `--force` to drop the recorded file.",
37
+ design_reference_changed: "The design file named for this page differs from the one the Design Source Package records. Rerun intake with `--force` to record the current one.",
38
+ design_reference_conflict: "The CampaignSpec page and the source-html manifest name different design files or frames for this page (or the manifest's producer file key differs). The package records the CampaignSpec's. Correct the wrong one and rerun intake with `--force`.",
39
+ });
40
+ const probeIncomplete = (code) => `The element probe did not complete (${code}); not compared.`;
41
+ const notRendered = (code) => `The source or built page did not render (${code}).`;
42
+ const partlyRead = "Only part of the page was read; findings are not accept-eligible.";
43
+ const textNotRead = "Some page text was not read (long text, closed components or frames) on the source page, the built page or both. Where the built page's text was not read, copy is not called missing.";
44
+ const packageMismatch = "The Design Source Package on disk does not match the build's; rerun intake or polish capture.";
45
+ const PARITY_COVERAGE_TEXT = Object.freeze({
46
+ ...Object.fromEntries(["navigation_failed", "http_error", "producer_timeout", "browser_unavailable", "source_html_missing", "source_root_unavailable"].map((code) => [code, notRendered(code)])),
47
+ ...Object.fromEntries(["probe_timeout", "probe_failed", "probe_not_run", "document_changed", "run_budget_exhausted"].map((code) => [code, probeIncomplete(code)])),
48
+ page_not_captured: "Not captured for element parity: beyond the 32-page run limit, or added since the last capture. Rerun polish capture.",
49
+ readiness_incomplete: "Fonts or awaited content were not ready in time; not compared.",
50
+ source_identity_unrecorded: "The Design Source Package records no hash for this source file; not compared.",
51
+ probe_file_absent: "Probe files are local and not committed; rerun polish capture on this machine.",
52
+ design_elements_unavailable: "The source has no text elements (for example an image-only export); element parity is unavailable.",
53
+ viewport_mismatch: "Source and build rendered at different sizes or pixel ratios; nothing is resized.",
54
+ probe_output_invalid: "The page measurements could not be read; not compared.",
55
+ comparator_failed: "The comparison could not run; not compared.",
56
+ element_cap_reached: partlyRead,
57
+ probe_time_cap_reached: partlyRead,
58
+ page_text_truncated: textNotRead,
59
+ closed_shadow_root_not_read: textNotRead,
60
+ frame_not_read: textNotRead,
61
+ font_face_cap_reached: "More than 200 font faces; web-font availability was not fully checked.",
62
+ source_package_unavailable: packageMismatch,
63
+ source_package_stale: packageMismatch,
64
+ });
65
+ export const QC_COVERAGE_TEXT = Object.freeze({
66
+ "design.access": Object.freeze({
67
+ design_access_missing: DESIGN_ACCESS_TEXT.design_access_missing,
68
+ design_reference_not_recorded: DESIGN_ACCESS_TEXT.design_reference_not_recorded,
69
+ }),
70
+ "parity.element": PARITY_COVERAGE_TEXT,
71
+ });
72
+ export const QC_COVERAGE_LIMIT_TEXT = Object.freeze({ "parity.element": Object.freeze({
73
+ font_usage_not_observed: "The font each glyph was drawn with is not read; a web font that loaded is assumed used.",
74
+ }) });
75
+
76
+ export function designAccessSummary(row) {
77
+ return row.check === "design.access" && row.result === "review" ? DESIGN_ACCESS_TEXT[row.reason_code] ?? null : null;
78
+ }
79
+
80
+ // A parity.element row in words, from its read-time observation: the design
81
+ // element's label, then each comparator member with its measured values and
82
+ // tolerance. Geometry also names the design selector path. Null for any other
83
+ // row, and for a parity row with nothing to say (an unexercised row has no
84
+ // observation members).
85
+ const signed = (value, unit = "") => `${value < 0 ? "−" : "+"}${Math.abs(value)}${unit}`;
86
+ const px = (value) => (typeof value === "number" ? `${value}px` : String(value));
87
+ const rgb = (value) => (Array.isArray(value) ? `rgb(${value.join(",")})` : "not measured");
88
+ const PARITY_GEOMETRY_TEXT = Object.freeze({
89
+ gap: (delta) => `moved ${signed(delta, "px")} against the element above (gap, tolerance ${PARITY_TOLERANCES.gap_px}px)`,
90
+ x_position: (delta) => `moved ${signed(delta, "px")} horizontally (x position, tolerance ${PARITY_TOLERANCES.position_px}px)`,
91
+ width: (delta) => `resized ${signed(delta, "px")} in width (width, tolerance ${PARITY_TOLERANCES.size_px}px)`,
92
+ height: (delta) => `resized ${signed(delta, "px")} in height (height, tolerance ${PARITY_TOLERANCES.size_px}px)`,
93
+ });
94
+ const PARITY_TYPE_TOLERANCES = Object.freeze({ font_size: `${PARITY_TOLERANCES.font_size_px}px`, line_height: `${PARITY_TOLERANCES.line_height_px}px`,
95
+ letter_spacing: `${PARITY_TOLERANCES.letter_spacing_px}px`, font_weight: "0" });
96
+ const PARITY_COPY_TEXT = Object.freeze({
97
+ copy_missing: () => "design copy not found on the built page",
98
+ copy_changed: (similarity) => `design copy changed (similarity ${similarity})`,
99
+ page_text_truncated: () => "design copy not found in the readable part of the built page (review)",
100
+ build_text_not_read: () => "design copy not found in the readable part of the built page (review)",
101
+ });
102
+
103
+ function parityMemberText(category, member, reason, result) {
104
+ const { property, design, build, delta } = member;
105
+ const review = result === "review" ? " (review)" : "";
106
+ if (category === "geom") return PARITY_GEOMETRY_TEXT[property]?.(delta) ?? `${property} ${signed(delta, "px")}`;
107
+ if (category === "type") {
108
+ const name = property === "font_face" ? "web font" : property.replace(/_/g, "-");
109
+ const units = ["font_size", "line_height", "letter_spacing"].includes(property) ? "px" : "";
110
+ const tolerance = typeof delta === "number" && Object.hasOwn(PARITY_TYPE_TOLERANCES, property) ? ` (${signed(delta, units)}, tolerance ${PARITY_TYPE_TOLERANCES[property]})` : "";
111
+ const value = units ? px : String;
112
+ return `${name} ${value(design)} → ${value(build)}${tolerance}${review}`;
113
+ }
114
+ if (category === "color") {
115
+ if (property === "color") return `colour not measured (source ${design ?? "unresolved"}, build ${build ?? "unresolved"})${review}`;
116
+ const name = property === "background_color" ? "background colour" : "text colour";
117
+ const tolerance = typeof delta === "number" ? ` (${delta}/255, tolerance ${PARITY_TOLERANCES.color_channel})` : "";
118
+ return `${name} ${rgb(design)} → ${rgb(build)}${tolerance}${review}`;
119
+ }
120
+ return PARITY_COPY_TEXT[reason]?.(delta) ?? null;
121
+ }
122
+
123
+ export function parityRowSummary(row) {
124
+ if (row?.check !== "parity.element") return null;
125
+ const key = String(row.subject?.key ?? "");
126
+ if (key === "comparison") return row.result === "review" && row.reason_code === "source_identity_mismatch"
127
+ ? "source on disk differs from the recorded source; element parity not compared" : null;
128
+ const category = key.split(":")[0];
129
+ const observation = isPlainObject(row.observation) ? row.observation : {};
130
+ const members = Array.isArray(observation.members) ? observation.members.filter(isPlainObject) : [];
131
+ if (!["copy", "type", "color", "geom"].includes(category) || !members.length) return null;
132
+ const results = new Map((Array.isArray(row.members) ? row.members : []).map((member) => [member?.key, member]));
133
+ const texts = members.map((member) => {
134
+ const reported = results.get(member.property);
135
+ return parityMemberText(category, member, reported?.reason_code ?? row.reason_code, reported?.result ?? row.result);
136
+ }).filter(Boolean);
137
+ if (!texts.length) return null;
138
+ const selector = isNonEmptyString(observation.design_selector_path) ? observation.design_selector_path : null;
139
+ const label = isNonEmptyString(observation.label) ? observation.label : selector ?? key;
140
+ return `${label}${category === "geom" && selector && label !== selector ? ` (${selector})` : ""}: ${texts.join("; ")}`;
141
+ }
31
142
 
32
143
  // Null for a Polish check with no record, or a leg with no source.
33
144
  export function measuredSourceFor(leg, check) {
@@ -165,9 +276,12 @@ function assessOne(record, currentById, nowMs) {
165
276
  if (!(Date.parse(record.accepted_at) > Date.parse(record.measured_at))) return { status: "inert", why: "accepted_not_after_measurement" };
166
277
  const current = currentById.get(record.result_id);
167
278
  if (!current) return { status: "orphaned", why: "no_current_result" };
168
- if (current.result === "unexercised" && [QC_REASON.STALE_BINDING, QC_REASON.EVIDENCE_NOT_REPRODUCIBLE].includes(current.reason_code)) {
279
+ if (current.result === "unexercised" && [QC_REASON.STALE_BINDING, QC_REASON.EVIDENCE_NOT_REPRODUCIBLE, "capture_file_absent"].includes(current.reason_code)) {
169
280
  return { status: "lapsed", why: current.reason_code, current };
170
281
  }
282
+ // Visual references lose their revision-baseline warning when provenance
283
+ // becomes independent (§6); that disposition precedes fingerprint changes.
284
+ if (current.check === "visual.reference" && current.result !== "warning") return { status: "inert", why: "target_not_warning", current };
171
285
  if (current.state_fingerprint !== record.state_fingerprint
172
286
  || current.check !== record.check
173
287
  || current.leg !== record.leg
@@ -325,6 +439,21 @@ function readabilityReviewCrop(row) {
325
439
  return typeof ref?.path === "string" ? ref.path : undefined;
326
440
  }
327
441
 
442
+ function visualSummary(row) {
443
+ const item = row.observation;
444
+ if (!item || !row.check?.startsWith("visual.")) return null;
445
+ const label = `${row.subject.page} ${row.subject.viewport || ""}`.trim();
446
+ if (row.check === "visual.reference" && row.reason_code === "self_referential") {
447
+ return `${label} reference ${item.ref_id} is the build's own output (${(item.provenance?.reasons || []).join(", ")}); accepting adopts a revision baseline, not design proof`;
448
+ }
449
+ if (row.check !== "visual.diff" || row.result !== "review") return null;
450
+ const percent = (value) => Number.isFinite(value) ? `${(100 * value).toFixed(2)}%` : "unavailable";
451
+ const delta = (value) => Number.isFinite(value) ? `${value >= 0 ? "+" : ""}${value}px` : "unavailable";
452
+ const worst = item.bands?.find((band) => band.status === "compared");
453
+ const heatmap = item.artifacts?.find((a) => a.band_key === worst?.key)?.heatmap?.path || item.artifacts?.[0]?.heatmap?.path;
454
+ return `${label} vs ${item.basis === "source_html" ? "source HTML" : item.ref_id}: ${percent(item.page?.mismatch_ratio)} differing, ${delta(item.page?.height_delta_px)} height${worst ? `; worst band ${worst.key} (${percent(worst.mismatch_ratio)}, ${delta(worst.height_delta_px)})` : ""}${heatmap ? `; heatmap ${heatmap}` : ""}`;
455
+ }
456
+
328
457
  function handoffEntry(row) {
329
458
  const crop = readabilityReviewCrop(row);
330
459
  return {
@@ -338,7 +467,7 @@ function handoffEntry(row) {
338
467
  reason_code: row.reason_code,
339
468
  accept_eligible: row.accept_eligible === true,
340
469
  members: Array.isArray(row.members) ? row.members : [],
341
- summary: readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
470
+ summary: designAccessSummary(row) ?? parityRowSummary(row) ?? visualSummary(row) ?? readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
342
471
  ...(crop === undefined ? {} : { crop }),
343
472
  };
344
473
  }
@@ -366,7 +495,8 @@ function groupOpenWarnings(entries, rows = []) {
366
495
  pages,
367
496
  accept_eligible: results.every((entry) => entry.accept_eligible),
368
497
  summary: results.length > 1
369
- ? readabilityPairSummary(results.map((entry) => rows.find((row) => qcResultRef(row) === entry.result_ref))) ?? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
498
+ ? parityRowSummary(rows.find((row) => qcResultRef(row) === lead.result_ref))
499
+ ?? readabilityPairSummary(results.map((entry) => rows.find((row) => qcResultRef(row) === entry.result_ref))) ?? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
370
500
  : lead.summary,
371
501
  results,
372
502
  };
@@ -427,6 +557,15 @@ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date
427
557
  }
428
558
  const grouped = new Map();
429
559
  for (const row of results) {
560
+ for (const limit of new Set(row.coverage?.limits || [])) {
561
+ const text = QC_COVERAGE_LIMIT_TEXT[row.check]?.[limit];
562
+ if (!text) continue;
563
+ const key = JSON.stringify([row.leg, row.check, "limit", limit]);
564
+ const entry = grouped.get(key) || { check: row.check, leg: row.leg, result: "limit", reason_code: limit, count: 0, pages: [], text };
565
+ entry.count += 1;
566
+ if (row.subject?.page != null && !entry.pages.includes(row.subject.page)) entry.pages.push(row.subject.page);
567
+ grouped.set(key, entry);
568
+ }
430
569
  if (row.result !== "unexercised" && row.result !== "excluded") continue;
431
570
  const key = JSON.stringify([row.leg, row.check, row.result, row.reason_code]);
432
571
  const entry = grouped.get(key) || { check: row.check, leg: row.leg, result: row.result, reason_code: row.reason_code, count: 0, pages: [] };
@@ -435,7 +574,10 @@ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date
435
574
  grouped.set(key, entry);
436
575
  }
437
576
  const coverageEntries = [...grouped.values(), ...coverage]
438
- .map((entry) => ({ ...entry, pages: [...entry.pages].sort(compareText) }))
577
+ .map((entry) => {
578
+ const text = entry.text ?? QC_COVERAGE_TEXT[entry.check]?.[entry.reason_code];
579
+ return { ...entry, pages: [...entry.pages].sort(compareText), ...(text ? { text } : {}) };
580
+ })
439
581
  .sort((a, b) => (LEG_ORDER[a.leg] ?? 9) - (LEG_ORDER[b.leg] ?? 9) || compareText(a.check, b.check) || compareText(a.reason_code, b.reason_code) || compareText(a.result, b.result));
440
582
  const openGroups = groupOpenWarnings(open, results);
441
583
  review.sort(byCheckKeyPage);
@@ -473,7 +615,7 @@ export function qcHandoffTextLines(handoff) {
473
615
  : openLine(entry)));
474
616
  section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${entry.crop ? ` crop: ${entry.crop}` : ""}${members(entry)}`);
475
617
  section("Lapsed accepts", handoff.lapsed, (entry) => `${entry.result_ref} ${entry.summary}; accepted by ${entry.accepted_by} at ${entry.accepted_at}; lapsed: ${entry.why}`);
476
- section("Coverage", handoff.coverage, (entry) => `${entry.leg} ${entry.check}: ${entry.result} (${entry.reason_code})${entry.count ? ` x${entry.count}` : ""}${entry.pages.length ? ` on ${entry.pages.join(", ")}` : ""}`);
618
+ section("Coverage", handoff.coverage, (entry) => `${entry.leg} ${entry.check}: ${entry.result} (${entry.reason_code})${entry.count ? ` x${entry.count}` : ""}${entry.pages.length ? ` on ${entry.pages.join(", ")}` : ""}${entry.text ? ` — ${entry.text}` : ""}`);
477
619
  section("Accepted", handoff.accepted, (entry) => `${entry.result_ref} accepted by ${entry.accepted_by} at ${entry.accepted_at}: ${entry.reason}${members(entry)}`);
478
620
  section("Inert accepts", handoff.inert_accepts, (entry) => `${entry.result_id ?? "(no result id)"}: ${entry.why}`);
479
621
  if (lines.length === 1) lines.push(" (no QC results)");
@@ -19,7 +19,12 @@
19
19
  //
20
20
  // The table holds only the shipped QC checks. A stand-in check never comes
21
21
  // from here: tests pass theirs in-process (qcStandIns).
22
+ import { createRequire } from "node:module";
23
+
24
+ const require = createRequire(import.meta.url);
25
+
22
26
  export const QC_CHECK_REGISTRY = Object.freeze({
27
+ "design.access": Object.freeze({ leg: "doctor", unit: "4.1", module: "./parity/design-access.mjs", rederive: "evaluateDesignAccess" }),
23
28
  // Tracking params reach the order
24
29
  "tracking.url": Object.freeze({ leg: "qa", unit: "1.1", module: "./qa-tracking-params.mjs", rederive: "rederiveQcResult" }),
25
30
  "tracking.order": Object.freeze({ leg: "qa", unit: "1.1", module: "./qa-tracking-params.mjs", rederive: "rederiveQcResult" }),
@@ -34,6 +39,10 @@ export const QC_CHECK_REGISTRY = Object.freeze({
34
39
  "policy.availability": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
35
40
  // Text readability
36
41
  "readability.contrast": Object.freeze({ leg: "polish", unit: "2.4", module: "./polish-readability.mjs", rederive: "READABILITY_QC_RULES", record: "readability" }),
42
+ "visual.reference": Object.freeze({ leg: "polish", unit: "3.2", module: "./polish-visual.mjs", rederive: "VISUAL_QC_RULES", record: "captures" }),
43
+ "visual.diff": Object.freeze({ leg: "polish", unit: "3.4", module: "./polish-visual.mjs", rederive: "VISUAL_QC_RULES", record: "captures" }),
44
+ // Element parity against the source HTML
45
+ "parity.element": Object.freeze({ leg: "polish", unit: "4.3", module: "./parity/element-parity-record.mjs", rederive: "ELEMENT_PARITY_QC_RULES", record: "element_parity" }),
37
46
  // Cart placeholders
38
47
  cart_placeholders: Object.freeze({ leg: "doctor", unit: "1.5", module: "./cart-placeholders.mjs", rederive: "evaluateCartPlaceholders" }),
39
48
  // Built-output smoke checks
@@ -58,6 +67,24 @@ function isMissingModule(error, href) {
58
67
 
59
68
  let loaded = null;
60
69
 
70
+ async function elementParityRules(rules) {
71
+ const { planPolishCapture } = await import("./polish-node.mjs");
72
+ return { ...rules, plan: planPolishCapture,
73
+ read(context, activeRules) {
74
+ // QC reads are synchronous. Node >=20.19 supports synchronous loading
75
+ // of this ESM graph; defer it until a selected packet has a mapped
76
+ // route. Loading the registry alone must never load snapshot/probe.
77
+ let applicable = false;
78
+ if (rules.applies(context?.packet)) {
79
+ try { applicable = planPolishCapture({ packet: context.packet, baseUrl: "http://127.0.0.1" })?.routes?.length > 0; } catch { /* reader refuses the invalid plan */ }
80
+ }
81
+ if (!applicable) return rules.read(context, activeRules);
82
+ const { readParityFile, readParityFileForIdentity } = require("./parity/snapshot.mjs");
83
+ return rules.read(context, { ...activeRules, readFile: readParityFile, readFileForIdentity: readParityFileForIdentity });
84
+ },
85
+ };
86
+ }
87
+
61
88
  // Loads every registered module of the given legs once per process and
62
89
  // returns { "<check>": {status: "loaded", rederive} | {status: "missing"} |
63
90
  // {status: "failed", error} }. `importer` resolves a specifier relative to
@@ -79,7 +106,16 @@ export async function loadQcRederivers({ legs = ["qa", "polish"], registry = QC_
79
106
  if (outcome.missing) cache[check] = Object.freeze({ status: "missing" });
80
107
  else if (outcome.error) cache[check] = Object.freeze({ status: "failed", error: String(outcome.error?.message || outcome.error) });
81
108
  else if (outcome.module?.[entry.rederive] == null) cache[check] = Object.freeze({ status: "failed", error: `${entry.module} has no export ${entry.rederive}` });
82
- else cache[check] = Object.freeze({ status: "loaded", rederive: outcome.module[entry.rederive] });
109
+ else {
110
+ // Load the planner after the QC substrate has initialized: polish-node
111
+ // itself uses that substrate, so a static import would form a TDZ cycle.
112
+ // Element parity also reads its probe files through the file module,
113
+ // which the capture node imports only when a plan has a route.
114
+ const rules = outcome.module[entry.rederive];
115
+ const rederive = entry.record === "captures" ? { ...rules, plan: (await import("./polish-node.mjs")).planPolishCapture }
116
+ : entry.record === "element_parity" ? await elementParityRules(rules) : rules;
117
+ cache[check] = Object.freeze({ status: "loaded", rederive });
118
+ }
83
119
  }
84
120
  if (useCache) loaded = cache;
85
121
  return Object.freeze({ ...cache });