@nextcommerce/campaigns-os 1.50.0 → 1.52.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 +426 -0
- package/agents/claude/CLAUDE.md +2 -2
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +81 -2
- package/contracts/release-ledger.json +906 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/build-packet.md +93 -9
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +45 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -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 +3 -3
- package/skills/next-campaigns-os/SKILL.md +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +12 -0
- package/src/build-brief.mjs +68 -21
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +310 -34
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/invocation.mjs +2 -1
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-browser.mjs +308 -96
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +104 -12
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-record.mjs +56 -17
|
@@ -104,11 +104,72 @@ function selectedShipping(page) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// One predicate for "this checkout row is an order bump": the spec marks a
|
|
107
|
-
// bump with `
|
|
107
|
+
// bump with `is_order_bump: true` (the schema's and authoring guide's flag) or
|
|
108
|
+
// `is_upsell: true` on a non-upsell page. Every consumer that must
|
|
108
109
|
// tell a bump from a main selector row (parity scenarios, bump deltas, the QA
|
|
109
|
-
// selector-tier planner) reads this, so a
|
|
110
|
+
// selector-tier planner, `next`'s order-bump QA command) reads this, so a
|
|
111
|
+
// marker change lands in one place.
|
|
110
112
|
export function isBumpRow(row) {
|
|
111
|
-
return Boolean(row?.is_upsell);
|
|
113
|
+
return Boolean(row?.is_order_bump || row?.is_upsell);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A checkout package row's ref, with the doctor's specPackageRecords
|
|
117
|
+
// tolerance (ref_id, then package_id, then id).
|
|
118
|
+
function checkoutRowRef(pkg) {
|
|
119
|
+
return [pkg.ref_id, pkg.package_id, pkg.id]
|
|
120
|
+
.map((value) => (value == null ? "" : String(value).trim()))
|
|
121
|
+
.find(Boolean);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Order bumps declared on a checkout page (isBumpRow rows), as refs in
|
|
125
|
+
// declaration order. They are add-ons to a selected tier, not tiers, so the
|
|
126
|
+
// QA tier planner reports and skips them and `next` names the --cart run that
|
|
127
|
+
// toggles them.
|
|
128
|
+
export function declaredOrderBumps(checkoutPage) {
|
|
129
|
+
const refs = [];
|
|
130
|
+
for (const pkg of array(checkoutPage?.packages)) {
|
|
131
|
+
if (!pkg || typeof pkg !== "object" || !isBumpRow(pkg)) continue;
|
|
132
|
+
const ref = checkoutRowRef(pkg);
|
|
133
|
+
if (ref && !refs.includes(ref)) refs.push(ref);
|
|
134
|
+
}
|
|
135
|
+
return refs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Selector tiers are the packages a spec declares on a checkout page, in
|
|
139
|
+
// declaration order. Order-bump rows are add-ons offered alongside the
|
|
140
|
+
// selected tier, not tiers of their own: they never become a tier.
|
|
141
|
+
export function declaredSelectorTiers(checkoutPage) {
|
|
142
|
+
const records = [];
|
|
143
|
+
const quantitiesByRef = new Map();
|
|
144
|
+
for (const pkg of array(checkoutPage?.packages)) {
|
|
145
|
+
if (!pkg || typeof pkg !== "object" || isBumpRow(pkg)) continue;
|
|
146
|
+
const ref = checkoutRowRef(pkg);
|
|
147
|
+
const declaredQuantity = Number(pkg.qty ?? pkg.quantity ?? 1);
|
|
148
|
+
if (!ref || !Number.isInteger(declaredQuantity) || declaredQuantity < 1) continue;
|
|
149
|
+
records.push({ pkg, ref, declaredQuantity });
|
|
150
|
+
if (!quantitiesByRef.has(ref)) quantitiesByRef.set(ref, new Set());
|
|
151
|
+
quantitiesByRef.get(ref).add(declaredQuantity);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tiers = [];
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
for (const { pkg, ref, declaredQuantity } of records) {
|
|
157
|
+
// A unique ref is a catalog package bought once, even when that package's
|
|
158
|
+
// own composition is 3x. Only repeated declarations of the SAME ref at
|
|
159
|
+
// different quantities express shopper purchase multipliers (e.g. a 1x
|
|
160
|
+
// and a 2x package).
|
|
161
|
+
const quantity = quantitiesByRef.get(ref).size > 1 ? declaredQuantity : 1;
|
|
162
|
+
const identity = `${ref}:${quantity}`;
|
|
163
|
+
if (seen.has(identity)) continue;
|
|
164
|
+
seen.add(identity);
|
|
165
|
+
const label = [pkg.name, pkg.title].find((value) => typeof value === "string" && value.trim());
|
|
166
|
+
tiers.push({
|
|
167
|
+
ref,
|
|
168
|
+
quantity,
|
|
169
|
+
declared_by: label ? label.trim() : undefined,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return tiers;
|
|
112
173
|
}
|
|
113
174
|
|
|
114
175
|
function lineForRow(row) {
|
|
@@ -932,7 +993,7 @@ function makePage(scenarios, catalog) {
|
|
|
932
993
|
row_index: row.row_index,
|
|
933
994
|
package_id: row.package_id,
|
|
934
995
|
quantity: row.quantity,
|
|
935
|
-
...(row
|
|
996
|
+
...(isBumpRow(row) ? { is_upsell: true } : {}),
|
|
936
997
|
name: row.name,
|
|
937
998
|
state: PricingState.Unresolved,
|
|
938
999
|
reason: pageReason,
|
|
@@ -607,7 +607,12 @@ function partitionCaptures(capturesValue, { countsOnly = false } = {}) {
|
|
|
607
607
|
|
|
608
608
|
function priceComparisonClaims(capture, page) {
|
|
609
609
|
const claims = array(capture?.price_claims);
|
|
610
|
-
|
|
610
|
+
// A voucher on the page that the plan cannot price (no calculated pair for
|
|
611
|
+
// its code) can move the shown price away from the planned total, so a
|
|
612
|
+
// difference there says nothing about the page. Its price claims stay
|
|
613
|
+
// unresolved, which already keeps coverage incomplete, rather than being
|
|
614
|
+
// reported as mismatches against the list-price total.
|
|
615
|
+
if (!exactMoneyFact(page?.representative_total) || voucherComparisonClaims(capture, page).unresolved > 0) {
|
|
611
616
|
return { compared: [], unresolved: claims.length };
|
|
612
617
|
}
|
|
613
618
|
const bindings = [...new Set(claims.map((claim) => String(claim.binding)))];
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Doctor checks: the check registries, validatePacket and the validators they run.
|
|
2
2
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
3
|
import { withHtmlScanSnapshot, readHtmlScanText } from "../html-scan.mjs";
|
|
4
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
|
|
7
7
|
import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthsDisagree } from "../proof-policy.mjs";
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
|
|
45
45
|
LOCAL_PROOF_NEVER_EDIT_RULE,
|
|
46
46
|
LOCAL_PROOF_PARITY_COMMAND,
|
|
47
|
+
LOCAL_PROOF_RECORD_BUILD_COMMAND,
|
|
47
48
|
LOCAL_PROOF_PARITY_FIELD,
|
|
48
49
|
LOCAL_PROOF_PARITY_SCOPE,
|
|
49
50
|
recordedBuildEnvironment,
|
|
@@ -84,6 +85,9 @@ import {
|
|
|
84
85
|
import { CAMPAIGN_IDENTITY, evaluateCampaignIdentity, externalScriptSources } from "../campaign-identity.mjs";
|
|
85
86
|
import { SDK_MARKUP, evaluateSdkMarkup } from "../sdk-markup.mjs";
|
|
86
87
|
import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSyntax } from "../built-script-syntax.mjs";
|
|
88
|
+
import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
|
|
89
|
+
import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
|
|
90
|
+
import { recordQcResults } from "../qc-results.mjs";
|
|
87
91
|
import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
|
|
88
92
|
import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
|
|
89
93
|
import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
@@ -104,6 +108,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
|
|
|
104
108
|
// so a fresh install (including the git-ref consumer) always has dist.
|
|
105
109
|
import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
106
110
|
import { cmd, asInvocation } from "../install-invocation.mjs";
|
|
111
|
+
import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
|
|
107
112
|
import {
|
|
108
113
|
isObject,
|
|
109
114
|
isNonEmptyString,
|
|
@@ -428,7 +433,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
428
433
|
{
|
|
429
434
|
id: CAMPAIGN_IDENTITY,
|
|
430
435
|
phase: "built-output",
|
|
431
|
-
run: ({ packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived),
|
|
436
|
+
run: ({ spec, packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived, spec),
|
|
432
437
|
},
|
|
433
438
|
{
|
|
434
439
|
id: SDK_MARKUP,
|
|
@@ -440,6 +445,16 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
440
445
|
phase: "built-output",
|
|
441
446
|
run: ({ packet, errors, warnings, ready, derived }) => validateBuiltScriptSyntax(packet, errors, warnings, ready, derived),
|
|
442
447
|
},
|
|
448
|
+
{
|
|
449
|
+
id: CART_PLACEHOLDERS,
|
|
450
|
+
phase: "built-output",
|
|
451
|
+
run: ({ packet, warnings, ready, derived }) => validateCartPlaceholders(packet, warnings, ready, derived),
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
id: SMOKE_QC,
|
|
455
|
+
phase: "built-output",
|
|
456
|
+
run: ({ packet, warnings, ready, derived, buildState }) => validateSmokeQc(packet, warnings, ready, derived, buildState),
|
|
457
|
+
},
|
|
443
458
|
{
|
|
444
459
|
id: "built_output.sdk_meta_tags",
|
|
445
460
|
phase: "built-output",
|
|
@@ -724,6 +739,16 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
724
739
|
addIssue(errors, localIdentity ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: localIdentity ? "local_spec" : "saved_map" });
|
|
725
740
|
}
|
|
726
741
|
ready.push("Local CampaignSpec parsed");
|
|
742
|
+
// QA refuses a local-spec run whose spec no longer has the material hash
|
|
743
|
+
// prepare-build bound on the Assembly Report; doctor (and next, which
|
|
744
|
+
// prints doctor's warnings) name it at the first read instead.
|
|
745
|
+
const boundMaterialHash = buildState.report?.identity?.spec_material_hash;
|
|
746
|
+
if (packet.spec?.local_spec_id != null && isNonEmptyString(boundMaterialHash)) {
|
|
747
|
+
const currentMaterialHash = specMaterialHash(spec);
|
|
748
|
+
if (!specHashesMatch(boundMaterialHash, currentMaterialHash)) {
|
|
749
|
+
addIssue(warnings, "spec.material_stale", `The CampaignSpec at ${localSpecPath} changed materially since prepare-build bound it (Assembly Report identity.spec_material_hash ${boundMaterialHash}; the spec now hashes to ${currentMaterialHash}). The build and its recorded evidence predate the edit, and QA refuses a stale spec. Re-run ${cmd("prepare-build")} from the edited spec before building on it further.`, { spec_path: localSpecPath, bound_material_hash: boundMaterialHash, current_material_hash: currentMaterialHash });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
727
752
|
runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
|
|
728
753
|
} else {
|
|
729
754
|
runDoctorChecks(
|
|
@@ -852,7 +877,7 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
|
|
|
852
877
|
}
|
|
853
878
|
|
|
854
879
|
const brief = readJson(resolvedPath);
|
|
855
|
-
const result = validateCampaignBuildBriefArtifact(brief, { spec });
|
|
880
|
+
const result = validateCampaignBuildBriefArtifact(brief, { spec, normalizedPath });
|
|
856
881
|
for (const issue of result.errors) errors.push(issue);
|
|
857
882
|
for (const issue of result.warnings) warnings.push(issue);
|
|
858
883
|
ready.push(...result.ready);
|
|
@@ -1129,7 +1154,7 @@ export function isLocalhostDevelopmentOrigin(value) {
|
|
|
1129
1154
|
// Both facts are read from the assembly stage's free-form evidence.
|
|
1130
1155
|
function validateLocalProof(packet, report, errors, warnings, ready) {
|
|
1131
1156
|
if (!stageIsTerminal(report?.stages?.assembly?.status)) {
|
|
1132
|
-
ready.push(`Local proof mode: the build stage renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}; polish capture and QA run against that served output, and ${asInvocation(LOCAL_PROOF_PARITY_COMMAND)} proves the production render before commit.`);
|
|
1157
|
+
ready.push(`Local proof mode: the build stage renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}; polish capture and QA run against that served output, and ${asInvocation(LOCAL_PROOF_PARITY_COMMAND)} proves the production render before commit.`);
|
|
1133
1158
|
return;
|
|
1134
1159
|
}
|
|
1135
1160
|
const environment = recordedBuildEnvironment(report);
|
|
@@ -1137,8 +1162,8 @@ function validateLocalProof(packet, report, errors, warnings, ready) {
|
|
|
1137
1162
|
ready.push(`Local proof mode: the built _site/ is recorded as a ${LOCAL_PROOF_BUILD_ENVIRONMENT} render (${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}); vendor loaders are environment-gated out, SDK dl_* events still fire.`);
|
|
1138
1163
|
} else {
|
|
1139
1164
|
addIssue(warnings, LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE, environment
|
|
1140
|
-
? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}" under deploy.target local-serve. A production build served over plain HTTP fails polish capture unwaivably on its protocol-relative vendor loaders (//host/...). Rebuild with ${LOCAL_PROOF_BUILD_COMMAND}, record
|
|
1141
|
-
: `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded under deploy.target local-serve. The build stage renders the development environment for local proof (${LOCAL_PROOF_BUILD_COMMAND}) and records it
|
|
1165
|
+
? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}" under deploy.target local-serve. A production build served over plain HTTP fails polish capture unwaivably on its protocol-relative vendor loaders (//host/...). Rebuild with ${LOCAL_PROOF_BUILD_COMMAND}, record it with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}, and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`
|
|
1166
|
+
: `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded under deploy.target local-serve. The build stage renders the development environment for local proof (${LOCAL_PROOF_BUILD_COMMAND}) and records it with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}; without the record doctor cannot tell a development render from a production build that will fail polish capture over plain HTTP. ${LOCAL_PROOF_NEVER_EDIT_RULE}`);
|
|
1142
1167
|
}
|
|
1143
1168
|
const parity = recordedProductionParity(report);
|
|
1144
1169
|
const parityCommand = asInvocation(LOCAL_PROOF_PARITY_COMMAND);
|
|
@@ -1983,17 +2008,30 @@ function recordUpsellSelectorScopeGate({ subject, pages, waivers, errors, warnin
|
|
|
1983
2008
|
// carries another funnel's key or tag arrives in a later edit round as often
|
|
1984
2009
|
// as at first assembly. Enumerates from the filesystem so both doctor paths
|
|
1985
2010
|
// scan the same pages, and stays blocking regardless of stage status.
|
|
1986
|
-
|
|
2011
|
+
//
|
|
2012
|
+
// With a CampaignSpec, each built page is marked by whether some active spec
|
|
2013
|
+
// page builds to it (the same path route drift claims), so a finding on a
|
|
2014
|
+
// file no spec page produces names it as leftover output.
|
|
2015
|
+
export function validateCampaignIdentity(packet, errors, ready, derived, spec = null) {
|
|
1987
2016
|
const targetRepo = derived.target_repo;
|
|
1988
2017
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1989
2018
|
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1990
2019
|
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
2020
|
+
const pages = scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [];
|
|
2021
|
+
const specPages = isObject(spec) ? activeSpecPages(spec) : [];
|
|
2022
|
+
if (specPages.length) {
|
|
2023
|
+
const claimed = new Set(specPages
|
|
2024
|
+
.map((page) => builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived))
|
|
2025
|
+
.filter(Boolean)
|
|
2026
|
+
.map((path) => resolve(path)));
|
|
2027
|
+
for (const page of pages) page.spec_page = claimed.has(resolve(targetRepo, page.file));
|
|
2028
|
+
}
|
|
1991
2029
|
recordCampaignIdentityGate({
|
|
1992
2030
|
subject: {
|
|
1993
2031
|
public_route_slug: publicRouteSlug || null,
|
|
1994
2032
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
1995
2033
|
},
|
|
1996
|
-
pages
|
|
2034
|
+
pages,
|
|
1997
2035
|
errors,
|
|
1998
2036
|
ready,
|
|
1999
2037
|
derived,
|
|
@@ -2009,7 +2047,24 @@ function validateCampaignIdentity(packet, errors, ready, derived) {
|
|
|
2009
2047
|
// `/<slug>/config.js`), then the campaign directory (a root-served campaign
|
|
2010
2048
|
// emits `/config.js`); relative srcs resolve against the page. Remote and
|
|
2011
2049
|
// missing scripts contribute nothing.
|
|
2012
|
-
|
|
2050
|
+
//
|
|
2051
|
+
// The bounded form ({ pages, bounds }) takes pages the caller already read
|
|
2052
|
+
// ({file, content?}, as collectCartPlaceholderPages gives them) and adds each
|
|
2053
|
+
// page's `scripts` as the smoke check's anchor script hint reads them
|
|
2054
|
+
// (boundedPageScripts), listed from the page's bounded parse5 tree
|
|
2055
|
+
// (pageScriptSources: any attribute quoting, spacing or case; never a
|
|
2056
|
+
// non-JavaScript type or a script in <template> or <noscript>). A page with
|
|
2057
|
+
// no content, or one that cannot be parsed, is returned as given. The smoke
|
|
2058
|
+
// check itself hands boundedPageScripts the srcs its own parse lists, so a
|
|
2059
|
+
// page is parsed once.
|
|
2060
|
+
function collectBuiltPageIdentityInputs(scope, targetRepo, { pages = null, bounds = null } = {}) {
|
|
2061
|
+
if (pages && bounds) {
|
|
2062
|
+
const scriptsOf = boundedPageScripts(scope.site_root, targetRepo, bounds);
|
|
2063
|
+
return pages.map((page) => {
|
|
2064
|
+
const sources = typeof page?.content === "string" ? boundedScriptSources(page.content) : null;
|
|
2065
|
+
return sources ? { ...page, scripts: scriptsOf(sources, join(targetRepo, page.file)) } : page;
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2013
2068
|
const scriptCache = new Map();
|
|
2014
2069
|
const readScript = (path) => {
|
|
2015
2070
|
if (!scriptCache.has(path)) {
|
|
@@ -2023,23 +2078,11 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
|
|
|
2023
2078
|
}
|
|
2024
2079
|
return scriptCache.get(path);
|
|
2025
2080
|
};
|
|
2026
|
-
const resolveLocalScript = (src, builtPath) => {
|
|
2027
|
-
const raw = String(src || "").trim();
|
|
2028
|
-
if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
|
|
2029
|
-
const clean = raw.replace(/[?#].*$/, "");
|
|
2030
|
-
if (!clean) return null;
|
|
2031
|
-
if (clean.startsWith("/")) {
|
|
2032
|
-
const rel = clean.replace(/^\/+/, "");
|
|
2033
|
-
const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
|
|
2034
|
-
return candidates.find((candidate) => existsSync(candidate)) || null;
|
|
2035
|
-
}
|
|
2036
|
-
return resolve(dirname(builtPath), clean);
|
|
2037
|
-
};
|
|
2038
2081
|
return scope.pages.map((page) => {
|
|
2039
2082
|
const content = readFileSync(page.built_path, "utf8");
|
|
2040
2083
|
const scripts = [];
|
|
2041
2084
|
for (const src of externalScriptSources(content)) {
|
|
2042
|
-
const path =
|
|
2085
|
+
const path = builtLocalScriptPath(scope, src, page.built_path);
|
|
2043
2086
|
const scriptContent = path ? readScript(path) : null;
|
|
2044
2087
|
if (scriptContent == null) continue;
|
|
2045
2088
|
scripts.push({ src, file: relFromDir(targetRepo, path), content: scriptContent });
|
|
@@ -2054,6 +2097,84 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
|
|
|
2054
2097
|
});
|
|
2055
2098
|
}
|
|
2056
2099
|
|
|
2100
|
+
// Where a page's local `<script src>` lives, or null for a remote, data: or
|
|
2101
|
+
// empty src. An absolute src is the first of its site-root and campaign
|
|
2102
|
+
// candidates that exists, otherwise null.
|
|
2103
|
+
function builtLocalScriptPath(scope, src, builtPath) {
|
|
2104
|
+
const raw = String(src || "").trim();
|
|
2105
|
+
if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
|
|
2106
|
+
const clean = raw.replace(/[?#].*$/, "");
|
|
2107
|
+
if (!clean) return null;
|
|
2108
|
+
if (clean.startsWith("/")) {
|
|
2109
|
+
const rel = clean.replace(/^\/+/, "");
|
|
2110
|
+
const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
|
|
2111
|
+
return candidates.find((candidate) => existsSync(candidate)) || null;
|
|
2112
|
+
}
|
|
2113
|
+
return resolve(dirname(builtPath), clean);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// The srcs of the scripts a page loads, or null when it cannot be parsed.
|
|
2117
|
+
function boundedScriptSources(content) {
|
|
2118
|
+
try {
|
|
2119
|
+
return pageScriptSources(parseBuiltPage(content));
|
|
2120
|
+
} catch (error) {
|
|
2121
|
+
if (!isBuiltPageReadFailure(error)) throw error;
|
|
2122
|
+
return null;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// The smoke check's script reader: given the srcs a page loads and the
|
|
2127
|
+
// page's file, every local script, read or not. The first `bounds.scripts`
|
|
2128
|
+
// are read when their real path lies inside the site root and they hold at
|
|
2129
|
+
// most `bounds.script_bytes` bytes ({src, file, content}); any other reads
|
|
2130
|
+
// {src, file, unread} with `missing`, `outside_site`, `too_large`,
|
|
2131
|
+
// `unreadable` or `script_cap`, or {src, unread: "unmappable"} when its src
|
|
2132
|
+
// names no file. Each src maps to its file through builtFileOf: the URL
|
|
2133
|
+
// parser resolves it against the page's URL under the site root (a
|
|
2134
|
+
// root-relative src starts at the site root, never the campaign directory),
|
|
2135
|
+
// and its path segments are percent-decoded. Each script file is read at most
|
|
2136
|
+
// once per run.
|
|
2137
|
+
function boundedPageScripts(siteRoot, targetRepo, bounds) {
|
|
2138
|
+
const realSiteRoot = realPathOf(siteRoot);
|
|
2139
|
+
const cache = new Map();
|
|
2140
|
+
const read = (path) => {
|
|
2141
|
+
if (!cache.has(path)) cache.set(path, readBoundedScript(realSiteRoot, path, bounds.script_bytes));
|
|
2142
|
+
return cache.get(path);
|
|
2143
|
+
};
|
|
2144
|
+
return (sources, builtPath) => {
|
|
2145
|
+
const scripts = [];
|
|
2146
|
+
for (const src of sources) {
|
|
2147
|
+
const mapped = builtFileOf(src, builtPath, siteRoot);
|
|
2148
|
+
if (!mapped) continue;
|
|
2149
|
+
if (mapped.unmappable) {
|
|
2150
|
+
scripts.push({ src, unread: "unmappable" });
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
const path = mapped.path;
|
|
2154
|
+
const file = relFromDir(targetRepo, path);
|
|
2155
|
+
scripts.push(scripts.length < bounds.scripts ? { src, file, ...read(path) } : { src, file, unread: "script_cap" });
|
|
2156
|
+
}
|
|
2157
|
+
return scripts;
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// { content } or { unread }. A file-system read failure reads unread; any
|
|
2162
|
+
// other error is a defect and throws.
|
|
2163
|
+
function readBoundedScript(realSiteRoot, path, maxBytes) {
|
|
2164
|
+
try {
|
|
2165
|
+
const real = realpathSync(path);
|
|
2166
|
+
if (!realSiteRoot || !insideRoot(realSiteRoot, real)) return { unread: "outside_site" };
|
|
2167
|
+
const stat = statSync(real);
|
|
2168
|
+
if (!stat.isFile()) return { unread: "unreadable" };
|
|
2169
|
+
if (stat.size > maxBytes) return { unread: "too_large" };
|
|
2170
|
+
const bytes = readFileSync(real);
|
|
2171
|
+
return bytes.length > maxBytes ? { unread: "too_large" } : { content: bytes.toString("utf8") };
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
if (!isFileReadFailure(error)) throw error;
|
|
2174
|
+
return { unread: error.code === "ENOENT" || error.code === "ENOTDIR" ? "missing" : "unreadable" };
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2057
2178
|
function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
|
|
2058
2179
|
const gate = evaluateCampaignIdentity({ subject, pages });
|
|
2059
2180
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
@@ -2143,6 +2264,148 @@ function recordScriptSyntaxGate({ subject, inputs, errors, warnings, ready, deri
|
|
|
2143
2264
|
return gate;
|
|
2144
2265
|
}
|
|
2145
2266
|
|
|
2267
|
+
// Raw cart placeholders. Every doctor invocation, both entry
|
|
2268
|
+
// points, filesystem enumeration, like the gates above; but a QC check, not a
|
|
2269
|
+
// checkpoint gate: its results land in derived.qc_results (warning and review
|
|
2270
|
+
// also in warnings[]), never in errors[] or derived.checkpoint_gates, so no
|
|
2271
|
+
// blocker elsewhere can withhold them and checkpoint waive never sees them.
|
|
2272
|
+
function validateCartPlaceholders(packet, warnings, ready, derived) {
|
|
2273
|
+
const targetRepo = derived.target_repo;
|
|
2274
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2275
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2276
|
+
recordCartPlaceholders({
|
|
2277
|
+
subject: {
|
|
2278
|
+
public_route_slug: publicRouteSlug || null,
|
|
2279
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2280
|
+
},
|
|
2281
|
+
pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
|
|
2282
|
+
warnings,
|
|
2283
|
+
ready,
|
|
2284
|
+
derived,
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// Every built page under the campaign directory, in path order, including
|
|
2289
|
+
// every symbolic link that may hold a page (an .html link, a directory link,
|
|
2290
|
+
// or a link whose target cannot be inspected; each one entry, read as
|
|
2291
|
+
// unreadable, never followed). Pages within the page cap carry their HTML,
|
|
2292
|
+
// read one page at a time, unless they are over the size cap or cannot be
|
|
2293
|
+
// read; pages past it carry only their path.
|
|
2294
|
+
function collectCartPlaceholderPages(targetRepo, slug) {
|
|
2295
|
+
const scope = resolveBuiltSiteScope(targetRepo, { slug, includeLinkedPages: true });
|
|
2296
|
+
const linked = new Set((scope.linked_pages || []).map((page) => page.built_path));
|
|
2297
|
+
const all = [...(scope.ok ? scope.pages : []), ...(scope.linked_pages || [])]
|
|
2298
|
+
.sort((a, b) => (a.built_path < b.built_path ? -1 : a.built_path > b.built_path ? 1 : 0));
|
|
2299
|
+
const fileOf = (page) => builtPageFile(targetRepo, page.built_path);
|
|
2300
|
+
return [
|
|
2301
|
+
...all.slice(0, CART_PLACEHOLDERS_LIMITS.pages).map((page) => readCartPlaceholderPage(page.built_path, fileOf(page), linked.has(page.built_path))),
|
|
2302
|
+
...all.slice(CART_PLACEHOLDERS_LIMITS.pages).map((page) => ({ file: fileOf(page) })),
|
|
2303
|
+
];
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// One page within the page cap: its HTML, its size when over the size cap, or
|
|
2307
|
+
// unreadable. A file-system read failure, before or after the readability
|
|
2308
|
+
// precheck (EIO, EACCES, EISDIR ...), reads it unreadable; any other error is
|
|
2309
|
+
// a defect and throws.
|
|
2310
|
+
function readCartPlaceholderPage(path, file, linked) {
|
|
2311
|
+
if (linked) return { file, unreadable: true };
|
|
2312
|
+
try {
|
|
2313
|
+
const bytes = statSync(path).size;
|
|
2314
|
+
if (!isReadableFile(path)) return { file, unreadable: true };
|
|
2315
|
+
if (bytes > CART_PLACEHOLDERS_LIMITS.page_bytes) return { file, bytes };
|
|
2316
|
+
return { file, content: readFileSync(path, "utf8") };
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
if (!isFileReadFailure(error)) throw error;
|
|
2319
|
+
return { file, unreadable: true };
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// A built page's path relative to the doctor target, "/"-separated and with no
|
|
2324
|
+
// "./" prefix ("_site/<slug>/index.html"). The directory is resolved, the file
|
|
2325
|
+
// name never is, so a symlinked page keeps its own name.
|
|
2326
|
+
function builtPageFile(targetRepo, path) {
|
|
2327
|
+
const dir = relFromDir(targetRepo, dirname(path)).replace(/^\.(?:\/|$)/, "");
|
|
2328
|
+
return [...(dir ? dir.split(sep) : []), basename(path)].join("/");
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Whether the page can be opened for reading. Only a file-system read failure
|
|
2332
|
+
// says it cannot; any other error is a defect and throws.
|
|
2333
|
+
function isReadableFile(path) {
|
|
2334
|
+
try {
|
|
2335
|
+
accessSync(path, fsConstants.R_OK);
|
|
2336
|
+
return true;
|
|
2337
|
+
} catch (error) {
|
|
2338
|
+
if (!isFileReadFailure(error)) throw error;
|
|
2339
|
+
return false;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
function recordCartPlaceholders({ subject, pages, warnings, ready, derived }) {
|
|
2344
|
+
const results = evaluateCartPlaceholders({ subject, pages });
|
|
2345
|
+
recordQcResults({ derived, warnings, results });
|
|
2346
|
+
if (!pages.length) {
|
|
2347
|
+
ready.push("Cart placeholder check not applicable: no built page to scan yet.");
|
|
2348
|
+
return results;
|
|
2349
|
+
}
|
|
2350
|
+
// A ready line only when no page went unexercised.
|
|
2351
|
+
if (results.some((row) => row.result === "unexercised")) return results;
|
|
2352
|
+
const passed = results.filter((row) => row.result === "pass").length;
|
|
2353
|
+
const flagged = results.filter((row) => row.result === "warning" || row.result === "review").length;
|
|
2354
|
+
ready.push(`Cart placeholder check on ${pages.length} built page(s): ${passed} page(s) pass, ${flagged} warning or review result(s)`);
|
|
2355
|
+
return results;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// Built-output smoke checks. Same placement and the same QC wiring as the
|
|
2359
|
+
// cart placeholder check above, over the same built pages. The build
|
|
2360
|
+
// environment is the one the Assembly Report records (nothing measures it),
|
|
2361
|
+
// and the deploy URLs give the base an absolute og:image maps from.
|
|
2362
|
+
function validateSmokeQc(packet, warnings, ready, derived, buildState = {}) {
|
|
2363
|
+
const targetRepo = derived.target_repo;
|
|
2364
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2365
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2366
|
+
recordSmokeQc({
|
|
2367
|
+
subject: {
|
|
2368
|
+
public_route_slug: publicRouteSlug || null,
|
|
2369
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2370
|
+
},
|
|
2371
|
+
targetRepo,
|
|
2372
|
+
pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
|
|
2373
|
+
environment: recordedBuildEnvironment(buildState?.report),
|
|
2374
|
+
deployBase: [packet?.deploy?.preview_url, packet?.deploy?.production_url],
|
|
2375
|
+
warnings,
|
|
2376
|
+
ready,
|
|
2377
|
+
derived,
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, warnings, ready, derived }) {
|
|
2382
|
+
// The site root is the scope's, whichever form of target doctor was given
|
|
2383
|
+
// (the repo, its _site/ or a campaign directory). The anchor script hint
|
|
2384
|
+
// reads each page's local scripts through boundedPageScripts, from the srcs
|
|
2385
|
+
// the gate's own parse lists. With no scope to resolve them in, a page's
|
|
2386
|
+
// local scripts read unread.
|
|
2387
|
+
const scope = targetRepo && pages.length ? resolveBuiltSiteScope(targetRepo, { slug: subject?.public_route_slug || null }) : null;
|
|
2388
|
+
const siteRoot = scope?.site_root || null;
|
|
2389
|
+
const results = evaluateSmokeQc({
|
|
2390
|
+
pages,
|
|
2391
|
+
environment,
|
|
2392
|
+
siteRoot,
|
|
2393
|
+
targetDir: targetRepo || null,
|
|
2394
|
+
readScripts: siteRoot ? boundedPageScripts(siteRoot, targetRepo, SMOKE_QC_LIMITS) : null,
|
|
2395
|
+
deployBase,
|
|
2396
|
+
});
|
|
2397
|
+
recordQcResults({ derived, warnings, results });
|
|
2398
|
+
if (!pages.length) {
|
|
2399
|
+
ready.push("Smoke checks not applicable: no built page to scan yet.");
|
|
2400
|
+
return results;
|
|
2401
|
+
}
|
|
2402
|
+
// A ready line only when no result went unexercised (contract 1.0).
|
|
2403
|
+
if (results.some((row) => row.result === "unexercised")) return results;
|
|
2404
|
+
const count = (...values) => results.filter((row) => values.includes(row.result)).length;
|
|
2405
|
+
ready.push(`Smoke checks on ${pages.length} built page(s): ${count("pass")} pass, ${count("warning", "review")} warning or review result(s)`);
|
|
2406
|
+
return results;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2146
2409
|
function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
|
|
2147
2410
|
const gate = evaluateSdkMarkup({ subject, pages });
|
|
2148
2411
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
@@ -2565,7 +2828,7 @@ function pageKitAssetPathViolation(reference, publicRouteSlug) {
|
|
|
2565
2828
|
};
|
|
2566
2829
|
}
|
|
2567
2830
|
|
|
2568
|
-
function resolveBuiltAssetPath(src, builtPath, targetRepo) {
|
|
2831
|
+
export function resolveBuiltAssetPath(src, builtPath, targetRepo) {
|
|
2569
2832
|
if (!isNonEmptyString(src)) return null;
|
|
2570
2833
|
const raw = src.trim();
|
|
2571
2834
|
if (raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
|
|
@@ -3090,7 +3353,7 @@ function coverageErrorMessage(page, { figmaGate = false } = {}) {
|
|
|
3090
3353
|
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="ai-generated"${fileUrlHint} — re-run the producing agent so the source HTML and source-html manifest land in the source root, then rerun prepare-build. ${sourceManifestHint(page, figmaGate)} See docs/entry-points.md for the AI-generated entry point contract.`;
|
|
3091
3354
|
}
|
|
3092
3355
|
if (!fileUrl) {
|
|
3093
|
-
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source is set but file_url is missing — add file_url to the spec before requesting a build
|
|
3356
|
+
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source is set but file_url is missing — add file_url to the spec before requesting a build, or, for hand-written or template HTML, remove design_source from the page and map it in the source-html manifest. ${sourceManifestHint(page, figmaGate)}`;
|
|
3094
3357
|
}
|
|
3095
3358
|
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="${designSource.type}" at ${fileUrl}; produce the source HTML for this page (or update design_source.type to a recognized producer — see docs/entry-points.md) before rerunning prepare-build. ${sourceManifestHint(page, figmaGate)}`;
|
|
3096
3359
|
}
|
|
@@ -3329,6 +3592,7 @@ function validateSourcePreparation(packet, packetPath, errors, warnings, ready,
|
|
|
3329
3592
|
sourceRoot,
|
|
3330
3593
|
pages,
|
|
3331
3594
|
wrapperPolicy: packet.source_html?.adapter_contract?.wrapper_policy,
|
|
3595
|
+
targetRoot: resolveFromFile(packetPath, packet.assembly?.target_repo),
|
|
3332
3596
|
});
|
|
3333
3597
|
derived.source_preparation = {
|
|
3334
3598
|
checked_page_count: result.checked_page_count,
|
|
@@ -4636,6 +4900,9 @@ export {
|
|
|
4636
4900
|
recordCampaignIdentityGate,
|
|
4637
4901
|
recordScriptSyntaxGate,
|
|
4638
4902
|
recordSdkMarkupGate,
|
|
4903
|
+
recordCartPlaceholders,
|
|
4904
|
+
collectCartPlaceholderPages,
|
|
4905
|
+
recordSmokeQc,
|
|
4639
4906
|
summarizeCopyMatches,
|
|
4640
4907
|
resolveBrandContract,
|
|
4641
4908
|
resolveBrandContractOnce,
|