@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
package/src/brand-theme.mjs
CHANGED
|
@@ -256,8 +256,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
256
256
|
const lower = name.toLowerCase();
|
|
257
257
|
if (isTargetContractToken(lower)) continue;
|
|
258
258
|
const parts = tokenNameParts(lower);
|
|
259
|
+
const decorativeName = hasTokenPart(parts, DECORATIVE_NAME_PARTS);
|
|
260
|
+
const namedRole = hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
|
|
259
261
|
const ctaName = hasTokenPart(parts, ["cta", "button", "btn"]);
|
|
260
|
-
if (!ctaName && (
|
|
262
|
+
if (!ctaName && (!decorativeName || namedRole)
|
|
263
|
+
&& (hasTokenSequence(parts, ["brand"], ["primary", "main"]) || (hasTokenPart(parts, ["primary"]) && !hasTokenPart(parts, ["text", "foreground", "surface", "background", "bg", "border", "outline"])))) {
|
|
261
264
|
addToken("--brand-primary", color);
|
|
262
265
|
}
|
|
263
266
|
if (ctaName) {
|
|
@@ -281,7 +284,7 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
281
284
|
if (hasTokenPart(parts, ["rating", "star", "review"])) addToken("--rating-star", color);
|
|
282
285
|
}
|
|
283
286
|
|
|
284
|
-
const rulePattern = /([^{}]+)\{([^{}]+)\}/g;
|
|
287
|
+
const rulePattern = /([^{};]+)\{([^{}]+)\}/g;
|
|
285
288
|
for (const match of stripCssComments(content).matchAll(rulePattern)) {
|
|
286
289
|
const selector = match[1] || "";
|
|
287
290
|
for (const declaration of match[2].split(";")) {
|
|
@@ -291,9 +294,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
291
294
|
const color = resolveDeclarationColor(rawValue, rootTokens);
|
|
292
295
|
if (!color) continue;
|
|
293
296
|
if (["background", "background-color"].includes(name)) {
|
|
294
|
-
if (
|
|
297
|
+
if (hasPrimarySelectorRole(selector)
|
|
298
|
+
&& isStrongBrandColor(color)) {
|
|
295
299
|
addToken("--brand-primary", color);
|
|
296
|
-
}
|
|
300
|
+
}
|
|
301
|
+
if (isCtaLikeSelector(eligibleSelectorText(selector)) && isStrongBrandColor(color)) {
|
|
297
302
|
addToken("--button-primary-bg", color);
|
|
298
303
|
addToken("--brand-cta", color);
|
|
299
304
|
addToken("--brand-accent", color);
|
|
@@ -319,6 +324,32 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
319
324
|
return tokens;
|
|
320
325
|
}
|
|
321
326
|
|
|
327
|
+
const DECORATIVE_NAME_PARTS = ["sale", "discount", "badge", "rating", "star", "strike", "border", "announcement"];
|
|
328
|
+
const EXPLICIT_BRAND_PARTS = ["brand", "primary", "cta", "button", "btn"];
|
|
329
|
+
|
|
330
|
+
function selectorSubjectParts(compound) {
|
|
331
|
+
return [...compound.classes, ...compound.ids].flatMap(tokenNameParts);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function subjectHasDecorativeRole(compound) {
|
|
335
|
+
// A button or submit input is a control whatever its classes say.
|
|
336
|
+
if (compound.type === "button") return false;
|
|
337
|
+
if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return false;
|
|
338
|
+
const parts = selectorSubjectParts(compound);
|
|
339
|
+
return hasTokenPart(parts, DECORATIVE_NAME_PARTS) && !hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function eligibleSelectorText(selector) {
|
|
343
|
+
const compounds = rightmostCompounds(selector);
|
|
344
|
+
if (!compounds) return selector;
|
|
345
|
+
return compounds.filter((compound) => !subjectHasDecorativeRole(compound))
|
|
346
|
+
.map((compound) => compound.selectorText).join(", ");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function hasPrimarySelectorRole(selector) {
|
|
350
|
+
return /(header|nav|announcement|brand|hero)/i.test(eligibleSelectorText(selector));
|
|
351
|
+
}
|
|
352
|
+
|
|
322
353
|
function isCtaLikeSelector(selector) {
|
|
323
354
|
return /(?:^|[.#\s:_-])(?:cta|button|btn|submit|cart|buy|order)(?:$|[.#\s:_-])/i.test(String(selector || ""));
|
|
324
355
|
}
|
|
@@ -337,11 +368,12 @@ const SELECTOR_ARGUMENT_PSEUDOS = new Set(["not", "is", "where", "has"]);
|
|
|
337
368
|
function rightmostCompounds(selectorList) {
|
|
338
369
|
const text = String(selectorList || "");
|
|
339
370
|
const compounds = [];
|
|
340
|
-
const fresh = () => ({ type: null, classes: [], attributes: [], pseudos: [] });
|
|
341
|
-
const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.attributes.length && !compound.pseudos.length;
|
|
371
|
+
const fresh = () => ({ type: null, classes: [], ids: [], attributes: [], pseudos: [] });
|
|
372
|
+
const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.ids.length && !compound.attributes.length && !compound.pseudos.length;
|
|
342
373
|
let current = fresh();
|
|
343
374
|
let afterCombinator = false;
|
|
344
375
|
let i = 0;
|
|
376
|
+
let selectorStart = 0;
|
|
345
377
|
const readName = () => {
|
|
346
378
|
const start = i;
|
|
347
379
|
while (i < text.length && (/[A-Za-z0-9_\- -]/.test(text[i]) || text[i] === "\\")) i += text[i] === "\\" ? 2 : 1;
|
|
@@ -371,10 +403,12 @@ function rightmostCompounds(selectorList) {
|
|
|
371
403
|
if (/[\s>+~]/.test(ch)) { afterCombinator = true; i += 1; continue; }
|
|
372
404
|
if (ch === ",") {
|
|
373
405
|
if (isEmpty(current)) return null;
|
|
406
|
+
current.selectorText = text.slice(selectorStart, i);
|
|
374
407
|
compounds.push(current);
|
|
375
408
|
current = fresh();
|
|
376
409
|
afterCombinator = false;
|
|
377
410
|
i += 1;
|
|
411
|
+
selectorStart = i;
|
|
378
412
|
continue;
|
|
379
413
|
}
|
|
380
414
|
if (afterCombinator) { current = fresh(); afterCombinator = false; }
|
|
@@ -383,6 +417,7 @@ function rightmostCompounds(selectorList) {
|
|
|
383
417
|
const name = readName();
|
|
384
418
|
if (!name) return null;
|
|
385
419
|
if (ch === ".") current.classes.push(name);
|
|
420
|
+
else current.ids.push(name);
|
|
386
421
|
} else if (ch === "[") {
|
|
387
422
|
const attribute = readGroup("[", "]");
|
|
388
423
|
if (attribute === null) return null;
|
|
@@ -404,6 +439,7 @@ function rightmostCompounds(selectorList) {
|
|
|
404
439
|
}
|
|
405
440
|
}
|
|
406
441
|
if (isEmpty(current)) return null;
|
|
442
|
+
current.selectorText = text.slice(selectorStart);
|
|
407
443
|
compounds.push(current);
|
|
408
444
|
return compounds;
|
|
409
445
|
}
|
|
@@ -469,7 +505,7 @@ function splitDeclarations(body) {
|
|
|
469
505
|
// declaredCtaForegroundValue.
|
|
470
506
|
function collectCtaLabelRules(content, rootTokens = {}) {
|
|
471
507
|
const rules = [];
|
|
472
|
-
for (const match of stripCssComments(content).matchAll(/([^{}]+)\{([^{}]+)\}/g)) {
|
|
508
|
+
for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
|
|
473
509
|
if (!isButtonSelector(match[1])) continue;
|
|
474
510
|
let color = null;
|
|
475
511
|
let background = null;
|
|
@@ -975,7 +1011,7 @@ function mapTokens(tokens, targetTokens, { rootTokens = {}, ctaLabelRules = [] }
|
|
|
975
1011
|
const hasCta = mappings.some((mapping) => mapping.target === "--brand--color--cta-primary");
|
|
976
1012
|
if (!hasCta && isNonEmptyString(tokens["--brand-accent"])) {
|
|
977
1013
|
addMapping("--brand-accent", "--brand--color--cta-primary", tokens["--brand-accent"].trim(), "medium", { method: "cta-fallback-from-brand-accent" });
|
|
978
|
-
warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-accent as --brand--color--cta-primary."));
|
|
1014
|
+
warnings.push(issue("theme.cta.fallback", "No explicit CTA token or button background was found; using --brand-accent as --brand--color--cta-primary. This may be a rating or decorative accent; confirm the CTA colour against the design."));
|
|
979
1015
|
} else if (!hasCta && isNonEmptyString(tokens["--brand-primary"])) {
|
|
980
1016
|
addMapping("--brand-primary", "--brand--color--cta-primary", tokens["--brand-primary"].trim(), "medium", { method: "cta-fallback-from-brand-primary" });
|
|
981
1017
|
warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-primary as --brand--color--cta-primary."));
|
package/src/build-brief.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import { basename, extname, join, resolve } from "node:path";
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
5
|
|
|
6
|
+
import { CHECKOUT_FLOW_PAGE_TYPES } from "../campaign-spec/dist/index.js";
|
|
6
7
|
import { cmd } from "./install-invocation.mjs";
|
|
7
8
|
import { canonicalJson } from "./polish-capture.mjs";
|
|
8
9
|
import { escapeRegExp } from "./repo-scan.mjs";
|
|
@@ -537,7 +538,7 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
|
|
|
537
538
|
|
|
538
539
|
const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
|
|
539
540
|
const paymentMethods = collectSpecPaymentMethods(spec);
|
|
540
|
-
const hasExitPop = activePages?.some((page) => page
|
|
541
|
+
const hasExitPop = activePages?.some((page) => isCheckoutFlowPage(page) && page?.exit_intent?.enabled === true) === true;
|
|
541
542
|
const hasOrderBump = hasOrderBumpSignals(spec);
|
|
542
543
|
// With no CampaignSpec surface to fill them, the template's promo
|
|
543
544
|
// placeholders are removed: "none" for both.
|
|
@@ -718,8 +719,15 @@ function addQuestion(questions, id, { detail = null, options = [] } = {}) {
|
|
|
718
719
|
});
|
|
719
720
|
}
|
|
720
721
|
|
|
722
|
+
// select, checkout_step and checkout all render the SDK checkout surface, so
|
|
723
|
+
// payment-page features (exit pop, promo input) and the conversion goal apply
|
|
724
|
+
// to each of them (campaigns-os#641).
|
|
725
|
+
function isCheckoutFlowPage(page) {
|
|
726
|
+
return CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type);
|
|
727
|
+
}
|
|
728
|
+
|
|
721
729
|
function inferConversionGoal(activePages = []) {
|
|
722
|
-
const hasCheckout = activePages.some((page) => page
|
|
730
|
+
const hasCheckout = activePages.some((page) => isCheckoutFlowPage(page));
|
|
723
731
|
const hasUpsell = activePages.some((page) => /upsell|downsell/i.test(String(page?.type || "")));
|
|
724
732
|
if (hasCheckout && hasUpsell) return "direct response funnel with post-purchase offers";
|
|
725
733
|
if (hasCheckout) return "single-product direct response funnel";
|
|
@@ -837,9 +845,10 @@ function templatePromoSurfaces({ spec = null, activePages = [] } = {}) {
|
|
|
837
845
|
surfaces.push("promo codes (funnels[].promo_codes)");
|
|
838
846
|
}
|
|
839
847
|
const pages = Array.isArray(activePages) ? activePages : [];
|
|
840
|
-
// Checkout-
|
|
841
|
-
// contract and QA's coupon
|
|
842
|
-
|
|
848
|
+
// Checkout-flow pages only (select, checkout_step, checkout) and enabled:
|
|
849
|
+
// true, the rule hasExitPop, doctor's exit-pop contract and QA's coupon
|
|
850
|
+
// orders use for these surfaces.
|
|
851
|
+
const checkoutSurface = (page, key) => isCheckoutFlowPage(page) && page?.[key]?.enabled === true;
|
|
843
852
|
if (pages.some((page) => checkoutSurface(page, "exit_intent"))) {
|
|
844
853
|
surfaces.push("an exit-intent offer (exit_intent)");
|
|
845
854
|
}
|
|
@@ -45,6 +45,7 @@ export const CAMPAIGN_IDENTITY = "built_output.campaign_identity";
|
|
|
45
45
|
// Finding kinds, each with its own issue code under the gate id.
|
|
46
46
|
export const CAMPAIGN_IDENTITY_KINDS = Object.freeze({
|
|
47
47
|
api_key_drift: `${CAMPAIGN_IDENTITY}.api_key_drift`,
|
|
48
|
+
api_key_mismatch: `${CAMPAIGN_IDENTITY}.api_key_mismatch`,
|
|
48
49
|
funnel_drift: `${CAMPAIGN_IDENTITY}.funnel_drift`,
|
|
49
50
|
funnel_missing: `${CAMPAIGN_IDENTITY}.funnel_missing`,
|
|
50
51
|
attribution_drift: `${CAMPAIGN_IDENTITY}.attribution_drift`,
|
|
@@ -270,7 +271,7 @@ function gateBase(subject) {
|
|
|
270
271
|
* file?: string|null, content: string, spec_page?: boolean,
|
|
271
272
|
* scripts?: Array<{ src: string, file?: string|null, content: string }> }> }} input
|
|
272
273
|
*/
|
|
273
|
-
export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
274
|
+
export function evaluateCampaignIdentity({ subject, pages = [], expectedApiKey = null } = {}) {
|
|
274
275
|
const all = Array.isArray(pages) ? pages : [];
|
|
275
276
|
const skipped = all
|
|
276
277
|
.filter((page) => isParkedPage(page.route ?? page.file ?? page.page_id))
|
|
@@ -298,7 +299,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
298
299
|
// value. Meta and config are compared together on purpose: a page whose
|
|
299
300
|
// meta names one campaign while its config.js names another is drift
|
|
300
301
|
// inside a single page, and the SDK's precedence rule only hides it.
|
|
301
|
-
const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, ...key })));
|
|
302
|
+
const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, page_file: identity.file || identity.page_id, ...key })));
|
|
302
303
|
const firstKey = keyObservations[0] || null;
|
|
303
304
|
for (const observation of keyObservations.slice(1)) {
|
|
304
305
|
if (observation.value === firstKey.value) continue;
|
|
@@ -312,6 +313,32 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
312
313
|
});
|
|
313
314
|
break; // one finding per kind names the first pair; the rest follow from the same edit
|
|
314
315
|
}
|
|
316
|
+
const wrongKeys = new Map();
|
|
317
|
+
if (expectedApiKey?.key) {
|
|
318
|
+
for (const observation of keyObservations) {
|
|
319
|
+
if (observation.value === expectedApiKey.key) continue;
|
|
320
|
+
if (!wrongKeys.has(observation.value)) wrongKeys.set(observation.value, new Map());
|
|
321
|
+
const sources = wrongKeys.get(observation.value);
|
|
322
|
+
const sourceId = `${observation.where}\u0000${observation.source}`;
|
|
323
|
+
if (!sources.has(sourceId)) sources.set(sourceId, { file: observation.where, source: observation.source, pages: new Set() });
|
|
324
|
+
sources.get(sourceId).pages.add(observation.page_file);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const wrongSourceFiles = [...new Set([...wrongKeys.values()].flatMap((sources) => [...sources.values()].map((source) => source.file)))];
|
|
328
|
+
if (wrongKeys.size) {
|
|
329
|
+
const firstWrong = keyObservations.find((observation) => observation.value !== expectedApiKey.key);
|
|
330
|
+
const wrongDetails = [...wrongKeys].map(([value, sources]) =>
|
|
331
|
+
`${[...sources.values()].map(({ file, source, pages }) => `${file} (${source}; used by ${[...pages].join(", ")})`).join(" and ")} has ${quote(value)}`,
|
|
332
|
+
).join("; ");
|
|
333
|
+
findings.push({
|
|
334
|
+
kind: "api_key_mismatch",
|
|
335
|
+
code: CAMPAIGN_IDENTITY_KINDS.api_key_mismatch,
|
|
336
|
+
a: { page_id: null, file: expectedApiKey.source, value: expectedApiKey.key },
|
|
337
|
+
b: { page_id: firstWrong.page_id, file: firstWrong.where, source: firstWrong.source, value: firstWrong.value },
|
|
338
|
+
evidence: `Built API key in ${wrongDetails} differs from the Campaigns API key in ${expectedApiKey.source}.`,
|
|
339
|
+
message: `Built API key in ${wrongDetails} differs from the Campaigns API key ${quote(expectedApiKey.key)} in ${expectedApiKey.source}. Replace the built key in the named source with the campaign's own key, then rebuild.`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
315
342
|
|
|
316
343
|
// 2. next-funnel: one value across pages, and present wherever the SDK is
|
|
317
344
|
// bootstrapped (a page that declares next-page-type is SDK-bound).
|
|
@@ -422,7 +449,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
422
449
|
// Short on purpose; the per-edit prose is findings[].message, which is
|
|
423
450
|
// what each doctor error carries. A reason that concatenates them is
|
|
424
451
|
// truncated by every single-line renderer.
|
|
425
|
-
reason: `${findings.length} campaign identity drift finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the
|
|
452
|
+
reason: `${findings.length} campaign identity ${findings.every((finding) => finding.kind === "api_key_mismatch") ? "API key mismatch" : "drift"} finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the sources and values to repair.`,
|
|
426
453
|
findings,
|
|
427
454
|
pages_scanned: scanned.length,
|
|
428
455
|
pages_skipped: skipped,
|
|
@@ -432,7 +459,9 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
|
432
459
|
id: "repair_identity",
|
|
433
460
|
kind: "edit",
|
|
434
461
|
command: null,
|
|
435
|
-
description:
|
|
462
|
+
description: findings.map((finding) => finding.kind === "api_key_mismatch"
|
|
463
|
+
? `Replace the built key in ${wrongSourceFiles.join(", ")} with the campaign's key, then rebuild.`
|
|
464
|
+
: `Make every page name the same campaign: ${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")}).`).join(" "),
|
|
436
465
|
},
|
|
437
466
|
],
|
|
438
467
|
};
|
package/src/cli-helpers.mjs
CHANGED
|
@@ -79,6 +79,134 @@ function isLocalAbsolutePath(value) {
|
|
|
79
79
|
return isNonEmptyString(value) && !isAbsoluteHttpUrl(value) && isAbsolute(value);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Commit-ready JSON has a finite set of filesystem fields. Route roots and URL
|
|
83
|
+
// paths are deliberately absent: a leading slash does not make one a file.
|
|
84
|
+
function portableArtifactPath(targetRepo, path, { baseDir = targetRepo } = {}) {
|
|
85
|
+
const rel = relative(resolve(baseDir), resolve(path));
|
|
86
|
+
return !rel ? "." : rel.startsWith(".") ? rel : `./${rel}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ARTIFACT_PATH_FIELDS = Object.freeze({
|
|
90
|
+
"campaign-runtime-build-packet/v0": [
|
|
91
|
+
"spec.local_path", "design_source_package.path", "source_html.root",
|
|
92
|
+
"source_html.pages.*.path", "source_html.pages.*.page_kit.target_path",
|
|
93
|
+
"source_html.pages.*.page_kit.output_path", "build_brief.input_path",
|
|
94
|
+
"build_brief.normalized_path", "assembly.target_repo", "assembly.output_dir",
|
|
95
|
+
"assembly.commerce_catalog.path",
|
|
96
|
+
],
|
|
97
|
+
"campaign-runtime-build-context/v0": [
|
|
98
|
+
"packet_path", "report_path", "intake.spec_path", "intake.source_root",
|
|
99
|
+
"intake.target_repo", "intake.brief_path", "intake.design_manifest_path",
|
|
100
|
+
"intake.packet_path", "design_source_package.path", "spec.path", "source.root",
|
|
101
|
+
"build_brief.input_path", "build_brief.normalized_path",
|
|
102
|
+
"page_map.*.source_path", "page_map.*.output_path",
|
|
103
|
+
"page_map.*.page_kit.target_path", "page_map.*.page_kit.output_path",
|
|
104
|
+
"scaffold.target_repo", "scaffold.output_dir", "scaffold.handoff_artifact",
|
|
105
|
+
"theme.source_files.*.path", "theme.generated.css_path", "theme.generated.report_path",
|
|
106
|
+
],
|
|
107
|
+
"campaign-runtime-assembly-report/v0": [
|
|
108
|
+
"inputs.packet_path", "inputs.context_path", "inputs.build_brief_path",
|
|
109
|
+
"inputs.spec_path", "inputs.source.root", "inputs.target_repo",
|
|
110
|
+
"design_source_package.path", "build_brief.input_path", "build_brief.normalized_path",
|
|
111
|
+
// `**` also reaches each stage's archived entries.
|
|
112
|
+
"theme.css_path", "stages.**.inputs.*", "stages.**.outputs.*",
|
|
113
|
+
],
|
|
114
|
+
"campaigns-os-doctor-output/v0": [
|
|
115
|
+
"packet_path", "assembly_report_path", "spec_path", "emitted_packet_path",
|
|
116
|
+
"derived.packet_path", "derived.assembly_report_path", "derived.spec_path",
|
|
117
|
+
"derived.target_repo", "derived.source_root", "derived.output_dir", "derived.target_output_dir",
|
|
118
|
+
"errors.*.detail.spec_path", "errors.*.detail.input_path",
|
|
119
|
+
"warnings.*.detail.spec_path", "warnings.*.detail.input_path",
|
|
120
|
+
],
|
|
121
|
+
"campaigns-os-build-brief/v1": ["_meta.input_path"],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const ARTIFACT_COMMAND_FIELDS = Object.freeze({
|
|
125
|
+
"campaign-runtime-build-packet/v0": ["source_html.pages.*.skip_reason"],
|
|
126
|
+
"campaign-runtime-build-context/v0": [
|
|
127
|
+
"decisions.*.decision", "decisions.*.evidence.*",
|
|
128
|
+
"prompts_required.*.message", "page_map.*.skip_reason", "source.manifest_warnings.*",
|
|
129
|
+
],
|
|
130
|
+
"campaign-runtime-assembly-report/v0": [
|
|
131
|
+
"next.command", "stages.**.commands.*",
|
|
132
|
+
"decisions.*.decision", "decisions.*.evidence.*",
|
|
133
|
+
"**.message", "**.blockers.*", "**.warnings.*",
|
|
134
|
+
],
|
|
135
|
+
"campaigns-os-doctor-output/v0": [
|
|
136
|
+
"next.command", "next.actions.*", "next_actions.*.command", "**.message", "**.blockers.*", "**.warnings.*",
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function fieldMatches(pattern, segments) {
|
|
141
|
+
const parts = pattern.split(".");
|
|
142
|
+
function matches(at, index) {
|
|
143
|
+
if (at === parts.length) return index === segments.length;
|
|
144
|
+
if (parts[at] === "**") return matches(at + 1, index) || (index < segments.length && matches(at, index + 1));
|
|
145
|
+
return index < segments.length && (parts[at] === "*" || parts[at] === segments[index]) && matches(at + 1, index + 1);
|
|
146
|
+
}
|
|
147
|
+
return matches(0, 0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function portableArtifactPaths(value, targetRepo, { artifactPath = null } = {}) {
|
|
151
|
+
const schema = isObject(value) ? value.schema_version : null;
|
|
152
|
+
const pathFields = ARTIFACT_PATH_FIELDS[schema] || [];
|
|
153
|
+
const commandFields = ARTIFACT_COMMAND_FIELDS[schema] || [];
|
|
154
|
+
const artifactDir = artifactPath ? dirname(resolve(artifactPath)) : null;
|
|
155
|
+
const packet = schema === "campaign-runtime-build-packet/v0";
|
|
156
|
+
const context = schema === "campaign-runtime-build-context/v0";
|
|
157
|
+
const report = schema === "campaign-runtime-assembly-report/v0";
|
|
158
|
+
const sourceField = packet ? value.source_html?.root : context ? value.source?.root : report ? value.inputs?.source?.root : null;
|
|
159
|
+
const sourceBase = packet ? artifactDir : targetRepo;
|
|
160
|
+
const sourceRoot = isNonEmptyString(sourceField) && sourceBase ? resolve(sourceBase, sourceField) : null;
|
|
161
|
+
const outputField = packet ? value.assembly?.output_dir : context ? value.scaffold?.output_dir : null;
|
|
162
|
+
const outputBase = targetRepo;
|
|
163
|
+
const outputDir = isNonEmptyString(outputField) && outputBase ? resolve(outputBase, outputField) : null;
|
|
164
|
+
const roots = [...new Set([targetRepo, sourceRoot].filter(Boolean).flatMap((root) => [resolve(root), canonicalPath(root)]))]
|
|
165
|
+
.filter((root) => root !== "/")
|
|
166
|
+
.sort((left, right) => right.length - left.length);
|
|
167
|
+
function fieldBase(segments) {
|
|
168
|
+
const path = segments.join(".");
|
|
169
|
+
if (packet) {
|
|
170
|
+
if (path === "source_html.pages.*.path") return sourceRoot;
|
|
171
|
+
if (path === "source_html.pages.*.page_kit.target_path") return outputDir;
|
|
172
|
+
if (path === "source_html.pages.*.page_kit.output_path") return targetRepo;
|
|
173
|
+
if (path === "assembly.output_dir") return targetRepo;
|
|
174
|
+
return artifactDir;
|
|
175
|
+
}
|
|
176
|
+
if (context) {
|
|
177
|
+
if (path === "page_map.*.source_path") return sourceRoot;
|
|
178
|
+
if (path === "page_map.*.page_kit.target_path") return outputDir;
|
|
179
|
+
if (path === "design_source_package.path") return artifactDir;
|
|
180
|
+
}
|
|
181
|
+
if (report && path === "design_source_package.path") return artifactDir;
|
|
182
|
+
if (schema === "campaigns-os-build-brief/v1") return artifactDir;
|
|
183
|
+
return targetRepo;
|
|
184
|
+
}
|
|
185
|
+
function portableCommand(command) {
|
|
186
|
+
let output = command;
|
|
187
|
+
for (const root of roots) {
|
|
188
|
+
const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
189
|
+
const replacement = portableArtifactPath(targetRepo, root);
|
|
190
|
+
output = output.replace(new RegExp(`(^|[\\s"'\`(=,;\\[<])${escaped}(?=$|[/\\s"'\`.,:;)\\]>])`, "g"),
|
|
191
|
+
(_match, prefix) => `${prefix}${replacement}`);
|
|
192
|
+
}
|
|
193
|
+
return output;
|
|
194
|
+
}
|
|
195
|
+
function visit(entry, segments = []) {
|
|
196
|
+
if (Array.isArray(entry)) return entry.map((item) => visit(item, [...segments, "*"]));
|
|
197
|
+
if (isObject(entry)) return Object.fromEntries(Object.entries(entry).map(([field, item]) => [field, visit(item, [...segments, field])]));
|
|
198
|
+
if (typeof entry !== "string") return entry;
|
|
199
|
+
if (Array.isArray(value) && segments.length === 1 && isLocalAbsolutePath(entry)) return portableArtifactPath(targetRepo, entry);
|
|
200
|
+
if (pathFields.some((pattern) => fieldMatches(pattern, segments)) && isLocalAbsolutePath(entry)) {
|
|
201
|
+
const baseDir = fieldBase(segments);
|
|
202
|
+
return baseDir ? portableArtifactPath(targetRepo, entry, { baseDir }) : entry;
|
|
203
|
+
}
|
|
204
|
+
if (commandFields.some((pattern) => fieldMatches(pattern, segments))) return portableCommand(entry);
|
|
205
|
+
return entry;
|
|
206
|
+
}
|
|
207
|
+
return visit(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
82
210
|
function resolveFromFile(filePath, targetPath) {
|
|
83
211
|
if (!isNonEmptyString(targetPath)) return null;
|
|
84
212
|
if (isAbsoluteHttpUrl(targetPath)) return targetPath;
|
|
@@ -111,6 +239,7 @@ export {
|
|
|
111
239
|
sha256File,
|
|
112
240
|
relFromDir,
|
|
113
241
|
isLocalAbsolutePath,
|
|
242
|
+
portableArtifactPaths,
|
|
114
243
|
resolveFromFile,
|
|
115
244
|
filesystemPathsMatch,
|
|
116
245
|
extractFrontmatterValue,
|