@nextcommerce/campaigns-os 1.48.0 → 1.52.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 (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -210,3 +210,74 @@ export function isKnownCheckoutFieldName(value) {
210
210
  return SDK_CHECKOUT_FIELD_NAMES.includes(name)
211
211
  || SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length);
212
212
  }
213
+
214
+ // The Campaign Cart template placeholders, for the raw cart placeholder check
215
+ // (built_output.cart_placeholders). Generated from the SDK renderers at the
216
+ // same tag as the attribute index above, v0.4.38:
217
+ //
218
+ // src/features/cart/cart-summary/cart-summary.renderer.ts:46-83 (bare vars)
219
+ // src/features/cart/cart-summary/cart-summary.line-renderer.ts:150-173,
220
+ // 228-240,256-314 ({item.*} and its {line.*} alias, {property.*},
221
+ // {discount.*})
222
+ // src/features/cart/package-selector/package-selector.renderer.ts:26
223
+ // ({package.<any key>})
224
+ // src/features/cart/bundle-selector/bundle-selector.renderer.ts:37-42
225
+ // ({bundle.<any key>})
226
+ // src/features/cart/package-toggle/package-toggle.enhancer.ts:59-65
227
+ // ({toggle.<any key>})
228
+ // src/features/cart/quantity-control/quantity-control.renderer.ts:69-70
229
+ // ({quantity}, {step})
230
+ // src/features/cart/remove-item/remove-item.renderer.ts:26 ({quantity})
231
+ // src/features/display/quantity-text/quantity-text.enhancer.ts:124,149
232
+ // ({qty...} and {singular|plural})
233
+ //
234
+ // Every namespaced renderer replaces any `{<namespace>.<key>}` in its
235
+ // template (item.property.<key> and the package, bundle and toggle keys are
236
+ // open-ended; an unmapped key renders empty), so the namespace alone makes a
237
+ // token known. `{tax}` is not a cart-summary var at this tag. Regenerate the
238
+ // lists whole from the renderers when the index pin advances; never edit them
239
+ // by hand.
240
+ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
241
+ cart_summary_vars: Object.freeze([
242
+ "subtotal",
243
+ "total",
244
+ "shipping",
245
+ "shippingName",
246
+ "shippingCode",
247
+ "shippingOriginal",
248
+ "shippingDiscountAmount",
249
+ "shippingDiscountPercentage",
250
+ "totalDiscount",
251
+ "totalDiscountPercentage",
252
+ "discounts",
253
+ "currency",
254
+ "isCalculating",
255
+ "isEmpty",
256
+ "itemCount",
257
+ "totalQuantity",
258
+ "isFreeShipping",
259
+ "hasShippingDiscount",
260
+ "hasDiscounts",
261
+ ]),
262
+ namespaces: Object.freeze(["item", "line", "discount", "property", "package", "bundle", "toggle"]),
263
+ // Tokens the SDK substitutes in live markup, and the elements that own them.
264
+ live_tokens: Object.freeze(["quantity", "step", "qty"]),
265
+ // [data-next-quantity="increase|decrease|set"]: {quantity} and {step}.
266
+ quantity_control: Object.freeze({ attribute: "data-next-quantity", values: Object.freeze(["increase", "decrease", "set"]), tokens: Object.freeze(["quantity", "step"]) }),
267
+ // [data-next-remove-item]: {quantity} only.
268
+ remove_item: Object.freeze({ attribute: "data-next-remove-item", tokens: Object.freeze(["quantity"]) }),
269
+ // [data-next-quantity-text]: {qty...} and the singular/plural form.
270
+ // `qty_forms` is the renderer's own {qty} pattern (quantity-text.enhancer.ts:
271
+ // 124, /\{qty([*+\-]?\d*)\}/): {qty}, {qty*2}, {qty+1}, {qty-1}.
272
+ quantity_text: Object.freeze({ attribute: "data-next-quantity-text", tokens: Object.freeze(["qty"]), qty_forms: "qty[*+\\-]?\\d*" }),
273
+ // Item lists whose innerHTML the SDK uses as the row template or replaces
274
+ // (cart-item-list.enhancer.ts:21-35, order-item-list.enhancer.ts:26-36).
275
+ item_list_containers: Object.freeze(["data-next-cart-items", "data-next-order-items"]),
276
+ item_template_selector: "data-item-template-selector",
277
+ item_template: "data-item-template",
278
+ });
279
+
280
+ // The SDK versions whose renderer files above were verified unchanged from
281
+ // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39 and v0.4.40). A page whose
282
+ // loader pins another version cannot pass the placeholder check.
283
+ export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40"]);
@@ -86,7 +86,7 @@ export const SDK_MARKUP_CODES = Object.freeze({
86
86
 
87
87
  // Attributes whose value names a <template> by id. The SDK reads the template
88
88
  // they point at, so that template is SDK-owned wherever it sits.
89
- const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
89
+ export const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
90
90
 
91
91
  // Containers whose DIRECT <template> child the SDK clones (each does a
92
92
  // `:scope > template` lookup at v0.4.38: cart-summary and its
@@ -98,7 +98,7 @@ const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
98
98
  // package-toggle).
99
99
  // Only the direct child: a vendor template nested deeper inside SDK chrome is
100
100
  // never read, so it may use any syntax.
101
- const TEMPLATE_CONTAINER_ATTRIBUTES = [
101
+ export const TEMPLATE_CONTAINER_ATTRIBUTES = [
102
102
  "data-next-cart-summary",
103
103
  "data-summary-lines",
104
104
  "data-next-discounts",
@@ -1,6 +1,7 @@
1
1
  // Read-only source evidence. SDK migration names come exclusively from the supplied manifest.
2
2
  import { parse as parseJs } from 'acorn';
3
3
  import { parse as parseHtml } from 'parse5';
4
+ import { parseDocument, isSeq, isScalar, LineCounter } from 'yaml';
4
5
  import { execFileSync } from 'node:child_process';
5
6
  import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
6
7
  import { resolve, relative, dirname, posix, sep } from 'node:path';
@@ -261,6 +262,35 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
261
262
  visit(ast, null);
262
263
  return findings;
263
264
  }
265
+ // Page Kit's campaign_asset filter serves src/<slug>/assets/<path>, <slug> being
266
+ // the campaign folder the template sits in. A layout's {% for script in scripts %}
267
+ // loop loads each page's frontmatter `scripts:` entries through the same filter.
268
+ const CAMPAIGN_ASSET_SRC = /^\{\{-?\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z_]\w*))\s*\|\s*campaign_asset\s*-?\}\}$/;
269
+ function campaignAssetRoot(path) {
270
+ const match = /^((?:.*\/)?src\/[^/]+)\//.exec(path);
271
+ return match ? `${match[1]}/assets` : null;
272
+ }
273
+ function loopsOverPageScripts(text, name) {
274
+ return new RegExp(`\\{%-?\\s*for\\s+${name}\\s+in\\s+(?:page\\.)?scripts\\b`).test(text);
275
+ }
276
+ // Each local-or-remote entry of a page's frontmatter `scripts:` list with the
277
+ // file line it is declared on, or an error naming why the list cannot be read.
278
+ function frontmatterScripts(text) {
279
+ const block = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
280
+ if (!block) return { scripts: [] };
281
+ const lineCounter = new LineCounter();
282
+ const document = parseDocument(block[1], { lineCounter });
283
+ if (document.errors.length) {
284
+ const { line } = lineCounter.linePos(document.errors[0].pos[0]);
285
+ return { error: `Frontmatter is not valid YAML (${document.errors[0].code}); review the scripts this page loads.`, line: line + 1 };
286
+ }
287
+ const scripts = document.get('scripts', true);
288
+ if (scripts === undefined || scripts === null || (isScalar(scripts) && scripts.value === null)) return { scripts: [] };
289
+ if (!isSeq(scripts) || !scripts.items.every(item => isScalar(item) && typeof item.value === 'string'))
290
+ return { error: 'Frontmatter scripts is not a list of paths; review the scripts this page loads.', line: lineCounter.linePos(scripts.range?.[0] ?? 0).line + 1 };
291
+ // linePos is 1-based within the YAML block, which starts on the file's line 2.
292
+ return { scripts: scripts.items.map(item => ({ value: item.value, line: lineCounter.linePos(item.range[0]).line + 1 })) };
293
+ }
264
294
  export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
