@nextcommerce/campaigns-os 1.55.0 → 1.59.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 +124 -0
- package/CONTEXT.md +11 -0
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +97 -0
- package/campaign-spec/dist/checkout-flow.js +245 -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/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +26 -27
- package/contracts/effects.v1.json +5 -5
- 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 +1137 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +68 -10
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +124 -22
- 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 +4 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -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/built-site-scope.mjs +91 -0
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +117 -57
- 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 +269 -16
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +18 -9
- 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 +8 -7
- package/src/qa-browser.mjs +363 -36
- package/src/qa-cart-entry.mjs +183 -14
- package/src/qa-node.mjs +38 -34
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict.mjs +24 -1
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +58 -7
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +203 -36
package/src/doctor/checks.mjs
CHANGED
|
@@ -76,7 +76,7 @@ import {
|
|
|
76
76
|
resolveTemplateBrandContract,
|
|
77
77
|
} from "../private-template-source.mjs";
|
|
78
78
|
import { assessTemplateFreshness, defaultSdkSupportPolicy, renderTemplateFreshness } from "../template-freshness.mjs";
|
|
79
|
-
import { computeBuildFingerprint, resolveBuiltSiteScope } from "../built-site-scope.mjs";
|
|
79
|
+
import { BUILD_DRIFT_PATH_LIMIT, computeBuildFingerprint, describeBuildOutputDrift, resolveBuiltSiteScope } from "../built-site-scope.mjs";
|
|
80
80
|
import {
|
|
81
81
|
UPSELL_SELECTOR_SCOPE,
|
|
82
82
|
builtPageTypeOverRouteGuess,
|
|
@@ -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("/") });
|
|
@@ -1882,12 +1895,33 @@ export function validateBuildOutputFingerprint(packet, errors, warnings, ready,
|
|
|
1882
1895
|
);
|
|
1883
1896
|
return;
|
|
1884
1897
|
}
|
|
1898
|
+
const drift = describeBuildOutputDrift(buildState.report?.stages?.assembly, current);
|
|
1899
|
+
const capped = (paths) => paths.slice(0, BUILD_DRIFT_PATH_LIMIT);
|
|
1885
1900
|
addIssue(
|
|
1886
1901
|
assemblyComplete ? errors : warnings,
|
|
1887
1902
|
"built_output.fingerprint_stale",
|
|
1888
1903
|
`Built output under _site/${publicRouteSlug}/ no longer matches stages.assembly.build_fingerprint (recorded ${recorded}, current ${current.fingerprint}, ${current.file_count} file(s)). `
|
|
1904
|
+
+ `${drift.summary} `
|
|
1889
1905
|
+ "The output changed after build recorded it; re-run build (page-kit build, then record the current fingerprint) before polish or QA evidence can bind to it.",
|
|
1890
|
-
{
|
|
1906
|
+
{
|
|
1907
|
+
root: `_site/${publicRouteSlug}/`,
|
|
1908
|
+
recorded,
|
|
1909
|
+
current: current.fingerprint,
|
|
1910
|
+
file_count: current.file_count,
|
|
1911
|
+
assembly_complete: assemblyComplete,
|
|
1912
|
+
drift: drift.manifest === "recorded"
|
|
1913
|
+
? {
|
|
1914
|
+
manifest: "recorded",
|
|
1915
|
+
extra_count: drift.extra.length,
|
|
1916
|
+
missing_count: drift.missing.length,
|
|
1917
|
+
changed_count: drift.changed.length,
|
|
1918
|
+
extra: capped(drift.extra),
|
|
1919
|
+
missing: capped(drift.missing),
|
|
1920
|
+
changed: capped(drift.changed),
|
|
1921
|
+
conflict_copies: capped(drift.conflict_copies),
|
|
1922
|
+
}
|
|
1923
|
+
: { manifest: "unavailable" },
|
|
1924
|
+
}
|
|
1891
1925
|
);
|
|
1892
1926
|
}
|
|
1893
1927
|
|
|
@@ -2815,7 +2849,7 @@ function bumpDisplaysPresent(block, displays) {
|
|
|
2815
2849
|
return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
|
|
2816
2850
|
}
|
|
2817
2851
|
|
|
2818
|
-
const CHECKOUT_BUMP_PAGE_TYPES = new Set(
|
|
2852
|
+
const CHECKOUT_BUMP_PAGE_TYPES = new Set(CHECKOUT_FLOW_PAGE_TYPES);
|
|
2819
2853
|
|
|
2820
2854
|
export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
|
|
2821
2855
|
const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
|
|
@@ -3000,6 +3034,11 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
|
|
|
3000
3034
|
|
|
3001
3035
|
function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
3002
3036
|
if (!targetRepo || !publicRouteSlug) return null;
|
|
3037
|
+
const entryRoute = entryPageKitServingRoute(targetRepo, publicRouteSlug, page, {
|
|
3038
|
+
targetOutputDir: derived?.target_output_dir,
|
|
3039
|
+
mapping: derived?.page_kit_entry_mappings?.[page?.id],
|
|
3040
|
+
});
|
|
3041
|
+
if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
|
|
3003
3042
|
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
|
|
3004
3043
|
const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
|
|
3005
3044
|
if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
@@ -3007,6 +3046,63 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
|
3007
3046
|
return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3008
3047
|
}
|
|
3009
3048
|
|
|
3049
|
+
export function isRootRoutedEntryPage(page, publicRouteSlug) {
|
|
3050
|
+
return Boolean(page?.is_entry) && runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug) === "";
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// An explicit permalink belongs to this entry's Page Kit source file. It wins
|
|
3054
|
+
// even when an older build left output at the filename-derived route.
|
|
3055
|
+
export function entryPageKitServingRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
|
|
3056
|
+
if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
|
|
3057
|
+
const permalink = entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping);
|
|
3058
|
+
if (permalink !== null) return { route: permalink, source: "permalink" };
|
|
3059
|
+
const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3060
|
+
if (existsSync(root) && statSync(root).isFile()) return { route: "", source: "root" };
|
|
3061
|
+
const fileRoute = entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir, mapping });
|
|
3062
|
+
return fileRoute ? { route: `${fileRoute}/`, source: "file" } : { route: "", source: "declared" };
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
function entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) {
|
|
3066
|
+
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
3067
|
+
const names = mapping?.page_kit?.target_path
|
|
3068
|
+
? [mapping.page_kit.target_path]
|
|
3069
|
+
: [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
|
|
3070
|
+
const routes = names
|
|
3071
|
+
.filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name))
|
|
3072
|
+
.map((name) => join(targetOutputDir, name))
|
|
3073
|
+
.filter((path) => existsSync(path) && statSync(path).isFile())
|
|
3074
|
+
.map((path) => extractFrontmatterValue(readFileSync(path, "utf8"), "permalink"))
|
|
3075
|
+
.filter(isNonEmptyString)
|
|
3076
|
+
.map((value) => stripPublicRoutePrefix(normalizePageKitRoute(value), publicRouteSlug));
|
|
3077
|
+
return routes[0] ?? null;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// Page Kit serves a page file without a permalink at its basename route.
|
|
3081
|
+
// Only the recorded target file (or the family's own materialised stock file)
|
|
3082
|
+
// may identify an entry fallback; an unrelated built index is not evidence.
|
|
3083
|
+
export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
|
|
3084
|
+
if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
|
|
3085
|
+
if (entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) !== null) return null;
|
|
3086
|
+
const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3087
|
+
if (existsSync(root) && statSync(root).isFile()) return null;
|
|
3088
|
+
let names;
|
|
3089
|
+
if (mapping?.page_kit?.target_path) {
|
|
3090
|
+
names = [mapping.page_kit.target_path];
|
|
3091
|
+
} else {
|
|
3092
|
+
if (!targetOutputDir) return null;
|
|
3093
|
+
names = [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
|
|
3094
|
+
}
|
|
3095
|
+
const routes = names
|
|
3096
|
+
.filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name) && name !== "index.html")
|
|
3097
|
+
.filter((name) => mapping?.page_kit || (existsSync(join(targetOutputDir, name)) && statSync(join(targetOutputDir, name)).isFile()))
|
|
3098
|
+
.map((name) => name.slice(0, -".html".length))
|
|
3099
|
+
.filter((route) => {
|
|
3100
|
+
const built = join(targetRepo, "_site", publicRouteSlug, route, "index.html");
|
|
3101
|
+
return existsSync(built) && statSync(built).isFile();
|
|
3102
|
+
});
|
|
3103
|
+
return routes[0] ?? null;
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3010
3106
|
function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
|
|
3011
3107
|
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
3012
3108
|
|
|
@@ -3267,7 +3363,7 @@ function collectHardcodedPhoneMatches(scanRoots, storePhone) {
|
|
|
3267
3363
|
const matches = [];
|
|
3268
3364
|
for (const { label: surface, root } of scanRoots) {
|
|
3269
3365
|
for (const file of collectHtmlFiles(root)) {
|
|
3270
|
-
const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
|
|
3366
|
+
const content = maskNonRenderedMarkup(maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path))));
|
|
3271
3367
|
for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
|
|
3272
3368
|
const found = normalizePhoneNumber(match[0]);
|
|
3273
3369
|
if (!found || found === expected) continue;
|
|
@@ -3295,6 +3391,18 @@ function maskMarketLintIgnoredRegions(content) {
|
|
|
3295
3391
|
.replace(ignoredTag, preserveNewlinesMask);
|
|
3296
3392
|
}
|
|
3297
3393
|
|
|
3394
|
+
// Markup a shopper never reads: HTML comments, <style> blocks, and CSS
|
|
3395
|
+
// comments (in style attributes too). Design exports leave long digit runs
|
|
3396
|
+
// there, such as a Figma frame name "Group 1000003339", which the phone
|
|
3397
|
+
// pattern would otherwise read as a number (campaigns-os#660). Masking keeps
|
|
3398
|
+
// offsets and newlines, so reported line numbers stay true.
|
|
3399
|
+
function maskNonRenderedMarkup(content) {
|
|
3400
|
+
return content
|
|
3401
|
+
.replace(/<!--[\s\S]*?-->/g, preserveNewlinesMask)
|
|
3402
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, preserveNewlinesMask)
|
|
3403
|
+
.replace(/\/\*[\s\S]*?\*\//g, preserveNewlinesMask);
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3298
3406
|
function preserveNewlinesMask(value) {
|
|
3299
3407
|
return value.replace(/[^\n]/g, " ");
|
|
3300
3408
|
}
|
|
@@ -3446,7 +3554,7 @@ function validateSpecPublicRoutes(spec, errors, ready) {
|
|
|
3446
3554
|
}
|
|
3447
3555
|
|
|
3448
3556
|
function pageRole(type) {
|
|
3449
|
-
if (["checkout", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
|
|
3557
|
+
if (["checkout", "checkout_step", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
|
|
3450
3558
|
return "visual";
|
|
3451
3559
|
}
|
|
3452
3560
|
|
|
@@ -3550,6 +3658,7 @@ function templateStockDecision(buildState, pageId) {
|
|
|
3550
3658
|
function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
3551
3659
|
const pages = packet.source_html?.pages || [];
|
|
3552
3660
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
3661
|
+
const routeRoot = campaignRouteRoot(packet);
|
|
3553
3662
|
const materialisedTemplateStock = [];
|
|
3554
3663
|
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
3555
3664
|
validateSourceHtmlManifestAtRoot(sourceRoot, {
|
|
@@ -3577,6 +3686,20 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3577
3686
|
}
|
|
3578
3687
|
mappedIds.add(page.page_id);
|
|
3579
3688
|
const specPage = activeById.get(page.page_id);
|
|
3689
|
+
const entryRoute = specPage
|
|
3690
|
+
? entryPageKitServingRoute(derived.target_repo, publicRouteSlug, specPage, {
|
|
3691
|
+
targetOutputDir: derived.target_output_dir,
|
|
3692
|
+
mapping: page,
|
|
3693
|
+
})
|
|
3694
|
+
: null;
|
|
3695
|
+
const builtRoute = entryRoute ? entryRoute.route : specPage ? publicRouteForPage(specPage) : null;
|
|
3696
|
+
const rootOutput = entryRoute?.route ? join(derived.target_repo, "_site", publicRouteSlug, "index.html") : null;
|
|
3697
|
+
if (rootOutput && (entryRoute.source === "file" || entryRoute.source === "permalink")
|
|
3698
|
+
&& !(existsSync(rootOutput) && statSync(rootOutput).isFile())) {
|
|
3699
|
+
const emittedRoute = `${routeRoot}${entryRoute.route}`;
|
|
3700
|
+
const pageKitRoot = `/${normalizePublicRouteSlug(publicRouteSlug)}/`;
|
|
3701
|
+
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 });
|
|
3702
|
+
}
|
|
3580
3703
|
if (!activeIds.has(page.page_id)) {
|
|
3581
3704
|
addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
|
|
3582
3705
|
}
|
|
@@ -3611,7 +3734,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3611
3734
|
page_id: specPage.id,
|
|
3612
3735
|
type: specPage.type || "page",
|
|
3613
3736
|
role: pageRole(specPage.type),
|
|
3614
|
-
route:
|
|
3737
|
+
route: builtRoute,
|
|
3615
3738
|
source_path: page.path,
|
|
3616
3739
|
});
|
|
3617
3740
|
}
|
|
@@ -3627,7 +3750,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3627
3750
|
page_id: specPage.id,
|
|
3628
3751
|
type: specPage.type || "page",
|
|
3629
3752
|
role: pageRole(specPage.type),
|
|
3630
|
-
route:
|
|
3753
|
+
route: builtRoute,
|
|
3631
3754
|
source_path: null,
|
|
3632
3755
|
template_stock: true,
|
|
3633
3756
|
template_family: family,
|
|
@@ -3956,6 +4079,134 @@ function packageRefsFromEntries(entries) {
|
|
|
3956
4079
|
.filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
|
|
3957
4080
|
.map((value) => String(value));
|
|
3958
4081
|
}
|
|
4082
|
+
|
|
4083
|
+
function supportsCheckoutVariantSlots(agentContract) {
|
|
4084
|
+
const required = frontmatterList(agentContract, "requiredWhenCloning");
|
|
4085
|
+
return required.includes("variant_slots[].id") && required.includes("variant_slots[].quantity");
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
function selectableVariantMatrixCount(packages) {
|
|
4089
|
+
const selectable = Array.isArray(packages)
|
|
4090
|
+
? packages.filter((pkg) => pkg && pkg.is_order_bump !== true && pkg.is_upsell !== true)
|
|
4091
|
+
: [];
|
|
4092
|
+
const quantityRows = selectable.filter((pkg) => String(pkg.qty ?? "").trim());
|
|
4093
|
+
if (quantityRows.length <= 1) return 0;
|
|
4094
|
+
const attributeIdentity = (pkg) => {
|
|
4095
|
+
if (!Array.isArray(pkg.variant_attributes) || pkg.variant_attributes.length === 0) return null;
|
|
4096
|
+
const attributes = pkg.variant_attributes.map((attribute) => {
|
|
4097
|
+
const value = String(attribute?.value ?? "").trim();
|
|
4098
|
+
if (!value) return null;
|
|
4099
|
+
return [String(attribute?.code || attribute?.name || "").trim(), value];
|
|
4100
|
+
});
|
|
4101
|
+
return attributes.every(Boolean) ? JSON.stringify(attributes.sort(([a], [b]) => a.localeCompare(b))) : null;
|
|
4102
|
+
};
|
|
4103
|
+
const textIdentity = (key) => (pkg) => {
|
|
4104
|
+
const value = pkg[key];
|
|
4105
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
4106
|
+
};
|
|
4107
|
+
for (const identity of [attributeIdentity, textIdentity("product_variant_name")]) {
|
|
4108
|
+
const values = quantityRows.map(identity);
|
|
4109
|
+
if (!values.every(Boolean)) continue;
|
|
4110
|
+
const quantitiesByVariant = new Map();
|
|
4111
|
+
for (const [index, value] of values.entries()) {
|
|
4112
|
+
const qty = String(quantityRows[index].qty).trim();
|
|
4113
|
+
if (!quantitiesByVariant.has(value)) quantitiesByVariant.set(value, new Set());
|
|
4114
|
+
quantitiesByVariant.get(value).add(qty);
|
|
4115
|
+
}
|
|
4116
|
+
return quantitiesByVariant.size > 1 && [...quantitiesByVariant.values()].some((quantities) => quantities.size > 1)
|
|
4117
|
+
? quantitiesByVariant.size
|
|
4118
|
+
: 0;
|
|
4119
|
+
}
|
|
4120
|
+
return 0;
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, report) {
|
|
4124
|
+
const selectedContract = catalog.families?.[family]?.agentContract;
|
|
4125
|
+
if (
|
|
4126
|
+
!selectedContract ||
|
|
4127
|
+
!isCertifiedTemplateFamily(family, catalog) ||
|
|
4128
|
+
!frontmatterList(selectedContract, "requiredWhenCloning").includes("packages.main_package") ||
|
|
4129
|
+
supportsCheckoutVariantSlots(selectedContract)
|
|
4130
|
+
) return;
|
|
4131
|
+
const fittingFamilies = [...certifiedTemplateFamilies(catalog)]
|
|
4132
|
+
.filter((candidate) => supportsCheckoutVariantSlots(catalog.families[candidate]?.agentContract))
|
|
4133
|
+
.sort();
|
|
4134
|
+
const checkoutPages = specPages.filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type));
|
|
4135
|
+
const byFunnel = new Map();
|
|
4136
|
+
for (const page of checkoutPages) {
|
|
4137
|
+
if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
|
|
4138
|
+
byFunnel.get(page.funnel_id).push(page);
|
|
4139
|
+
}
|
|
4140
|
+
const paths = new Map();
|
|
4141
|
+
for (const page of checkoutPages) {
|
|
4142
|
+
const pages = byFunnel.get(page.funnel_id);
|
|
4143
|
+
const path = checkoutPathFrom(pages, page) || [page];
|
|
4144
|
+
const end = path[path.length - 1];
|
|
4145
|
+
const key = `${page.funnel_id}:${end.id}`;
|
|
4146
|
+
if (!paths.has(key)) paths.set(key, []);
|
|
4147
|
+
const pathPages = paths.get(key);
|
|
4148
|
+
for (const candidate of path) {
|
|
4149
|
+
if (!pathPages.includes(candidate)) pathPages.push(candidate);
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
const hasStageEvidence = assemblyReportStagesWithEvidence(report).length > 0;
|
|
4153
|
+
const multiStep = [...paths.values()].some((pathPages) =>
|
|
4154
|
+
pathPages.some((candidate) => candidate.type === "checkout_step" || candidate.type === "select"));
|
|
4155
|
+
for (const pathPages of paths.values()) {
|
|
4156
|
+
const page = pathPages.find((candidate) => selectableVariantMatrixCount(candidate.packages) > 1);
|
|
4157
|
+
if (!page) continue;
|
|
4158
|
+
const variantCount = selectableVariantMatrixCount(page.packages);
|
|
4159
|
+
const shapeMatches = fittingFamilies.filter((candidate) => Boolean(catalog.families[candidate]?.canonicalSurfaces?.selectStep) === multiStep);
|
|
4160
|
+
// The catalog has no base-family field; its family-name prefix is the shared-base convention.
|
|
4161
|
+
const baseMatches = shapeMatches.filter((candidate) => candidate.startsWith(`${family}-`));
|
|
4162
|
+
const ranked = [...baseMatches, ...shapeMatches.filter((candidate) => !baseMatches.includes(candidate))];
|
|
4163
|
+
const action = hasStageEvidence
|
|
4164
|
+
? "The template family is a build-time decision; changing it requires re-running intake with --force (destructive; clears recorded stage evidence)."
|
|
4165
|
+
: "The family is chosen at intake with --template-family.";
|
|
4166
|
+
addIssue(
|
|
4167
|
+
warnings,
|
|
4168
|
+
"template_contract.checkout_package_fit",
|
|
4169
|
+
`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}`,
|
|
4170
|
+
{ page_id: page.id, funnel_id: page.funnel_id, template_family: family, distinct_product_variants: variantCount, fitting_families: ranked },
|
|
4171
|
+
);
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
// A family that puts package selection on its own `select` page (the catalog
|
|
4175
|
+
// names a selectStep surface) and carries no selector on checkout (no
|
|
4176
|
+
// mainSelector surface) can only fill the cart from a declared select page.
|
|
4177
|
+
// Without one on the path into a Checkout, QA resolves the landing as the
|
|
4178
|
+
// entry page, finds no add-to-cart control, and every order path fails after
|
|
4179
|
+
// the whole build (campaigns-os#658). Families whose checkout carries its own
|
|
4180
|
+
// selector, and families that fill the cart on the landing page, are not
|
|
4181
|
+
// affected.
|
|
4182
|
+
function warnMissingSelectStep(specPages, family, catalog, warnings) {
|
|
4183
|
+
const surfaces = catalog.families?.[family]?.canonicalSurfaces;
|
|
4184
|
+
if (!surfaces?.selectStep || surfaces.mainSelector) return;
|
|
4185
|
+
const byFunnel = new Map();
|
|
4186
|
+
for (const page of specPages) {
|
|
4187
|
+
if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
|
|
4188
|
+
byFunnel.get(page.funnel_id).push(page);
|
|
4189
|
+
}
|
|
4190
|
+
for (const [funnelId, pages] of byFunnel) {
|
|
4191
|
+
const reachedFromSelect = new Set();
|
|
4192
|
+
for (const select of pages.filter((page) => page.type === "select")) {
|
|
4193
|
+
const path = checkoutPathFrom(pages, select);
|
|
4194
|
+
if (path) reachedFromSelect.add(path[path.length - 1]);
|
|
4195
|
+
}
|
|
4196
|
+
for (const checkout of pages.filter((page) => page.type === "checkout")) {
|
|
4197
|
+
if (reachedFromSelect.has(checkout)) continue;
|
|
4198
|
+
// A split-test path built on another family is judged by that family.
|
|
4199
|
+
const pageFamily = checkout.sdk_hints?.template_family;
|
|
4200
|
+
if (isNonEmptyString(pageFamily) && pageFamily !== family) continue;
|
|
4201
|
+
addIssue(
|
|
4202
|
+
warnings,
|
|
4203
|
+
"template_contract.select_step_missing",
|
|
4204
|
+
`Template family "${family}" puts package selection on a select page, and its checkout carries no selector, but no select page leads to Checkout "${checkout.id}" in funnel "${funnelId}". QA will find no add-to-cart control on the entry page and every order path will fail. Fix: add a Select page between the entry page and checkout in Map Builder.`,
|
|
4205
|
+
{ funnel_id: funnelId, checkout_page_id: checkout.id, template_family: family },
|
|
4206
|
+
);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
3959
4210
|
function offerRefsFromEntries(entries) {
|
|
3960
4211
|
if (!Array.isArray(entries)) return [];
|
|
3961
4212
|
return entries
|
|
@@ -4140,6 +4391,8 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4140
4391
|
|
|
4141
4392
|
// ADR-003 step 2: template-contract checks ported from the private doctor.
|
|
4142
4393
|
const specPages = activeSpecPages(spec);
|
|
4394
|
+
warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
|
|
4395
|
+
warnMissingSelectStep(specPages, family, catalog, warnings);
|
|
4143
4396
|
|
|
4144
4397
|
const mismatchedFamilies = specPages.filter(
|
|
4145
4398
|
(page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
|
|
@@ -4153,7 +4406,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4153
4406
|
}
|
|
4154
4407
|
|
|
4155
4408
|
const checkoutPackageRefs = specPages
|
|
4156
|
-
.filter((page) =>
|
|
4409
|
+
.filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type))
|
|
4157
4410
|
.flatMap((page) => packageRefsFromEntries(page.packages));
|
|
4158
4411
|
if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
|
|
4159
4412
|
addIssue(
|
|
@@ -4268,7 +4521,7 @@ export function validateExitPopContract(contract, spec, family, warnings, ready,
|
|
|
4268
4521
|
const exitPop = contract?.exit_pop;
|
|
4269
4522
|
if (!exitPop || !spec) return;
|
|
4270
4523
|
const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
|
|
4271
|
-
page?.type
|
|
4524
|
+
CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type) && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
|
|
4272
4525
|
));
|
|
4273
4526
|
if (hasGovernedOfferSurface) {
|
|
4274
4527
|
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
|
|
@@ -519,6 +520,7 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
519
520
|
|
|
520
521
|
return {
|
|
521
522
|
stage: "done",
|
|
523
|
+
outcome: "standard_closeout",
|
|
522
524
|
reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
|
|
523
525
|
};
|
|
524
526
|
}
|
|
@@ -577,7 +579,7 @@ export const DOCTOR_NEXT_STAGE_OWNERS = NEXT_STAGE_OWNERS;
|
|
|
577
579
|
// The code -> action strings doctor prints under `Next:`. They describe the
|
|
578
580
|
// repairs the findings ask for and are independent of which stage the picker
|
|
579
581
|
// names, so they survive the picker consolidation unchanged.
|
|
580
|
-
function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef = derived.packet_path || "<packet>" }) {
|
|
582
|
+
function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet = null, packetRef = derived.packet_path || "<packet>" }) {
|
|
581
583
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
582
584
|
const onlyPolishErrors = doctorErrorsAreOnlyPolishGate(errors);
|
|
583
585
|
const actions = [];
|
|
@@ -633,11 +635,17 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
|
|
|
633
635
|
actions.push(`Target campaign output directory is missing; run ${cmd("next")} setup --packet ${packetRef} before build.`);
|
|
634
636
|
}
|
|
635
637
|
if (polishBlocked) {
|
|
636
|
-
|
|
638
|
+
const needsHostedPreview = hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate);
|
|
639
|
+
if (needsHostedPreview) {
|
|
640
|
+
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}.`);
|
|
641
|
+
}
|
|
642
|
+
if (polishGate.status === "blocked" && (!needsHostedPreview || !polishGateCanCarryPreview(polishGate))) {
|
|
637
643
|
actions.push(`${polishGate.reason} Run next-campaigns-polish and record structured evidence before deploy/QA handoff.`);
|
|
638
644
|
}
|
|
639
|
-
if (polishCheckpointGate?.status === "blocked") {
|
|
640
|
-
actions.push(
|
|
645
|
+
if (polishCheckpointGate?.status === "blocked" && !needsHostedPreview) {
|
|
646
|
+
actions.push(polishCheckpointGate.code === NO_CAPTURABLE_ROUTES_CODE
|
|
647
|
+
? `${polishCheckpointGate.reason} ${polishCheckpointGate.required_actions?.[0]?.description || "Map a design route and rerun intake before QA."}`
|
|
648
|
+
: `${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
|
|
641
649
|
}
|
|
642
650
|
}
|
|
643
651
|
return actions;
|
|
@@ -656,7 +664,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
656
664
|
&& (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
|
|
657
665
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
658
666
|
const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
|
|
659
|
-
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
|
|
667
|
+
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
|
|
660
668
|
// The picker's vocabulary and this table must not drift apart: a stage the
|
|
661
669
|
// table does not know would otherwise be relabelled as an operator step and
|
|
662
670
|
// sliced into the whole ladder. Fail loudly instead.
|
|
@@ -665,8 +673,8 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
665
673
|
}
|
|
666
674
|
// An explicit --context / --report is carried into every recommended
|
|
667
675
|
// 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 });
|
|
676
|
+
const packetRef = `${derived.packet_path ? shellToken(derived.packet_path) : "<packet>"}${sidecarArgs}`;
|
|
677
|
+
const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet, packetRef });
|
|
670
678
|
const deployStatus = String(report?.stages?.deploy?.status || "");
|
|
671
679
|
const deploySatisfied = ["completed", "completed_with_warnings", "ready_with_exceptions"].some((prefix) => deployStatus.startsWith(prefix))
|
|
672
680
|
|| Boolean(deployUrlFromReportOutputs(report));
|
|
@@ -733,6 +741,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
733
741
|
default_skill: owners.default_skill,
|
|
734
742
|
command,
|
|
735
743
|
reason: picked.reason,
|
|
744
|
+
...(picked.outcome ? { outcome: picked.outcome } : {}),
|
|
736
745
|
actions: actions.length ? actions : [fallbackAction],
|
|
737
746
|
// Gate-blocked stages stay listed even when the recommended stage is
|
|
738
747
|
// 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
|