@nextcommerce/campaigns-os 1.57.0 → 1.60.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 +176 -0
- package/README.md +6 -5
- package/campaign-spec/dist/checkout-flow.d.ts +7 -0
- package/campaign-spec/dist/checkout-flow.js +17 -1
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -28
- package/contracts/effects.v1.json +5 -5
- package/contracts/release-ledger.json +975 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +35 -19
- package/docs/campaign-build-brief.md +1 -1
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +1 -1
- package/docs/qa-and-test-orders.md +54 -12
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- 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 +7 -7
- 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 +9 -10
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +193 -0
- package/skills.json +18 -10
- package/src/built-site-scope.mjs +96 -12
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +50 -81
- package/src/doctor/checks.mjs +79 -6
- package/src/doctor/inspect.mjs +2 -8
- package/src/doctor/next-step.mjs +9 -17
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -0
- package/src/polish-gate.mjs +1 -5
- package/src/polish-node.mjs +5 -4
- package/src/progress-node.mjs +2 -1
- package/src/qa-browser.mjs +137 -2
- package/src/qa-cart-entry.mjs +105 -0
- package/src/qa-node.mjs +3 -1
- package/src/qa-verdict.mjs +24 -1
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +27 -18
- package/src/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +218 -35
package/src/cli.mjs
CHANGED
|
@@ -193,13 +193,9 @@ import {
|
|
|
193
193
|
import {
|
|
194
194
|
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
195
195
|
NEXT_STAGE_ORDER,
|
|
196
|
-
STAGE_TERMINAL_STATUS_PREFIXES,
|
|
197
196
|
stageIsTerminal,
|
|
198
197
|
} from "./orchestration-stage-contract.mjs";
|
|
199
|
-
import {
|
|
200
|
-
currentBuildFingerprint,
|
|
201
|
-
evaluatePolishGate,
|
|
202
|
-
} from "./polish-gate.mjs";
|
|
198
|
+
import { currentBuildFingerprint } from "./polish-gate.mjs";
|
|
203
199
|
import {
|
|
204
200
|
assertPolishCaptureBindingUnchanged,
|
|
205
201
|
capturePolishPageLoad,
|
|
@@ -221,6 +217,7 @@ import {
|
|
|
221
217
|
import {
|
|
222
218
|
loadPageKitCampaignEntry,
|
|
223
219
|
PAGE_KIT_CAMPAIGNS_REL_PATH,
|
|
220
|
+
pageKitIgnoresAssetsHtml,
|
|
224
221
|
} from "./page-kit-campaign-config.mjs";
|
|
225
222
|
import {
|
|
226
223
|
evaluatePageKitStoreProfile,
|
|
@@ -240,6 +237,7 @@ import {
|
|
|
240
237
|
applySpecDerive,
|
|
241
238
|
formatDeriveValue,
|
|
242
239
|
isPageTreeIgnoredDir,
|
|
240
|
+
isPageTreeIgnoredPath,
|
|
243
241
|
frontmatterPermalink,
|
|
244
242
|
pageRouteForFile,
|
|
245
243
|
permalinkRoute,
|
|
@@ -293,7 +291,7 @@ import {
|
|
|
293
291
|
addPrepareBuildGateErrors,
|
|
294
292
|
assessPurchaseProofCoverage,
|
|
295
293
|
deployUrlFromReportOutputs,
|
|
296
|
-
|
|
294
|
+
readinessStatus,
|
|
297
295
|
} from "./doctor/next-step.mjs";
|
|
298
296
|
|
|
299
297
|
export { derivePackagePin, localInstallStatus };
|
|
@@ -343,7 +341,7 @@ Usage:
|
|
|
343
341
|
campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: records scalar adapter decisions on the Assembly Report (all pairs in one flag; a repeated flag keeps only the last), stamps stages.assembly.build_fingerprint from doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when present), and makes Polish required unless its evidence is bound to this output; --build-environment records stages.assembly.evidence.build_environment (local proof mode records development)
|
|
344
342
|
campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
|
|
345
343
|
campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
|
|
346
|
-
campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a
|
|
344
|
+
campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a served deploy after polish is recorded: GETs every built page under the URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again). A local-serve packet takes a loopback URL and each page must answer 2xx; any other deploy target takes an https URL (plain http only on a loopback host), each page must answer 200, and every script and stylesheet the built pages load from that origin must be served byte-identical (sha256) to the built output (HTML is not compared). Refused, writing nothing, when the URL breaks those rules or is not the route root, a page does not answer, a served asset differs or is missing, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
|
|
347
345
|
campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
|
|
348
346
|
campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
|
|
349
347
|
campaigns-os validate-assembly-report --report <json> [--json]
|
|
@@ -3546,10 +3544,10 @@ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.
|
|
|
3546
3544
|
// the route each one builds to (filename-derived, or the file's permalink;
|
|
3547
3545
|
// a permalink derive cannot read as a campaign route carries `problem` and a
|
|
3548
3546
|
// null route). Discovery follows page-kit's own: every .html outside
|
|
3549
|
-
// `_layouts/` and `_includes
|
|
3550
|
-
//
|
|
3551
|
-
// than "no pages".
|
|
3552
|
-
function listPageKitPageFiles(outputDir, publicRouteSlug) {
|
|
3547
|
+
// `_layouts/` and `_includes/` (and, from page-kit 0.3.0, the top-level
|
|
3548
|
+
// `assets/`), symlinked entries included. Returns null when the directory
|
|
3549
|
+
// does not exist, so the plan can say "no page tree" rather than "no pages".
|
|
3550
|
+
function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored = false } = {}) {
|
|
3553
3551
|
if (!outputDir || !existsSync(outputDir) || !statSync(outputDir).isDirectory()) return null;
|
|
3554
3552
|
const files = [];
|
|
3555
3553
|
const walk = (dir) => {
|
|
@@ -3563,8 +3561,7 @@ function listPageKitPageFiles(outputDir, publicRouteSlug) {
|
|
|
3563
3561
|
}
|
|
3564
3562
|
if (!stats.isFile() || extname(entry.name).toLowerCase() !== ".html") continue;
|
|
3565
3563
|
const path = relative(outputDir, fullPath).split(sep).join("/");
|
|
3566
|
-
|
|
3567
|
-
if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) continue;
|
|
3564
|
+
if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) continue;
|
|
3568
3565
|
// page-kit honours a permalink before its filename rule, so the
|
|
3569
3566
|
// frontmatter is read first (quoting, comments, BOM and CRLF as
|
|
3570
3567
|
// page-kit's reader takes them); only a file with no usable permalink
|
|
@@ -3773,7 +3770,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
3773
3770
|
try {
|
|
3774
3771
|
if (existsSync(outputDir)) {
|
|
3775
3772
|
const realOutputDir = realpathSync(outputDir);
|
|
3776
|
-
if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug);
|
|
3773
|
+
if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
|
|
3777
3774
|
else addIssue(result.warnings, "spec.derive.page_tree_escapes_repo", `${outputDir} resolves to ${realOutputDir}, outside the target repo; routes were not read from it.`);
|
|
3778
3775
|
}
|
|
3779
3776
|
} catch (error) {
|
|
@@ -4318,7 +4315,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4318
4315
|
}
|
|
4319
4316
|
}
|
|
4320
4317
|
const themeGate = doctor.derived?.theme_gate || null;
|
|
4321
|
-
const polishGate = doctor.derived?.polish_gate
|
|
4318
|
+
const polishGate = doctor.derived?.polish_gate;
|
|
4322
4319
|
const polishCheckpointGate = doctor.derived?.polish_checkpoint_gate || null;
|
|
4323
4320
|
// Packet 03 (INV-5 first slice): compare the ledger's self-report against
|
|
4324
4321
|
// the repo's artifacts before any recommendation goes out. Additive:
|
|
@@ -4398,21 +4395,25 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4398
4395
|
const ready = [...doctor.ready];
|
|
4399
4396
|
if (!doctor.ok && !doctorHasOnlyPolishGateErrors) errors.push(...doctor.errors.map((issue) => withPacketSubstitutedIssue(issue, packetPath)));
|
|
4400
4397
|
|
|
4401
|
-
//
|
|
4402
|
-
//
|
|
4403
|
-
//
|
|
4404
|
-
//
|
|
4405
|
-
if (
|
|
4398
|
+
// prepare-build is upstream of every stage. While its authoritative gate is
|
|
4399
|
+
// blocked or unavailable, doctor's picker chooses prepare-build, and an
|
|
4400
|
+
// explicit stage request gets the same recovery-only shape (requested_stage
|
|
4401
|
+
// names it) instead of that stage's prompt or executable actions.
|
|
4402
|
+
if (prepareBuildGate) {
|
|
4406
4403
|
addPrepareBuildGateErrors(errors, report, prepareBuildGate);
|
|
4407
4404
|
return finalize({
|
|
4408
4405
|
ok: false,
|
|
4409
4406
|
status: "blocked",
|
|
4410
4407
|
stage: "prepare-build",
|
|
4411
|
-
requested_stage: stage,
|
|
4408
|
+
...(stage ? { requested_stage: stage } : {}),
|
|
4412
4409
|
reason: prepareBuildGate.reason,
|
|
4413
4410
|
errors,
|
|
4414
4411
|
warnings,
|
|
4415
4412
|
ready,
|
|
4413
|
+
// Packet 03: when the artifacts contradict the ledger, do NOT tell
|
|
4414
|
+
// the operator to start over — rerunning prepare-build/start is the
|
|
4415
|
+
// most destructive documented recovery, and the ledger alone is not
|
|
4416
|
+
// trustworthy evidence that it is needed.
|
|
4416
4417
|
prompt: prepareBuildRecoveryPrompt,
|
|
4417
4418
|
stage_blocked: true,
|
|
4418
4419
|
});
|
|
@@ -4438,34 +4439,16 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4438
4439
|
prompt: `Resolve the doctor errors above before continuing. Re-run \`${cmd("doctor")} --packet <path> --write\` to record the recovery, then \`${cmd("next")} --packet <path>\` to advance.`,
|
|
4439
4440
|
});
|
|
4440
4441
|
}
|
|
4441
|
-
if (picked.stage === "prepare-build") {
|
|
4442
|
-
addPrepareBuildGateErrors(errors, report, prepareBuildGate);
|
|
4443
|
-
return finalize({
|
|
4444
|
-
ok: false,
|
|
4445
|
-
status: "blocked",
|
|
4446
|
-
stage: "prepare-build",
|
|
4447
|
-
reason: picked.reason,
|
|
4448
|
-
errors,
|
|
4449
|
-
warnings,
|
|
4450
|
-
ready,
|
|
4451
|
-
// Packet 03: when the artifacts contradict the ledger, do NOT tell
|
|
4452
|
-
// the operator to start over — rerunning prepare-build/start is the
|
|
4453
|
-
// most destructive documented recovery, and the ledger alone is not
|
|
4454
|
-
// trustworthy evidence that it is needed.
|
|
4455
|
-
prompt: prepareBuildRecoveryPrompt,
|
|
4456
|
-
stage_blocked: true,
|
|
4457
|
-
});
|
|
4458
|
-
}
|
|
4459
4442
|
if (picked.stage === "done") {
|
|
4460
4443
|
return finalize({
|
|
4461
4444
|
ok: true,
|
|
4462
|
-
status:
|
|
4445
|
+
status: readinessStatus(warnings, doctor.derived),
|
|
4463
4446
|
stage: "done",
|
|
4464
4447
|
reason: picked.reason,
|
|
4465
4448
|
errors,
|
|
4466
4449
|
warnings,
|
|
4467
4450
|
ready,
|
|
4468
|
-
prompt:
|
|
4451
|
+
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
|
|
4469
4452
|
});
|
|
4470
4453
|
}
|
|
4471
4454
|
stage = picked.stage;
|
|
@@ -4509,13 +4492,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4509
4492
|
addThemeGateErrors(errors, themeGate, "qa");
|
|
4510
4493
|
prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary, polishCheckpointGate);
|
|
4511
4494
|
}
|
|
4512
|
-
const status = errors.length
|
|
4513
|
-
? "blocked"
|
|
4514
|
-
: checkpointExceptionPresent(doctor.derived)
|
|
4515
|
-
? "ready_with_waivers"
|
|
4516
|
-
: warnings.length
|
|
4517
|
-
? "ready_with_warnings"
|
|
4518
|
-
: "ready";
|
|
4495
|
+
const status = errors.length ? "blocked" : readinessStatus(warnings, doctor.derived);
|
|
4519
4496
|
const result = {
|
|
4520
4497
|
ok: errors.length === 0,
|
|
4521
4498
|
status,
|
|
@@ -4877,8 +4854,8 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4877
4854
|
return actions;
|
|
4878
4855
|
}
|
|
4879
4856
|
if (result.stage === "polish" && polishGate?.carried_forward?.policy === "hosted_template_preview") {
|
|
4880
|
-
push("
|
|
4881
|
-
push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "
|
|
4857
|
+
push("record_deploy", "command", `${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}`, "Record the hosted preview deploy; missing Polish evidence remains a warning.");
|
|
4858
|
+
push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Then run browser and typed-card QA against the hosted preview URL.");
|
|
4882
4859
|
return actions;
|
|
4883
4860
|
}
|
|
4884
4861
|
}
|
|
@@ -4934,20 +4911,13 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4934
4911
|
: `Run the visual polish pass and ${cmd("polish")} capture, then record polish with ${cmd("record")} polish --packet ${shellToken(packetPath)} --evidence <polish-evidence.json>.`);
|
|
4935
4912
|
if (polishCheckpointGate?.status === "blocked") pushPolishCheckpointActions();
|
|
4936
4913
|
} else if (result.stage === "deploy") {
|
|
4937
|
-
if (
|
|
4938
|
-
push("next_qa", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect the QA stage for this hosted preview without recording a hosted deploy.");
|
|
4939
|
-
push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Run browser and typed-card QA against the hosted preview; its missing Polish evidence remains a warning.");
|
|
4940
|
-
} else if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
|
|
4914
|
+
if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
|
|
4941
4915
|
const plan = localServePlan(packet);
|
|
4942
4916
|
push("deploy", "manual", null, `Serve the built ${plan.dir} output locally as the origin root (deploy.target is local-serve)${plan.rewrite ? ` — ${plan.rewrite}` : ""}, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url <served url>: it checks every built page answers and records deploy.preview_url and stages.deploy. Localhost on any port is a Development domain: SDK allowed, analytics suppressed.`);
|
|
4943
4917
|
} else {
|
|
4944
|
-
push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"}, then record deploy.preview_url (
|
|
4945
|
-
}
|
|
4946
|
-
if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
|
|
4947
|
-
push("advance", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect QA for the hosted preview without a deploy record.");
|
|
4948
|
-
} else {
|
|
4949
|
-
push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
|
|
4918
|
+
push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"} with _site/ as the origin root, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${packet.deploy?.preview_url ? shellToken(packet.deploy.preview_url) : "<https preview url>"}: it checks every built page answers HTTP 200 and every script and stylesheet the pages load from that origin is served byte-identical to the build, and records deploy.preview_url and stages.deploy.`);
|
|
4950
4919
|
}
|
|
4920
|
+
push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
|
|
4951
4921
|
} else if (result.stage === "qa") {
|
|
4952
4922
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
4953
4923
|
push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
|
|
@@ -5108,11 +5078,11 @@ Rules:
|
|
|
5108
5078
|
- For one-time prepurchase/order-bump packages outside the main bundles, default package_sync=false and show_line_total_price=false unless the spec explicitly requires quantity sync.
|
|
5109
5079
|
- Record spec-driven removals, especially unsupported payment methods, so polish does not reintroduce them.
|
|
5110
5080
|
- Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
|
|
5111
|
-
- Multi-step checkout paths run select -> checkout_step... -> checkout; only the checkout page takes payment. On a select page the swap-mode bundle selector (data-next-bundle-selector with data-next-selection-mode="swap") writes the cart and the SDK persists it between pages; a checkout button or link (href from the page's
|
|
5112
|
-
- Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its
|
|
5081
|
+
- Multi-step checkout paths run select -> checkout_step... -> checkout; only the checkout page takes payment. On a select page the swap-mode bundle selector (data-next-bundle-selector with data-next-selection-mode="swap") writes the cart and the SDK persists it between pages; a checkout button or link (href from the page's next_step) goes to the next page. Use forcePackageId only on a page with no selector, such as a landing call-to-action into a checkout that renders none.
|
|
5082
|
+
- Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_step>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter next_step and step_number); the checkout page's form carries neither. On select and checkout_step pages, frontmatter next_url is the Checkout's post-payment destination, the same meaning it has on the checkout page, and the layout renders it as next-success-url because the SDK reads that tag for express orders; next_step is the forward link. A landing page keeps next_url as its call-to-action target; if it has express checkout buttons, set its next-success-url to the Checkout's own next_url from the Build Context page_map, never to the landing page's next_url. Forward navigation uses the step form attribute or a plain link, never that meta tag. Page Kit frontmatter has no success_url key (success_url remains a CampaignSpec field on checkout pages).
|
|
5113
5083
|
- After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
|
|
5114
5084
|
- Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""} --adapter-decision <key>=<value>[,<key>=<value>...]\`. Record the value that is true for this build: for example, raw_html_conversion_status=completed once source HTML is converted, or not_required when there is none. Put all scalar adapter decisions in one comma-separated flag; a repeated flag keeps only the last. Use the allowed values in docs/build-packet.md. Record the nine recordable scalar decisions with the command, never by editing those fields in the Assembly Report. wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; the object-valued template_files_copied proof has no flag. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
|
|
5115
|
-
- Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
|
|
5085
|
+
- Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND), and on page-kit >= 0.3.0 for its top-level assetWarnings: ASSET_IGNORED names an .html file under assets/ that is neither built nor copied, so a link or iframe to it 404s. If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
|
|
5116
5086
|
}
|
|
5117
5087
|
|
|
5118
5088
|
// Local proof mode lines for the build prompt: under local-serve the build is
|
|
@@ -5193,7 +5163,7 @@ function polishPrompt(packetPath, reportPath, packet, intent, polishCheckpointGa
|
|
|
5193
5163
|
return `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward only on a hosted preview whose deploy.preview_url is a non-loopback http(s) URL different from deploy.production_url; this packet's is not. Set one with \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5194
5164
|
}
|
|
5195
5165
|
return packet.deploy?.preview_url
|
|
5196
|
-
? `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward on the hosted preview. Run \`${cmd("
|
|
5166
|
+
? `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward on the hosted preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\`, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\`. Production QA remains blocked without the evidence.`
|
|
5197
5167
|
: `Every mapped page is template stock, so there is no design route to capture. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` to record the hosted preview URL, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5198
5168
|
}
|
|
5199
5169
|
return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
|
|
@@ -5255,10 +5225,8 @@ function localServePlan(packet) {
|
|
|
5255
5225
|
}
|
|
5256
5226
|
|
|
5257
5227
|
function deployPrompt(packetPath, reportPath, packet, polishGate = null, polishCheckpointGate = null) {
|
|
5258
|
-
if (packet
|
|
5259
|
-
|
|
5260
|
-
&& !packet.deploy?.preview_url) {
|
|
5261
|
-
return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`. There is no hosted record deploy command.`;
|
|
5228
|
+
if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
|
|
5229
|
+
return `Every mapped page is template stock, and deploy needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5262
5230
|
}
|
|
5263
5231
|
const target = packet.deploy?.target || "unknown";
|
|
5264
5232
|
const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
|
|
@@ -5282,7 +5250,7 @@ Once the server is up:
|
|
|
5282
5250
|
If the served build cannot be reached, set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
|
|
5283
5251
|
}
|
|
5284
5252
|
if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
|
|
5285
|
-
return `The hosted preview URL is recorded at deploy.preview_url. Every mapped page is template stock, so missing Polish evidence is carried forward as a warning for this preview. Run \`${cmd("
|
|
5253
|
+
return `The hosted preview URL is recorded at deploy.preview_url. Every mapped page is template stock, so missing Polish evidence is carried forward as a warning for this preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\` to record the deploy, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\` to test the preview. Production QA remains strict.`;
|
|
5286
5254
|
}
|
|
5287
5255
|
return `Deploy the built campaign to ${target}.
|
|
5288
5256
|
|
|
@@ -5295,10 +5263,9 @@ Read first:
|
|
|
5295
5263
|
Deploy is currently an out-of-band step: the page-kit build produces _site/ output; you (or your CI) ship it to ${target}. Use the deploy target's normal tooling (netlify deploy, wrangler pages deploy, vercel deploy, etc.).
|
|
5296
5264
|
|
|
5297
5265
|
After deploy succeeds:
|
|
5298
|
-
1.
|
|
5299
|
-
2.
|
|
5300
|
-
3.
|
|
5301
|
-
4. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
|
|
5266
|
+
1. Run \`${cmd("record")} deploy --packet ${packetPath} --base-url <https preview origin>${liveUrlPath}\`. It requests every built page under that URL (each must answer HTTP 200) and compares every script and stylesheet the built pages load from that origin with the built output by sha256 (HTML is not compared; hosts inject markup), then records the URL at deploy.preview_url and stages.deploy as completed with the URL in outputs and the build fingerprint it probed. It refuses, writing nothing, naming each page or asset that does not match.
|
|
5267
|
+
2. Verify the SDK initialises on the tested origin. Localhost on any port is globally available as a Campaigns App Development domain (analytics suppressed). Non-localhost preview/production hosts must be in the Campaigns App SDK origin allowlist before QA.
|
|
5268
|
+
3. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
|
|
5302
5269
|
|
|
5303
5270
|
If the deploy is blocked (non-localhost allowed-domain not yet added, CI permission missing, host-side outage), set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
|
|
5304
5271
|
}
|
|
@@ -5352,10 +5319,8 @@ function orderBumpRunReason(bumpCart) {
|
|
|
5352
5319
|
}
|
|
5353
5320
|
|
|
5354
5321
|
function qaPrompt(packetPath, reportPath, packet, spec = null, intent, polishCheckpointGate = null) {
|
|
5355
|
-
if (packet
|
|
5356
|
-
|
|
5357
|
-
&& !packet.deploy?.preview_url) {
|
|
5358
|
-
return `Every mapped page is template stock, and QA needs the hosted preview URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} qa --packet ${shellToken(packetPath)}\`.`;
|
|
5322
|
+
if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
|
|
5323
|
+
return `Every mapped page is template stock, and QA needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5359
5324
|
}
|
|
5360
5325
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
5361
5326
|
const briefPath = packet.build_brief?.normalized_path || "(missing)";
|
|
@@ -7125,6 +7090,12 @@ function runSessionStatus(args, ambient = null) {
|
|
|
7125
7090
|
// Session progress: incomplete assembly-report stages, the deviation count,
|
|
7126
7091
|
// and the exact next command. Read-only and best-effort; the status command
|
|
7127
7092
|
// must never fail because an artifact is missing or torn.
|
|
7093
|
+
//
|
|
7094
|
+
// The incomplete stages are the recorded statuses. Whether every stage is
|
|
7095
|
+
// actually done is `next`'s decision, not this one: an input change or a new
|
|
7096
|
+
// build owes a stage again while its recorded status still reads completed.
|
|
7097
|
+
// So the next command is always `next`, which names `run end` once the
|
|
7098
|
+
// campaign is done and a session is active.
|
|
7128
7099
|
function runSessionProgress(found) {
|
|
7129
7100
|
const packetPath = found.session.packet;
|
|
7130
7101
|
if (!isNonEmptyString(packetPath) || !existsSync(packetPath)) return null;
|
|
@@ -7134,7 +7105,7 @@ function runSessionProgress(found) {
|
|
|
7134
7105
|
const incomplete = [];
|
|
7135
7106
|
for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
|
|
7136
7107
|
const status = String(report?.stages?.[key]?.status || "");
|
|
7137
|
-
if (!
|
|
7108
|
+
if (!stageIsTerminal(status)) {
|
|
7138
7109
|
incomplete.push({ stage: key, status: status || null });
|
|
7139
7110
|
}
|
|
7140
7111
|
}
|
|
@@ -7143,9 +7114,7 @@ function runSessionProgress(found) {
|
|
|
7143
7114
|
incomplete_stages: incomplete,
|
|
7144
7115
|
deviations,
|
|
7145
7116
|
qa_attempt_count: Array.isArray(found.session.qa_attempts) ? found.session.qa_attempts.length : 0,
|
|
7146
|
-
next_command:
|
|
7147
|
-
? `${cmd("next")} --packet ${packetPath} --json`
|
|
7148
|
-
: `${cmd("run")} end --packet ${packetPath}`,
|
|
7117
|
+
next_command: `${cmd("next")} --packet ${packetPath} --json`,
|
|
7149
7118
|
};
|
|
7150
7119
|
} catch {
|
|
7151
7120
|
return null;
|
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,
|
|
@@ -99,7 +99,7 @@ import {
|
|
|
99
99
|
assessAssemblySourcePackageFreshnessWaivers,
|
|
100
100
|
currentBuildFingerprint,
|
|
101
101
|
} from "../polish-gate.mjs";
|
|
102
|
-
import { loadPageKitCampaignEntry, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
|
|
102
|
+
import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
|
|
103
103
|
import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
|
|
104
104
|
import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
|
|
105
105
|
// ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
|
|
@@ -1895,12 +1895,33 @@ export function validateBuildOutputFingerprint(packet, errors, warnings, ready,
|
|
|
1895
1895
|
);
|
|
1896
1896
|
return;
|
|
1897
1897
|
}
|
|
1898
|
+
const drift = describeBuildOutputDrift(buildState.report?.stages?.assembly, current);
|
|
1899
|
+
const capped = (paths) => paths.slice(0, BUILD_DRIFT_PATH_LIMIT);
|
|
1898
1900
|
addIssue(
|
|
1899
1901
|
assemblyComplete ? errors : warnings,
|
|
1900
1902
|
"built_output.fingerprint_stale",
|
|
1901
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} `
|
|
1902
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.",
|
|
1903
|
-
{
|
|
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
|
+
}
|
|
1904
1925
|
);
|
|
1905
1926
|
}
|
|
1906
1927
|
|
|
@@ -3018,7 +3039,7 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
|
3018
3039
|
mapping: derived?.page_kit_entry_mappings?.[page?.id],
|
|
3019
3040
|
});
|
|
3020
3041
|
if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
|
|
3021
|
-
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
|
|
3042
|
+
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
|
|
3022
3043
|
const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
|
|
3023
3044
|
if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3024
3045
|
const clean = route.replace(/^\/+|\/+$/g, "");
|
|
@@ -3082,13 +3103,16 @@ export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targe
|
|
|
3082
3103
|
return routes[0] ?? null;
|
|
3083
3104
|
}
|
|
3084
3105
|
|
|
3085
|
-
|
|
3106
|
+
// page-kit 0.3.0 renders no .html under the campaign's top-level assets/, so
|
|
3107
|
+
// a permalink stated there is no route on that version (assetsHtmlIgnored).
|
|
3108
|
+
function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page, { assetsHtmlIgnored = false } = {}) {
|
|
3086
3109
|
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
3087
3110
|
|
|
3088
3111
|
const expectedTerminal = terminalRouteSegment(publicRouteForPage(page));
|
|
3089
3112
|
const candidates = [];
|
|
3090
3113
|
for (const file of collectHtmlFiles(targetOutputDir)) {
|
|
3091
3114
|
if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
|
|
3115
|
+
if (assetsHtmlIgnored && file.path.split(sep)[0] === "assets") continue;
|
|
3092
3116
|
const fullPath = join(targetOutputDir, file.path);
|
|
3093
3117
|
const content = readFileSync(fullPath, "utf8");
|
|
3094
3118
|
const permalink = extractFrontmatterValue(content, "permalink");
|
|
@@ -3342,7 +3366,7 @@ function collectHardcodedPhoneMatches(scanRoots, storePhone) {
|
|
|
3342
3366
|
const matches = [];
|
|
3343
3367
|
for (const { label: surface, root } of scanRoots) {
|
|
3344
3368
|
for (const file of collectHtmlFiles(root)) {
|
|
3345
|
-
const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
|
|
3369
|
+
const content = maskNonRenderedMarkup(maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path))));
|
|
3346
3370
|
for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
|
|
3347
3371
|
const found = normalizePhoneNumber(match[0]);
|
|
3348
3372
|
if (!found || found === expected) continue;
|
|
@@ -3370,6 +3394,18 @@ function maskMarketLintIgnoredRegions(content) {
|
|
|
3370
3394
|
.replace(ignoredTag, preserveNewlinesMask);
|
|
3371
3395
|
}
|
|
3372
3396
|
|
|
3397
|
+
// Markup a shopper never reads: HTML comments, <style> blocks, and CSS
|
|
3398
|
+
// comments (in style attributes too). Design exports leave long digit runs
|
|
3399
|
+
// there, such as a Figma frame name "Group 1000003339", which the phone
|
|
3400
|
+
// pattern would otherwise read as a number (campaigns-os#660). Masking keeps
|
|
3401
|
+
// offsets and newlines, so reported line numbers stay true.
|
|
3402
|
+
function maskNonRenderedMarkup(content) {
|
|
3403
|
+
return content
|
|
3404
|
+
.replace(/<!--[\s\S]*?-->/g, preserveNewlinesMask)
|
|
3405
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, preserveNewlinesMask)
|
|
3406
|
+
.replace(/\/\*[\s\S]*?\*\//g, preserveNewlinesMask);
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3373
3409
|
function preserveNewlinesMask(value) {
|
|
3374
3410
|
return value.replace(/[^\n]/g, " ");
|
|
3375
3411
|
}
|
|
@@ -4138,6 +4174,42 @@ function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, repo
|
|
|
4138
4174
|
);
|
|
4139
4175
|
}
|
|
4140
4176
|
}
|
|
4177
|
+
// A family that puts package selection on its own `select` page (the catalog
|
|
4178
|
+
// names a selectStep surface) and carries no selector on checkout (no
|
|
4179
|
+
// mainSelector surface) can only fill the cart from a declared select page.
|
|
4180
|
+
// Without one on the path into a Checkout, QA resolves the landing as the
|
|
4181
|
+
// entry page, finds no add-to-cart control, and every order path fails after
|
|
4182
|
+
// the whole build (campaigns-os#658). Families whose checkout carries its own
|
|
4183
|
+
// selector, and families that fill the cart on the landing page, are not
|
|
4184
|
+
// affected.
|
|
4185
|
+
function warnMissingSelectStep(specPages, family, catalog, warnings) {
|
|
4186
|
+
const surfaces = catalog.families?.[family]?.canonicalSurfaces;
|
|
4187
|
+
if (!surfaces?.selectStep || surfaces.mainSelector) return;
|
|
4188
|
+
const byFunnel = new Map();
|
|
4189
|
+
for (const page of specPages) {
|
|
4190
|
+
if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
|
|
4191
|
+
byFunnel.get(page.funnel_id).push(page);
|
|
4192
|
+
}
|
|
4193
|
+
for (const [funnelId, pages] of byFunnel) {
|
|
4194
|
+
const reachedFromSelect = new Set();
|
|
4195
|
+
for (const select of pages.filter((page) => page.type === "select")) {
|
|
4196
|
+
const path = checkoutPathFrom(pages, select);
|
|
4197
|
+
if (path) reachedFromSelect.add(path[path.length - 1]);
|
|
4198
|
+
}
|
|
4199
|
+
for (const checkout of pages.filter((page) => page.type === "checkout")) {
|
|
4200
|
+
if (reachedFromSelect.has(checkout)) continue;
|
|
4201
|
+
// A split-test path built on another family is judged by that family.
|
|
4202
|
+
const pageFamily = checkout.sdk_hints?.template_family;
|
|
4203
|
+
if (isNonEmptyString(pageFamily) && pageFamily !== family) continue;
|
|
4204
|
+
addIssue(
|
|
4205
|
+
warnings,
|
|
4206
|
+
"template_contract.select_step_missing",
|
|
4207
|
+
`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.`,
|
|
4208
|
+
{ funnel_id: funnelId, checkout_page_id: checkout.id, template_family: family },
|
|
4209
|
+
);
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4141
4213
|
function offerRefsFromEntries(entries) {
|
|
4142
4214
|
if (!Array.isArray(entries)) return [];
|
|
4143
4215
|
return entries
|
|
@@ -4323,6 +4395,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4323
4395
|
// ADR-003 step 2: template-contract checks ported from the private doctor.
|
|
4324
4396
|
const specPages = activeSpecPages(spec);
|
|
4325
4397
|
warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
|
|
4398
|
+
warnMissingSelectStep(specPages, family, catalog, warnings);
|
|
4326
4399
|
|
|
4327
4400
|
const mismatchedFamilies = specPages.filter(
|
|
4328
4401
|
(page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
nextPrepareBuildBindingIssues,
|
|
64
64
|
prepareBuildGateIssue,
|
|
65
65
|
addPrepareBuildGateErrors,
|
|
66
|
-
|
|
66
|
+
readinessStatus,
|
|
67
67
|
buildNextStep,
|
|
68
68
|
} from "./next-step.mjs";
|
|
69
69
|
|
|
@@ -664,13 +664,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
664
664
|
}
|
|
665
665
|
}
|
|
666
666
|
}
|
|
667
|
-
const status = errors.length
|
|
668
|
-
? "blocked"
|
|
669
|
-
: checkpointExceptionPresent(derived)
|
|
670
|
-
? "ready_with_waivers"
|
|
671
|
-
: warnings.length
|
|
672
|
-
? "ready_with_warnings"
|
|
673
|
-
: "ready";
|
|
667
|
+
const status = errors.length ? "blocked" : readinessStatus(warnings, derived);
|
|
674
668
|
const result = {
|
|
675
669
|
schema_version: DOCTOR_SIDECAR_SCHEMA,
|
|
676
670
|
generated_at: new Date().toISOString(),
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -413,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
|
|
|
413
413
|
};
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null
|
|
416
|
+
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
|
|
417
417
|
const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
|
|
418
418
|
const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
|
|
419
419
|
// prepare-build is the earliest lifecycle prerequisite. Surface its
|
|
@@ -477,12 +477,6 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
477
477
|
// A first local preview may carry missing Polish forward. A theme waiver
|
|
478
478
|
// or prior capture makes the recorded Polish stage owed again.
|
|
479
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;
|
|
486
480
|
const reportKey = reportKeyForCliStage(cliStage);
|
|
487
481
|
const stage = report.stages[reportKey];
|
|
488
482
|
if (!stage) {
|
|
@@ -524,13 +518,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
524
518
|
}
|
|
525
519
|
}
|
|
526
520
|
|
|
527
|
-
const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
|
|
528
521
|
return {
|
|
529
522
|
stage: "done",
|
|
530
|
-
outcome:
|
|
531
|
-
reason:
|
|
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
|
+
outcome: "standard_closeout",
|
|
524
|
+
reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
|
|
534
525
|
};
|
|
535
526
|
}
|
|
536
527
|
|
|
@@ -551,6 +542,9 @@ function checkpointExceptionPresent(derived) {
|
|
|
551
542
|
|| derived?.polish_checkpoint_gate?.status === "waived";
|
|
552
543
|
}
|
|
553
544
|
|
|
545
|
+
// The readiness ladder below "blocked", written once: doctor's status, the
|
|
546
|
+
// `next` command's status and doctor's `next` block each decide "blocked"
|
|
547
|
+
// first, then call it with their own warnings.
|
|
554
548
|
function readinessStatus(warnings, derived) {
|
|
555
549
|
if (checkpointExceptionPresent(derived)) return "ready_with_waivers";
|
|
556
550
|
return warnings.length ? "ready_with_warnings" : "ready";
|
|
@@ -673,7 +667,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
673
667
|
&& (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
|
|
674
668
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
675
669
|
const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
|
|
676
|
-
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof
|
|
670
|
+
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
|
|
677
671
|
// The picker's vocabulary and this table must not drift apart: a stage the
|
|
678
672
|
// table does not know would otherwise be relabelled as an operator step and
|
|
679
673
|
// sliced into the whole ladder. Fail loudly instead.
|
|
@@ -730,9 +724,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
730
724
|
? `${cmd("next")} --packet ${packetRef}`
|
|
731
725
|
: `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
|
|
732
726
|
const fallbackAction = picked.stage === "done"
|
|
733
|
-
?
|
|
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.`)
|
|
727
|
+
? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
|
|
736
728
|
: `Run ${command}.`;
|
|
737
729
|
if (picked.stage === "done") {
|
|
738
730
|
actions.unshift(fallbackAction);
|
|
@@ -769,6 +761,6 @@ export {
|
|
|
769
761
|
prepareBuildGateIssue,
|
|
770
762
|
addPrepareBuildGateErrors,
|
|
771
763
|
deployUrlFromReportOutputs,
|
|
772
|
-
|
|
764
|
+
readinessStatus,
|
|
773
765
|
buildNextStep,
|
|
774
766
|
};
|
|
@@ -50,9 +50,12 @@ export function reportKeyForCliStage(cliStage) {
|
|
|
50
50
|
* "completed", "completed_with_warnings", and "completed_partial" all
|
|
51
51
|
* count as terminal under "completed". This matches how the existing
|
|
52
52
|
* stages already report sub-statuses (see report.stages.assembly.status
|
|
53
|
-
* shapes in src/cli.mjs and qa/shared/qa-verdict.js).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* shapes in src/cli.mjs and qa/shared/qa-verdict.js). This is the rule for a
|
|
54
|
+
* RECORDED status only (the session progress summary's incomplete stages,
|
|
55
|
+
* the prepare-build gate, doctor's checks). Whether a stage is done is
|
|
56
|
+
* decided on its effective status (src/input-currency.mjs), which the `next`
|
|
57
|
+
* picker and the Assembly Report's derived summary (src/stage-ledger.mjs)
|
|
58
|
+
* read: a stage owed again is not done, whatever status it records.
|
|
56
59
|
*/
|
|
57
60
|
export const STAGE_TERMINAL_STATUS_PREFIXES = Object.freeze(["completed", "skipped"]);
|
|
58
61
|
|