265
295
  version(targetSdkVersion);
266
296
  if (!Array.isArray(scope) || !scope.length)
@@ -320,20 +350,50 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
320
350
  for (const child of node.childNodes ?? []) findBase(child);
321
351
  }
322
352
  findBase(document);
353
+ const assetRoot = campaignAssetRoot(path);
354
+ const requireSelected = (sourcePath, line) => {
355
+ if (!selected.includes(sourcePath))
356
+ unknown(path, 'shared-script-outside-scope', sourcePath, line);
357
+ };
358
+ // Page Kit serves campaign_asset paths from the assets folder only; an entry
359
+ // that normalizes outside it is not a file the page loads from there.
360
+ const requireAsset = (asset, line) => {
361
+ const sourcePath = posix.normalize(`${assetRoot}/${asset.split(/[?#]/)[0]}`);
362
+ if (sourcePath.startsWith(`${assetRoot}/`)) requireSelected(sourcePath, line);
363
+ else unknown(path, 'shared-script-outside-scope', `${asset} resolves outside ${assetRoot}`, line);
364
+ };
365
+ if (assetRoot) {
366
+ const frontmatter = frontmatterScripts(text);
367
+ if (frontmatter.error)
368
+ unknown(path, 'shared-script-outside-scope', frontmatter.error, frontmatter.line);
369
+ for (const { value, line } of frontmatter.scripts ?? []) {
370
+ if (/^(?:[a-z]+:)?\/\//i.test(value) || value.startsWith('data:')) continue;
371
+ requireAsset(value, line);
372
+ }
373
+ }
323
374
  function html(node) {
324
375
  if (node.tagName === 'script') {
325
376
  const attrs = Object.fromEntries((node.attrs ?? []).map(a => [a.name, a.value]));
326
377
  const type = (attrs.type ?? '').trim().toLowerCase();
327
378
  if (!type || ['module', 'text/javascript', 'application/javascript', 'text/ecmascript', 'application/ecmascript'].includes(type)) {
328
- if (attrs.src) {
379
+ const campaignAsset = attrs.src ? CAMPAIGN_ASSET_SRC.exec(attrs.src.trim()) : null;
380
+ if (campaignAsset) {
381
+ const line = node.sourceCodeLocation?.startLine ?? 1;
382
+ const literal = campaignAsset[1] ?? campaignAsset[2];
383
+ if (assetRoot && literal !== undefined)
384
+ requireAsset(literal, line);
385
+ else if (!assetRoot || !loopsOverPageScripts(text, campaignAsset[3]))
386
+ unknown(path, 'shared-script-outside-scope', attrs.src, line);
387
+ // A frontmatter scripts loop: each page's own entries are checked above.
388
+ }
389
+ else if (attrs.src) {
329
390
  if (baseHref !== null && !attrs.src.startsWith('/') && !/^[a-z]+:/i.test(attrs.src)) {
330
391
  unknown(path, 'html-base-script-resolution', `Relative script ${attrs.src} resolves against base ${baseHref}; include/review the actual dependency.`, node.sourceCodeLocation?.startLine ?? 1);
331
392
  }
332
393
  if (!/^(?:[a-z]+:)?\/\//i.test(attrs.src) && !attrs.src.startsWith('data:')) {
333
394
  const localSource = attrs.src.split(/[?#]/)[0];
334
395
  const sourcePath = posix.normalize(localSource.startsWith('/') ? localSource.slice(1) : posix.join(posix.dirname(path), localSource));
335
- if (!selected.includes(sourcePath))
336
- unknown(path, 'shared-script-outside-scope', sourcePath, node.sourceCodeLocation?.startLine ?? 1);
396
+ requireSelected(sourcePath, node.sourceCodeLocation?.startLine ?? 1);
337
397
  }
338
398
  }
339
399
  else if (node.sourceCodeLocation?.startTag) {
@@ -8,7 +8,7 @@
8
8
  // source asset crawl (source_asset.* codes) so the two checks never disagree
9
9
  // about the same reference.
10
10
  import { existsSync, readFileSync, statSync } from "node:fs";
11
- import { dirname, resolve } from "node:path";
11
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
12
12
  import { collectDocumentWrapperNames } from "./adapter-decision-contract.mjs";
13
13
 
14
14
  export const SOURCE_PREP_DOCUMENT_WRAPPER = "source_html.prep.document_wrapper";
@@ -201,7 +201,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
201
201
  const policyNote = wrapperPolicy === "preserve_document_wrappers"
202
202
  ? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
203
203
  : "";
204
- return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
204
+ return `Mapped source HTML (the converted page-kit page once it exists at page_kit.output_path, else the design) is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
205
205
  }
206
206
  if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
207
207
  const listed = sample.map((page) => {
@@ -220,6 +220,26 @@ function describeFinding(code, pages, { wrapperPolicy }) {
220
220
  return `Mapped source HTML still links to source files instead of CampaignSpec routes: ${listed}${more}. Replace internal links and CTA destinations with CampaignSpec-derived routes, usually via campaign_link; source filenames like checkout.html are not built campaign URLs. See ${docs}.`;
221
221
  }
222
222
 
223
+ // The converted page-kit page for a mapped design (page_kit.output_path under
224
+ // the target repo), when it has been written.
225
+ function convertedPageContent(targetRoot, page) {
226
+ const outputPath = page?.page_kit?.output_path;
227
+ if (!isNonEmptyString(targetRoot) || !isNonEmptyString(outputPath)) return null;
228
+ // output_path is repo-relative. One that is absolute or climbs out of the
229
+ // target repo is not a converted page of this campaign, so it reads like a
230
+ // missing one and the design is checked instead.
231
+ const root = resolve(targetRoot);
232
+ const fullPath = resolve(root, outputPath);
233
+ const rel = relative(root, fullPath);
234
+ if (isAbsolute(outputPath) || rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
235
+ if (!safeIsFile(fullPath)) return null;
236
+ try {
237
+ return { path: toPosixPath(outputPath), content: readFileSync(fullPath, "utf8") };
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
223
243
  /**
224
244
  * Evaluates the page-kit source-preparation expectations for every mapped
225
245
  * source page. Deterministic: same files in, same findings out. Unreadable or
@@ -227,7 +247,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
227
247
  *
228
248
  * @returns {{ checked_page_count: number, findings: Array<{code, severity, message, docs, pages}> }}
229
249
  */
230
- export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolicy = null }) {
250
+ export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolicy = null, targetRoot = null }) {
231
251
  const mappedPaths = new Set(
232
252
  pages
233
253
  .map((page) => (isNonEmptyString(page?.path) ? toPosixPath(page.path) : null))
@@ -247,14 +267,24 @@ export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolic
247
267
  continue;
248
268
  }
249
269
  checked += 1;
250
- const pageFindings = inspectPageContent({ content, sourceRoot, pagePath: page.path, mappedPaths });
270
+ let pageFindings = inspectPageContent({ content, sourceRoot, pagePath: page.path, mappedPaths });
271
+ // Wrappers are stripped when the design is converted into the page-kit
272
+ // page at page_kit.output_path, the file page-kit builds. Once that page
273
+ // exists, it is the one checked; the design keeps its wrappers.
274
+ const converted = convertedPageContent(targetRoot, page);
275
+ if (converted) {
276
+ pageFindings = pageFindings.filter((finding) => finding.code !== SOURCE_PREP_DOCUMENT_WRAPPER);
277
+ const wrappers = collectDocumentWrapperNames(converted.content);
278
+ if (wrappers.length) pageFindings.push({ code: SOURCE_PREP_DOCUMENT_WRAPPER, wrappers, path: converted.path });
279
+ }
251
280
  for (const finding of pageFindings) {
252
281
  if (!byCode.has(finding.code)) byCode.set(finding.code, new Map());
253
282
  const pagesForCode = byCode.get(finding.code);
254
- if (!pagesForCode.has(page.path)) {
255
- pagesForCode.set(page.path, { page_id: page.page_id || null, path: page.path, wrappers: [], hrefs: [], variants: [] });
283
+ const findingPath = finding.path || page.path;
284
+ if (!pagesForCode.has(findingPath)) {
285
+ pagesForCode.set(findingPath, { page_id: page.page_id || null, path: findingPath, wrappers: [], hrefs: [], variants: [] });
256
286
  }
257
- const entry = pagesForCode.get(page.path);
287
+ const entry = pagesForCode.get(findingPath);
258
288
  if (finding.wrappers) entry.wrappers.push(...finding.wrappers);
259
289
  if (finding.hrefs) entry.hrefs.push(...finding.hrefs);
260
290
  if (finding.variant) entry.variants.push({ variant: finding.variant, lines: finding.lines || [] });