@nextcommerce/campaigns-os 1.54.0 → 1.57.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 (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  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 +133 -29
  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/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
@@ -26,11 +26,12 @@ import { fileURLToPath } from "node:url";
26
26
  import Ajv2020 from "ajv/dist/2020.js";
27
27
 
28
28
  import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
29
+ import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
29
30
  import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
30
31
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
31
- import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
32
+ import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
32
33
  import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
33
- import { CARRIED_FORWARD } from "./local-preview-policy.mjs";
34
+ import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
34
35
  import { isLoopbackHostname } from "./remit.mjs";
35
36
  import { campaignRouteRoot } from "./route-identity.mjs";
36
37
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
@@ -60,7 +61,7 @@ export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme",
60
61
  const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal", "deviation-reason"]);
61
62
  const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
62
63
  const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
63
- const BUILD_RECORD_FLAGS = Object.freeze(["build-environment"]);
64
+ const BUILD_RECORD_FLAGS = Object.freeze(["build-environment", "adapter-decision"]);
64
65
  // `record brief` saves a brief file: --brief names it (otherwise intake's
65
66
  // discovery finds it).
66
67
  const BRIEF_RECORD_FLAGS = Object.freeze(["brief"]);
@@ -130,10 +131,43 @@ export function refuseRecord(stage, problems) {
130
131
  return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
131
132
  }
132
133
 
134
+ function parseAdapterDecisions(value) {
135
+ const allowedKeys = Object.keys(ADAPTER_DECISION_SCALAR_VALUES).join(", ");
136
+ const reject = (reason, key = null) => {
137
+ const allowedValues = key && Object.hasOwn(ADAPTER_DECISION_SCALAR_VALUES, key)
138
+ ? ` Allowed values for ${key}: ${ADAPTER_DECISION_SCALAR_VALUES[key].join(", ")}.`
139
+ : "";
140
+ throw refused(`${reason} Allowed keys: ${allowedKeys}.${allowedValues}`);
141
+ };
142
+ if (typeof value !== "string" || !value) reject("--adapter-decision requires a comma-separated list of key=value pairs.");
143
+ const decisions = {};
144
+ for (const pair of value.split(",")) {
145
+ if (!pair) reject("--adapter-decision has an empty pair; use key=value pairs separated by commas.");
146
+ const equal = pair.indexOf("=");
147
+ if (equal < 0) reject(`--adapter-decision pair ${JSON.stringify(pair)} must be key=value.`);
148
+ const key = pair.slice(0, equal);
149
+ const choice = pair.slice(equal + 1);
150
+ if (!key || /\s/.test(key)) reject(`--adapter-decision key ${JSON.stringify(key)} must be a nonempty key without whitespace.`);
151
+ if (key === "wrapper_policy") reject("wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; record build cannot change it.");
152
+ if (!Object.hasOwn(ADAPTER_DECISION_SCALAR_VALUES, key)) reject(`Unknown --adapter-decision key ${JSON.stringify(key)}.`);
153
+ if (Object.hasOwn(decisions, key)) reject(`Duplicate --adapter-decision key ${JSON.stringify(key)} in one list.`);
154
+ const allowed = ADAPTER_DECISION_SCALAR_VALUES[key];
155
+ if (!choice || /\s/.test(choice) || choice.includes("=") || !allowed.includes(choice)) {
156
+ reject(`--adapter-decision ${key} must be one of: ${allowed.join(", ")} (got ${JSON.stringify(choice)}).`, key);
157
+ }
158
+ decisions[key] = choice;
159
+ }
160
+ return decisions;
161
+ }
162
+
133
163
  export function parseRecordArgs(args) {
134
164
  const stage = args._[1];
165
+ // The shared argv parser leaves an explicit empty value in positional args.
166
+ if (stage === "build" && args["adapter-decision"] === true && args._.length === 3 && args._[2] === "") {
167
+ parseAdapterDecisions("");
168
+ }
135
169
  if (!RECORD_SUBCOMMANDS.includes(stage) || args._.length !== 2) {
136
- throw refused(`Use: ${cmd("record")} <${RECORD_SUBCOMMANDS.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>], and record brief [--brief <yaml|json>].`);
170
+ throw refused(`Use: ${cmd("record")} <${RECORD_SUBCOMMANDS.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>] [--adapter-decision <key=value[,key=value...]>], and record brief [--brief <yaml|json>].`);
137
171
  }
138
172
  const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : []), ...(stage === "brief" ? BRIEF_RECORD_FLAGS : [])]);
139
173
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
@@ -151,6 +185,7 @@ export function parseRecordArgs(args) {
151
185
  if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
152
186
  throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
153
187
  }
