@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.
- package/CHANGELOG.md +148 -0
- package/CONTEXT.md +19 -5
- package/README.md +26 -12
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +90 -0
- package/campaign-spec/dist/checkout-flow.js +229 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1146 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +86 -26
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +133 -29
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +4 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +6 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +21 -6
- package/skills/next-campaigns-qa/SKILL.md +5 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +27 -13
- package/src/brand-theme.mjs +44 -8
- package/src/build-brief.mjs +14 -5
- package/src/campaign-identity.mjs +33 -4
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +156 -111
- package/src/commercial-journey.mjs +25 -2
- package/src/consent.mjs +3 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +8 -8
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +212 -19
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +46 -16
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +116 -12
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +9 -4
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +741 -120
- package/src/qa-cart-entry.mjs +107 -20
- package/src/qa-node.mjs +179 -72
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-markup.mjs +41 -1
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +18 -4
- package/src/stage-record.mjs +53 -16
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { specHashOf, specHashesMatch } from "./spec-hash.mjs";
|
|
14
|
+
import { CHECKOUT_FLOW_PAGE_TYPES, upgradeCampaignSpec } from "../campaign-spec/dist/index.js";
|
|
14
15
|
|
|
15
16
|
export const CALC_LABEL = "Campaigns-calculated · before tax";
|
|
16
17
|
export const CALCULATED_PAIR_EVIDENCE = "calculated_pair";
|
|
@@ -213,6 +214,20 @@ function catalogImportedAt(mapDoc) {
|
|
|
213
214
|
?? null;
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
// The page's type in the v5 vocabulary: a v4 Map's `checkout → checkout` chain
|
|
218
|
+
// reads as `checkout_step` pages ahead of the one Checkout (campaigns-os#641).
|
|
219
|
+
const upgradedMaps = new WeakMap();
|
|
220
|
+
function v5PageType(mapDoc, page) {
|
|
221
|
+
if (!mapDoc || typeof mapDoc !== "object") return page?.type ?? null;
|
|
222
|
+
if (!upgradedMaps.has(mapDoc)) upgradedMaps.set(mapDoc, upgradeCampaignSpec(mapDoc));
|
|
223
|
+
const upgraded = upgradedMaps.get(mapDoc);
|
|
224
|
+
for (const funnel of array(upgraded?.funnels)) {
|
|
225
|
+
const hit = array(funnel?.pages).find((candidate) => present(page?.id) && String(candidate?.id) === String(page.id));
|
|
226
|
+
if (hit) return hit.type ?? null;
|
|
227
|
+
}
|
|
228
|
+
return page?.type ?? null;
|
|
229
|
+
}
|
|
230
|
+
|
|
216
231
|
function descriptor(page, mapDoc, role, lines, options = {}) {
|
|
217
232
|
const funnel = findFunnel(mapDoc, page);
|
|
218
233
|
const suffix = options.id_suffix ? `:${options.id_suffix}` : "";
|
|
@@ -221,7 +236,7 @@ function descriptor(page, mapDoc, role, lines, options = {}) {
|
|
|
221
236
|
upsell: page?.type === "upsell" || page?.type === "downsell",
|
|
222
237
|
context: {
|
|
223
238
|
page_id: page?.id ?? null,
|
|
224
|
-
page_type: page
|
|
239
|
+
page_type: v5PageType(mapDoc, page),
|
|
225
240
|
page_label: page?.label ?? page?.name ?? page?.id ?? null,
|
|
226
241
|
page_order: Number(page?.order) || 0,
|
|
227
242
|
funnel_id: funnel?.id ?? null,
|
|
@@ -1045,8 +1060,16 @@ function acceptDelta(page) {
|
|
|
1045
1060
|
return moneyFact(signedCents(cents), total.state);
|
|
1046
1061
|
}
|
|
1047
1062
|
|
|
1063
|
+
// The checkout total is the cart the shopper pays for at the Checkout. That cart
|
|
1064
|
+
// is priced on whichever checkout-flow page carries it: the select page of a
|
|
1065
|
+
// two-step flow, the first step of a split checkout, or the Checkout itself
|
|
1066
|
+
// (campaigns-os#641). The Checkout of a multi-step path carries no packages, so
|
|
1067
|
+
// looking for the page typed "checkout" priced nothing on those flows. Only
|
|
1068
|
+
// pages that declare packages are planned, so every page here carries a cart;
|
|
1069
|
+
// pages arrive sorted by page_order, and with several paths the first is
|
|
1070
|
+
// summarized.
|
|
1048
1071
|
function journeySummary(pages) {
|
|
1049
|
-
const checkout = pages.find((page) => page.page_type
|
|
1072
|
+
const checkout = pages.find((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.page_type));
|
|
1050
1073
|
const checkoutTotal = checkout?.representative_total
|
|
1051
1074
|
|| unresolvedFact("Unresolved: checkout scenario unavailable");
|
|
1052
1075
|
const bumps = pages.flatMap((page) => page.bumps.map((bump) => ({ ...bump, page_id: page.page_id })));
|
package/src/consent.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
|
+
import { timeOperatorWait } from "./lifecycle.mjs";
|
|
17
18
|
|
|
18
19
|
export const TELEMETRY_ENV_VAR = "CAMPAIGNS_OS_TELEMETRY";
|
|
19
20
|
export const TELEMETRY_CONFIG_SCHEMA = "campaigns-os-telemetry-config/v0";
|
|
@@ -337,9 +338,9 @@ export async function promptAndPersistConsent({
|
|
|
337
338
|
if (existing.resolved) return { ...existing, prompted: false };
|
|
338
339
|
if (!isTTY) return { state: "off", source: "default", resolved: false, prompted: false };
|
|
339
340
|
|
|
340
|
-
const answer = await ask(
|
|
341
|
+
const answer = await timeOperatorWait(() => ask(
|
|
341
342
|
"Campaigns OS can send build telemetry and minimal stage observations to Next Commerce to improve templates, tools, and guidance; stage observations support Workspace progress. Share telemetry from this machine? [Y/n] (change any time): ",
|
|
342
|
-
);
|
|
343
|
+
));
|
|
343
344
|
const normalized = String(answer || "").trim().toLowerCase();
|
|
344
345
|
const state = (normalized === "" || ["y", "yes", "1", "true", "on"].includes(normalized)) ? "on" : "off";
|
|
345
346
|
writeConsentConfig(state, { configPath, proxyBase, source: "prompt", now });
|
|
@@ -2085,7 +2085,7 @@ function sourceReferenceKindForManifestFile(file) {
|
|
|
2085
2085
|
function normalizedPageRole(type) {
|
|
2086
2086
|
const normalized = slugify(type || "page");
|
|
2087
2087
|
if (["landing", "product", "presell", "advertorial"].includes(normalized)) return "landing";
|
|
2088
|
-
if (["checkout", "select", "cart"].includes(normalized)) return "checkout";
|
|
2088
|
+
if (["checkout", "checkout-step", "checkout_step", "select", "cart"].includes(normalized)) return "checkout";
|
|
2089
2089
|
if (["upsell", "oto", "one-time-offer"].includes(normalized)) return "upsell";
|
|
2090
2090
|
if (["downsell", "downsell-offer"].includes(normalized)) return "downsell";
|
|
2091
2091
|
if (["receipt", "thank-you", "thankyou", "confirmation"].includes(normalized)) return "receipt";
|
|
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { accessSync, constants as fsConstants, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { writeThemeArtifacts } from "./brand-theme.mjs";
|
|
7
|
-
import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
|
|
7
|
+
import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, portableArtifactPaths, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
|
|
8
8
|
import { DESIGN_SOURCE_PACKAGE_REL_PATH, createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, serializeDesignSourcePackage, synthesizeHtmlFunnelDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
|
|
9
9
|
import { stampDoctorProducer } from "./doctor-sidecar.mjs";
|
|
10
10
|
import { stageWriteInputs } from "./input-currency.mjs";
|
|
@@ -21,7 +21,7 @@ import { resolveTemplateFamilyDesignSource } from "./template-reference.mjs";
|
|
|
21
21
|
// states never count as accumulated agent evidence. setup's seed state may
|
|
22
22
|
// legitimately be "skipped" (scaffold already present); every other stage is
|
|
23
23
|
// seeded "pending" with empty ledger arrays.
|
|
24
|
-
function assemblyReportStagesWithEvidence(existingReport) {
|
|
24
|
+
export function assemblyReportStagesWithEvidence(existingReport) {
|
|
25
25
|
const stages = existingReport?.stages;
|
|
26
26
|
if (!isObject(stages)) return [];
|
|
27
27
|
const withEvidence = [];
|
|
@@ -69,7 +69,7 @@ function guardAssemblyReportOverwrite(reportPath, args, { announced = null } = {
|
|
|
69
69
|
throw new Error(
|
|
70
70
|
`Assembly report at ${reportPath} ${announced ? "gained stage evidence while prepare-build was running" : "already carries stage evidence"} (${stageKeys.join(", ")}). `
|
|
71
71
|
+ `Rerunning prepare-build/start/build would reset ${stageKeys.length === 1 ? "this stage" : "these stages"} to pending and destroy that evidence. `
|
|
72
|
-
+ `Pass --force to overwrite (destructive).`,
|
|
72
|
+
+ `If the brief or CampaignSpec changed, run record brief or record spec instead to bind it while keeping that evidence. Pass --force only to overwrite (destructive).`,
|
|
73
73
|
);
|
|
74
74
|
}
|
|
75
75
|
const unannounced = announced ? stageKeys.filter((key) => !announced.includes(key)) : stageKeys;
|
|
@@ -979,10 +979,10 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
|
|
|
979
979
|
: [],
|
|
980
980
|
);
|
|
981
981
|
publishPrepareBuildJsonOutputs([
|
|
982
|
-
{ label: "Build Packet", path: packetPath, value: packet },
|
|
983
|
-
{ label: "Campaign Build Brief", path: briefPath, value: brief },
|
|
984
|
-
{ label: "Build Context", path: contextPath, value: context },
|
|
985
|
-
{ label: "Assembly Report", path: reportPath, value: report },
|
|
982
|
+
{ label: "Build Packet", path: packetPath, value: portableArtifactPaths(packet, targetRepo, { artifactPath: packetPath }) },
|
|
983
|
+
{ label: "Campaign Build Brief", path: briefPath, value: portableArtifactPaths(brief, targetRepo, { artifactPath: briefPath }) },
|
|
984
|
+
{ label: "Build Context", path: contextPath, value: portableArtifactPaths(context, targetRepo, { artifactPath: contextPath }) },
|
|
985
|
+
{ label: "Assembly Report", path: reportPath, value: portableArtifactPaths(report, targetRepo, { artifactPath: reportPath }) },
|
|
986
986
|
], prepareBuildCollisionPaths, {
|
|
987
987
|
beforePublish: recheckStageEvidence,
|
|
988
988
|
});
|
|
@@ -997,7 +997,7 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
|
|
|
997
997
|
// stamped with the intake command that ran doctor (`start` or `build`,
|
|
998
998
|
// #312) without a second write path for it.
|
|
999
999
|
publishPrepareBuildJsonOutputs([
|
|
1000
|
-
{ label: "Doctor Output", path: doctorOutPath, value: stampDoctorProducer(doctor, command) },
|
|
1000
|
+
{ label: "Doctor Output", path: doctorOutPath, value: portableArtifactPaths(stampDoctorProducer(doctor, command), targetRepo) },
|
|
1001
1001
|
], prepareBuildCollisionPaths);
|
|
1002
1002
|
},
|
|
1003
1003
|
}));
|
package/src/deviation.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const DEVIATION_JOURNAL_REL_PATH = ".campaign-runtime/agent-deviations.js
|
|
|
15
15
|
|
|
16
16
|
// Commands that advance the pipeline. Read-only / bookkeeping commands
|
|
17
17
|
// (doctor, next, findings, telemetry, run, validate-*) never deviate.
|
|
18
|
-
export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["
|
|
18
|
+
export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["prepare-build", "theme", "polish", "qa", "run-record"]));
|
|
19
19
|
|
|
20
20
|
// Setup and metadata subcommands of a tracked command. They produce no stage
|
|
21
21
|
// output, so running one outside the recommendation is not a detour:
|
|
@@ -77,6 +77,7 @@ export function buildRecommendation({ stage, status, expectedCommands, now = new
|
|
|
77
77
|
* recommendation. Returns a deviation entry or null.
|
|
78
78
|
*/
|
|
79
79
|
export function detectDeviation({ lastRecommendation, command, subcommand = null, argvShape = [], runId = null, deviationReason = null, now = new Date() }) {
|
|
80
|
+
if (argvShape.includes("--help")) return null;
|
|
80
81
|
if (!TRACKED_STAGE_COMMANDS.has(command)) return null;
|
|
81
82
|
if (subcommand && UNTRACKED_SUBCOMMANDS.has(`${command} ${subcommand}`)) return null;
|
|
82
83
|
if (!lastRecommendation || !Array.isArray(lastRecommendation.expected_commands)) return null;
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -93,6 +93,7 @@ import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvena
|
|
|
93
93
|
import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
|
|
94
94
|
import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
|
|
95
95
|
import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
96
|
+
import { assemblyReportStagesWithEvidence } from "../design-source-publication.mjs";
|
|
96
97
|
import {
|
|
97
98
|
assemblySourcePackageFingerprintMissing,
|
|
98
99
|
assessAssemblySourcePackageFreshnessWaivers,
|
|
@@ -108,7 +109,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
|
|
|
108
109
|
// `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
|
|
109
110
|
// node engine in package.json without type-stripping. build runs on `prepare`,
|
|
110
111
|
// so a fresh install (including the git-ref consumer) always has dist.
|
|
111
|
-
import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
112
|
+
import { CHECKOUT_FLOW_PAGE_TYPES, checkoutPathFrom, isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules, upgradeCampaignSpec } from "../../campaign-spec/dist/index.js";
|
|
112
113
|
import { cmd, asInvocation } from "../install-invocation.mjs";
|
|
113
114
|
import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
|
|
114
115
|
import {
|
|
@@ -237,8 +238,12 @@ const US_MARKET_COPY_PATTERNS = [
|
|
|
237
238
|
const HARDCODED_CURRENCY_REGEX = /\$\s?\d[\d,]*(?:\.\d+)?(?:\/[A-Za-z]+)?/g;
|
|
238
239
|
const HARDCODED_PHONE_REGEX = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g;
|
|
239
240
|
|
|
241
|
+
// Pages read through here see the v5 vocabulary: a v4 spec's `checkout → checkout`
|
|
242
|
+
// chain is retyped `checkout_step` on read (campaigns-os#641), so every doctor
|
|
243
|
+
// check asks "is this the page that takes payment" of `type === "checkout"`.
|
|
240
244
|
function normalizeFunnels(spec) {
|
|
241
|
-
|
|
245
|
+
const upgraded = upgradeCampaignSpec(spec);
|
|
246
|
+
if (Array.isArray(upgraded?.funnels)) return upgraded.funnels;
|
|
242
247
|
if (Array.isArray(spec?.funnel_pages)) {
|
|
243
248
|
return [{ id: "default", weight: 100, pages: spec.funnel_pages }];
|
|
244
249
|
}
|
|
@@ -440,7 +445,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
440
445
|
{
|
|
441
446
|
id: SDK_MARKUP,
|
|
442
447
|
phase: "built-output",
|
|
443
|
-
run: ({ packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived),
|
|
448
|
+
run: ({ spec, packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived, spec),
|
|
444
449
|
},
|
|
445
450
|
{
|
|
446
451
|
id: SCRIPT_SYNTAX,
|
|
@@ -542,6 +547,11 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
542
547
|
addIssue(errors, "packet.type", "Build Packet must be a JSON object.");
|
|
543
548
|
return;
|
|
544
549
|
}
|
|
550
|
+
Object.defineProperty(derived, "page_kit_entry_mappings", {
|
|
551
|
+
value: Object.fromEntries((packet.source_html?.pages || [])
|
|
552
|
+
.filter((page) => page?.page_kit?.target_path).map((page) => [page.page_id, page])),
|
|
553
|
+
configurable: true,
|
|
554
|
+
});
|
|
545
555
|
const synthesizedBuiltSite = isSynthesizedBuiltSitePacket(packet);
|
|
546
556
|
if (packet.schema_version !== PACKET_SCHEMA) addIssue(errors, "schema_version", `Expected ${PACKET_SCHEMA}.`);
|
|
547
557
|
else ready.push(`Build Packet schema ${PACKET_SCHEMA}`);
|
|
@@ -832,7 +842,7 @@ function validateAdapterContracts(packet, packetPath, spec, errors, warnings, re
|
|
|
832
842
|
const packetContract = packet.source_html?.adapter_contract;
|
|
833
843
|
validateAdapterDecisionShape(packetContract, "source_html.adapter_contract", warnings, ready, { addIssue });
|
|
834
844
|
validateAdapterSourceFiles({
|
|
835
|
-
decisions: packetContract,
|
|
845
|
+
decisions: buildState.report?.adapter_decisions || buildState.context?.adapter_decisions || packetContract,
|
|
836
846
|
sourceRoot: resolveFromFile(packetPath, packet.source_html?.root),
|
|
837
847
|
pages: packet.source_html?.pages || [],
|
|
838
848
|
warnings,
|
|
@@ -1201,17 +1211,20 @@ function paymentLogoForcedOn(html, method) {
|
|
|
1201
1211
|
// unless the checkout page passes show_<method>=false.
|
|
1202
1212
|
export const STARTER_TEMPLATE_DEFAULT_ON_PAYMENT_METHODS = Object.freeze(["paypal", "klarna", "apple_pay", "google_pay"]);
|
|
1203
1213
|
|
|
1204
|
-
// Built checkout pages on disk for the spec's active
|
|
1205
|
-
// rendered _site/<slug>/<route>/index.html files that
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1214
|
+
// Built checkout-flow pages on disk for the spec's active select, checkout_step
|
|
1215
|
+
// and checkout pages: the rendered _site/<slug>/<route>/index.html files that
|
|
1216
|
+
// exist. Steps and select pages are included on purpose: they carry express
|
|
1217
|
+
// payment buttons upstream of the Checkout (campaigns-os#641), and an
|
|
1218
|
+
// unsupported method rendered there ships just the same. Empty before a build
|
|
1219
|
+
// (or when the spec declares no checkout page), which is the pre-build state
|
|
1220
|
+
// the spec-only advisory covers.
|
|
1208
1221
|
function builtCheckoutPagesForSpec(spec, packet, derived = {}) {
|
|
1209
1222
|
const targetRepo = derived?.target_repo;
|
|
1210
1223
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1211
1224
|
if (!targetRepo || !publicRouteSlug) return [];
|
|
1212
1225
|
const built = [];
|
|
1213
1226
|
for (const page of activeSpecPages(spec)) {
|
|
1214
|
-
if (String(page?.type || page?.page_type || "").toLowerCase().trim()
|
|
1227
|
+
if (!CHECKOUT_FLOW_PAGE_TYPES.includes(String(page?.type || page?.page_type || "").toLowerCase().trim())) continue;
|
|
1215
1228
|
const path = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
|
|
1216
1229
|
if (!path || !existsSync(path) || !statSync(path).isFile()) continue;
|
|
1217
1230
|
built.push({ page_id: page.id, path, file: relative(targetRepo, path).split(sep).join("/") });
|
|
@@ -2147,6 +2160,10 @@ export function validateCampaignIdentity(packet, errors, ready, derived, spec =
|
|
|
2147
2160
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2148
2161
|
},
|
|
2149
2162
|
pages,
|
|
2163
|
+
expectedApiKey: (() => {
|
|
2164
|
+
const resolved = resolveCampaignsApiKeySource(packet, null, process.env, { spec });
|
|
2165
|
+
return resolved.key ? { key: resolved.key, source: resolved.origin } : null;
|
|
2166
|
+
})(),
|
|
2150
2167
|
errors,
|
|
2151
2168
|
ready,
|
|
2152
2169
|
derived,
|
|
@@ -2290,8 +2307,8 @@ function readBoundedScript(realSiteRoot, path, maxBytes) {
|
|
|
2290
2307
|
}
|
|
2291
2308
|
}
|
|
2292
2309
|
|
|
2293
|
-
function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
|
|
2294
|
-
const gate = evaluateCampaignIdentity({ subject, pages });
|
|
2310
|
+
function recordCampaignIdentityGate({ subject, pages, expectedApiKey = null, errors, ready, derived }) {
|
|
2311
|
+
const gate = evaluateCampaignIdentity({ subject, pages, expectedApiKey });
|
|
2295
2312
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
2296
2313
|
|
|
2297
2314
|
if (gate.status === "blocked") {
|
|
@@ -2315,17 +2332,23 @@ function recordCampaignIdentityGate({ subject, pages, errors, ready, derived })
|
|
|
2315
2332
|
// Static SDK markup checks (#303). Every doctor invocation, both entry points,
|
|
2316
2333
|
// filesystem enumeration, blocking regardless of stage status — the same
|
|
2317
2334
|
// contract as the two gates above, for the same reasons.
|
|
2318
|
-
function validateSdkMarkup(packet, errors, warnings, ready, derived) {
|
|
2335
|
+
function validateSdkMarkup(packet, errors, warnings, ready, derived, spec = null) {
|
|
2319
2336
|
const targetRepo = derived.target_repo;
|
|
2320
2337
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2321
2338
|
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2322
2339
|
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
2340
|
+
const declaredTypes = new Map(activeSpecPages(spec).filter((page) => ["receipt", "thankyou"].includes(page.type)).map((page) => [
|
|
2341
|
+
builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived), "receipt",
|
|
2342
|
+
]));
|
|
2323
2343
|
recordSdkMarkupGate({
|
|
2324
2344
|
subject: {
|
|
2325
2345
|
public_route_slug: publicRouteSlug || null,
|
|
2326
2346
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2327
2347
|
},
|
|
2328
|
-
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo)
|
|
2348
|
+
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo).map((page) => ({
|
|
2349
|
+
...page,
|
|
2350
|
+
page_type: declaredTypes.get(resolve(targetRepo, page.file)) || null,
|
|
2351
|
+
})) : [],
|
|
2329
2352
|
errors,
|
|
2330
2353
|
warnings,
|
|
2331
2354
|
ready,
|
|
@@ -2805,7 +2828,7 @@ function bumpDisplaysPresent(block, displays) {
|
|
|
2805
2828
|
return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
|
|
2806
2829
|
}
|
|
2807
2830
|
|
|
2808
|
-
const CHECKOUT_BUMP_PAGE_TYPES = new Set(
|
|
2831
|
+
const CHECKOUT_BUMP_PAGE_TYPES = new Set(CHECKOUT_FLOW_PAGE_TYPES);
|
|
2809
2832
|
|
|
2810
2833
|
export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
|
|
2811
2834
|
const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
|
|
@@ -2990,6 +3013,11 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
|
|
|
2990
3013
|
|
|
2991
3014
|
function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
2992
3015
|
if (!targetRepo || !publicRouteSlug) return null;
|
|
3016
|
+
const entryRoute = entryPageKitServingRoute(targetRepo, publicRouteSlug, page, {
|
|
3017
|
+
targetOutputDir: derived?.target_output_dir,
|
|
3018
|
+
mapping: derived?.page_kit_entry_mappings?.[page?.id],
|
|
3019
|
+
});
|
|
3020
|
+
if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
|
|
2993
3021
|
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
|
|
2994
3022
|
const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
|
|
2995
3023
|
if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
@@ -2997,6 +3025,63 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
|
2997
3025
|
return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
2998
3026
|
}
|
|
2999
3027
|
|
|
3028
|
+
export function isRootRoutedEntryPage(page, publicRouteSlug) {
|
|
3029
|
+
return Boolean(page?.is_entry) && runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug) === "";
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
// An explicit permalink belongs to this entry's Page Kit source file. It wins
|
|
3033
|
+
// even when an older build left output at the filename-derived route.
|
|
3034
|
+
export function entryPageKitServingRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
|
|
3035
|
+
if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
|
|
3036
|
+
const permalink = entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping);
|
|
3037
|
+
if (permalink !== null) return { route: permalink, source: "permalink" };
|
|
3038
|
+
const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3039
|
+
if (existsSync(root) && statSync(root).isFile()) return { route: "", source: "root" };
|
|
3040
|
+
const fileRoute = entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir, mapping });
|
|
3041
|
+
return fileRoute ? { route: `${fileRoute}/`, source: "file" } : { route: "", source: "declared" };
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
function entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) {
|
|
3045
|
+
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
3046
|
+
const names = mapping?.page_kit?.target_path
|
|
3047
|
+
? [mapping.page_kit.target_path]
|
|
3048
|
+
: [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
|
|
3049
|
+
const routes = names
|
|
3050
|
+
.filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name))
|
|
3051
|
+
.map((name) => join(targetOutputDir, name))
|
|
3052
|
+
.filter((path) => existsSync(path) && statSync(path).isFile())
|
|
3053
|
+
.map((path) => extractFrontmatterValue(readFileSync(path, "utf8"), "permalink"))
|
|
3054
|
+
.filter(isNonEmptyString)
|
|
3055
|
+
.map((value) => stripPublicRoutePrefix(normalizePageKitRoute(value), publicRouteSlug));
|
|
3056
|
+
return routes[0] ?? null;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// Page Kit serves a page file without a permalink at its basename route.
|
|
3060
|
+
// Only the recorded target file (or the family's own materialised stock file)
|
|
3061
|
+
// may identify an entry fallback; an unrelated built index is not evidence.
|
|
3062
|
+
export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
|
|
3063
|
+
if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
|
|
3064
|
+
if (entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) !== null) return null;
|
|
3065
|
+
const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3066
|
+
if (existsSync(root) && statSync(root).isFile()) return null;
|
|
3067
|
+
let names;
|
|
3068
|
+
if (mapping?.page_kit?.target_path) {
|
|
3069
|
+
names = [mapping.page_kit.target_path];
|
|
3070
|
+
} else {
|
|
3071
|
+
if (!targetOutputDir) return null;
|
|
3072
|
+
names = [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
|
|
3073
|
+
}
|
|
3074
|
+
const routes = names
|
|
3075
|
+
.filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name) && name !== "index.html")
|
|
3076
|
+
.filter((name) => mapping?.page_kit || (existsSync(join(targetOutputDir, name)) && statSync(join(targetOutputDir, name)).isFile()))
|
|
3077
|
+
.map((name) => name.slice(0, -".html".length))
|
|
3078
|
+
.filter((route) => {
|
|
3079
|
+
const built = join(targetRepo, "_site", publicRouteSlug, route, "index.html");
|
|
3080
|
+
return existsSync(built) && statSync(built).isFile();
|
|
3081
|
+
});
|
|
3082
|
+
return routes[0] ?? null;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3000
3085
|
function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
|
|
3001
3086
|
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
3002
3087
|
|
|
@@ -3436,7 +3521,7 @@ function validateSpecPublicRoutes(spec, errors, ready) {
|
|
|
3436
3521
|
}
|
|
3437
3522
|
|
|
3438
3523
|
function pageRole(type) {
|
|
3439
|
-
if (["checkout", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
|
|
3524
|
+
if (["checkout", "checkout_step", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
|
|
3440
3525
|
return "visual";
|
|
3441
3526
|
}
|
|
3442
3527
|
|
|
@@ -3540,6 +3625,7 @@ function templateStockDecision(buildState, pageId) {
|
|
|
3540
3625
|
function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
3541
3626
|
const pages = packet.source_html?.pages || [];
|
|
3542
3627
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
3628
|
+
const routeRoot = campaignRouteRoot(packet);
|
|
3543
3629
|
const materialisedTemplateStock = [];
|
|
3544
3630
|
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
3545
3631
|
validateSourceHtmlManifestAtRoot(sourceRoot, {
|
|
@@ -3567,6 +3653,20 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3567
3653
|
}
|
|
3568
3654
|
mappedIds.add(page.page_id);
|
|
3569
3655
|
const specPage = activeById.get(page.page_id);
|
|
3656
|
+
const entryRoute = specPage
|
|
3657
|
+
? entryPageKitServingRoute(derived.target_repo, publicRouteSlug, specPage, {
|
|
3658
|
+
targetOutputDir: derived.target_output_dir,
|
|
3659
|
+
mapping: page,
|
|
3660
|
+
})
|
|
3661
|
+
: null;
|
|
3662
|
+
const builtRoute = entryRoute ? entryRoute.route : specPage ? publicRouteForPage(specPage) : null;
|
|
3663
|
+
const rootOutput = entryRoute?.route ? join(derived.target_repo, "_site", publicRouteSlug, "index.html") : null;
|
|
3664
|
+
if (rootOutput && (entryRoute.source === "file" || entryRoute.source === "permalink")
|
|
3665
|
+
&& !(existsSync(rootOutput) && statSync(rootOutput).isFile())) {
|
|
3666
|
+
const emittedRoute = `${routeRoot}${entryRoute.route}`;
|
|
3667
|
+
const pageKitRoot = `/${normalizePublicRouteSlug(publicRouteSlug)}/`;
|
|
3668
|
+
addIssue(warnings, "entry_route.unserved", `CampaignSpec entry page "${specPage.id}" is routed to ${routeRoot}, but Page Kit emitted it at ${emittedRoute} and nothing serves ${routeRoot}. Add a redirect from ${routeRoot} to ${emittedRoute}, or set \`permalink: ${pageKitRoot}\` (the campaign root as Page Kit emits it) on the entry page.`, { page_id: specPage.id, root_route: routeRoot, emitted_route: emittedRoute });
|
|
3669
|
+
}
|
|
3570
3670
|
if (!activeIds.has(page.page_id)) {
|
|
3571
3671
|
addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
|
|
3572
3672
|
}
|
|
@@ -3601,7 +3701,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3601
3701
|
page_id: specPage.id,
|
|
3602
3702
|
type: specPage.type || "page",
|
|
3603
3703
|
role: pageRole(specPage.type),
|
|
3604
|
-
route:
|
|
3704
|
+
route: builtRoute,
|
|
3605
3705
|
source_path: page.path,
|
|
3606
3706
|
});
|
|
3607
3707
|
}
|
|
@@ -3617,7 +3717,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3617
3717
|
page_id: specPage.id,
|
|
3618
3718
|
type: specPage.type || "page",
|
|
3619
3719
|
role: pageRole(specPage.type),
|
|
3620
|
-
route:
|
|
3720
|
+
route: builtRoute,
|
|
3621
3721
|
source_path: null,
|
|
3622
3722
|
template_stock: true,
|
|
3623
3723
|
template_family: family,
|
|
@@ -3946,6 +4046,98 @@ function packageRefsFromEntries(entries) {
|
|
|
3946
4046
|
.filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
|
|
3947
4047
|
.map((value) => String(value));
|
|
3948
4048
|
}
|
|
4049
|
+
|
|
4050
|
+
function supportsCheckoutVariantSlots(agentContract) {
|
|
4051
|
+
const required = frontmatterList(agentContract, "requiredWhenCloning");
|
|
4052
|
+
return required.includes("variant_slots[].id") && required.includes("variant_slots[].quantity");
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
function selectableVariantMatrixCount(packages) {
|
|
4056
|
+
const selectable = Array.isArray(packages)
|
|
4057
|
+
? packages.filter((pkg) => pkg && pkg.is_order_bump !== true && pkg.is_upsell !== true)
|
|
4058
|
+
: [];
|
|
4059
|
+
const quantityRows = selectable.filter((pkg) => String(pkg.qty ?? "").trim());
|
|
4060
|
+
if (quantityRows.length <= 1) return 0;
|
|
4061
|
+
const attributeIdentity = (pkg) => {
|
|
4062
|
+
if (!Array.isArray(pkg.variant_attributes) || pkg.variant_attributes.length === 0) return null;
|
|
4063
|
+
const attributes = pkg.variant_attributes.map((attribute) => {
|
|
4064
|
+
const value = String(attribute?.value ?? "").trim();
|
|
4065
|
+
if (!value) return null;
|
|
4066
|
+
return [String(attribute?.code || attribute?.name || "").trim(), value];
|
|
4067
|
+
});
|
|
4068
|
+
return attributes.every(Boolean) ? JSON.stringify(attributes.sort(([a], [b]) => a.localeCompare(b))) : null;
|
|
4069
|
+
};
|
|
4070
|
+
const textIdentity = (key) => (pkg) => {
|
|
4071
|
+
const value = pkg[key];
|
|
4072
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
4073
|
+
};
|
|
4074
|
+
for (const identity of [attributeIdentity, textIdentity("product_variant_name")]) {
|
|
4075
|
+
const values = quantityRows.map(identity);
|
|
4076
|
+
if (!values.every(Boolean)) continue;
|
|
4077
|
+
const quantitiesByVariant = new Map();
|
|
4078
|
+
for (const [index, value] of values.entries()) {
|
|
4079
|
+
const qty = String(quantityRows[index].qty).trim();
|
|
4080
|
+
if (!quantitiesByVariant.has(value)) quantitiesByVariant.set(value, new Set());
|
|
4081
|
+
quantitiesByVariant.get(value).add(qty);
|
|
4082
|
+
}
|
|
4083
|
+
return quantitiesByVariant.size > 1 && [...quantitiesByVariant.values()].some((quantities) => quantities.size > 1)
|
|
4084
|
+
? quantitiesByVariant.size
|
|
4085
|
+
: 0;
|
|
4086
|
+
}
|
|
4087
|
+
return 0;
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, report) {
|
|
4091
|
+
const selectedContract = catalog.families?.[family]?.agentContract;
|
|
4092
|
+
if (
|
|
4093
|
+
!selectedContract ||
|
|
4094
|
+
!isCertifiedTemplateFamily(family, catalog) ||
|
|
4095
|
+
!frontmatterList(selectedContract, "requiredWhenCloning").includes("packages.main_package") ||
|
|
4096
|
+
supportsCheckoutVariantSlots(selectedContract)
|
|
4097
|
+
) return;
|
|
4098
|
+
const fittingFamilies = [...certifiedTemplateFamilies(catalog)]
|
|
4099
|
+
.filter((candidate) => supportsCheckoutVariantSlots(catalog.families[candidate]?.agentContract))
|
|
4100
|
+
.sort();
|
|
4101
|
+
const checkoutPages = specPages.filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type));
|
|
4102
|
+
const byFunnel = new Map();
|
|
4103
|
+
for (const page of checkoutPages) {
|
|
4104
|
+
if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
|
|
4105
|
+
byFunnel.get(page.funnel_id).push(page);
|
|
4106
|
+
}
|
|
4107
|
+
const paths = new Map();
|
|
4108
|
+
for (const page of checkoutPages) {
|
|
4109
|
+
const pages = byFunnel.get(page.funnel_id);
|
|
4110
|
+
const path = checkoutPathFrom(pages, page) || [page];
|
|
4111
|
+
const end = path[path.length - 1];
|
|
4112
|
+
const key = `${page.funnel_id}:${end.id}`;
|
|
4113
|
+
if (!paths.has(key)) paths.set(key, []);
|
|
4114
|
+
const pathPages = paths.get(key);
|
|
4115
|
+
for (const candidate of path) {
|
|
4116
|
+
if (!pathPages.includes(candidate)) pathPages.push(candidate);
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
const hasStageEvidence = assemblyReportStagesWithEvidence(report).length > 0;
|
|
4120
|
+
const multiStep = [...paths.values()].some((pathPages) =>
|
|
4121
|
+
pathPages.some((candidate) => candidate.type === "checkout_step" || candidate.type === "select"));
|
|
4122
|
+
for (const pathPages of paths.values()) {
|
|
4123
|
+
const page = pathPages.find((candidate) => selectableVariantMatrixCount(candidate.packages) > 1);
|
|
4124
|
+
if (!page) continue;
|
|
4125
|
+
const variantCount = selectableVariantMatrixCount(page.packages);
|
|
4126
|
+
const shapeMatches = fittingFamilies.filter((candidate) => Boolean(catalog.families[candidate]?.canonicalSurfaces?.selectStep) === multiStep);
|
|
4127
|
+
// The catalog has no base-family field; its family-name prefix is the shared-base convention.
|
|
4128
|
+
const baseMatches = shapeMatches.filter((candidate) => candidate.startsWith(`${family}-`));
|
|
4129
|
+
const ranked = [...baseMatches, ...shapeMatches.filter((candidate) => !baseMatches.includes(candidate))];
|
|
4130
|
+
const action = hasStageEvidence
|
|
4131
|
+
? "The template family is a build-time decision; changing it requires re-running intake with --force (destructive; clears recorded stage evidence)."
|
|
4132
|
+
: "The family is chosen at intake with --template-family.";
|
|
4133
|
+
addIssue(
|
|
4134
|
+
warnings,
|
|
4135
|
+
"template_contract.checkout_package_fit",
|
|
4136
|
+
`Checkout path through page "${page.id}" has ${variantCount} variants, some offered at several quantities, but template family "${family}" has no configurable variant_slots[] checkout surface. ${ranked.length ? `Families that can present it: ${ranked.join(", ")}.` : "No certified family has a matching configurable variant-slot checkout surface."} ${action}`,
|
|
4137
|
+
{ page_id: page.id, funnel_id: page.funnel_id, template_family: family, distinct_product_variants: variantCount, fitting_families: ranked },
|
|
4138
|
+
);
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
3949
4141
|
function offerRefsFromEntries(entries) {
|
|
3950
4142
|
if (!Array.isArray(entries)) return [];
|
|
3951
4143
|
return entries
|
|
@@ -4130,6 +4322,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4130
4322
|
|
|
4131
4323
|
// ADR-003 step 2: template-contract checks ported from the private doctor.
|
|
4132
4324
|
const specPages = activeSpecPages(spec);
|
|
4325
|
+
warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
|
|
4133
4326
|
|
|
4134
4327
|
const mismatchedFamilies = specPages.filter(
|
|
4135
4328
|
(page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
|
|
@@ -4143,7 +4336,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4143
4336
|
}
|
|
4144
4337
|
|
|
4145
4338
|
const checkoutPackageRefs = specPages
|
|
4146
|
-
.filter((page) =>
|
|
4339
|
+
.filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type))
|
|
4147
4340
|
.flatMap((page) => packageRefsFromEntries(page.packages));
|
|
4148
4341
|
if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
|
|
4149
4342
|
addIssue(
|
|
@@ -4258,7 +4451,7 @@ export function validateExitPopContract(contract, spec, family, warnings, ready,
|
|
|
4258
4451
|
const exitPop = contract?.exit_pop;
|
|
4259
4452
|
if (!exitPop || !spec) return;
|
|
4260
4453
|
const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
|
|
4261
|
-
page?.type
|
|
4454
|
+
CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type) && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
|
|
4262
4455
|
));
|
|
4263
4456
|
if (hasGovernedOfferSurface) {
|
|
4264
4457
|
ready.push(`${family} exit-pop/promo-code behavior is governed by CampaignSpec offer-surface fields`);
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readJsonIfExists,
|
|
35
35
|
relFromDir,
|
|
36
36
|
isLocalAbsolutePath,
|
|
37
|
+
portableArtifactPaths,
|
|
37
38
|
addIssue,
|
|
38
39
|
} from "../cli-helpers.mjs";
|
|
39
40
|
import {
|
|
@@ -187,18 +188,19 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
|
|
|
187
188
|
commitAssemblyReport(workspace, (report) => recordDoctorStageOutcome(report, result, {
|
|
188
189
|
command: `campaigns-os ${DOCTOR_PRODUCER}`,
|
|
189
190
|
doctorOutPath: workspace.doctorOutPath,
|
|
191
|
+
targetRepo: workspace.targetRepo,
|
|
190
192
|
}), { stage: "doctor", command: DOCTOR_PRODUCER, refreshDoctor: () => result });
|
|
191
193
|
}
|
|
192
194
|
return result;
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
function recordDoctorStageOutcome(report, result, { command, doctorOutPath }) {
|
|
197
|
+
function recordDoctorStageOutcome(report, result, { command, doctorOutPath, targetRepo }) {
|
|
196
198
|
return recordProducerStageOutcome(report, {
|
|
197
199
|
stage: "doctor",
|
|
198
200
|
disposition: result.ok ? (result.warnings?.length ? "ready_with_warnings" : "ready") : "blocked",
|
|
199
201
|
timestamp: result.generated_at,
|
|
200
202
|
command,
|
|
201
|
-
outputs: [doctorOutPath],
|
|
203
|
+
outputs: portableArtifactPaths([doctorOutPath], targetRepo),
|
|
202
204
|
blockers: (result.errors || []).map((issue) => issue?.message).filter(isNonEmptyString),
|
|
203
205
|
warnings: (result.warnings || []).map((issue) => issue?.message).filter(isNonEmptyString),
|
|
204
206
|
});
|