@nextcommerce/campaigns-os 1.55.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 +64 -0
- package/CONTEXT.md +11 -0
- 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 +705 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +8 -4
- package/docs/build-packet.md +52 -8
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +78 -18
- 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/package.json +2 -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/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 +4 -3
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/build-brief.mjs +14 -5
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +121 -53
- package/src/commercial-journey.mjs +25 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +7 -7
- package/src/doctor/checks.mjs +196 -13
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +31 -11
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/local-preview-policy.mjs +110 -12
- package/src/polish-node.mjs +3 -3
- package/src/qa-browser.mjs +226 -34
- package/src/qa-cart-entry.mjs +78 -14
- package/src/qa-node.mjs +35 -33
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +3 -3
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
|
}
|
|
@@ -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}`);
|
|
@@ -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("/") });
|
|
@@ -2815,7 +2828,7 @@ function bumpDisplaysPresent(block, displays) {
|
|
|
2815
2828
|
return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
|
|
2816
2829
|
}
|
|
2817
2830
|
|
|
2818
|
-
const CHECKOUT_BUMP_PAGE_TYPES = new Set(
|
|
2831
|
+
const CHECKOUT_BUMP_PAGE_TYPES = new Set(CHECKOUT_FLOW_PAGE_TYPES);
|
|
2819
2832
|
|
|
2820
2833
|
export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
|
|
2821
2834
|
const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
|
|
@@ -3000,6 +3013,11 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
|
|
|
3000
3013
|
|
|
3001
3014
|
function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
3002
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");
|
|
3003
3021
|
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
|
|
3004
3022
|
const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
|
|
3005
3023
|
if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
@@ -3007,6 +3025,63 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
|
3007
3025
|
return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3008
3026
|
}
|
|
3009
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
|
+
|
|
3010
3085
|
function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
|
|
3011
3086
|
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
3012
3087
|
|
|
@@ -3446,7 +3521,7 @@ function validateSpecPublicRoutes(spec, errors, ready) {
|
|
|
3446
3521
|
}
|
|
3447
3522
|
|
|
3448
3523
|
function pageRole(type) {
|
|
3449
|
-
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";
|
|
3450
3525
|
return "visual";
|
|
3451
3526
|
}
|
|
3452
3527
|
|
|
@@ -3550,6 +3625,7 @@ function templateStockDecision(buildState, pageId) {
|
|
|
3550
3625
|
function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
3551
3626
|
const pages = packet.source_html?.pages || [];
|
|
3552
3627
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
3628
|
+
const routeRoot = campaignRouteRoot(packet);
|
|
3553
3629
|
const materialisedTemplateStock = [];
|
|
3554
3630
|
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
3555
3631
|
validateSourceHtmlManifestAtRoot(sourceRoot, {
|
|
@@ -3577,6 +3653,20 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3577
3653
|
}
|
|
3578
3654
|
mappedIds.add(page.page_id);
|
|
3579
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
|
+
}
|
|
3580
3670
|
if (!activeIds.has(page.page_id)) {
|
|
3581
3671
|
addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
|
|
3582
3672
|
}
|
|
@@ -3611,7 +3701,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3611
3701
|
page_id: specPage.id,
|
|
3612
3702
|
type: specPage.type || "page",
|
|
3613
3703
|
role: pageRole(specPage.type),
|
|
3614
|
-
route:
|
|
3704
|
+
route: builtRoute,
|
|
3615
3705
|
source_path: page.path,
|
|
3616
3706
|
});
|
|
3617
3707
|
}
|
|
@@ -3627,7 +3717,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3627
3717
|
page_id: specPage.id,
|
|
3628
3718
|
type: specPage.type || "page",
|
|
3629
3719
|
role: pageRole(specPage.type),
|
|
3630
|
-
route:
|
|
3720
|
+
route: builtRoute,
|
|
3631
3721
|
source_path: null,
|
|
3632
3722
|
template_stock: true,
|
|
3633
3723
|
template_family: family,
|
|
@@ -3956,6 +4046,98 @@ function packageRefsFromEntries(entries) {
|
|
|
3956
4046
|
.filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
|
|
3957
4047
|
.map((value) => String(value));
|
|
3958
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
|
+
}
|
|
3959
4141
|
function offerRefsFromEntries(entries) {
|
|
3960
4142
|
if (!Array.isArray(entries)) return [];
|
|
3961
4143
|
return entries
|
|
@@ -4140,6 +4322,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4140
4322
|
|
|
4141
4323
|
// ADR-003 step 2: template-contract checks ported from the private doctor.
|
|
4142
4324
|
const specPages = activeSpecPages(spec);
|
|
4325
|
+
warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
|
|
4143
4326
|
|
|
4144
4327
|
const mismatchedFamilies = specPages.filter(
|
|
4145
4328
|
(page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
|
|
@@ -4153,7 +4336,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4153
4336
|
}
|
|
4154
4337
|
|
|
4155
4338
|
const checkoutPackageRefs = specPages
|
|
4156
|
-
.filter((page) =>
|
|
4339
|
+
.filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type))
|
|
4157
4340
|
.flatMap((page) => packageRefsFromEntries(page.packages));
|
|
4158
4341
|
if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
|
|
4159
4342
|
addIssue(
|
|
@@ -4268,7 +4451,7 @@ export function validateExitPopContract(contract, spec, family, warnings, ready,
|
|
|
4268
4451
|
const exitPop = contract?.exit_pop;
|
|
4269
4452
|
if (!exitPop || !spec) return;
|
|
4270
4453
|
const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
|
|
4271
|
-
page?.type
|
|
4454
|
+
CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type) && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
|
|
4272
4455
|
));
|
|
4273
4456
|
if (hasGovernedOfferSurface) {
|
|
4274
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
|
});
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The next step doctor recommends, and the gate issues `next` reads from doctor.
|
|
2
2
|
import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
|
-
import { polishCarriedForwardForLadder } from "../local-preview-policy.mjs";
|
|
3
|
+
import { NO_CAPTURABLE_ROUTES_CODE, hostedTemplateNeedsUsablePreview, polishCarriedForwardForLadder, polishGateCanCarryPreview } from "../local-preview-policy.mjs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { orderPathDepthDriftText } from "../proof-policy.mjs";
|
|
6
6
|
import { anyAssemblyReportStageBlocked, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "../stage-ledger.mjs";
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { currentBuildFingerprint, evaluatePolishGate } from "../polish-gate.mjs";
|
|
20
20
|
import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
|
|
21
21
|
import { cmd } from "../install-invocation.mjs";
|
|
22
|
+
import { shellToken } from "../shell-token.mjs";
|
|
22
23
|
import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
|
|
23
24
|
import { orderPathDepthDrift } from "./checks.mjs";
|
|
24
25
|
|
|
@@ -412,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
|
|
|
412
413
|
};
|
|
413
414
|
}
|
|
414
415
|
|
|
415
|
-
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
|
|
416
|
+
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null, packet = null) {
|
|
416
417
|
const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
|
|
417
418
|
const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
|
|
418
419
|
// prepare-build is the earliest lifecycle prerequisite. Surface its
|
|
@@ -476,6 +477,12 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
476
477
|
// A first local preview may carry missing Polish forward. A theme waiver
|
|
477
478
|
// or prior capture makes the recorded Polish stage owed again.
|
|
478
479
|
if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
|
|
480
|
+
// A hosted all-template preview has no hosted deploy recorder. The
|
|
481
|
+
// recorded preview URL satisfies this one deploy handoff while the
|
|
482
|
+
// carried-forward gate identifies the exact preview-only shape.
|
|
483
|
+
if (cliStage === "deploy" && packet?.deploy?.preview_url
|
|
484
|
+
&& polishGate?.carried_forward?.policy === "hosted_template_preview"
|
|
485
|
+
&& polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview") continue;
|
|
479
486
|
const reportKey = reportKeyForCliStage(cliStage);
|
|
480
487
|
const stage = report.stages[reportKey];
|
|
481
488
|
if (!stage) {
|
|
@@ -517,9 +524,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
517
524
|
}
|
|
518
525
|
}
|
|
519
526
|
|
|
527
|
+
const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
|
|
520
528
|
return {
|
|
521
529
|
stage: "done",
|
|
522
|
-
|
|
530
|
+
outcome: hostedPreview ? "hosted_preview_satisfies_deploy" : "standard_closeout",
|
|
531
|
+
reason: hostedPreview
|
|
532
|
+
? "The recorded hosted preview satisfies deploy, and QA is terminal for the current build. Pipeline is complete."
|
|
533
|
+
: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
|
|
523
534
|
};
|
|
524
535
|
}
|
|
525
536
|
|
|
@@ -577,7 +588,7 @@ export const DOCTOR_NEXT_STAGE_OWNERS = NEXT_STAGE_OWNERS;
|
|
|
577
588
|
// The code -> action strings doctor prints under `Next:`. They describe the
|
|
578
589
|
// repairs the findings ask for and are independent of which stage the picker
|
|
579
590
|
// names, so they survive the picker consolidation unchanged.
|
|
580
|
-
function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef = derived.packet_path || "<packet>" }) {
|
|
591
|
+
function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet = null, packetRef = derived.packet_path || "<packet>" }) {
|
|
581
592
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
582
593
|
const onlyPolishErrors = doctorErrorsAreOnlyPolishGate(errors);
|
|
583
594
|
const actions = [];
|
|
@@ -633,11 +644,17 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
|
|
|
633
644
|
actions.push(`Target campaign output directory is missing; run ${cmd("next")} setup --packet ${packetRef} before build.`);
|
|
634
645
|
}
|
|
635
646
|
if (polishBlocked) {
|
|
636
|
-
|
|
647
|
+
const needsHostedPreview = hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate);
|
|
648
|
+
if (needsHostedPreview) {
|
|
649
|
+
actions.push(`Every mapped page is template stock and needs a hosted preview URL that differs from the production URL. Run ${cmd("qa")} policy set --packet ${packetRef} --preview-url <url>, then run ${cmd("next")} --packet ${packetRef}.`);
|
|
650
|
+
}
|
|
651
|
+
if (polishGate.status === "blocked" && (!needsHostedPreview || !polishGateCanCarryPreview(polishGate))) {
|
|
637
652
|
actions.push(`${polishGate.reason} Run next-campaigns-polish and record structured evidence before deploy/QA handoff.`);
|
|
638
653
|
}
|
|
639
|
-
if (polishCheckpointGate?.status === "blocked") {
|
|
640
|
-
actions.push(
|
|
654
|
+
if (polishCheckpointGate?.status === "blocked" && !needsHostedPreview) {
|
|
655
|
+
actions.push(polishCheckpointGate.code === NO_CAPTURABLE_ROUTES_CODE
|
|
656
|
+
? `${polishCheckpointGate.reason} ${polishCheckpointGate.required_actions?.[0]?.description || "Map a design route and rerun intake before QA."}`
|
|
657
|
+
: `${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
|
|
641
658
|
}
|
|
642
659
|
}
|
|
643
660
|
return actions;
|
|
@@ -656,7 +673,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
656
673
|
&& (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
|
|
657
674
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
658
675
|
const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
|
|
659
|
-
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
|
|
676
|
+
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
|
|
660
677
|
// The picker's vocabulary and this table must not drift apart: a stage the
|
|
661
678
|
// table does not know would otherwise be relabelled as an operator step and
|
|
662
679
|
// sliced into the whole ladder. Fail loudly instead.
|
|
@@ -665,8 +682,8 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
665
682
|
}
|
|
666
683
|
// An explicit --context / --report is carried into every recommended
|
|
667
684
|
// command, so a recovery reads the same artifacts the recommendation did.
|
|
668
|
-
const packetRef = `${derived.packet_path
|
|
669
|
-
const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef });
|
|
685
|
+
const packetRef = `${derived.packet_path ? shellToken(derived.packet_path) : "<packet>"}${sidecarArgs}`;
|
|
686
|
+
const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet, packetRef });
|
|
670
687
|
const deployStatus = String(report?.stages?.deploy?.status || "");
|
|
671
688
|
const deploySatisfied = ["completed", "completed_with_warnings", "ready_with_exceptions"].some((prefix) => deployStatus.startsWith(prefix))
|
|
672
689
|
|| Boolean(deployUrlFromReportOutputs(report));
|
|
@@ -713,7 +730,9 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
713
730
|
? `${cmd("next")} --packet ${packetRef}`
|
|
714
731
|
: `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
|
|
715
732
|
const fallbackAction = picked.stage === "done"
|
|
716
|
-
?
|
|
733
|
+
? (picked.outcome === "hosted_preview_satisfies_deploy"
|
|
734
|
+
? `${picked.reason} Run ${cmd("next")} to confirm the closeout actions.`
|
|
735
|
+
: `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`)
|
|
717
736
|
: `Run ${command}.`;
|
|
718
737
|
if (picked.stage === "done") {
|
|
719
738
|
actions.unshift(fallbackAction);
|
|
@@ -733,6 +752,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
733
752
|
default_skill: owners.default_skill,
|
|
734
753
|
command,
|
|
735
754
|
reason: picked.reason,
|
|
755
|
+
...(picked.outcome ? { outcome: picked.outcome } : {}),
|
|
736
756
|
actions: actions.length ? actions : [fallbackAction],
|
|
737
757
|
// Gate-blocked stages stay listed even when the recommended stage is
|
|
738
758
|
// runnable (a Design Source Package change after assembly names build,
|
package/src/doctor-sidecar.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { portableArtifactPaths } from "./cli-helpers.mjs";
|
|
4
5
|
|
|
5
6
|
export const DOCTOR_SIDECAR_REL_PATH = ".campaign-runtime/doctor-output.json";
|
|
6
7
|
export const DOCTOR_SIDECAR_SCHEMA = "campaigns-os-doctor-output/v0";
|
|
@@ -61,8 +62,9 @@ export function stampDoctorProducer(doctor, command) {
|
|
|
61
62
|
// through this (or through `stampDoctorProducer` when its own transactional
|
|
62
63
|
// writer must do the rename), so `generated_by` cannot be skipped by one of
|
|
63
64
|
// them the way #327's cause labels once were.
|
|
64
|
-
export function writeDoctorSidecar(path, doctor, { command } = {}) {
|
|
65
|
-
|
|
65
|
+
export function writeDoctorSidecar(path, doctor, { command, targetRepo = null } = {}) {
|
|
66
|
+
const stamped = stampDoctorProducer(doctor, command);
|
|
67
|
+
writeJsonAtomic(path, targetRepo ? portableArtifactPaths(stamped, targetRepo) : stamped);
|
|
66
68
|
return path;
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -89,7 +91,7 @@ export function markDoctorSidecarStale(targetBaseDir, { command = null, reason =
|
|
|
89
91
|
stale_reason: reason
|
|
90
92
|
|| "A later command changed doctor inputs after this snapshot was written. Re-run campaigns-os doctor (or campaigns-os next) for current state.",
|
|
91
93
|
};
|
|
92
|
-
writeJsonAtomic(path, stamped);
|
|
94
|
+
writeJsonAtomic(path, portableArtifactPaths(stamped, targetBaseDir));
|
|
93
95
|
return path;
|
|
94
96
|
}
|
|
95
97
|
|
package/src/gate-actions.mjs
CHANGED
|
@@ -68,10 +68,30 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
|
|
|
68
68
|
id: "polish.hidden_eager_media.map_design_route",
|
|
69
69
|
kind: "manual",
|
|
70
70
|
command: null,
|
|
71
|
-
description: "Every mapped page is template stock, so
|
|
71
|
+
description: "Every mapped page is template stock, so there is no design route to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or use a local or hosted preview for QA with the missing evidence carried forward as a warning.",
|
|
72
72
|
}),
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
export function qaRunCommand(packetPath, url, bumpCart = null) {
|
|
76
|
+
return `${invocationPrefixFor(PACKAGE_ROOT)} qa run --packet ${packetPath ? shellToken(packetPath) : "<packet>"} --base-url ${url === "<preview-url>" ? url : shellToken(url)} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hostedTemplateQaAction(previewUrl) {
|
|
80
|
+
return {
|
|
81
|
+
id: "polish.hidden_eager_media.hosted_preview_qa",
|
|
82
|
+
kind: "command",
|
|
83
|
+
command: qaRunCommand(null, previewUrl),
|
|
84
|
+
description: "Run QA against the packet's hosted preview URL; missing Polish evidence remains a warning.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const HOSTED_TEMPLATE_PREVIEW_URL_ACTION = Object.freeze({
|
|
89
|
+
id: "polish.hidden_eager_media.set_hosted_preview_url",
|
|
90
|
+
kind: "command",
|
|
91
|
+
command: "campaigns-os qa policy set --packet <packet> --preview-url <url>",
|
|
92
|
+
description: "Record a hosted preview URL that differs from the production URL with qa policy set, then run next again; missing Polish evidence can be carried forward on that preview.",
|
|
93
|
+
});
|
|
94
|
+
|
|
75
95
|
const PACKET_PLACEHOLDER = "--packet <packet>";
|
|
76
96
|
|
|
77
97
|
// The placeholder becomes the packet this run read, shell-quoted, so the
|
package/src/input-refresh.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
loadCampaignBuildBriefFile,
|
|
27
27
|
} from "./build-brief.mjs";
|
|
28
28
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
29
|
-
import { isObject, optionalString, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
|
|
29
|
+
import { isObject, optionalString, portableArtifactPaths, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
|
|
30
30
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
31
31
|
import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
|
|
32
32
|
import {
|
|
@@ -475,9 +475,9 @@ function saveBriefUnderLock({ packetPath, explicitBrief, sidecars, lockedTarget,
|
|
|
475
475
|
if (!dryRun) {
|
|
476
476
|
// Intake's publication order, the report last: a failure between files
|
|
477
477
|
// leaves the report bound to the old material, which reads as owed.
|
|
478
|
-
writeJsonAtomic(packetPath, nextPacket);
|
|
479
|
-
writeJsonAtomic(normalizedPath, buildBrief.artifact);
|
|
480
|
-
writeJsonAtomic(contextPath, saved.nextContext);
|
|
478
|
+
writeJsonAtomic(packetPath, portableArtifactPaths(nextPacket, targetRepo, { artifactPath: packetPath }));
|
|
479
|
+
writeJsonAtomic(normalizedPath, portableArtifactPaths(buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
|
|
480
|
+
writeJsonAtomic(contextPath, portableArtifactPaths(saved.nextContext, targetRepo, { artifactPath: contextPath }));
|
|
481
481
|
commitAssemblyReport(workspace, () => nextReport, {
|
|
482
482
|
command: "record brief",
|
|
483
483
|
staleReason: "The Campaign Build Brief was saved after this doctor snapshot. Re-run doctor (or next) for current state.",
|
|
@@ -674,8 +674,8 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
674
674
|
if (!dryRun) {
|
|
675
675
|
// Intake's publication order, the report last: a failure between files
|
|
676
676
|
// leaves the report bound to the old spec material, which reads as owed.
|
|
677
|
-
if (briefWritten) writeJsonAtomic(normalizedPath, brief.buildBrief.artifact);
|
|
678
|
-
writeJsonAtomic(contextPath, nextContext);
|
|
677
|
+
if (briefWritten) writeJsonAtomic(normalizedPath, portableArtifactPaths(brief.buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
|
|
678
|
+
writeJsonAtomic(contextPath, portableArtifactPaths(nextContext, targetRepo, { artifactPath: contextPath }));
|
|
679
679
|
commitAssemblyReport(workspace, () => nextReport, {
|
|
680
680
|
command: "record spec",
|
|
681
681
|
staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",
|