188
+ const adapterDecisions = Object.hasOwn(args, "adapter-decision") ? parseAdapterDecisions(args["adapter-decision"]) : null;
154
189
  return {
155
190
  stage,
156
191
  packetPath: resolve(requireArg(args, "packet")),
@@ -158,6 +193,7 @@ export function parseRecordArgs(args) {
158
193
  baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
159
194
  dryRun: args["dry-run"] === true,
160
195
  buildEnvironment,
196
+ adapterDecisions,
161
197
  briefPath: stage === "brief" && Object.hasOwn(args, "brief") ? resolve(args.brief) : null,
162
198
  deviationReason: optionalString(args["deviation-reason"]),
163
199
  };
@@ -291,7 +327,8 @@ function currentStamps(inputs) {
291
327
  return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
292
328
  }
293
329
 
294
- function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, inputs = {}, deviationReason = null }) {
330
+ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
331
+ const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
295
332
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
296
333
  const previousAssembly = stageObject(report, "assembly");
297
334
  const stamps = currentStamps(inputs);
@@ -337,7 +374,7 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
337
374
  const deploy = deployOwed ? { deploy: deployOwed } : {};
338
375
  if (detected) {
339
376
  const cause = detected === "spec_material_changed" ? "spec" : "presentation";
340
- const demoted = demoteStages({ ...report, stages: { ...report.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
377
+ const demoted = demoteStages({ ...nextReport, stages: { ...nextReport.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
341
378
  return { report: demoted.report, context: null };
342
379
  }
343
380
  const previousPolish = stageObject(report, "polish");
@@ -352,7 +389,7 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
352
389
  required_by: "build",
353
390
  required_for: ["qa"],
354
391
  };
355
- return { report: { ...report, stages: { ...report.stages, assembly, polish, ...deploy } }, context: null };
392
+ return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
356
393
  }
357
394
 
358
395
  // stages.deploy after a build of `fingerprint`, or null when it is kept: a
@@ -711,9 +748,8 @@ function ladderProblems(stage, doctor, report) {
711
748
  if (gate) return [`next answers prepare-build: ${gate.reason}`];
712
749
  const problems = [];
713
750
  for (const earlier of NEXT_STAGE_ORDER.slice(0, NEXT_STAGE_ORDER.indexOf(stage))) {
714
- // The rule next's stage picker reads: on the local preview a missing polish
715
- // is carried forward (local-preview-policy), and next moves on to deploy.
716
- if (earlier === "polish" && doctor.derived?.polish_gate?.status === CARRIED_FORWARD) continue;
751
+ // The same carried-forward rule next's stage picker reads.
752
+ if (earlier === "polish" && polishCarriedForwardForLadder(report, doctor.derived?.polish_gate)) continue;
717
753
  // The effective status: a stage owed again by an input change reads
718
754
  // required, and one whose inputs cannot be confirmed reads unknown.
719
755
  const key = reportKeyForCliStage(earlier);
@@ -852,7 +888,7 @@ function readPacketFile(stage, packetPath) {
852
888
  * composed or written.
853
889
  */
854
890
  export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
855
- const { stage, packetPath, evidencePath, dryRun, buildEnvironment, deviationReason } = parseRecordArgs(args);
891
+ const { stage, packetPath, evidencePath, dryRun, buildEnvironment, adapterDecisions, deviationReason } = parseRecordArgs(args);
856
892
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
857
893
  if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
858
894
  // Operator input, not target state: no campaigns-os writer produces it.
@@ -873,7 +909,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
873
909
  // A dry run writes nothing, so it takes no lock and creates no lock files
874
910
  // (the commitAssemblyReport preview convention); it reads in the same order.
875
911
  const run = () => recordUnderLock({
876
- stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment, deviationReason,
912
+ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment, adapterDecisions, deviationReason,
877
913
  });
878
914
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
879
915
  const { composed, facts, layer, reportPath, contextPath, after } = recorded;
@@ -891,6 +927,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
891
927
  `stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
892
928
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
893
929
  ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
930
+ ...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
894
931
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
895
932
  ...(stage === "build" && composed.report.stages.deploy?.required_by === "build" ? [`stages.deploy.status = ${composed.report.stages.deploy.status} (the recorded deploy probed other output; run ${cmd("record")} deploy again)`] : []),
896
933
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
@@ -930,7 +967,7 @@ function recordInputs(doctor) {
930
967
  return { briefMaterial: currency?.brief?.current ?? null, specMaterial: currency?.spec?.current ?? null };
931
968
  }
932
969
 
933
- function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null, deviationReason = null }) {
970
+ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null, adapterDecisions = null, deviationReason = null }) {
934
971
  // The same workspace `next` resolves, so the record lands in the report
935
972
  // `next` reads now, not the one it read before the lock was free.
936
973
  const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
@@ -965,7 +1002,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
965
1002
  const next = stage === "setup"
966
1003
  ? composeSetup(report, context, { now: timestamp, recordedBy })
967
1004
  : stage === "build"
968
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, inputs: recordInputs(doctor), deviationReason })
1005
+ ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
969
1006
  : stage === "theme"
970
1007
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
971
1008
  : stage === "deploy"
@@ -980,8 +1017,8 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
980
1017
  if (dryRun) return null;
981
1018
  // Written inside the report's critical section, after every check and
982
1019
  // before the report itself, so the files move together.
983
- if (next.context) writeJsonAtomic(contextPath, next.context);
984
- if (next.packet) writeJsonAtomic(packetPath, next.packet);
1020
+ if (next.context) writeJsonAtomic(contextPath, portableArtifactPaths(next.context, workspace.targetRepo, { artifactPath: contextPath }));
1021
+ if (next.packet) writeJsonAtomic(packetPath, portableArtifactPaths(next.packet, workspace.targetRepo, { artifactPath: packetPath }));
985
1022
  return next.report;
986
1023
  };
987
1024
  // Already inside the target lock, which commitAssemblyReport re-enters.