@nextcommerce/campaigns-os 1.43.1 → 1.43.2
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/AGENTS.md +4 -2
- package/CHANGELOG.md +451 -0
- package/README.md +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +8 -8
- package/contracts/release-ledger.json +672 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/build-packet.md +64 -2
- package/docs/campaigns-os-build-flow.md +1 -0
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +16 -4
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +131 -7
- package/docs/release-ledger-authoring-guide.md +6 -4
- package/docs/runtime-readiness.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 +4 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1211 -7495
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/diagnostic.mjs +2 -1
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4415 -0
- package/src/doctor/inspect.mjs +636 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +6 -35
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +778 -77
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-node.mjs +218 -13
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
|
@@ -0,0 +1,4415 @@
|
|
|
1
|
+
// Doctor checks: the check registries, validatePacket and the validators they run.
|
|
2
|
+
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
|
+
import { withHtmlScanSnapshot, readHtmlScanText } from "../html-scan.mjs";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
|
|
7
|
+
import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthsDisagree } from "../proof-policy.mjs";
|
|
8
|
+
import { QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild } from "../stage-ledger.mjs";
|
|
9
|
+
import {
|
|
10
|
+
validateAdapterDecisionGates,
|
|
11
|
+
validateAdapterDecisionShape,
|
|
12
|
+
validateAdapterSourceFiles,
|
|
13
|
+
} from "../adapter-decision-contract.mjs";
|
|
14
|
+
import { createDoctorCheckRegistry, runDoctorCheckRegistry } from "../doctor-check-registry.mjs";
|
|
15
|
+
import { evaluateSourcePreparation } from "../source-prep.mjs";
|
|
16
|
+
import { isLoopbackHostname } from "../remit.mjs";
|
|
17
|
+
import { publicRouteForPage } from "../source-html-intake.mjs";
|
|
18
|
+
import {
|
|
19
|
+
readSourceHtmlManifestFile,
|
|
20
|
+
SOURCE_HASH_PATTERN,
|
|
21
|
+
SOURCE_HTML_MANIFEST_SCHEMA,
|
|
22
|
+
} from "../source-html-manifest.mjs";
|
|
23
|
+
import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
|
|
24
|
+
import { singleLineDetail, singleLineField } from "../text-safety.mjs";
|
|
25
|
+
import {
|
|
26
|
+
campaignRouteRoot,
|
|
27
|
+
isAbsoluteHttpUrl,
|
|
28
|
+
normalizePageKitRoute,
|
|
29
|
+
normalizePublicRouteSlug,
|
|
30
|
+
packetRouteRoot,
|
|
31
|
+
runtimeRelativeRouteForSpecValue,
|
|
32
|
+
stripPublicRoutePrefix,
|
|
33
|
+
} from "../route-identity.mjs";
|
|
34
|
+
import { evaluatePageKitBuildSummary } from "../page-kit-build-summary.mjs";
|
|
35
|
+
import {
|
|
36
|
+
LOCAL_PROOF_BUILD_COMMAND,
|
|
37
|
+
LOCAL_PROOF_BUILD_ENVIRONMENT,
|
|
38
|
+
LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD,
|
|
39
|
+
LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
|
|
40
|
+
LOCAL_PROOF_NEVER_EDIT_RULE,
|
|
41
|
+
LOCAL_PROOF_PARITY_COMMAND,
|
|
42
|
+
LOCAL_PROOF_PARITY_FIELD,
|
|
43
|
+
LOCAL_PROOF_PARITY_SCOPE,
|
|
44
|
+
recordedBuildEnvironment,
|
|
45
|
+
recordedProductionParity,
|
|
46
|
+
} from "../local-proof.mjs";
|
|
47
|
+
import {
|
|
48
|
+
contractHasPaletteResidueChecks,
|
|
49
|
+
demoAssetConfig,
|
|
50
|
+
paymentMethodMarkupMatches,
|
|
51
|
+
paymentMethodStaticScanGaps,
|
|
52
|
+
withoutHiddenPaymentLogos,
|
|
53
|
+
placeholderTextResidueConfig,
|
|
54
|
+
placeholderTextResidueMatches,
|
|
55
|
+
} from "../template-brand-contract.mjs";
|
|
56
|
+
import {
|
|
57
|
+
scanBuiltOutputContentResidue,
|
|
58
|
+
loadBriefPayload,
|
|
59
|
+
briefUrgencyVerified,
|
|
60
|
+
collectRenderedHtmlFiles,
|
|
61
|
+
evaluateProofAssets,
|
|
62
|
+
attestationBlockers,
|
|
63
|
+
visibleText,
|
|
64
|
+
BRIEF_PAYLOAD_REL_PATH,
|
|
65
|
+
} from "../content-residue.mjs";
|
|
66
|
+
import {
|
|
67
|
+
resolveCommerceCatalog,
|
|
68
|
+
resolvePacketCommerceCatalogPath,
|
|
69
|
+
resolveTemplateBrandContract,
|
|
70
|
+
} from "../private-template-source.mjs";
|
|
71
|
+
import { assessTemplateFreshness, defaultSdkSupportPolicy, renderTemplateFreshness } from "../template-freshness.mjs";
|
|
72
|
+
import { computeBuildFingerprint, resolveBuiltSiteScope } from "../built-site-scope.mjs";
|
|
73
|
+
import {
|
|
74
|
+
UPSELL_SELECTOR_SCOPE,
|
|
75
|
+
evaluateUpsellSelectorScope,
|
|
76
|
+
isPostPurchasePageType,
|
|
77
|
+
} from "../upsell-selector-scope.mjs";
|
|
78
|
+
import { CAMPAIGN_IDENTITY, evaluateCampaignIdentity, externalScriptSources } from "../campaign-identity.mjs";
|
|
79
|
+
import { SDK_MARKUP, evaluateSdkMarkup } from "../sdk-markup.mjs";
|
|
80
|
+
import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSyntax } from "../built-script-syntax.mjs";
|
|
81
|
+
import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
|
|
82
|
+
import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
83
|
+
import {
|
|
84
|
+
assemblySourcePackageFingerprintMissing,
|
|
85
|
+
assessAssemblySourcePackageFreshnessWaivers,
|
|
86
|
+
currentBuildFingerprint,
|
|
87
|
+
} from "../polish-gate.mjs";
|
|
88
|
+
import { loadPageKitCampaignEntry, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
|
|
89
|
+
import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE } from "../page-kit-store-profile.mjs";
|
|
90
|
+
import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
|
|
91
|
+
// ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
|
|
92
|
+
// campaign authoring UI (e.g. a Map Builder bundle) import the same rules, so a
|
|
93
|
+
// spec check is authored once and reaches internal teams and agencies alike.
|
|
94
|
+
// Authored as pure TypeScript with no heavy deps; compiled to plain ESM by
|
|
95
|
+
// `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
|
|
96
|
+
// node engine in package.json without type-stripping. build runs on `prepare`,
|
|
97
|
+
// so a fresh install (including the git-ref consumer) always has dist.
|
|
98
|
+
import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
99
|
+
import { cmd, asInvocation } from "../install-invocation.mjs";
|
|
100
|
+
import {
|
|
101
|
+
isObject,
|
|
102
|
+
isNonEmptyString,
|
|
103
|
+
optionalString,
|
|
104
|
+
firstNonEmptyString,
|
|
105
|
+
readJson,
|
|
106
|
+
sha256File,
|
|
107
|
+
relFromDir,
|
|
108
|
+
resolveFromFile,
|
|
109
|
+
extractFrontmatterValue,
|
|
110
|
+
addIssue,
|
|
111
|
+
} from "../cli-helpers.mjs";
|
|
112
|
+
import { resolveCampaignsApiKeySource, describeCampaignKeyRejection } from "../campaigns-api-key.mjs";
|
|
113
|
+
|
|
114
|
+
const PACKET_SCHEMA = "campaign-runtime-build-packet/v0";
|
|
115
|
+
const CONTEXT_SCHEMA = "campaign-runtime-build-context/v0";
|
|
116
|
+
const REPORT_SCHEMA = "campaign-runtime-assembly-report/v0";
|
|
117
|
+
const PROOF_POLICY_REQUIRED_FIELDS = Object.freeze([
|
|
118
|
+
"browser_qa_required",
|
|
119
|
+
"typed_card_depth",
|
|
120
|
+
"localhost_development_domain_allowed",
|
|
121
|
+
"non_localhost_origin_allowlist_required",
|
|
122
|
+
"order_path_depth",
|
|
123
|
+
"operator_approval_state",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
// `--proxy-base` overrides DEFAULT_PROXY_BASE (src/spec-fetch.mjs) for
|
|
127
|
+
// staging environments or a local backend. The same flag aims the
|
|
128
|
+
// credential-bearing rails (remit, verdict publish, `telemetry list`), and
|
|
129
|
+
// those require https unless the host is loopback — see assertSecureProxyBase
|
|
130
|
+
// in src/remit.mjs.
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
const KNOWN_TEMPLATE_FAMILIES = new Set([
|
|
134
|
+
"undecided",
|
|
135
|
+
"apollo",
|
|
136
|
+
"apollo-mv-single-step",
|
|
137
|
+
"olympus",
|
|
138
|
+
"demeter",
|
|
139
|
+
"olympus-mv-single-step",
|
|
140
|
+
"olympus-mv-two-step",
|
|
141
|
+
"shop-single-step",
|
|
142
|
+
"shop-three-step",
|
|
143
|
+
"custom",
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// Certified template families: present in the commerce surface catalog AND
|
|
147
|
+
// carrying a template brand contract. The OS automates certified families
|
|
148
|
+
// only — "NEXT provides the rails": deterministic assembly, residue QA, and
|
|
149
|
+
// pricing contracts all assume a certified family. "custom" (or any family
|
|
150
|
+
// outside the catalog) is an explicit operator decision recorded as a
|
|
151
|
+
// waiver, never a default road the agent can wander onto.
|
|
152
|
+
// Recomputed per call (a handful of small JSON reads) so long-lived
|
|
153
|
+
// processes never serve a stale certified set after contract edits.
|
|
154
|
+
function certifiedTemplateFamilies(catalog = resolveCommerceCatalog()) {
|
|
155
|
+
const certified = Object.keys(catalog.families || {}).filter((family) => {
|
|
156
|
+
try {
|
|
157
|
+
return resolveTemplateBrandContract(family) !== null;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return new Set(certified);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isCertifiedTemplateFamily(family, catalog) {
|
|
166
|
+
return certifiedTemplateFamilies(catalog).has(String(family || ""));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isKnownTemplateFamily(family) {
|
|
170
|
+
const value = String(family || "");
|
|
171
|
+
return KNOWN_TEMPLATE_FAMILIES.has(value) || certifiedTemplateFamilies().has(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isSynthesizedBuiltSitePacket(packet) {
|
|
175
|
+
return packet?._synthesized?.from === "built_site";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const KNOWN_DEPLOY_TARGETS = new Set([
|
|
179
|
+
"netlify",
|
|
180
|
+
"cloudflare-pages",
|
|
181
|
+
"vercel",
|
|
182
|
+
"shopify-proxy",
|
|
183
|
+
"agency-ci",
|
|
184
|
+
"local-serve",
|
|
185
|
+
"unknown",
|
|
186
|
+
]);
|
|
187
|
+
// The localhost QA path: the built _site/ is served locally instead of
|
|
188
|
+
// deployed. Localhost on any port is a Development domain, so doctor and
|
|
189
|
+
// `next` read a localhost deploy URL under this target as the intended state,
|
|
190
|
+
// not as a deploy that has not happened.
|
|
191
|
+
const LOCAL_SERVE_DEPLOY_TARGET = "local-serve";
|
|
192
|
+
|
|
193
|
+
const REQUIRED_STORE_PROFILE_FIELDS = [
|
|
194
|
+
"store_url",
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
// Packet fields removed in supported surface 1.28.0. They were booleans no
|
|
198
|
+
// command read since the permission gate on test orders was retired; doctor
|
|
199
|
+
// warns when a packet still carries one.
|
|
200
|
+
const REMOVED_QA_POLICY_FIELDS = ["test_orders_allowed", "sandbox_test_card_confirmed"];
|
|
201
|
+
|
|
202
|
+
const US_MARKET_COPY_PATTERNS = [
|
|
203
|
+
{ label: "USPS", regex: /\bUSPS\b/i },
|
|
204
|
+
{ label: "ships from the USA", regex: /\bships?\s+from\s+(?:the\s+)?(?:USA|U\.S\.A\.|US|U\.S\.|United States)\b/i },
|
|
205
|
+
{ label: "US warehouse", regex: /\b(?:US|U\.S\.|USA|United States)\s+warehouse\b/i },
|
|
206
|
+
{ label: "contiguous US", regex: /\bcontiguous\s+(?:US|U\.S\.|USA|United States)\b/i },
|
|
207
|
+
{ label: "US-only shipping", regex: /\b(?:US|U\.S\.|USA|United States)(?:-|\s+)?only\b/i },
|
|
208
|
+
{ label: "All US orders ship free", regex: /\bAll\s+(?:US|U\.S\.|USA|United States)\s+orders\s+ship\s+free\b/i },
|
|
209
|
+
{ label: "Made in USA", regex: /\bMade\s+in\s+(?:the\s+)?(?:USA|U\.S\.A\.|US|U\.S\.|United States)\b/i },
|
|
210
|
+
{ label: "manufactured in the USA", regex: /\bmanufactur(?:ed|ing)\s+in\s+(?:the\s+)?(?:USA|U\.S\.A\.|US|U\.S\.|United States)\b/i },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
const HARDCODED_CURRENCY_REGEX = /\$\s?\d[\d,]*(?:\.\d+)?(?:\/[A-Za-z]+)?/g;
|
|
214
|
+
const HARDCODED_PHONE_REGEX = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g;
|
|
215
|
+
|
|
216
|
+
const SDK_ROUTING_META_TAGS = [
|
|
217
|
+
"next-success-url",
|
|
218
|
+
"next-upsell-accept-url",
|
|
219
|
+
"next-upsell-decline-url",
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
function normalizeFunnels(spec) {
|
|
223
|
+
if (Array.isArray(spec?.funnels)) return spec.funnels;
|
|
224
|
+
if (Array.isArray(spec?.funnel_pages)) {
|
|
225
|
+
return [{ id: "default", weight: 100, pages: spec.funnel_pages }];
|
|
226
|
+
}
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function activeSpecPages(spec) {
|
|
231
|
+
const pages = [];
|
|
232
|
+
for (const funnel of normalizeFunnels(spec)) {
|
|
233
|
+
for (const page of Array.isArray(funnel.pages) ? funnel.pages : []) {
|
|
234
|
+
if (page && page.enabled !== false && isNonEmptyString(page.id)) {
|
|
235
|
+
pages.push({ ...page, funnel_id: funnel.id || "default" });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return pages;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function hasHtmlExtensionRoute(value) {
|
|
243
|
+
if (!isNonEmptyString(value)) return false;
|
|
244
|
+
const raw = value.trim();
|
|
245
|
+
try {
|
|
246
|
+
const url = new URL(raw);
|
|
247
|
+
return /\.html$/i.test(url.pathname);
|
|
248
|
+
} catch {
|
|
249
|
+
return /\.html$/i.test(raw.replace(/[?#].*$/, ""));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function collectHtmlFiles(root) {
|
|
254
|
+
const files = [];
|
|
255
|
+
const resolvedRoot = resolve(root);
|
|
256
|
+
if (!existsSync(resolvedRoot) || !statSync(resolvedRoot).isDirectory()) return files;
|
|
257
|
+
|
|
258
|
+
function walk(dir) {
|
|
259
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
260
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
261
|
+
const fullPath = join(dir, entry.name);
|
|
262
|
+
if (entry.isDirectory()) {
|
|
263
|
+
walk(fullPath);
|
|
264
|
+
} else if (entry.isFile() && extname(entry.name).toLowerCase() === ".html") {
|
|
265
|
+
files.push({
|
|
266
|
+
path: relative(resolvedRoot, fullPath),
|
|
267
|
+
name: entry.name,
|
|
268
|
+
basename: basename(entry.name, ".html"),
|
|
269
|
+
bytes: statSync(fullPath).size,
|
|
270
|
+
sha256: sha256File(fullPath),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
walk(resolvedRoot);
|
|
277
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const BUILT_TEXT_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".json"]);
|
|
281
|
+
const BUILT_TEXT_SCAN_IGNORED_DIRS = new Set(["node_modules", ".git", "_includes", "_layouts"]);
|
|
282
|
+
const DISCOUNT_CLAIM_TOLERANCE = 0.01;
|
|
283
|
+
|
|
284
|
+
function collectBuiltTextFiles(root) {
|
|
285
|
+
const files = [];
|
|
286
|
+
const resolvedRoot = resolve(root);
|
|
287
|
+
if (!existsSync(resolvedRoot) || !statSync(resolvedRoot).isDirectory()) return files;
|
|
288
|
+
|
|
289
|
+
function walk(dir) {
|
|
290
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
291
|
+
if (BUILT_TEXT_SCAN_IGNORED_DIRS.has(entry.name)) continue;
|
|
292
|
+
const fullPath = join(dir, entry.name);
|
|
293
|
+
if (entry.isDirectory()) {
|
|
294
|
+
walk(fullPath);
|
|
295
|
+
} else if (entry.isFile() && BUILT_TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
296
|
+
files.push({
|
|
297
|
+
path: relative(resolvedRoot, fullPath),
|
|
298
|
+
name: entry.name,
|
|
299
|
+
bytes: statSync(fullPath).size,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
walk(resolvedRoot);
|
|
306
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Doctor Check Registry: keep packet/spec/build/artifact check order as data so
|
|
310
|
+
// agents add new checks in one deterministic slot instead of editing a long call chain.
|
|
311
|
+
function runDoctorChecks(checks, registryContext, options = {}) {
|
|
312
|
+
if (!isObject(registryContext?.derived) || !Array.isArray(registryContext.derived.doctor_checks)) {
|
|
313
|
+
throw new Error("Doctor check registry execution needs derived.doctor_checks for deterministic trace output.");
|
|
314
|
+
}
|
|
315
|
+
const executed = runDoctorCheckRegistry(checks, registryContext, options);
|
|
316
|
+
registryContext.derived.doctor_checks.push(...executed);
|
|
317
|
+
return executed;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
321
|
+
{
|
|
322
|
+
id: "campaign-spec.rule-registry",
|
|
323
|
+
phase: "spec",
|
|
324
|
+
run: ({ spec, errors, warnings }) => validateCampaignSpecRuleRegistry(spec, errors, warnings),
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "spec.identity_export",
|
|
328
|
+
phase: "spec",
|
|
329
|
+
run: ({ spec, warnings, ready }) => validateSpecIdentityExport(spec, warnings, ready),
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: "campaign.route_slug_identity",
|
|
333
|
+
phase: "spec",
|
|
334
|
+
run: ({ spec, packet, errors, ready }) => validateRouteSlugIdentity(spec, packet, errors, ready),
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: "campaign.route_root",
|
|
338
|
+
phase: "spec",
|
|
339
|
+
run: ({ packet, errors, ready }) => validateRouteRootDeclaration(packet, errors, ready),
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: "spec.public_routes",
|
|
343
|
+
phase: "spec",
|
|
344
|
+
run: ({ spec, errors, ready }) => validateSpecPublicRoutes(spec, errors, ready),
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: "spec.store_profile",
|
|
348
|
+
phase: "spec",
|
|
349
|
+
run: ({ spec, packet, errors, warnings, ready, derived, buildState }) =>
|
|
350
|
+
validateSpecStoreProfile(spec, errors, warnings, ready, { packet, derived, buildState }),
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: PAGE_KIT_SDK_VERSION_SCOPE,
|
|
354
|
+
phase: "target",
|
|
355
|
+
run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState),
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: PAGE_KIT_STORE_PROFILE_SCOPE,
|
|
359
|
+
phase: "target",
|
|
360
|
+
run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState),
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "spec.shipping_countries",
|
|
364
|
+
phase: "spec",
|
|
365
|
+
run: ({ spec, warnings, ready }) => validateSpecShippingCountries(spec, warnings, ready),
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: "spec.routing_meta_tags",
|
|
369
|
+
phase: "spec",
|
|
370
|
+
run: ({ spec, packet, warnings, ready, derived, buildState }) => validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived, buildState),
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: "source_html.coverage",
|
|
374
|
+
phase: "source",
|
|
375
|
+
run: ({ packet, packetPath, spec, errors, warnings, ready, derived, buildState }) => validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived, buildState),
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: "source_html.preparation",
|
|
379
|
+
phase: "source",
|
|
380
|
+
run: ({ packet, packetPath, errors, warnings, ready, derived }) => validateSourcePreparation(packet, packetPath, errors, warnings, ready, derived),
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: "spec.package_availability",
|
|
384
|
+
phase: "spec",
|
|
385
|
+
run: ({ spec, warnings, ready }) => validateSpecPackageAvailability(spec, warnings, ready),
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
id: "built_output.target_root",
|
|
389
|
+
phase: "built-output",
|
|
390
|
+
run: ({ packet, errors, warnings, ready, derived, buildState }) => validateBuiltOutputTargetRoot(packet, errors, warnings, ready, derived, buildState),
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "built_output.fingerprint",
|
|
394
|
+
phase: "built-output",
|
|
395
|
+
run: ({ packet, errors, warnings, ready, derived, buildState }) => validateBuildOutputFingerprint(packet, errors, warnings, ready, derived, buildState),
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
id: "built_output.pages",
|
|
399
|
+
phase: "built-output",
|
|
400
|
+
run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltOutputPages(spec, packet, errors, warnings, ready, derived, buildState),
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
id: UPSELL_SELECTOR_SCOPE,
|
|
404
|
+
phase: "built-output",
|
|
405
|
+
run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateUpsellSelectorScope(spec, packet, errors, warnings, ready, derived, buildState),
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: CAMPAIGN_IDENTITY,
|
|
409
|
+
phase: "built-output",
|
|
410
|
+
run: ({ packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived),
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
id: SDK_MARKUP,
|
|
414
|
+
phase: "built-output",
|
|
415
|
+
run: ({ packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived),
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
id: SCRIPT_SYNTAX,
|
|
419
|
+
phase: "built-output",
|
|
420
|
+
run: ({ packet, errors, warnings, ready, derived }) => validateBuiltScriptSyntax(packet, errors, warnings, ready, derived),
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: "built_output.sdk_meta_tags",
|
|
424
|
+
phase: "built-output",
|
|
425
|
+
run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltSdkMetaTags(spec, packet, errors, warnings, ready, derived, buildState),
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
id: "built_output.build_summary",
|
|
429
|
+
phase: "built-output",
|
|
430
|
+
run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuildSummary(spec, packet, errors, warnings, ready, derived, buildState),
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
id: "built_output.route_drift",
|
|
434
|
+
phase: "built-output",
|
|
435
|
+
run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltRouteDrift(spec, packet, errors, warnings, ready, derived, buildState),
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
id: "built_output.content_residue",
|
|
439
|
+
phase: "built-output",
|
|
440
|
+
run: ({ packet, errors, warnings, ready, derived, buildState }) => validateBuiltContentResidue(packet, errors, warnings, ready, derived, buildState),
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
id: "built_output.proof_attestation",
|
|
444
|
+
phase: "built-output",
|
|
445
|
+
run: ({ packet, errors, warnings, ready, derived, buildState }) => validateProofAttestation(packet, errors, warnings, ready, derived, buildState),
|
|
446
|
+
},
|
|
447
|
+
], { registryId: "packet.spec" });
|
|
448
|
+
|
|
449
|
+
const PACKET_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
450
|
+
{
|
|
451
|
+
id: "campaign.api_key",
|
|
452
|
+
phase: "packet",
|
|
453
|
+
run: ({ packet, spec, warnings, ready }) => validateCampaignsApiKey(packet, spec, warnings, ready),
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: "assembly.commerce_catalog",
|
|
457
|
+
phase: "template-contract",
|
|
458
|
+
run: ({ packet, packetPath, spec, errors, warnings, ready, derived, buildState }) => validateCommerceCatalog(packet, packetPath, spec, errors, warnings, ready, derived, buildState),
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
id: "market_copy",
|
|
462
|
+
phase: "copy",
|
|
463
|
+
run: ({ spec, warnings, ready, derived }) => validateMarketSensitiveCopy(spec, warnings, ready, derived),
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
id: "source_html.adapter_contract",
|
|
467
|
+
phase: "source",
|
|
468
|
+
run: ({ packet, packetPath, spec, errors, warnings, ready, derived, buildState }) => validateAdapterContracts(packet, packetPath, spec, errors, warnings, ready, derived, buildState),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: "qa.proof_policy",
|
|
472
|
+
phase: "qa",
|
|
473
|
+
run: ({ packet, packetPath, report, warnings, ready }) => validateProofPolicy(packet, warnings, ready, { report, packetPath }),
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: "build_brief.artifact",
|
|
477
|
+
phase: "brief",
|
|
478
|
+
run: ({ packet, packetPath, spec, context, errors, warnings, ready }) => validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready),
|
|
479
|
+
},
|
|
480
|
+
], { registryId: "packet.always" });
|
|
481
|
+
|
|
482
|
+
const ARTIFACT_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
483
|
+
// Artifact phases are deterministic labels for inspection/filtering; artifact
|
|
484
|
+
// presence is gated by `when` because context and report sidecars are optional.
|
|
485
|
+
{
|
|
486
|
+
id: "context.shape",
|
|
487
|
+
phase: "context",
|
|
488
|
+
when: ({ context }) => Boolean(context),
|
|
489
|
+
run: ({ context, errors, warnings, ready, derived }) => validateContext(context, errors, warnings, ready, derived),
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
id: "assembly_report.shape",
|
|
493
|
+
phase: "report",
|
|
494
|
+
when: ({ report }) => Boolean(report),
|
|
495
|
+
run: ({ report, errors, warnings, ready }) => validateAssemblyReportShape(report, errors, warnings, ready),
|
|
496
|
+
},
|
|
497
|
+
], { registryId: "artifact.optional" });
|
|
498
|
+
|
|
499
|
+
function validatePacket(packet, packetPath, errors, warnings, ready, derived, buildState = {}) {
|
|
500
|
+
if (!isObject(packet)) {
|
|
501
|
+
addIssue(errors, "packet.type", "Build Packet must be a JSON object.");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const synthesizedBuiltSite = isSynthesizedBuiltSitePacket(packet);
|
|
505
|
+
if (packet.schema_version !== PACKET_SCHEMA) addIssue(errors, "schema_version", `Expected ${PACKET_SCHEMA}.`);
|
|
506
|
+
else ready.push(`Build Packet schema ${PACKET_SCHEMA}`);
|
|
507
|
+
|
|
508
|
+
requireString(packet, errors, "campaign.public_route_slug");
|
|
509
|
+
requireBoolean(packet, errors, "campaign.allowed_domains_confirmed");
|
|
510
|
+
if (!resolveCampaignIdentity(packet.spec)) addIssue(errors, packet.spec?.local_spec_id != null ? "spec.local_identity" : "spec.map_id", "Packet spec requires exactly one valid map_id or local_spec_id.", { kind: packet.spec?.local_spec_id != null ? "local_spec" : "saved_map" });
|
|
511
|
+
if (packet.spec?.local_spec_id != null && (packet.spec.spec_url != null || !isNonEmptyString(packet.spec.local_path))) {
|
|
512
|
+
addIssue(errors, "spec.local_identity", "Local-spec packets require a local_path and no saved-Map spec_url.");
|
|
513
|
+
}
|
|
514
|
+
if (!synthesizedBuiltSite) {
|
|
515
|
+
requireString(packet, errors, "source_html.root");
|
|
516
|
+
requireArray(packet, errors, "source_html.pages");
|
|
517
|
+
} else {
|
|
518
|
+
ready.push("Synthesized built-site packet: source_html provenance is absent by design");
|
|
519
|
+
}
|
|
520
|
+
requireString(packet, errors, "assembly.target_repo");
|
|
521
|
+
requireString(packet, errors, "assembly.output_dir");
|
|
522
|
+
requireString(packet, errors, "assembly.template_family");
|
|
523
|
+
// Test Orders have no permission flag: the two booleans that once gated
|
|
524
|
+
// them left the packet in surface 1.28.0. A packet still carrying them is
|
|
525
|
+
// valid (nothing reads them); doctor says so once so the residue is removed.
|
|
526
|
+
// The schema requires qa as an object (proof_policy and the notes live
|
|
527
|
+
// there); with the boolean checks gone this is the check that keeps doctor
|
|
528
|
+
// and bundle check agreeing on a packet whose qa is missing or malformed.
|
|
529
|
+
if (!isObject(packet.qa)) addIssue(errors, "qa", "qa must be an object.");
|
|
530
|
+
const removedQaPolicyFields = REMOVED_QA_POLICY_FIELDS.filter((field) => isObject(packet.qa) && field in packet.qa);
|
|
531
|
+
if (removedQaPolicyFields.length) {
|
|
532
|
+
addIssue(warnings, "qa.removed_policy_fields", `qa.${removedQaPolicyFields.join(" and qa.")} ${removedQaPolicyFields.length > 1 ? "are" : "is"} no longer part of the Build Packet (removed in supported surface 1.28.0; nothing reads ${removedQaPolicyFields.length > 1 ? "them" : "it"}). Delete the field${removedQaPolicyFields.length > 1 ? "s" : ""} from qa; test orders run from --test-order <mode> alone.`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (!isKnownTemplateFamily(packet.assembly?.template_family)) {
|
|
536
|
+
addIssue(errors, "assembly.template_family", `Unknown template family "${packet.assembly?.template_family}".`);
|
|
537
|
+
}
|
|
538
|
+
if (!KNOWN_DEPLOY_TARGETS.has(packet.deploy?.target)) {
|
|
539
|
+
addIssue(errors, "deploy.target", `Unknown deploy target "${packet.deploy?.target}".`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!synthesizedBuiltSite && (packet.assembly?.template_family === "undecided" || packet.assembly?.template_lock?.locked !== true)) {
|
|
543
|
+
addIssue(errors, "assembly.template_lock", "Template family must be explicitly locked before commerce wiring.");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Certified-template gate: a decided family must be certified (catalog +
|
|
547
|
+
// brand contract) or carry an explicit uncertified waiver. Uncertified
|
|
548
|
+
// families have no deterministic assembly path, no residue QA, and no
|
|
549
|
+
// pricing contract — the OS cannot stand behind the output, so the
|
|
550
|
+
// decision to leave the rails is recorded, never improvised.
|
|
551
|
+
const decidedFamily = packet.assembly?.template_family;
|
|
552
|
+
if (isNonEmptyString(decidedFamily) && decidedFamily !== "undecided") {
|
|
553
|
+
if (isCertifiedTemplateFamily(decidedFamily)) {
|
|
554
|
+
ready.push(`Template family "${decidedFamily}" is certified (commerce catalog + brand contract)`);
|
|
555
|
+
// Certification freshness (#263): certified is not the whole story — an
|
|
556
|
+
// operator must also see which SDK the certification evidence covers.
|
|
557
|
+
// Current freshness stays informational (ready); a stale or unrecorded
|
|
558
|
+
// verification surfaces as a warning, because an older evidence record
|
|
559
|
+
// is not current certification.
|
|
560
|
+
const freshness = assessTemplateFreshness({
|
|
561
|
+
family: decidedFamily,
|
|
562
|
+
catalog: resolveCommerceCatalog(),
|
|
563
|
+
sdkSupportPolicy: defaultSdkSupportPolicy(),
|
|
564
|
+
});
|
|
565
|
+
const freshnessLine = renderTemplateFreshness(freshness);
|
|
566
|
+
if (freshness.state === "current") {
|
|
567
|
+
ready.push(freshnessLine);
|
|
568
|
+
} else {
|
|
569
|
+
addIssue(warnings, "assembly.template_certification.freshness", freshnessLine);
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
const waiver = packet.assembly?.template_certification?.waiver;
|
|
573
|
+
if (waiver && isNonEmptyString(waiver.reason)) {
|
|
574
|
+
addIssue(warnings, "assembly.template_certification", `Template family "${decidedFamily}" is NOT certified; proceeding under recorded waiver: ${waiver.reason}. Deterministic assembly, residue QA, and pricing contracts do not cover this family.`);
|
|
575
|
+
} else {
|
|
576
|
+
addIssue(errors, "assembly.template_certification", `Template family "${decidedFamily}" is not certified. Certified families: ${[...certifiedTemplateFamilies()].sort().join(", ")}. Pick a certified family, or rerun prepare-build with --allow-uncertified-template "<reason>" to record an explicit waiver.`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const deployUrl = packet.deploy?.preview_url || packet.deploy?.production_url;
|
|
582
|
+
if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
|
|
583
|
+
if (!deployUrl) {
|
|
584
|
+
ready.push("Deploy target is local-serve: serve the built _site/ locally and record the localhost URL on deploy.preview_url; localhost on any port is a Development domain, so no SDK origin allowlist entry is needed for QA.");
|
|
585
|
+
} else if (isLocalhostDevelopmentOrigin(deployUrl)) {
|
|
586
|
+
ready.push(`Deploy target is local-serve and the deploy URL ${deployUrl} is localhost: Campaigns App treats localhost on any port as a Development domain, so SDK initialization is allowed and analytics are suppressed for local QA.`);
|
|
587
|
+
} else if (isLoopbackDeployUrl(deployUrl)) {
|
|
588
|
+
// A static server bound to 127.0.0.1 or [::1] is the same local serve;
|
|
589
|
+
// the Development-domain rule is stated for the hostname localhost, so
|
|
590
|
+
// say which spelling to fall back to if the SDK refuses the numeric one.
|
|
591
|
+
ready.push(`Deploy target is local-serve and the deploy URL ${deployUrl} is a loopback origin: it is served locally for QA. Campaigns App states its Development-domain rule for the hostname localhost on any port; if SDK initialization is refused on this host, open the same server as http://localhost:<port>/ and record that URL instead.`);
|
|
592
|
+
} else {
|
|
593
|
+
addIssue(warnings, "deploy.local_serve_url", `deploy.target is local-serve but the recorded deploy URL ${deployUrl} is not a localhost or loopback origin. Record the served localhost URL, or set deploy.target to where that origin is actually hosted.`);
|
|
594
|
+
}
|
|
595
|
+
validateLocalProof(packet, buildState?.report || null, errors, warnings, ready);
|
|
596
|
+
} else if (packet.campaign?.allowed_domains_confirmed !== true) {
|
|
597
|
+
if (isLocalhostDevelopmentOrigin(deployUrl)) {
|
|
598
|
+
ready.push("Deploy URL is localhost; Campaigns App treats localhost on any port as a Development domain, so SDK initialization is allowed and analytics are suppressed for local QA.");
|
|
599
|
+
} else {
|
|
600
|
+
addIssue(warnings, "campaign.allowed_domains_confirmed", "Non-localhost preview/production origins are not confirmed in the Campaigns App SDK origin allowlist. SDK runtime checks may be blocked after deploy.");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (synthesizedBuiltSite) {
|
|
605
|
+
const builtPages = (Array.isArray(packet.pages) ? packet.pages : []).map((page) => ({
|
|
606
|
+
page_id: String(page?.page_id || page?.id || page?.route || "page"),
|
|
607
|
+
type: String(page?.type || page?.page_type || "page"),
|
|
608
|
+
role: pageRole(String(page?.type || page?.page_type || "page")),
|
|
609
|
+
route: typeof page?.route === "string" ? page.route : null,
|
|
610
|
+
}));
|
|
611
|
+
derived.scope = {
|
|
612
|
+
mode: "built_site",
|
|
613
|
+
built_pages: builtPages,
|
|
614
|
+
out_of_scope_pages: [],
|
|
615
|
+
previewable_routes: builtPages.map((page) => ({ page_id: page.page_id, type: page.type, route: page.route })),
|
|
616
|
+
blocked_runtime_pages: [],
|
|
617
|
+
};
|
|
618
|
+
derived.source_root = null;
|
|
619
|
+
} else {
|
|
620
|
+
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
621
|
+
derived.source_root = sourceRoot;
|
|
622
|
+
if (!sourceRoot || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
|
|
623
|
+
addIssue(errors, "source_html.root", `Source root does not exist: ${packet.source_html?.root}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const targetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
|
|
628
|
+
derived.target_repo = targetRepo;
|
|
629
|
+
const pageKitCampaignConfig = loadPageKitCampaignEntry({
|
|
630
|
+
targetRepo,
|
|
631
|
+
publicRouteSlug: packet?.campaign?.public_route_slug,
|
|
632
|
+
});
|
|
633
|
+
buildState.pageKitCampaignConfig = pageKitCampaignConfig;
|
|
634
|
+
derived.page_kit_campaign_config = projectPageKitCampaignLoad(pageKitCampaignConfig);
|
|
635
|
+
if (!targetRepo || !existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
|
|
636
|
+
addIssue(errors, "assembly.target_repo", `Target repo does not exist: ${packet.assembly?.target_repo}`);
|
|
637
|
+
} else {
|
|
638
|
+
const outputDir = resolve(targetRepo, packet.assembly?.output_dir || "");
|
|
639
|
+
derived.target_output_dir = outputDir;
|
|
640
|
+
derived.scaffold_required = !existsSync(outputDir);
|
|
641
|
+
derived.scaffold_reason = derived.scaffold_required
|
|
642
|
+
? `Target output directory does not exist: ${packet.assembly?.output_dir}`
|
|
643
|
+
: null;
|
|
644
|
+
if (derived.scaffold_required) {
|
|
645
|
+
addIssue(warnings, "page_kit.scaffold_required", "Target campaign output directory is missing; setup should run before build.");
|
|
646
|
+
} else {
|
|
647
|
+
ready.push("Target campaign output directory exists");
|
|
648
|
+
}
|
|
649
|
+
const pkgPath = join(targetRepo, "package.json");
|
|
650
|
+
if (!existsSync(pkgPath)) {
|
|
651
|
+
addIssue(warnings, "page_kit.package_json", "Target repo has no package.json. Page-kit command detection is unavailable.");
|
|
652
|
+
} else {
|
|
653
|
+
const pkg = readJson(pkgPath);
|
|
654
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
655
|
+
if (!deps["next-campaign-page-kit"]) {
|
|
656
|
+
addIssue(warnings, "page_kit.dependency", "Target package.json does not declare next-campaign-page-kit; page-kit may still be installed through another local path.");
|
|
657
|
+
} else {
|
|
658
|
+
ready.push(`Target page-kit dependency ${deps["next-campaign-page-kit"]}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let spec = null;
|
|
664
|
+
if (synthesizedBuiltSite) {
|
|
665
|
+
derived.spec_path = null;
|
|
666
|
+
ready.push("Synthesized built-site packet skips CampaignSpec/source checks; built-output gates should run through doctor --built or qa --site.");
|
|
667
|
+
} else {
|
|
668
|
+
const localSpecPath = packet.spec?.local_path;
|
|
669
|
+
const specPath = isNonEmptyString(localSpecPath) ? resolveFromFile(packetPath, localSpecPath) : null;
|
|
670
|
+
derived.spec_path = specPath;
|
|
671
|
+
let specStatus = "missing";
|
|
672
|
+
if (localSpecPath != null && !isNonEmptyString(localSpecPath)) {
|
|
673
|
+
// A present-but-unusable local_path is a malformed packet, not an
|
|
674
|
+
// unconfigured one. Both block, but they are repaired in different
|
|
675
|
+
// places, so the operator is told which one they have.
|
|
676
|
+
addIssue(errors, "spec.local_path", `CampaignSpec local_path must be a non-empty string; the packet declares ${typeof localSpecPath}. Repair the packet's spec.local_path before build or QA.`);
|
|
677
|
+
} else if (!isNonEmptyString(localSpecPath)) {
|
|
678
|
+
addIssue(errors, "spec.local_path", "No local CampaignSpec path is present. Assembly must use a local exported CampaignSpec JSON so page coverage, routing, meta tags, and commerce refs are not guessed.");
|
|
679
|
+
} else if (!existsSync(specPath)) {
|
|
680
|
+
addIssue(errors, "spec.local_path", `CampaignSpec local_path does not exist: ${localSpecPath}`);
|
|
681
|
+
} else {
|
|
682
|
+
try {
|
|
683
|
+
const loaded = readJson(specPath);
|
|
684
|
+
if (isObject(loaded)) {
|
|
685
|
+
spec = loaded;
|
|
686
|
+
specStatus = "ok";
|
|
687
|
+
} else {
|
|
688
|
+
specStatus = "root_not_object";
|
|
689
|
+
addIssue(errors, "spec.local_path", "CampaignSpec local_path must contain a JSON object. Restore a valid packet-local CampaignSpec export before build or QA.");
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
specStatus = "invalid_json";
|
|
693
|
+
addIssue(errors, "spec.local_path", "CampaignSpec local_path is not valid JSON. Restore a valid packet-local CampaignSpec export before build or QA.");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
buildState.specStatus = specStatus;
|
|
697
|
+
if (specStatus === "ok") {
|
|
698
|
+
const specMapId = spec.spec_identity?.map_id || spec.map_id;
|
|
699
|
+
if ((specMapId && specMapId !== packet.spec.map_id)
|
|
700
|
+
|| ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
|
|
701
|
+
&& !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
|
|
702
|
+
const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
|
|
703
|
+
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" });
|
|
704
|
+
}
|
|
705
|
+
ready.push("Local CampaignSpec parsed");
|
|
706
|
+
runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
|
|
707
|
+
} else {
|
|
708
|
+
runDoctorChecks(
|
|
709
|
+
SPEC_DOCTOR_CHECKS,
|
|
710
|
+
{ packet, packetPath, spec: null, targetRepo, errors, warnings, ready, derived, buildState },
|
|
711
|
+
{ phase: "target" },
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (synthesizedBuiltSite) {
|
|
717
|
+
ready.push("Packet source/proof checks skipped for synthesized built-site packet");
|
|
718
|
+
} else {
|
|
719
|
+
runDoctorChecks(PACKET_DOCTOR_CHECKS, { packet, packetPath, spec, context: buildState.context, report: buildState.report, errors, warnings, ready, derived, buildState });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (!packet.deploy?.preview_url && !packet.deploy?.production_url) {
|
|
723
|
+
const partialScope = derived.scope?.mode === "partial";
|
|
724
|
+
addIssue(
|
|
725
|
+
warnings,
|
|
726
|
+
"deploy.preview_url",
|
|
727
|
+
partialScope
|
|
728
|
+
? "No preview or production URL yet. After deploy, mapped pages are route/visual-testable; checkout/runtime launch QA remains blocked for out-of-scope pages."
|
|
729
|
+
: "No preview or production URL yet. QA remains blocked after build/polish."
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
// Test Orders use global test cards that bypass the gateway and create no
|
|
733
|
+
// transactions, so they need no per-packet permission. The packet qa.* booleans
|
|
734
|
+
// are retained as informational metadata but no longer gate test orders or QA
|
|
735
|
+
// stage progression.
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function validateCampaignSpecRuleRegistry(spec, errors, warnings) {
|
|
739
|
+
// ADR-003: run the shared campaign-spec rule registry — the single public
|
|
740
|
+
// source of CampaignSpec validation. specOnlyRules is the right preset here:
|
|
741
|
+
// the doctor runs without a deployed URL, so any rule requiring one is
|
|
742
|
+
// skipped. These pure spec-shape rules are complementary to the
|
|
743
|
+
// packet/build-aware spec checks; both run so internal teams and agencies get
|
|
744
|
+
// identical spec-shape validation. Emitted under the single spec.validation
|
|
745
|
+
// code, with rule identity preserved in detail for JSON consumers.
|
|
746
|
+
try {
|
|
747
|
+
for (const violation of runRules(normalizeCampaignSpec(spec), specOnlyRules)) {
|
|
748
|
+
addIssue(
|
|
749
|
+
violation.severity === "error" ? errors : warnings,
|
|
750
|
+
"spec.validation",
|
|
751
|
+
violation.message,
|
|
752
|
+
{ ruleId: violation.ruleId, path: violation.path, data: violation.data }
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
} catch (error) {
|
|
756
|
+
addIssue(errors, "spec.validation", `CampaignSpec validation failed: ${error.message}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function validateAdapterContracts(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
761
|
+
const packetContract = packet.source_html?.adapter_contract;
|
|
762
|
+
validateAdapterDecisionShape(packetContract, "source_html.adapter_contract", warnings, ready, { addIssue });
|
|
763
|
+
validateAdapterSourceFiles({
|
|
764
|
+
decisions: packetContract,
|
|
765
|
+
sourceRoot: resolveFromFile(packetPath, packet.source_html?.root),
|
|
766
|
+
pages: packet.source_html?.pages || [],
|
|
767
|
+
warnings,
|
|
768
|
+
ready,
|
|
769
|
+
addIssue,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const contextDecisions = buildState.context?.adapter_decisions;
|
|
773
|
+
const reportDecisions = buildState.report?.adapter_decisions;
|
|
774
|
+
|
|
775
|
+
const decisions = reportDecisions || contextDecisions || packetContract;
|
|
776
|
+
validateAdapterDecisionGates({
|
|
777
|
+
decisions,
|
|
778
|
+
location: reportDecisions ? "report.adapter_decisions" : contextDecisions ? "context.adapter_decisions" : "source_html.adapter_contract",
|
|
779
|
+
specPages: activeSpecPages(spec),
|
|
780
|
+
family: packet.assembly?.template_family,
|
|
781
|
+
assemblyComplete: isStageComplete(buildState.report, "assembly"),
|
|
782
|
+
targetRepo: derived.target_repo,
|
|
783
|
+
errors,
|
|
784
|
+
warnings,
|
|
785
|
+
ready,
|
|
786
|
+
addIssue,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function validateProofPolicy(packet, warnings, ready, { report = null, packetPath = null } = {}) {
|
|
791
|
+
const policy = packet.qa?.proof_policy;
|
|
792
|
+
if (!policy) {
|
|
793
|
+
addIssue(warnings, "qa.proof_policy", "qa.proof_policy is missing. New packets make browser QA, typed-card depth, SDK origin allowlist state, order path depth, and approval state explicit.");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
validateProofPolicyObject(policy, "qa.proof_policy", warnings, ready, { requireBrowserQa: true });
|
|
797
|
+
// The report's proof_policy is a mirror written at prepare-build. A packet
|
|
798
|
+
// edited afterwards (by hand, or by an older `qa policy set` that never
|
|
799
|
+
// refreshed the mirror) leaves the two disagreeing, and
|
|
800
|
+
// assessPurchaseProofCoverage then reads the depth as unknown — so `next`
|
|
801
|
+
// could not reach done and nothing named the fix. Advisory, never a
|
|
802
|
+
// blocker: the warning carries the one command that reconciles them.
|
|
803
|
+
const drift = orderPathDepthDrift(packet, report);
|
|
804
|
+
if (drift) {
|
|
805
|
+
addIssue(warnings, ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText({ packetDepth: drift.packet, reportDepth: drift.report, packetPath }));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// The packet's declared order-path depth beside the report's mirror when the
|
|
810
|
+
// two disagree, else null. The comparison itself is the leaf's
|
|
811
|
+
// orderPathDepthsDisagree, which `next` also asks of a coverage result.
|
|
812
|
+
function orderPathDepthDrift(packet, report) {
|
|
813
|
+
const packetDepth = optionalString(packet?.qa?.proof_policy?.order_path_depth);
|
|
814
|
+
const reportDepth = optionalString(report?.proof_policy?.order_path_depth);
|
|
815
|
+
return orderPathDepthsDisagree(packetDepth, reportDepth) ? { packet: packetDepth, report: reportDepth } : null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready) {
|
|
819
|
+
const briefRef = packet.build_brief || context?.build_brief || null;
|
|
820
|
+
const normalizedPath = optionalString(packet.build_brief?.normalized_path)
|
|
821
|
+
|| optionalString(context?.build_brief?.normalized_path);
|
|
822
|
+
if (!briefRef || !normalizedPath) {
|
|
823
|
+
addIssue(warnings, "build_brief.missing", "No Campaign Build Brief artifact is referenced. Existing builds may continue, but new build intake should provide or generate .campaign-runtime/input/campaign-build-brief.normalized.json so business/design decisions are durable.");
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const resolvedPath = resolveFromFile(packetPath, normalizedPath);
|
|
828
|
+
if (!resolvedPath || !existsSync(resolvedPath)) {
|
|
829
|
+
addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is missing: ${normalizedPath}`);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const brief = readJson(resolvedPath);
|
|
834
|
+
const result = validateCampaignBuildBriefArtifact(brief, { spec });
|
|
835
|
+
for (const issue of result.errors) errors.push(issue);
|
|
836
|
+
for (const issue of result.warnings) warnings.push(issue);
|
|
837
|
+
ready.push(...result.ready);
|
|
838
|
+
|
|
839
|
+
if (context?.build_brief?.status && brief.status && context.build_brief.status !== brief.status) {
|
|
840
|
+
addIssue(warnings, "build_brief.context_status", `Build context says brief status is "${context.build_brief.status}" but normalized artifact says "${brief.status}". Rerun prepare-build to refresh the handoff.`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function validateProofPolicyObject(policy, location, warnings, ready, { requireBrowserQa = false } = {}) {
|
|
845
|
+
if (!isObject(policy)) {
|
|
846
|
+
addIssue(warnings, location, `${location} must be an object when present.`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
for (const field of PROOF_POLICY_REQUIRED_FIELDS) {
|
|
850
|
+
if (!(field in policy)) {
|
|
851
|
+
addIssue(warnings, `${location}.${field}`, `${location}.${field} is missing; proof policy must make browser QA, typed-card depth, SDK origin allowlist state, order path depth, and approval state explicit.`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (policy.browser_qa_required != null && typeof policy.browser_qa_required !== "boolean") {
|
|
855
|
+
addIssue(warnings, `${location}.browser_qa_required`, `${location}.browser_qa_required must be a boolean.`);
|
|
856
|
+
} else if ("browser_qa_required" in policy && requireBrowserQa && policy.browser_qa_required !== true) {
|
|
857
|
+
addIssue(warnings, `${location}.browser_qa_required`, "Browser QA should stay explicit in the packet/report before launch proof.");
|
|
858
|
+
}
|
|
859
|
+
if (!isNonEmptyString(policy.typed_card_depth)) {
|
|
860
|
+
addIssue(warnings, `${location}.typed_card_depth`, `${location}.typed_card_depth should name the intended typed-card depth, usually common.`);
|
|
861
|
+
}
|
|
862
|
+
if ("localhost_development_domain_allowed" in policy && policy.localhost_development_domain_allowed !== true) {
|
|
863
|
+
addIssue(warnings, `${location}.localhost_development_domain_allowed`, `${location}.localhost_development_domain_allowed should be true; localhost on any port is the public Development-domain QA origin.`);
|
|
864
|
+
}
|
|
865
|
+
if ("non_localhost_origin_allowlist_required" in policy && policy.non_localhost_origin_allowlist_required !== true) {
|
|
866
|
+
addIssue(warnings, `${location}.non_localhost_origin_allowlist_required`, `${location}.non_localhost_origin_allowlist_required should be true; preview/production origins require SDK origin allowlist confirmation.`);
|
|
867
|
+
}
|
|
868
|
+
if (!isNonEmptyString(policy.order_path_depth)) {
|
|
869
|
+
addIssue(warnings, `${location}.order_path_depth`, `${location}.order_path_depth should name checkout/upsell order-path depth.`);
|
|
870
|
+
}
|
|
871
|
+
if (!isNonEmptyString(policy.operator_approval_state)) {
|
|
872
|
+
addIssue(warnings, `${location}.operator_approval_state`, `${location}.operator_approval_state should be explicit, e.g. not_required_global_test_cards.`);
|
|
873
|
+
}
|
|
874
|
+
if (policy.qa_portal_publish_default != null && typeof policy.qa_portal_publish_default !== "boolean") {
|
|
875
|
+
addIssue(warnings, `${location}.qa_portal_publish_default`, `${location}.qa_portal_publish_default must be a boolean when present.`);
|
|
876
|
+
}
|
|
877
|
+
ready.push(`${location} loaded: browser=${policy.browser_qa_required === true}, typed_card_depth=${policy.typed_card_depth || "unspecified"}, order_path_depth=${policy.order_path_depth || "unspecified"}`);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export function validateSpecStoreProfile(spec, errors, warnings, ready, { packet = null, derived = {}, buildState = {} } = {}) {
|
|
881
|
+
const campaign = spec?.campaign || {};
|
|
882
|
+
const missing = REQUIRED_STORE_PROFILE_FIELDS.filter((field) => !isNonEmptyString(campaign[field]));
|
|
883
|
+
if (missing.length > 0) {
|
|
884
|
+
addIssue(
|
|
885
|
+
errors,
|
|
886
|
+
"spec.store_profile",
|
|
887
|
+
`CampaignSpec campaign is missing required Store Profile field for page-kit campaigns.json: ${missing.join(", ")}. Add ${missing.map((field) => `campaign.${field}`).join(", ")} to the CampaignSpec Store Profile, then rerun start/prepare-build. Campaigns OS does not infer or silently mutate these storefront/legal values.`,
|
|
888
|
+
{
|
|
889
|
+
missing_fields: missing.map((field) => `campaign.${field}`),
|
|
890
|
+
repair: {
|
|
891
|
+
owner: "operator",
|
|
892
|
+
action: `Update the CampaignSpec Store Profile export with merchant storefront metadata, then rerun ${cmd("start")} or ${cmd("prepare-build")}.`,
|
|
893
|
+
example_patch: Object.fromEntries(missing.map((field) => [field, field === "store_url" ? "https://<merchant-store-domain>" : "<merchant value>"])),
|
|
894
|
+
},
|
|
895
|
+
}
|
|
896
|
+
);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
ready.push("CampaignSpec required Store Profile fields are present for page-kit campaigns.json");
|
|
900
|
+
|
|
901
|
+
// R2-B5: a store profile can pass the required-field check yet
|
|
902
|
+
// still be unable to serve a real shopper — the gap neither doctor nor
|
|
903
|
+
// browser QA surfaced in the Round 2 run. These are real-shopper readiness
|
|
904
|
+
// *warnings* (not blockers): a routing/visual-only run is fine, and they are
|
|
905
|
+
// independent of test orders (test cards bypass the gateway). The concern is
|
|
906
|
+
// that a real customer cannot complete checkout against a placeholder store
|
|
907
|
+
// URL or a store with no payment methods configured.
|
|
908
|
+
const storeUrl = campaign.store_url;
|
|
909
|
+
if (isNonEmptyString(storeUrl) && looksLikePlaceholderStoreUrl(storeUrl)) {
|
|
910
|
+
addIssue(
|
|
911
|
+
warnings,
|
|
912
|
+
"spec.store_profile.placeholder_store_url",
|
|
913
|
+
`CampaignSpec campaign.store_url "${storeUrl}" looks like a local/placeholder store, not a live storefront. Localhost is valid as a Development-domain QA origin, but a real shopper cannot transact against it. Set the merchant's production store_url before launch.`
|
|
914
|
+
);
|
|
915
|
+
} else if (isNonEmptyString(storeUrl)) {
|
|
916
|
+
ready.push("CampaignSpec store_url points at a non-placeholder storefront");
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const paymentMethods = campaign.available_payment_methods;
|
|
920
|
+
if (Array.isArray(paymentMethods) && paymentMethods.length === 0) {
|
|
921
|
+
addIssue(
|
|
922
|
+
warnings,
|
|
923
|
+
"spec.store_profile.no_payment_methods",
|
|
924
|
+
"CampaignSpec campaign.available_payment_methods is empty. A real shopper would have no payment method to complete checkout. Confirm the store's payment methods before launch."
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Every starter-template family's checkout page calls
|
|
929
|
+
// {% campaign_include 'payment-methods.html' %} with no arguments, and the
|
|
930
|
+
// include defaults show_paypal/show_klarna/show_apple_pay/show_google_pay to
|
|
931
|
+
// true. So a method the spec does not support still renders unless the build
|
|
932
|
+
// passes show_<method>=false on that include call. When the spec declares its
|
|
933
|
+
// supported methods and one of those four is absent from both
|
|
934
|
+
// available_payment_methods and available_express_payment_methods, warn so the
|
|
935
|
+
// build disables it (or the spec adds it). Methods may be plain strings or
|
|
936
|
+
// { code, label } objects.
|
|
937
|
+
//
|
|
938
|
+
// Once the checkout is built, the rendered page is the authority: doctor
|
|
939
|
+
// reads _site/<slug>/<checkout route>/index.html for the method's markup and
|
|
940
|
+
// stays silent when none shipped — the same markers browser QA's
|
|
941
|
+
// template-residue gate keys on — instead of repeating a pre-build advisory
|
|
942
|
+
// the build already satisfied.
|
|
943
|
+
const normalizeMethod = (method) =>
|
|
944
|
+
String(method && typeof method === "object" ? method.code : method).toLowerCase().replace(/[\s-]+/g, "_");
|
|
945
|
+
const supportedMethods = new Set([
|
|
946
|
+
...(Array.isArray(paymentMethods) ? paymentMethods : []).map(normalizeMethod),
|
|
947
|
+
...(Array.isArray(campaign.available_express_payment_methods) ? campaign.available_express_payment_methods : []).map(normalizeMethod),
|
|
948
|
+
]);
|
|
949
|
+
if (supportedMethods.size === 0) return;
|
|
950
|
+
const unsupportedDefaults = STARTER_TEMPLATE_DEFAULT_ON_PAYMENT_METHODS.filter((method) => !supportedMethods.has(method));
|
|
951
|
+
if (unsupportedDefaults.length === 0) return;
|
|
952
|
+
|
|
953
|
+
const family = packet?.assembly?.template_family;
|
|
954
|
+
const builtCheckouts = builtCheckoutPagesForSpec(spec, packet, derived);
|
|
955
|
+
if (builtCheckouts.length === 0) {
|
|
956
|
+
const includeCall = `{% campaign_include 'payment-methods.html' ${unsupportedDefaults.map((method) => `show_${method}=false`).join(" ")} %}`;
|
|
957
|
+
addIssue(
|
|
958
|
+
warnings,
|
|
959
|
+
"spec.store_profile.payment_methods_default_on",
|
|
960
|
+
`Starter-template checkout pages render ${unsupportedDefaults.join(", ")} by default: the checkout page includes payment-methods.html with no arguments and the include defaults show_${unsupportedDefaults.length > 1 ? "<method>" : unsupportedDefaults[0]} to true, but the CampaignSpec does not list ${unsupportedDefaults.length > 1 ? "them" : "it"} in available_payment_methods/available_express_payment_methods. `
|
|
961
|
+
+ `Pass ${unsupportedDefaults.map((method) => `show_${method}=false`).join(" ")} on that include call in the ${isNonEmptyString(family) ? `${family} ` : ""}checkout page (${includeCall}) or add the method to the spec, so unsupported methods do not ship. Doctor re-reads the built checkout once it exists.`,
|
|
962
|
+
{
|
|
963
|
+
methods: unsupportedDefaults,
|
|
964
|
+
template_family: isNonEmptyString(family) ? family : null,
|
|
965
|
+
basis: "spec_only",
|
|
966
|
+
repair: {
|
|
967
|
+
owner: "operator",
|
|
968
|
+
action: `Pass ${unsupportedDefaults.map((method) => `show_${method}=false`).join(" ")} on the checkout page's payment-methods.html include call, or add the method(s) to the CampaignSpec, then rebuild.`,
|
|
969
|
+
include_call: includeCall,
|
|
970
|
+
},
|
|
971
|
+
}
|
|
972
|
+
);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const chrome = isNonEmptyString(family) ? resolveBrandContractOnce(derived, family).contract?.default_residue?.payment_chrome || null : null;
|
|
977
|
+
const shipped = [];
|
|
978
|
+
for (const built of builtCheckouts) {
|
|
979
|
+
const html = readFileSync(built.path, "utf8");
|
|
980
|
+
for (const method of unsupportedDefaults) {
|
|
981
|
+
const markers = paymentMethodMarkupMatches(html, method, chrome);
|
|
982
|
+
if (markers.length) shipped.push({ page_id: built.page_id, file: built.file, method, markers, forced_logo: paymentLogoForcedOn(html, method) });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const builtFiles = [...new Set(builtCheckouts.map((built) => built.file))].join(", ");
|
|
986
|
+
// What the static scan could not attribute (compound selectors, shared
|
|
987
|
+
// chrome assets) stays with browser QA; name it so "no markup" is never
|
|
988
|
+
// read as "nothing left to check".
|
|
989
|
+
const gaps = { compound_selectors: [], shared_assets: [] };
|
|
990
|
+
for (const method of unsupportedDefaults) {
|
|
991
|
+
const methodGaps = paymentMethodStaticScanGaps(chrome, method);
|
|
992
|
+
gaps.compound_selectors.push(...methodGaps.compound_selectors);
|
|
993
|
+
gaps.shared_assets.push(...methodGaps.shared_assets);
|
|
994
|
+
}
|
|
995
|
+
gaps.compound_selectors = [...new Set(gaps.compound_selectors)];
|
|
996
|
+
gaps.shared_assets = [...new Set(gaps.shared_assets)];
|
|
997
|
+
const gapClauses = [];
|
|
998
|
+
if (gaps.shared_assets.length) gapClauses.push(`shared chrome asset${gaps.shared_assets.length > 1 ? "s" : ""} ${gaps.shared_assets.join(", ")}`);
|
|
999
|
+
if (gaps.compound_selectors.length) gapClauses.push(`compound selector${gaps.compound_selectors.length > 1 ? "s" : ""} ${gaps.compound_selectors.join(", ")}`);
|
|
1000
|
+
const gapNote = gapClauses.length ? `; left to browser QA: ${gapClauses.join(" and ")}` : "";
|
|
1001
|
+
if (shipped.length === 0) {
|
|
1002
|
+
ready.push(`Built checkout carries no ${unsupportedDefaults.join(", ")} payment-method markup (${builtFiles})${gapNote}`);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const shippedMethods = [...new Set(shipped.map((hit) => hit.method))];
|
|
1006
|
+
const forcedLogoMethods = [...new Set(shipped.filter((hit) => hit.forced_logo).map((hit) => hit.method))];
|
|
1007
|
+
const evidence = shipped.map((hit) => `${hit.file}: ${hit.method} (${hit.markers.join(", ")})`).join("; ");
|
|
1008
|
+
addIssue(
|
|
1009
|
+
warnings,
|
|
1010
|
+
"spec.store_profile.payment_methods_default_on",
|
|
1011
|
+
`Built checkout still renders ${shippedMethods.join(", ")}, which the CampaignSpec does not list in available_payment_methods/available_express_payment_methods: ${evidence}. `
|
|
1012
|
+
+ `Pass ${shippedMethods.map((method) => `show_${method}=false`).join(" ")} on the checkout page's payment-methods.html include call and rebuild (or add the method to the spec); browser QA's template-residue gate fails on this markup.`
|
|
1013
|
+
+ (forcedLogoMethods.length ? ` The payment-logos.html row forces ${forcedLogoMethods.join(", ")} on: remove payment_flags.${forcedLogoMethods.map((method) => `show_${method}`).join("/")}: true from the page frontmatter (the row otherwise shows only what the campaign offers).` : ""),
|
|
1014
|
+
{
|
|
1015
|
+
methods: shippedMethods,
|
|
1016
|
+
template_family: isNonEmptyString(family) ? family : null,
|
|
1017
|
+
basis: "built_output",
|
|
1018
|
+
pages: shipped,
|
|
1019
|
+
static_scan_gaps: gaps,
|
|
1020
|
+
repair: {
|
|
1021
|
+
owner: "operator",
|
|
1022
|
+
action: `Pass ${shippedMethods.map((method) => `show_${method}=false`).join(" ")} on the checkout page's payment-methods.html include call, or add the method(s) to the CampaignSpec, then rebuild.`,
|
|
1023
|
+
include_call: `{% campaign_include 'payment-methods.html' ${shippedMethods.map((method) => `show_${method}=false`).join(" ")} %}`,
|
|
1024
|
+
},
|
|
1025
|
+
}
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// A visible payment-logos.html <img> for this method: the template only leaves
|
|
1030
|
+
// one visible when the page forces it with payment_flags.show_<method>: true.
|
|
1031
|
+
function paymentLogoForcedOn(html, method) {
|
|
1032
|
+
const code = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
|
|
1033
|
+
return new RegExp(`<img\\b[^>]*\\sdata-payment-logo\\s*=\\s*["']${code}["']`, "i").test(withoutHiddenPaymentLogos(html));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// The four methods every starter-template payment-methods include renders
|
|
1037
|
+
// unless the checkout page passes show_<method>=false.
|
|
1038
|
+
export const STARTER_TEMPLATE_DEFAULT_ON_PAYMENT_METHODS = Object.freeze(["paypal", "klarna", "apple_pay", "google_pay"]);
|
|
1039
|
+
|
|
1040
|
+
// Built checkout pages on disk for the spec's active checkout pages: the
|
|
1041
|
+
// rendered _site/<slug>/<route>/index.html files that exist. Empty before a
|
|
1042
|
+
// build (or when the spec declares no checkout page), which is the pre-build
|
|
1043
|
+
// state the spec-only advisory covers.
|
|
1044
|
+
function builtCheckoutPagesForSpec(spec, packet, derived = {}) {
|
|
1045
|
+
const targetRepo = derived?.target_repo;
|
|
1046
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1047
|
+
if (!targetRepo || !publicRouteSlug) return [];
|
|
1048
|
+
const built = [];
|
|
1049
|
+
for (const page of activeSpecPages(spec)) {
|
|
1050
|
+
if (String(page?.type || page?.page_type || "").toLowerCase().trim() !== "checkout") continue;
|
|
1051
|
+
const path = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
|
|
1052
|
+
if (!path || !existsSync(path) || !statSync(path).isFile()) continue;
|
|
1053
|
+
built.push({ page_id: page.id, path, file: relative(targetRepo, path).split(sep).join("/") });
|
|
1054
|
+
}
|
|
1055
|
+
return built;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// R2-B5: a best-effort check for store URLs that clearly cannot be
|
|
1059
|
+
// a live storefront (local dev hosts, reserved test/example TLDs). Intentionally
|
|
1060
|
+
// conservative — only obvious non-production hosts trip it, so a real merchant
|
|
1061
|
+
// domain never false-positives. A non-URL string is left to other validators.
|
|
1062
|
+
function looksLikePlaceholderStoreUrl(value) {
|
|
1063
|
+
let url;
|
|
1064
|
+
try {
|
|
1065
|
+
url = new URL(String(value).trim());
|
|
1066
|
+
} catch {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
const host = url.hostname.toLowerCase();
|
|
1070
|
+
if (["localhost", "127.0.0.1", "0.0.0.0", "::1"].includes(host)) return true;
|
|
1071
|
+
if (/\.(local|test|example|invalid|localhost)$/.test(host)) return true;
|
|
1072
|
+
if (host === "example.com" || host.endsWith(".example.com")) return true;
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// A URL whose host is a loopback address (localhost, 127.0.0.1, [::1]) —
|
|
1077
|
+
// the remit rail's rule, reused so local-serve and the loopback receiver
|
|
1078
|
+
// agree on what "local" means. Distinct from isLocalhostDevelopmentOrigin,
|
|
1079
|
+
// which states the Campaigns App Development-domain rule (hostname localhost).
|
|
1080
|
+
function isLoopbackDeployUrl(value) {
|
|
1081
|
+
if (!isNonEmptyString(value)) return false;
|
|
1082
|
+
try {
|
|
1083
|
+
return isLoopbackHostname(new URL(String(value).trim()).hostname);
|
|
1084
|
+
} catch {
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
export function isLocalhostDevelopmentOrigin(value) {
|
|
1090
|
+
if (!isNonEmptyString(value)) return false;
|
|
1091
|
+
let url;
|
|
1092
|
+
try {
|
|
1093
|
+
url = new URL(String(value).trim());
|
|
1094
|
+
} catch {
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
return url.hostname.toLowerCase() === "localhost";
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Local proof mode rows (deploy.target local-serve). Once the build stage is
|
|
1101
|
+
// terminal, the served _site/ must be the DEVELOPMENT render — a production
|
|
1102
|
+
// build's protocol-relative vendor loaders fail over a plain-HTTP local serve
|
|
1103
|
+
// and void polish capture — and the production render must have been proven
|
|
1104
|
+
// to differ from it only in environment-gated output (`page-kit parity`).
|
|
1105
|
+
// Both facts are read from the assembly stage's free-form evidence.
|
|
1106
|
+
function validateLocalProof(packet, report, errors, warnings, ready) {
|
|
1107
|
+
if (!stageIsTerminal(report?.stages?.assembly?.status)) {
|
|
1108
|
+
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.`);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
const environment = recordedBuildEnvironment(report);
|
|
1112
|
+
if (environment === LOCAL_PROOF_BUILD_ENVIRONMENT) {
|
|
1113
|
+
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.`);
|
|
1114
|
+
} else {
|
|
1115
|
+
addIssue(warnings, LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE, environment
|
|
1116
|
+
? `${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 the environment as "${LOCAL_PROOF_BUILD_ENVIRONMENT}", and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`
|
|
1117
|
+
: `${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 there; 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}`);
|
|
1118
|
+
}
|
|
1119
|
+
const parity = recordedProductionParity(report);
|
|
1120
|
+
const parityCommand = asInvocation(LOCAL_PROOF_PARITY_COMMAND);
|
|
1121
|
+
if (!parity) {
|
|
1122
|
+
addIssue(warnings, LOCAL_PROOF_PARITY_SCOPE, `Production parity is not recorded (${LOCAL_PROOF_PARITY_FIELD}). After proving the development build, run ${parityCommand} before committing: it renders the current source in development and production into temp dirs and asserts the served _site/ is the current development render and that production differs only in environment-gated output (same pages, route slugs, Campaign Cart pin and next-api-key). The PR preview is the second check, not the first.`);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const currentFingerprint = optionalString(report?.stages?.assembly?.build_fingerprint) || null;
|
|
1126
|
+
if (parity.build_fingerprint && currentFingerprint && parity.build_fingerprint !== currentFingerprint) {
|
|
1127
|
+
addIssue(warnings, LOCAL_PROOF_PARITY_SCOPE, `Production parity was recorded for build ${parity.build_fingerprint}, but stages.assembly.build_fingerprint is now ${currentFingerprint}. Re-run ${parityCommand} on the current build.`);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (parity.status === "pass") {
|
|
1131
|
+
ready.push(`Local proof production parity: PASS — ${singleLineField(String(parity.summary || ""))}`);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const difference = isObject(parity.first_difference) ? parity.first_difference : null;
|
|
1135
|
+
addIssue(errors, LOCAL_PROOF_PARITY_SCOPE, `Production parity FAILED${difference ? ` — first non-gated difference: ${singleLineField(String(difference.kind))} at ${singleLineField(String(difference.route))}${difference.line ? ` line ${difference.line}` : ""}: ${singleLineField(String(difference.detail || ""))}` : `: ${singleLineField(String(parity.summary || ""))}`}. Rebuild in development, re-prove, and run ${parityCommand} again. ${LOCAL_PROOF_NEVER_EDIT_RULE}`, difference ? { first_difference: difference } : undefined);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState) {
|
|
1139
|
+
const report = buildState?.report || null;
|
|
1140
|
+
const required = stageIsTerminal(report?.stages?.setup?.status)
|
|
1141
|
+
|| stageIsTerminal(report?.stages?.assembly?.status)
|
|
1142
|
+
|| derived.scaffold_required !== true;
|
|
1143
|
+
const gate = evaluatePageKitSdkVersion({
|
|
1144
|
+
spec,
|
|
1145
|
+
specStatus: buildState?.specStatus || "ok",
|
|
1146
|
+
targetLoad: buildState?.pageKitCampaignConfig,
|
|
1147
|
+
waivers: report?.waivers,
|
|
1148
|
+
required,
|
|
1149
|
+
});
|
|
1150
|
+
derived.checkpoint_gates.push(gate);
|
|
1151
|
+
|
|
1152
|
+
const inertCounts = Object.fromEntries(
|
|
1153
|
+
["stale", "foreign", "malformed", "expired"].map((kind) => [kind, gate.waiver_assessment?.inert_counts?.[kind] || 0]),
|
|
1154
|
+
);
|
|
1155
|
+
const inertTotal = Object.values(inertCounts).reduce((sum, count) => sum + count, 0);
|
|
1156
|
+
if (inertTotal > 0) {
|
|
1157
|
+
addIssue(
|
|
1158
|
+
warnings,
|
|
1159
|
+
"page_kit.sdk_version.waiver_inert",
|
|
1160
|
+
`SDK-pin waiver history contains ${inertTotal} inert record(s); stale, foreign, malformed, and expired decisions never satisfy the current checkpoint.`,
|
|
1161
|
+
{ counts: inertCounts },
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (gate.status === "blocked") {
|
|
1166
|
+
addIssue(errors, gate.code, gate.reason, { checkpoint_gate: gate });
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (gate.status === "waived") {
|
|
1170
|
+
addIssue(warnings, gate.code, `${gate.reason} Waived by ${gate.waiver.waived_by}: ${gate.waiver.reason}`, { checkpoint_gate: gate });
|
|
1171
|
+
ready.push(`SDK-pin checkpoint accepted under named-human exception (${gate.waiver.waived_by}).`);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (gate.status === "not_applicable") {
|
|
1175
|
+
ready.push("SDK-pin checkpoint not applicable before Page Kit scaffold; it becomes mandatory once the target entry exists or setup completes.");
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (gate.code === "page_kit.sdk_version.repo_newer") {
|
|
1179
|
+
// The repo pin is the authority (#413): a completed bump the Map has not
|
|
1180
|
+
// been re-saved for is advisory, and the ready line names what ships.
|
|
1181
|
+
addIssue(warnings, gate.code, gate.reason, { checkpoint_gate: gate });
|
|
1182
|
+
ready.push(`Target campaigns.json SDK version ${gate.observed_sdk_version} is what ships; the CampaignSpec pin ${gate.expected_sdk_version} is a stale build hint.`);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
ready.push(`Target campaigns.json SDK version matches CampaignSpec (${gate.expected_sdk_version}).`);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState) {
|
|
1189
|
+
const report = buildState?.report || null;
|
|
1190
|
+
const required = stageIsTerminal(report?.stages?.setup?.status)
|
|
1191
|
+
|| stageIsTerminal(report?.stages?.assembly?.status)
|
|
1192
|
+
|| derived.scaffold_required !== true;
|
|
1193
|
+
const gate = evaluatePageKitStoreProfile({
|
|
1194
|
+
specCampaign: spec?.campaign || {},
|
|
1195
|
+
specStatus: buildState?.specStatus || "ok",
|
|
1196
|
+
targetLoad: buildState?.pageKitCampaignConfig,
|
|
1197
|
+
waivers: report?.waivers,
|
|
1198
|
+
required,
|
|
1199
|
+
});
|
|
1200
|
+
derived.checkpoint_gates.push(gate);
|
|
1201
|
+
|
|
1202
|
+
const inertCounts = Object.fromEntries(
|
|
1203
|
+
["stale", "foreign", "malformed", "expired"].map((kind) => [kind, gate.waiver_assessment?.inert_counts?.[kind] || 0]),
|
|
1204
|
+
);
|
|
1205
|
+
const inertTotal = Object.values(inertCounts).reduce((sum, count) => sum + count, 0);
|
|
1206
|
+
if (inertTotal > 0) {
|
|
1207
|
+
addIssue(
|
|
1208
|
+
warnings,
|
|
1209
|
+
"page_kit.store_profile.waiver_inert",
|
|
1210
|
+
`Store Profile waiver history contains ${inertTotal} inert record(s); stale, foreign, malformed, and expired decisions never satisfy the current checkpoint.`,
|
|
1211
|
+
{ counts: inertCounts },
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (gate.status === "blocked") {
|
|
1216
|
+
addIssue(errors, gate.code, gate.reason, { checkpoint_gate: gate });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (gate.status === "waived") {
|
|
1220
|
+
addIssue(warnings, gate.code, `${gate.reason} Waived by ${gate.waiver.waived_by}: ${gate.waiver.reason}`, { checkpoint_gate: gate });
|
|
1221
|
+
ready.push(`Store Profile checkpoint accepted under named-human exception (${gate.waiver.waived_by}).`);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (gate.status === "not_applicable") {
|
|
1225
|
+
ready.push("Store Profile checkpoint not applicable before Page Kit scaffold; it becomes mandatory once the target entry exists or setup completes.");
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (gate.warning_fields.length) {
|
|
1229
|
+
addIssue(warnings, gate.code, gate.reason, { checkpoint_gate: gate });
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
ready.push("Target campaigns.json Store Profile matches the CampaignSpec across all nine governed fields.");
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function validateSpecShippingCountries(spec, warnings, ready) {
|
|
1236
|
+
const countries = spec?.campaign?.available_shipping_countries;
|
|
1237
|
+
if (countries === "all" || (Array.isArray(countries) && countries.length === 0)) {
|
|
1238
|
+
ready.push("CampaignSpec shipping countries: all countries");
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (Array.isArray(countries)) {
|
|
1242
|
+
ready.push(`CampaignSpec shipping countries: ${countries.join(", ")}`);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (countries == null) {
|
|
1246
|
+
ready.push("CampaignSpec shipping countries: all countries");
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
addIssue(warnings, "spec.available_shipping_countries", 'CampaignSpec campaign.available_shipping_countries should be "all" or an array of country codes.');
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Commerce refs may be numeric in Map exports; general text fields may not.
|
|
1253
|
+
function firstCommerceRef(...values) {
|
|
1254
|
+
for (const value of values) {
|
|
1255
|
+
if (isNonEmptyString(value)) return value.trim();
|
|
1256
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1257
|
+
}
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function specPackageRecords(spec) {
|
|
1262
|
+
const records = [];
|
|
1263
|
+
const add = (pkg, source) => {
|
|
1264
|
+
if (!isObject(pkg)) return;
|
|
1265
|
+
const ref = firstCommerceRef(pkg.ref_id, pkg.package_id, pkg.id);
|
|
1266
|
+
if (!ref) return;
|
|
1267
|
+
records.push({ ref, source, package: pkg });
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
for (const page of activeSpecPages(spec)) {
|
|
1271
|
+
for (const pkg of Array.isArray(page.packages) ? page.packages : []) {
|
|
1272
|
+
add(pkg, `page:${page.id}`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
for (const offer of Array.isArray(spec?.offers) ? spec.offers : []) {
|
|
1276
|
+
for (const pkg of Array.isArray(offer.packages) ? offer.packages : []) {
|
|
1277
|
+
add(pkg, `offer:${offer.ref_id || offer.code || offer.name || "unknown"}`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
for (const pkg of Array.isArray(spec?.packages) ? spec.packages : []) {
|
|
1281
|
+
add(pkg, "packages");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return records;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function specPackageRefs(spec) {
|
|
1288
|
+
return new Set(specPackageRecords(spec).map((record) => String(record.ref)));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function specShippingRefs(spec) {
|
|
1292
|
+
const refs = new Set();
|
|
1293
|
+
const add = (method) => {
|
|
1294
|
+
const ref = firstCommerceRef(method?.ref_id, method?.id, method?.shipping_method_id);
|
|
1295
|
+
if (ref) refs.add(String(ref));
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
for (const method of Array.isArray(spec?.shipping_methods) ? spec.shipping_methods : []) add(method);
|
|
1299
|
+
for (const offer of Array.isArray(spec?.offers) ? spec.offers : []) {
|
|
1300
|
+
for (const method of Array.isArray(offer.shipping_methods) ? offer.shipping_methods : []) add(method);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
return refs;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// R2-B1: the set of refs the CampaignSpec itself declares as real
|
|
1307
|
+
// commerce entities — packages (page/offer/top-level), shipping methods, and
|
|
1308
|
+
// offer ref_ids. A reference whose value matches one of these points at a
|
|
1309
|
+
// genuinely-declared entity, so the demo-ref check below should not treat it
|
|
1310
|
+
// as a starter placeholder even when the Map export omitted ref-level
|
|
1311
|
+
// `_provenance.api` stamping (the provenance gap that produced the noise).
|
|
1312
|
+
function specDeclaredCommerceRefs(spec) {
|
|
1313
|
+
const refs = new Set([...specPackageRefs(spec), ...specShippingRefs(spec)]);
|
|
1314
|
+
for (const offer of Array.isArray(spec?.offers) ? spec.offers : []) {
|
|
1315
|
+
const ref = firstCommerceRef(offer?.ref_id, offer?.id);
|
|
1316
|
+
if (ref) refs.add(String(ref));
|
|
1317
|
+
}
|
|
1318
|
+
return refs;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function validateSpecPackageAvailability(spec, warnings, ready) {
|
|
1322
|
+
const unavailable = specPackageRecords(spec).filter((record) => {
|
|
1323
|
+
const availability = firstNonEmptyString(
|
|
1324
|
+
record.package.product_purchase_availability,
|
|
1325
|
+
record.package.purchase_availability,
|
|
1326
|
+
record.package.availability
|
|
1327
|
+
);
|
|
1328
|
+
return availability && availability.toLowerCase() === "unavailable";
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
if (!unavailable.length) {
|
|
1332
|
+
ready.push("CampaignSpec package purchase availability has no unavailable package refs in active build data");
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const sample = unavailable
|
|
1337
|
+
.slice(0, 6)
|
|
1338
|
+
.map((record) => `${record.ref} (${record.source})`)
|
|
1339
|
+
.join(", ");
|
|
1340
|
+
const more = unavailable.length > 6 ? `; plus ${unavailable.length - 6} more` : "";
|
|
1341
|
+
addIssue(
|
|
1342
|
+
warnings,
|
|
1343
|
+
"spec.package_unavailable",
|
|
1344
|
+
`CampaignSpec contains package refs marked product_purchase_availability=unavailable: ${sample}${more}. Checkout or upsell API calls may 403 until the store variant is available.`
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function validateSpecIdentityExport(spec, warnings, ready) {
|
|
1349
|
+
const identity = spec?.spec_identity;
|
|
1350
|
+
if (isObject(identity) && resolveCampaignIdentity(identity) && isNonEmptyString(identity.public_route_slug)) {
|
|
1351
|
+
ready.push(`CampaignSpec spec_identity includes ${identity.local_spec_id ? "local_spec_id" : "map_id"} and public_route_slug`);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
addIssue(
|
|
1356
|
+
warnings,
|
|
1357
|
+
"spec_identity.export",
|
|
1358
|
+
"CampaignSpec is missing complete spec_identity: declare map_id for a saved Map or local_spec_id for a local spec, plus public_route_slug. CLI identity overrides should stay diagnostic-only."
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Identity cross-check for the root key of every built-output gate. The c1
|
|
1363
|
+
// negative control (2026-08-02) showed that corrupting campaign.public_route_slug
|
|
1364
|
+
// (classically: to the Map ID) raises no blocker — every built_output.* check
|
|
1365
|
+
// roots at _site/<public_route_slug>/, finds nothing to check, and silently
|
|
1366
|
+
// stops running, so doctor output is indistinguishable from a healthy run
|
|
1367
|
+
// while all built-output guarantees are off. The packet slug must match the
|
|
1368
|
+
// spec's declared public route slug and must not be the Map ID.
|
|
1369
|
+
export function validateRouteSlugIdentity(spec, packet, errors, ready) {
|
|
1370
|
+
const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1371
|
+
if (!packetSlug) return;
|
|
1372
|
+
|
|
1373
|
+
const mapId = optionalString(spec?.spec_identity?.map_id)
|
|
1374
|
+
|| optionalString(spec?.map_id)
|
|
1375
|
+
|| optionalString(packet?.spec?.map_id);
|
|
1376
|
+
const specSlug = normalizePublicRouteSlug(
|
|
1377
|
+
optionalString(spec?.spec_identity?.public_route_slug)
|
|
1378
|
+
|| optionalString(spec?.campaign?.slug)
|
|
1379
|
+
|| optionalString(spec?.campaign?.id)
|
|
1380
|
+
);
|
|
1381
|
+
const specDeclaresMapIdSlug = Boolean(specSlug) && Boolean(mapId) && specSlug === mapId;
|
|
1382
|
+
|
|
1383
|
+
if (mapId && packetSlug === mapId && !specDeclaresMapIdSlug) {
|
|
1384
|
+
addIssue(
|
|
1385
|
+
errors,
|
|
1386
|
+
"campaign.route_slug_identity",
|
|
1387
|
+
`Packet campaign.public_route_slug "${packetSlug}" equals the Map ID. The Map ID is spec identity (spec_identity.map_id), not a route; built output lives at _site/<public_route_slug>/, so this slug disarms every built-output check. Set the packet slug to the campaign's public route slug${specSlug ? ` ("${specSlug}")` : ""} or re-run prepare-build from the spec.`
|
|
1388
|
+
);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (specSlug && packetSlug !== specSlug) {
|
|
1393
|
+
addIssue(
|
|
1394
|
+
errors,
|
|
1395
|
+
"campaign.route_slug_identity",
|
|
1396
|
+
`Packet campaign.public_route_slug "${packetSlug}" does not match the CampaignSpec declared public route slug "${specSlug}". Built-output checks root at _site/<public_route_slug>/, so a wrong slug silently disarms them. Correct the packet slug or re-run prepare-build from the spec.`
|
|
1397
|
+
);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (specSlug) {
|
|
1402
|
+
// Kilo review (PR #174): only claim "not the Map ID" when that is true —
|
|
1403
|
+
// a spec may (confusingly, but authoritatively) declare its route slug
|
|
1404
|
+
// equal to its Map ID, and the packet matching it is not an error here.
|
|
1405
|
+
ready.push(
|
|
1406
|
+
specDeclaresMapIdSlug
|
|
1407
|
+
? `Packet public_route_slug "${packetSlug}" matches CampaignSpec identity (note: the spec declares its public route slug equal to its Map ID)`
|
|
1408
|
+
: `Packet public_route_slug "${packetSlug}" matches CampaignSpec identity and is not the Map ID`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// Loud failure for the state the c1 control exposed: _site/ was built but
|
|
1414
|
+
// _site/<public_route_slug>/ is absent, so the whole built_output.* family is
|
|
1415
|
+
// about to silently skip — the exact shape a slug corrupted to the Map ID
|
|
1416
|
+
// produces. When _site/ does not exist at all (pre-build, or gate tests that
|
|
1417
|
+
// never run page-kit) this stays quiet regardless of assembly status —
|
|
1418
|
+
// sdk_hints.meta_tags already reports that deferral, and "assembly recorded
|
|
1419
|
+
// complete without any built output" is a pre-existing contract other
|
|
1420
|
+
// lifecycle checks exercise.
|
|
1421
|
+
export function validateBuiltOutputTargetRoot(packet, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
1422
|
+
const targetRepo = derived.target_repo;
|
|
1423
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1424
|
+
if (!targetRepo || !publicRouteSlug) return;
|
|
1425
|
+
|
|
1426
|
+
// isDirectory, not bare existence: a stray regular file at _site/<slug>
|
|
1427
|
+
// must not read as a found root — downstream built_output.* checks would
|
|
1428
|
+
// still skip on it (Kilo review, PR #174).
|
|
1429
|
+
const siteRoot = join(targetRepo, "_site", publicRouteSlug);
|
|
1430
|
+
if (existsSync(siteRoot) && statSync(siteRoot).isDirectory()) {
|
|
1431
|
+
ready.push(`Built output root found at _site/${publicRouteSlug}/`);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const siteDir = join(targetRepo, "_site");
|
|
1436
|
+
if (!existsSync(siteDir) || !statSync(siteDir).isDirectory()) return;
|
|
1437
|
+
|
|
1438
|
+
const assemblyComplete = isStageComplete(buildState.report, "assembly");
|
|
1439
|
+
const builtRoots = readdirSync(siteDir, { withFileTypes: true })
|
|
1440
|
+
.filter((entry) => entry.isDirectory())
|
|
1441
|
+
.map((entry) => entry.name)
|
|
1442
|
+
.sort();
|
|
1443
|
+
addIssue(
|
|
1444
|
+
assemblyComplete ? errors : warnings,
|
|
1445
|
+
"built_output.target_root",
|
|
1446
|
+
`Target route not found in _site: expected built output at _site/${publicRouteSlug}/ but the slug root does not exist (built root(s): ${builtRoots.length ? builtRoots.join(", ") : "none"}). `
|
|
1447
|
+
+ `Every built_output.* check roots at _site/<public_route_slug>/ and skips when it is missing, so built-output verification cannot run until the route exists or campaign.public_route_slug is corrected.`,
|
|
1448
|
+
{ expected_root: `_site/${publicRouteSlug}/`, built_roots: builtRoots, assembly_complete: assemblyComplete }
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived = {}, buildState = {}) {
|
|
1453
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1454
|
+
if (!publicRouteSlug) return;
|
|
1455
|
+
const routeRoot = campaignRouteRoot(packet);
|
|
1456
|
+
|
|
1457
|
+
// R2-B2: the spec only carries unrooted routing-meta *hints*; the
|
|
1458
|
+
// page-kit build roots them when it renders _site/<slug>/. Once that built
|
|
1459
|
+
// output exists and assembly is complete, validateBuiltSdkMetaTags checks the
|
|
1460
|
+
// actual rendered values authoritatively. Re-warning on the spec literal here
|
|
1461
|
+
// would just repeat a "fix before QA" message the build already satisfied
|
|
1462
|
+
// (browser QA later proved the deployed output correct), so defer to the
|
|
1463
|
+
// built-output check instead of double-flagging.
|
|
1464
|
+
const targetRepo = derived.target_repo;
|
|
1465
|
+
const siteRoot = targetRepo ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1466
|
+
if (isStageComplete(buildState.report, "assembly") && siteRoot && existsSync(siteRoot)) {
|
|
1467
|
+
ready.push(`CampaignSpec routing meta deferred to built-output verification (_site/${publicRouteSlug}/).`);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const hits = [];
|
|
1472
|
+
for (const page of activeSpecPages(spec)) {
|
|
1473
|
+
const metaTags = page.sdk_hints?.meta_tags;
|
|
1474
|
+
if (!isObject(metaTags)) continue;
|
|
1475
|
+
|
|
1476
|
+
for (const tag of SDK_ROUTING_META_TAGS) {
|
|
1477
|
+
const value = metaTags[tag];
|
|
1478
|
+
if (!isNonEmptyString(value)) continue;
|
|
1479
|
+
const route = value.trim();
|
|
1480
|
+
if (isRuntimeRootedRoutingMeta(route, publicRouteSlug, routeRoot)) continue;
|
|
1481
|
+
hits.push(`${page.id}:${tag}=${route}`);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (!hits.length) {
|
|
1486
|
+
ready.push(`CampaignSpec SDK routing meta tags are runtime-rooted for ${routeRoot}`);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const sample = hits.slice(0, 5).join("; ");
|
|
1491
|
+
const more = hits.length > 5 ? `; plus ${hits.length - 5} more` : "";
|
|
1492
|
+
addIssue(
|
|
1493
|
+
warnings,
|
|
1494
|
+
"routing_meta.runtime_root",
|
|
1495
|
+
`CampaignSpec sdk_hints.meta_tags routing values must render as campaign-rooted paths before QA. Expected values like "${routeRoot}upsell/" for ${SDK_ROUTING_META_TAGS.join(", ")}; found ${sample}${more}.`
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Pages declared out of source scope (#238/#239: a manifest skip_reason entry
|
|
1500
|
+
// or CampaignSpec build_scope "partial") assemble from the template family and
|
|
1501
|
+
// are not built by a partial-scope build, so their absence from _site/ is the
|
|
1502
|
+
// declared state — not a missing-page failure. In-scope pages keep the full
|
|
1503
|
+
// post-assembly escalation.
|
|
1504
|
+
//
|
|
1505
|
+
// The authority is stages.prepare_build.declared_out_of_scope on the recorded
|
|
1506
|
+
// assembly report — NOT derived.scope.out_of_scope_pages, which also contains
|
|
1507
|
+
// blocked pages carrying the auto-generated skip_reason remedy text (a packet
|
|
1508
|
+
// blocked on MISSING_SOURCE_PAGE has skip mappings too, and those pages must
|
|
1509
|
+
// keep failing loud). With no report recorded the set is empty, so every page
|
|
1510
|
+
// stays in scope — the safe default.
|
|
1511
|
+
function declaredOutOfScopePageIds(buildState) {
|
|
1512
|
+
const declared = buildState?.report?.stages?.prepare_build?.declared_out_of_scope;
|
|
1513
|
+
if (!Array.isArray(declared)) return new Set();
|
|
1514
|
+
return new Set(declared.map((skip) => skip?.page_id).filter(isNonEmptyString));
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
export function validateBuiltSdkMetaTags(spec, packet, errors, warnings, ready, derived, buildState = {}) {
|
|
1518
|
+
const expectedPages = activeSpecPages(spec)
|
|
1519
|
+
.map((page) => ({
|
|
1520
|
+
page,
|
|
1521
|
+
metaTags: page.sdk_hints?.meta_tags,
|
|
1522
|
+
}))
|
|
1523
|
+
.filter(({ metaTags }) => isObject(metaTags) && Object.keys(metaTags).length > 0);
|
|
1524
|
+
if (expectedPages.length === 0) return;
|
|
1525
|
+
|
|
1526
|
+
// A spec key the SDK does not read (sdk-meta-tags.mjs, the list QA reads
|
|
1527
|
+
// too) is a stale Map page hint, not a tag the build owes: it is never
|
|
1528
|
+
// required and never `missing`, whether or not it rendered. One advisory
|
|
1529
|
+
// per page names the keys and the reason, so the fix is an edit to the
|
|
1530
|
+
// Map, not the build; it does not wait for built output.
|
|
1531
|
+
for (const { page, metaTags } of expectedPages) {
|
|
1532
|
+
const ignoredTags = Object.keys(metaTags).filter((name) => isSdkIgnoredMetaTag(name));
|
|
1533
|
+
if (ignoredTags.length === 0) continue;
|
|
1534
|
+
addIssue(
|
|
1535
|
+
warnings,
|
|
1536
|
+
"sdk_hints.meta_tags.ignored_by_sdk",
|
|
1537
|
+
`CampaignSpec page "${page.id}" lists SDK meta tag(s) the Campaign Cart SDK does not read; remove from the Map's page hints: ${describeSdkIgnoredMetaTags(ignoredTags)}.`,
|
|
1538
|
+
{ page_id: page.id, tags: ignoredTags }
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const allExpectedTags = [...new Set(expectedPages.flatMap(({ metaTags }) => Object.keys(metaTags)))]
|
|
1543
|
+
.filter((name) => !isSdkIgnoredMetaTag(name))
|
|
1544
|
+
.sort();
|
|
1545
|
+
const targetRepo = derived.target_repo;
|
|
1546
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1547
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1548
|
+
const assemblyComplete = isStageComplete(buildState.report, "assembly");
|
|
1549
|
+
|
|
1550
|
+
if (!siteRoot || !existsSync(siteRoot)) {
|
|
1551
|
+
if (allExpectedTags.length === 0) return;
|
|
1552
|
+
addIssue(
|
|
1553
|
+
warnings,
|
|
1554
|
+
"sdk_hints.meta_tags",
|
|
1555
|
+
`CampaignSpec expects SDK meta tags (${allExpectedTags.join(", ")}). Doctor cannot verify rendered output until page-kit build writes _site/${publicRouteSlug || "<slug>"}/.`
|
|
1556
|
+
);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const outOfScopePageIds = declaredOutOfScopePageIds(buildState);
|
|
1561
|
+
const skippedOutOfScope = [];
|
|
1562
|
+
let checked = 0;
|
|
1563
|
+
for (const { page, metaTags } of expectedPages) {
|
|
1564
|
+
const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
|
|
1565
|
+
if (!builtPath || !existsSync(builtPath)) {
|
|
1566
|
+
// A declared out-of-scope page has no built HTML by declaration; a page
|
|
1567
|
+
// that IS built despite the declaration still gets its meta verified
|
|
1568
|
+
// below, so the skip covers exactly the declared absence.
|
|
1569
|
+
if (outOfScopePageIds.has(page.id)) {
|
|
1570
|
+
skippedOutOfScope.push(page.id);
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
const issue = {
|
|
1574
|
+
code: "built_output.page_missing",
|
|
1575
|
+
message: `Built HTML is missing for CampaignSpec page "${page.id}" at ${builtPath ? relFromDir(targetRepo, builtPath) : "_site/<slug>/..."}.`,
|
|
1576
|
+
detail: { page_id: page.id },
|
|
1577
|
+
};
|
|
1578
|
+
(assemblyComplete ? errors : warnings).push(issue);
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
checked += 1;
|
|
1583
|
+
const content = readFileSync(builtPath, "utf8");
|
|
1584
|
+
|
|
1585
|
+
for (const [name, expectedValue] of Object.entries(metaTags)) {
|
|
1586
|
+
if (isSdkIgnoredMetaTag(name)) continue;
|
|
1587
|
+
const actualValue = extractMetaContent(content, name);
|
|
1588
|
+
if (!isNonEmptyString(actualValue)) {
|
|
1589
|
+
addIssue(
|
|
1590
|
+
assemblyComplete ? errors : warnings,
|
|
1591
|
+
"sdk_hints.meta_tags.missing",
|
|
1592
|
+
`Built page "${page.id}" is missing SDK meta tag "${name}" expected from CampaignSpec.`,
|
|
1593
|
+
{ page_id: page.id, file: relFromDir(targetRepo, builtPath) }
|
|
1594
|
+
);
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
if (SDK_ROUTING_META_TAGS.includes(name) && isNonEmptyString(expectedValue)) {
|
|
1598
|
+
const expectedRoute = runtimeRouteForMetaValue(expectedValue, publicRouteSlug, campaignRouteRoot(packet));
|
|
1599
|
+
if (expectedRoute && actualValue.trim() !== expectedRoute) {
|
|
1600
|
+
addIssue(
|
|
1601
|
+
assemblyComplete ? errors : warnings,
|
|
1602
|
+
"sdk_hints.meta_tags.route_mismatch",
|
|
1603
|
+
`Built page "${page.id}" emits ${name}="${actualValue}", expected "${expectedRoute}".`,
|
|
1604
|
+
{ page_id: page.id, file: relFromDir(targetRepo, builtPath) }
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
if (checked > 0) ready.push(`Built SDK meta tags checked in _site/${publicRouteSlug}/ for ${checked} page(s)`);
|
|
1612
|
+
if (skippedOutOfScope.length > 0) {
|
|
1613
|
+
ready.push(`Built SDK meta verification skipped for ${skippedOutOfScope.length} declared out-of-scope page(s): ${skippedOutOfScope.join(", ")}`);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Build output fingerprint. Every stage after build binds its evidence to
|
|
1618
|
+
// stages.assembly.build_fingerprint by string equality, so the value has to
|
|
1619
|
+
// be one anyone can recompute from the output that is actually on disk.
|
|
1620
|
+
// Doctor recomputes it from _site/<slug>/ on every run and publishes the
|
|
1621
|
+
// current value at derived.build_output_fingerprint (the value build records,
|
|
1622
|
+
// and the value an operator checks by hand), then compares it with what the
|
|
1623
|
+
// report recorded: equal = pass, different = the output changed since build
|
|
1624
|
+
// recorded it (a rebuild, a toolkit upgrade, a hand edit), absent = build has
|
|
1625
|
+
// not recorded it yet. Stale is blocking once assembly is complete because
|
|
1626
|
+
// every polish/QA artifact bound to the old value is then evidence about a
|
|
1627
|
+
// build that no longer exists.
|
|
1628
|
+
export function validateBuildOutputFingerprint(packet, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
1629
|
+
const targetRepo = derived.target_repo;
|
|
1630
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1631
|
+
if (!targetRepo || !publicRouteSlug) return;
|
|
1632
|
+
const siteRoot = join(targetRepo, "_site", publicRouteSlug);
|
|
1633
|
+
if (!existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
|
|
1634
|
+
|
|
1635
|
+
const current = computeBuildFingerprint(siteRoot);
|
|
1636
|
+
// The root was a directory a moment ago; if it is not one now (removed
|
|
1637
|
+
// between the check and the walk) there is no output to fingerprint and no
|
|
1638
|
+
// verdict to give, the same skip as a missing root.
|
|
1639
|
+
if (!current.ok) return;
|
|
1640
|
+
const recorded = currentBuildFingerprint(buildState.report);
|
|
1641
|
+
const assemblyComplete = isStageComplete(buildState.report, "assembly");
|
|
1642
|
+
const status = !recorded ? "missing" : recorded === current.fingerprint ? "pass" : "stale";
|
|
1643
|
+
derived.build_output_fingerprint = {
|
|
1644
|
+
root: `_site/${publicRouteSlug}/`,
|
|
1645
|
+
algorithm: current.algorithm,
|
|
1646
|
+
value: current.fingerprint,
|
|
1647
|
+
file_count: current.file_count,
|
|
1648
|
+
excluded: current.excluded,
|
|
1649
|
+
recorded: recorded || null,
|
|
1650
|
+
status,
|
|
1651
|
+
};
|
|
1652
|
+
if (status === "pass") {
|
|
1653
|
+
ready.push(`Build output fingerprint matches stages.assembly.build_fingerprint (${current.file_count} file(s) under _site/${publicRouteSlug}/)`);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
if (status === "missing") {
|
|
1657
|
+
addIssue(
|
|
1658
|
+
warnings,
|
|
1659
|
+
"built_output.fingerprint_missing",
|
|
1660
|
+
`Build has not recorded stages.assembly.build_fingerprint. The current output fingerprint of _site/${publicRouteSlug}/ is ${current.fingerprint} (${current.file_count} file(s); doctor --json derived.build_output_fingerprint.value); record it on stages.assembly.build_fingerprint after page-kit build.`,
|
|
1661
|
+
{ root: `_site/${publicRouteSlug}/`, current: current.fingerprint, file_count: current.file_count, assembly_complete: assemblyComplete }
|
|
1662
|
+
);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
addIssue(
|
|
1666
|
+
assemblyComplete ? errors : warnings,
|
|
1667
|
+
"built_output.fingerprint_stale",
|
|
1668
|
+
`Built output under _site/${publicRouteSlug}/ no longer matches stages.assembly.build_fingerprint (recorded ${recorded}, current ${current.fingerprint}, ${current.file_count} file(s)). `
|
|
1669
|
+
+ "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.",
|
|
1670
|
+
{ root: `_site/${publicRouteSlug}/`, recorded, current: current.fingerprint, file_count: current.file_count, assembly_complete: assemblyComplete }
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function validateBuiltOutputPages(spec, packet, errors, warnings, ready, derived, buildState = {}) {
|
|
1675
|
+
const pages = activeSpecPages(spec);
|
|
1676
|
+
if (pages.length === 0) return;
|
|
1677
|
+
|
|
1678
|
+
const targetRepo = derived.target_repo;
|
|
1679
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1680
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1681
|
+
if (!siteRoot || !existsSync(siteRoot)) return;
|
|
1682
|
+
|
|
1683
|
+
const assemblyComplete = isStageComplete(buildState.report, "assembly");
|
|
1684
|
+
let checked = 0;
|
|
1685
|
+
for (const page of pages) {
|
|
1686
|
+
const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
|
|
1687
|
+
if (!builtPath || !existsSync(builtPath)) continue;
|
|
1688
|
+
|
|
1689
|
+
checked += 1;
|
|
1690
|
+
validateBuiltHtmlStructure(
|
|
1691
|
+
readFileSync(builtPath, "utf8"),
|
|
1692
|
+
builtPath,
|
|
1693
|
+
targetRepo,
|
|
1694
|
+
page,
|
|
1695
|
+
spec,
|
|
1696
|
+
publicRouteSlug,
|
|
1697
|
+
errors,
|
|
1698
|
+
warnings,
|
|
1699
|
+
assemblyComplete
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
if (checked > 0) ready.push(`Built HTML structure and commerce refs checked in _site/${publicRouteSlug}/ for ${checked} page(s)`);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// Upsell selector scope (#270). Every doctor invocation, deliberately — not
|
|
1707
|
+
// only the one that follows assembly. The real-world instance was introduced by
|
|
1708
|
+
// a LATER human review round that layered a correctly-scoped selector on top of
|
|
1709
|
+
// an existing unscoped one and left both in place, so a gate that fired only at
|
|
1710
|
+
// first assembly would have watched the defect arrive and said nothing. It also
|
|
1711
|
+
// stays blocking regardless of stage status, unlike the per-page structure
|
|
1712
|
+
// checks that soften to warnings before assembly completes: built markup that
|
|
1713
|
+
// charges a shopper is not a work-in-progress state that becomes true later.
|
|
1714
|
+
function validateUpsellSelectorScope(spec, packet, errors, warnings, ready, derived, buildState = {}) {
|
|
1715
|
+
const targetRepo = derived.target_repo;
|
|
1716
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1717
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1718
|
+
const pages = [];
|
|
1719
|
+
if (siteRoot && existsSync(siteRoot)) {
|
|
1720
|
+
// Enumerate from the FILESYSTEM, not from the CampaignSpec, so both doctor
|
|
1721
|
+
// paths scan the same set. Walking active spec pages would miss any built
|
|
1722
|
+
// page the spec does not declare — the ordinary state for a page-kit
|
|
1723
|
+
// `campaign-build` campaign, and the state route drift produces — which
|
|
1724
|
+
// would leave the packet path blind to exactly the pages `doctor --built`
|
|
1725
|
+
// catches. A gate whose coverage depends on which flag you passed is not
|
|
1726
|
+
// the gate this was written to be.
|
|
1727
|
+
const declaredByPath = new Map();
|
|
1728
|
+
for (const page of activeSpecPages(spec)) {
|
|
1729
|
+
const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
|
|
1730
|
+
if (builtPath) declaredByPath.set(resolve(builtPath), page);
|
|
1731
|
+
}
|
|
1732
|
+
const scope = resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug });
|
|
1733
|
+
for (const builtPage of (scope.ok ? scope.pages : [])) {
|
|
1734
|
+
const declared = declaredByPath.get(resolve(builtPage.built_path)) || null;
|
|
1735
|
+
// Declared type wins only when it is the post-purchase answer; otherwise
|
|
1736
|
+
// the route-inferred type stands. Same fail-closed rule the evaluator
|
|
1737
|
+
// applies between a declared type and the page's own next-page-type meta:
|
|
1738
|
+
// any signal saying "post-purchase" is enough.
|
|
1739
|
+
const declaredType = declared?.type || null;
|
|
1740
|
+
pages.push({
|
|
1741
|
+
page_id: declared?.id || builtPage.page_id,
|
|
1742
|
+
page_type: isPostPurchasePageType(declaredType) ? declaredType : builtPage.page_type,
|
|
1743
|
+
file: relFromDir(targetRepo, builtPage.built_path),
|
|
1744
|
+
content: readFileSync(builtPage.built_path, "utf8"),
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
recordUpsellSelectorScopeGate({
|
|
1749
|
+
subject: {
|
|
1750
|
+
public_route_slug: publicRouteSlug || null,
|
|
1751
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
1752
|
+
},
|
|
1753
|
+
pages,
|
|
1754
|
+
waivers: buildState?.report?.waivers,
|
|
1755
|
+
errors,
|
|
1756
|
+
warnings,
|
|
1757
|
+
ready,
|
|
1758
|
+
derived,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Shared by both doctor entry points: the packet path above and the
|
|
1763
|
+
// built-site-only path (`doctor --built`), which is how a page-kit
|
|
1764
|
+
// `campaign-build` campaign with no hand-authored packet gets inspected — and
|
|
1765
|
+
// therefore the invocation that most needs this gate.
|
|
1766
|
+
function recordUpsellSelectorScopeGate({ subject, pages, waivers, errors, warnings, ready, derived }) {
|
|
1767
|
+
const gate = evaluateUpsellSelectorScope({ subject, pages, waivers });
|
|
1768
|
+
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
1769
|
+
|
|
1770
|
+
const inertCounts = Object.fromEntries(
|
|
1771
|
+
["stale", "foreign", "malformed", "expired"].map((kind) => [kind, gate.waiver_assessment?.inert_counts?.[kind] || 0]),
|
|
1772
|
+
);
|
|
1773
|
+
const inertTotal = Object.values(inertCounts).reduce((sum, count) => sum + count, 0);
|
|
1774
|
+
if (inertTotal > 0) {
|
|
1775
|
+
addIssue(
|
|
1776
|
+
warnings,
|
|
1777
|
+
"built_output.upsell_selector_scope.waiver_inert",
|
|
1778
|
+
`Upsell selector-scope waiver history contains ${inertTotal} inert record(s); stale, foreign, malformed, and expired decisions never satisfy the current checkpoint.`,
|
|
1779
|
+
{ counts: inertCounts },
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
if (gate.status === "blocked") {
|
|
1784
|
+
addIssue(errors, gate.code, gate.reason, { checkpoint_gate: gate });
|
|
1785
|
+
return gate;
|
|
1786
|
+
}
|
|
1787
|
+
if (gate.status === "waived") {
|
|
1788
|
+
addIssue(warnings, gate.code, `${gate.reason} Waived by ${gate.waiver.waived_by}: ${gate.waiver.reason}`, { checkpoint_gate: gate });
|
|
1789
|
+
ready.push(`Upsell selector-scope checkpoint accepted under named-human exception (${gate.waiver.waived_by}).`);
|
|
1790
|
+
return gate;
|
|
1791
|
+
}
|
|
1792
|
+
if (gate.status === "not_applicable") {
|
|
1793
|
+
ready.push("Upsell selector-scope checkpoint not applicable: no built upsell/downsell page to scan yet.");
|
|
1794
|
+
return gate;
|
|
1795
|
+
}
|
|
1796
|
+
if (gate.warned.length) {
|
|
1797
|
+
addIssue(warnings, gate.code, gate.reason, { checkpoint_gate: gate });
|
|
1798
|
+
// A ready line beside the warning, so "the gate ran and found no cart-writing
|
|
1799
|
+
// selector" and "the gate did not run" are never the same JSON shape.
|
|
1800
|
+
ready.push(`Upsell selector-scope scan found 0 cart-writing selector(s) on ${gate.pages_scanned} built post-purchase page(s), with ${gate.warned.length} select-mode note(s)`);
|
|
1801
|
+
return gate;
|
|
1802
|
+
}
|
|
1803
|
+
ready.push(`Every bundle selector on ${gate.pages_scanned} built post-purchase page(s) is scoped away from the live cart (${gate.selectors_scanned} selector(s) scanned)`);
|
|
1804
|
+
return gate;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Cross-page campaign identity (#301). Every doctor invocation, like the
|
|
1808
|
+
// selector-scope gate above and for the same reason: the borrowed page that
|
|
1809
|
+
// carries another funnel's key or tag arrives in a later edit round as often
|
|
1810
|
+
// as at first assembly. Enumerates from the filesystem so both doctor paths
|
|
1811
|
+
// scan the same pages, and stays blocking regardless of stage status.
|
|
1812
|
+
function validateCampaignIdentity(packet, errors, ready, derived) {
|
|
1813
|
+
const targetRepo = derived.target_repo;
|
|
1814
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1815
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1816
|
+
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
1817
|
+
recordCampaignIdentityGate({
|
|
1818
|
+
subject: {
|
|
1819
|
+
public_route_slug: publicRouteSlug || null,
|
|
1820
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
1821
|
+
},
|
|
1822
|
+
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
|
|
1823
|
+
errors,
|
|
1824
|
+
ready,
|
|
1825
|
+
derived,
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// The identity evaluator is pure, so the filesystem work happens here: each
|
|
1830
|
+
// built page's HTML plus the LOCAL scripts it loads. The API key of every
|
|
1831
|
+
// certified family lives in a shared config.js the pages reference by
|
|
1832
|
+
// `<script src>`, not in the page itself, so a page-only scan would see no
|
|
1833
|
+
// key at all and pass a borrowed page whose config.js names another store.
|
|
1834
|
+
// Absolute srcs resolve against the site root first (page-kit emits
|
|
1835
|
+
// `/<slug>/config.js`), then the campaign directory (a root-served campaign
|
|
1836
|
+
// emits `/config.js`); relative srcs resolve against the page. Remote and
|
|
1837
|
+
// missing scripts contribute nothing.
|
|
1838
|
+
function collectBuiltPageIdentityInputs(scope, targetRepo) {
|
|
1839
|
+
const scriptCache = new Map();
|
|
1840
|
+
const readScript = (path) => {
|
|
1841
|
+
if (!scriptCache.has(path)) {
|
|
1842
|
+
let content = null;
|
|
1843
|
+
try {
|
|
1844
|
+
if (existsSync(path) && statSync(path).isFile()) content = readFileSync(path, "utf8");
|
|
1845
|
+
} catch {
|
|
1846
|
+
content = null;
|
|
1847
|
+
}
|
|
1848
|
+
scriptCache.set(path, content);
|
|
1849
|
+
}
|
|
1850
|
+
return scriptCache.get(path);
|
|
1851
|
+
};
|
|
1852
|
+
const resolveLocalScript = (src, builtPath) => {
|
|
1853
|
+
const raw = String(src || "").trim();
|
|
1854
|
+
if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
|
|
1855
|
+
const clean = raw.replace(/[?#].*$/, "");
|
|
1856
|
+
if (!clean) return null;
|
|
1857
|
+
if (clean.startsWith("/")) {
|
|
1858
|
+
const rel = clean.replace(/^\/+/, "");
|
|
1859
|
+
const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
|
|
1860
|
+
return candidates.find((candidate) => existsSync(candidate)) || null;
|
|
1861
|
+
}
|
|
1862
|
+
return resolve(dirname(builtPath), clean);
|
|
1863
|
+
};
|
|
1864
|
+
return scope.pages.map((page) => {
|
|
1865
|
+
const content = readFileSync(page.built_path, "utf8");
|
|
1866
|
+
const scripts = [];
|
|
1867
|
+
for (const src of externalScriptSources(content)) {
|
|
1868
|
+
const path = resolveLocalScript(src, page.built_path);
|
|
1869
|
+
const scriptContent = path ? readScript(path) : null;
|
|
1870
|
+
if (scriptContent == null) continue;
|
|
1871
|
+
scripts.push({ src, file: relFromDir(targetRepo, path), content: scriptContent });
|
|
1872
|
+
}
|
|
1873
|
+
return {
|
|
1874
|
+
page_id: page.page_id,
|
|
1875
|
+
route: page.route,
|
|
1876
|
+
file: relFromDir(targetRepo, page.built_path),
|
|
1877
|
+
content,
|
|
1878
|
+
scripts,
|
|
1879
|
+
};
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
|
|
1884
|
+
const gate = evaluateCampaignIdentity({ subject, pages });
|
|
1885
|
+
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
1886
|
+
|
|
1887
|
+
if (gate.status === "blocked") {
|
|
1888
|
+
// One error per finding, each under its own code, so a report reader can
|
|
1889
|
+
// tell key drift from tag drift without parsing prose; every error carries
|
|
1890
|
+
// the whole gate so the JSON shape matches the other checkpoint gates.
|
|
1891
|
+
for (const finding of gate.findings) {
|
|
1892
|
+
addIssue(errors, finding.code, finding.message, { finding, checkpoint_gate: gate });
|
|
1893
|
+
}
|
|
1894
|
+
return gate;
|
|
1895
|
+
}
|
|
1896
|
+
if (gate.status === "not_applicable") {
|
|
1897
|
+
ready.push("Campaign identity checkpoint not applicable: no built page to scan yet.");
|
|
1898
|
+
return gate;
|
|
1899
|
+
}
|
|
1900
|
+
const skipped = gate.pages_skipped.length ? `; skipped ${gate.pages_skipped.length} parked page(s): ${gate.pages_skipped.join(", ")}` : "";
|
|
1901
|
+
ready.push(`All ${gate.pages_scanned} built page(s) agree on campaign identity (next-funnel ${gate.identity.funnel ? `"${gate.identity.funnel}"` : "not declared"}, API key ${gate.identity.api_key ? "consistent" : "not declared"})${skipped}`);
|
|
1902
|
+
return gate;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Static SDK markup checks (#303). Every doctor invocation, both entry points,
|
|
1906
|
+
// filesystem enumeration, blocking regardless of stage status — the same
|
|
1907
|
+
// contract as the two gates above, for the same reasons.
|
|
1908
|
+
function validateSdkMarkup(packet, errors, warnings, ready, derived) {
|
|
1909
|
+
const targetRepo = derived.target_repo;
|
|
1910
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1911
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1912
|
+
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
1913
|
+
recordSdkMarkupGate({
|
|
1914
|
+
subject: {
|
|
1915
|
+
public_route_slug: publicRouteSlug || null,
|
|
1916
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
1917
|
+
},
|
|
1918
|
+
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
|
|
1919
|
+
errors,
|
|
1920
|
+
warnings,
|
|
1921
|
+
ready,
|
|
1922
|
+
derived,
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// Campaign-owned script syntax (#480). Every doctor invocation, both entry
|
|
1927
|
+
// points, filesystem enumeration, blocking regardless of stage status — the
|
|
1928
|
+
// same contract as the gates above: a script that throws a SyntaxError on
|
|
1929
|
+
// load is not a work-in-progress state that becomes true later.
|
|
1930
|
+
function validateBuiltScriptSyntax(packet, errors, warnings, ready, derived) {
|
|
1931
|
+
const targetRepo = derived.target_repo;
|
|
1932
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1933
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1934
|
+
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
1935
|
+
recordScriptSyntaxGate({
|
|
1936
|
+
subject: {
|
|
1937
|
+
public_route_slug: publicRouteSlug || null,
|
|
1938
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
1939
|
+
},
|
|
1940
|
+
inputs: scope?.ok ? collectBuiltScriptSyntaxInputs(scope, targetRepo) : {},
|
|
1941
|
+
errors,
|
|
1942
|
+
warnings,
|
|
1943
|
+
ready,
|
|
1944
|
+
derived,
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function recordScriptSyntaxGate({ subject, inputs, errors, warnings, ready, derived }) {
|
|
1949
|
+
const gate = evaluateBuiltScriptSyntax({ subject, ...inputs });
|
|
1950
|
+
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
1951
|
+
// A referenced local script missing from the built output is a warning
|
|
1952
|
+
// (#502). One terminal disposition per gate, as with SDK markup: while a
|
|
1953
|
+
// parse failure blocks, the missing scripts stay on gate.warned[].
|
|
1954
|
+
if (gate.status !== "blocked" && Array.isArray(warnings)) {
|
|
1955
|
+
for (const item of gate.warned) addIssue(warnings, item.code, item.message, { finding: item, checkpoint_gate: gate });
|
|
1956
|
+
}
|
|
1957
|
+
if (gate.status === "blocked") {
|
|
1958
|
+
// One error per file, each naming the file, line and column.
|
|
1959
|
+
for (const finding of gate.findings) {
|
|
1960
|
+
addIssue(errors, finding.code, finding.message, { finding, checkpoint_gate: gate });
|
|
1961
|
+
}
|
|
1962
|
+
return gate;
|
|
1963
|
+
}
|
|
1964
|
+
if (gate.status === "not_applicable") {
|
|
1965
|
+
ready.push(`Script syntax checkpoint not applicable: ${gate.reason}`);
|
|
1966
|
+
return gate;
|
|
1967
|
+
}
|
|
1968
|
+
ready.push(`All ${gate.scripts_scanned} campaign-owned script(s) loaded by built pages parse`);
|
|
1969
|
+
return gate;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
|
|
1973
|
+
const gate = evaluateSdkMarkup({ subject, pages });
|
|
1974
|
+
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
1975
|
+
|
|
1976
|
+
if (gate.status === "not_applicable") {
|
|
1977
|
+
ready.push("SDK markup checkpoint not applicable: no built page to scan yet.");
|
|
1978
|
+
return gate;
|
|
1979
|
+
}
|
|
1980
|
+
// Blockers and advisories each carry their own code (the kit's lint code,
|
|
1981
|
+
// lower-cased, under the gate id) and the finding, so a reader can filter
|
|
1982
|
+
// by shape without parsing prose.
|
|
1983
|
+
for (const item of gate.findings) addIssue(errors, item.code, item.message, { finding: item, checkpoint_gate: gate });
|
|
1984
|
+
// One terminal disposition per gate: while blockers stand, the advisories
|
|
1985
|
+
// stay on gate.warned[] (visible in --json) and are surfaced as warnings
|
|
1986
|
+
// only once the gate passes, so a blocked gate does not also read as a
|
|
1987
|
+
// warned one.
|
|
1988
|
+
if (gate.status !== "blocked") {
|
|
1989
|
+
for (const item of gate.warned) addIssue(warnings, item.code, item.message, { finding: item, checkpoint_gate: gate });
|
|
1990
|
+
}
|
|
1991
|
+
// Unknown data-next-* names are information, not a warning: the certified
|
|
1992
|
+
// templates carry a handful of their own data-next-* hooks the SDK never
|
|
1993
|
+
// reads, and a warning that fires on every canonical build is noise that
|
|
1994
|
+
// trains readers to skip the channel. The list stays on the gate and in
|
|
1995
|
+
// one ready line, where an invented attribute is still one grep away.
|
|
1996
|
+
if (gate.unknown_attributes.length) {
|
|
1997
|
+
ready.push(`SDK markup: ${gate.unknown_attributes.length} data-next-* name(s) not in the Campaign Cart ${gate.sdk_attribute_index_version} attribute index (advisory; the SDK does not read them): ${gate.unknown_attributes.map((item) => item.name).join(", ")}`);
|
|
1998
|
+
}
|
|
1999
|
+
if (gate.status === "blocked") return gate;
|
|
2000
|
+
ready.push(`SDK markup checks passed on ${gate.pages_scanned} built page(s)${gate.warned.length ? ` with ${gate.warned.length} advisory finding(s)` : ""}`);
|
|
2001
|
+
return gate;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// Route drift: a CampaignSpec page whose declared route has no built page at
|
|
2005
|
+
// that path. page-kit derives the public route from the source FILENAME, so a
|
|
2006
|
+
// file named presell-running.html builds at /presell-running/ even if the spec
|
|
2007
|
+
// page_url says "presell/". QA resolves page URLs from the spec page_url, so
|
|
2008
|
+
// this drift makes QA fetch phantom URLs (404s) and misreport pages as down —
|
|
2009
|
+
// exactly what happened on the Shield QA (presell/landing). validateBuiltOutputPages
|
|
2010
|
+
// silently skips missing pages, so this surfaces the drift and the actual
|
|
2011
|
+
// built routes for reconciliation. See the Shield build QA (#1).
|
|
2012
|
+
export function validateBuiltRouteDrift(spec, packet, errors, warnings, ready, derived, buildState = {}) {
|
|
2013
|
+
const pages = activeSpecPages(spec);
|
|
2014
|
+
if (pages.length === 0) return;
|
|
2015
|
+
const targetRepo = derived.target_repo;
|
|
2016
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2017
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2018
|
+
if (!siteRoot || !existsSync(siteRoot)) return;
|
|
2019
|
+
|
|
2020
|
+
const claimed = new Set();
|
|
2021
|
+
const drifted = [];
|
|
2022
|
+
const unverifiable = [];
|
|
2023
|
+
const skippedOutOfScope = [];
|
|
2024
|
+
const outOfScopePageIds = declaredOutOfScopePageIds(buildState);
|
|
2025
|
+
// Served-route display honors route_root: a root-served campaign's pages
|
|
2026
|
+
// are reached at /<route>/, not /<slug>/<route>/, even though the built
|
|
2027
|
+
// files still live under _site/<slug>/.
|
|
2028
|
+
const routeRoot = campaignRouteRoot(packet);
|
|
2029
|
+
const rootSegments = routeRoot === "/" ? [] : [publicRouteSlug];
|
|
2030
|
+
for (const page of pages) {
|
|
2031
|
+
const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
|
|
2032
|
+
if (builtPath && existsSync(builtPath)) {
|
|
2033
|
+
claimed.add(resolve(builtPath));
|
|
2034
|
+
continue;
|
|
2035
|
+
}
|
|
2036
|
+
// Declared out-of-scope pages (#238) are not built by a partial-scope
|
|
2037
|
+
// build; their absence is the declared state, not route drift. A built
|
|
2038
|
+
// page is still claimed above regardless of declaration.
|
|
2039
|
+
if (outOfScopePageIds.has(page.id)) {
|
|
2040
|
+
skippedOutOfScope.push(page.id);
|
|
2041
|
+
continue;
|
|
2042
|
+
}
|
|
2043
|
+
if (!builtPath) {
|
|
2044
|
+
// No page_url / source permalink to resolve a route from: doctor cannot
|
|
2045
|
+
// determine the expected route, so this is "unverifiable", not drift.
|
|
2046
|
+
unverifiable.push({ page_id: page.id, type: page.type || "page", reason: "no page_url / source permalink to resolve an expected route" });
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
const segments = [...rootSegments, ...relFromDir(siteRoot, dirname(builtPath)).split("/")].filter((segment) => segment && segment !== ".");
|
|
2050
|
+
drifted.push({
|
|
2051
|
+
page_id: page.id,
|
|
2052
|
+
type: page.type || "page",
|
|
2053
|
+
expected_route: `/${segments.join("/")}/`,
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Denominator is the in-scope page count when a declaration is present:
|
|
2058
|
+
// "2/9 verified, 7 declared out of scope" reads as seven attempted-but-
|
|
2059
|
+
// unverified pages, while "2/2 in-scope verified" states what was actually
|
|
2060
|
+
// checked. Full-scope campaigns keep the original phrasing untouched.
|
|
2061
|
+
const inScopeCount = pages.length - skippedOutOfScope.length;
|
|
2062
|
+
const verifiedNote = (skippedOutOfScope.length
|
|
2063
|
+
? `${claimed.size}/${inScopeCount} in-scope verified, ${skippedOutOfScope.length} declared out of scope`
|
|
2064
|
+
: `${claimed.size}/${pages.length} verified`)
|
|
2065
|
+
+ `${unverifiable.length ? `, ${unverifiable.length} unverifiable` : ""}`;
|
|
2066
|
+
if (drifted.length === 0) {
|
|
2067
|
+
ready.push(`Built routes match CampaignSpec page routes (${verifiedNote})`);
|
|
2068
|
+
if (unverifiable.length) {
|
|
2069
|
+
addIssue(
|
|
2070
|
+
warnings,
|
|
2071
|
+
"built_output.route_unverifiable",
|
|
2072
|
+
`Doctor could not determine the expected route for ${unverifiable.length} CampaignSpec page(s) (no page_url / source permalink): ${unverifiable.map((u) => `"${u.page_id}" (${u.type})`).join(", ")}.`,
|
|
2073
|
+
{ unverifiable },
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
const scope = resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug });
|
|
2080
|
+
const servedPrefix = routeRoot === "/" ? "/" : `/${publicRouteSlug}/`;
|
|
2081
|
+
const unmatched = (scope.ok ? scope.pages : [])
|
|
2082
|
+
.filter((builtPage) => !claimed.has(resolve(builtPage.built_path)))
|
|
2083
|
+
.map((builtPage) => `${servedPrefix}${builtPage.route ? `${builtPage.route}/` : ""}`.replace(/\/{2,}/g, "/"));
|
|
2084
|
+
const assemblyComplete = isStageComplete(buildState.report, "assembly");
|
|
2085
|
+
addIssue(
|
|
2086
|
+
assemblyComplete ? errors : warnings,
|
|
2087
|
+
"built_output.route_drift",
|
|
2088
|
+
`CampaignSpec page(s) have no built page at their declared route: ${drifted.map((d) => `"${d.page_id}" (${d.type}) → ${d.expected_route}`).join("; ")}. `
|
|
2089
|
+
+ (unmatched.length ? `Built output has unmatched route(s): ${unmatched.join(", ")}. ` : "")
|
|
2090
|
+
+ (unverifiable.length ? `Unverifiable (no page_url): ${unverifiable.map((u) => `"${u.page_id}"`).join(", ")}. ` : "")
|
|
2091
|
+
+ `page-kit routes by source filename, so reconcile the spec page_url with the built route — otherwise QA (which resolves URLs from page_url) targets phantom URLs and reports live pages as 404.`,
|
|
2092
|
+
// Detail granularity contract: per-page issues (built_output.page_missing)
|
|
2093
|
+
// carry only that page's identity — a declared page never produces one, so
|
|
2094
|
+
// the declared list would be dead weight there. This aggregate is the
|
|
2095
|
+
// campaign-level route reconciliation, and its detail is the full
|
|
2096
|
+
// inventory; declared_out_of_scope belongs here because the ready summary
|
|
2097
|
+
// that otherwise reports the skips is suppressed when drift fires.
|
|
2098
|
+
{ drifted, unmatched_built_routes: unmatched, unverifiable, verified_count: claimed.size, declared_out_of_scope: skippedOutOfScope },
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function validateBuildSummary(spec, packet, errors, warnings, ready, derived, buildState = {}) {
|
|
2103
|
+
const targetRepo = derived.target_repo;
|
|
2104
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2105
|
+
const result = evaluatePageKitBuildSummary({
|
|
2106
|
+
targetRepo,
|
|
2107
|
+
publicRouteSlug,
|
|
2108
|
+
activePages: activeSpecPages(spec),
|
|
2109
|
+
assemblyComplete: isStageComplete(buildState.report, "assembly"),
|
|
2110
|
+
builtPathForPage: (page) => builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived),
|
|
2111
|
+
});
|
|
2112
|
+
for (const issue of result.errors) addIssue(errors, issue.code, issue.message, issue.detail ?? null);
|
|
2113
|
+
for (const issue of result.warnings) addIssue(warnings, issue.code, issue.message, issue.detail ?? null);
|
|
2114
|
+
ready.push(...result.ready);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function validateBuiltHtmlStructure(content, builtPath, targetRepo, page, spec, publicRouteSlug, errors, warnings, assemblyComplete) {
|
|
2118
|
+
const issueTarget = assemblyComplete ? errors : warnings;
|
|
2119
|
+
const relPath = relFromDir(targetRepo, builtPath);
|
|
2120
|
+
if (!/<body(?:\s|>)/i.test(content) || !/<\/body>/i.test(content)) {
|
|
2121
|
+
addIssue(issueTarget, "built_output.body_missing", `Built page "${page.id}" does not contain a complete <body> element.`, { page_id: page.id, file: relPath });
|
|
2122
|
+
}
|
|
2123
|
+
if (!/(data-next-|window\.next|next-page-type|campaign-cart-sdk|campaign-cart)/i.test(content)) {
|
|
2124
|
+
addIssue(issueTarget, "built_output.runtime_missing", `Built page "${page.id}" has no obvious Campaign Cart runtime markers.`, { page_id: page.id, file: relPath });
|
|
2125
|
+
}
|
|
2126
|
+
validateBuiltPreCheckoutBootstrap(content, builtPath, targetRepo, page, issueTarget);
|
|
2127
|
+
validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget);
|
|
2128
|
+
validateBuiltStarterLogoResidue(content, builtPath, targetRepo, page, issueTarget);
|
|
2129
|
+
validateBuiltPageKitAssetPaths(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget);
|
|
2130
|
+
validateBuiltScriptAssets(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget);
|
|
2131
|
+
validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, issueTarget);
|
|
2132
|
+
validateBuiltAnalyticsContract(content, builtPath, targetRepo, page, spec, issueTarget);
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Build-time enforcement of the declared analytics contract (CampaignSpec
|
|
2136
|
+
// `analytics` block). This is the static twin of the runtime QA correctness
|
|
2137
|
+
// leg: where QA confirms a content param FIRES on a live page, this confirms the
|
|
2138
|
+
// built page even HAS a handler for it — catching the gap before QA runs.
|
|
2139
|
+
//
|
|
2140
|
+
// Specifically the "?reviews=n with no handler" case from the Chamelo Shield
|
|
2141
|
+
// build: the spec (or a synthesized one) declares a content param, but the
|
|
2142
|
+
// built page never wired `data-next-hide="param.<name>=='n'"`, so the param
|
|
2143
|
+
// silently no-ops. Only fires when the spec declares `analytics.params.content`;
|
|
2144
|
+
// silent otherwise (the common case until specs carry an analytics block).
|
|
2145
|
+
export function validateBuiltAnalyticsContract(content, builtPath, targetRepo, page, spec, issueTarget) {
|
|
2146
|
+
const contentParams = spec?.analytics?.params?.content;
|
|
2147
|
+
if (!Array.isArray(contentParams) || contentParams.length === 0) return;
|
|
2148
|
+
const relPath = relFromDir(targetRepo, builtPath);
|
|
2149
|
+
for (const cp of contentParams) {
|
|
2150
|
+
const name = typeof cp?.name === "string" ? cp.name.trim() : "";
|
|
2151
|
+
if (!name) continue;
|
|
2152
|
+
// A content param applies to this page when `pages` is unspecified (all
|
|
2153
|
+
// pages) or explicitly lists this page id. An explicit empty `pages: []`
|
|
2154
|
+
// (applies to no page) is a spec-shape misconfiguration flagged once at
|
|
2155
|
+
// spec-validation time by AnalyticsContractShape, not per built page here.
|
|
2156
|
+
const pages = Array.isArray(cp.pages) ? cp.pages : null;
|
|
2157
|
+
if (pages && !pages.includes(page.id)) continue;
|
|
2158
|
+
// The SDK drives content-param visibility via data-next-hide/show using
|
|
2159
|
+
// `param.<name>` (persisted to sessionStorage). Require the reference to sit
|
|
2160
|
+
// inside an actual data-next-hide/show attribute — a bare `param.<name>` in
|
|
2161
|
+
// a script/comment/pixel is not a handler (avoids false negatives).
|
|
2162
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2163
|
+
const handlerPattern = new RegExp(
|
|
2164
|
+
`data-next-(?:hide|show)\\s*=\\s*["'][^"']*\\bparam\\.${escaped}\\b[^"']*["']`,
|
|
2165
|
+
"i",
|
|
2166
|
+
);
|
|
2167
|
+
if (!handlerPattern.test(content)) {
|
|
2168
|
+
addIssue(
|
|
2169
|
+
issueTarget,
|
|
2170
|
+
"analytics_contract.content_param_no_handler",
|
|
2171
|
+
`Built page "${page.id}" declares analytics content param "?${name}" but has no data-next-hide/show="param.${name}…" handler. The param will silently no-op — the Chamelo Shield "?reviews=n with no handler" gap.`,
|
|
2172
|
+
{ page_id: page.id, file: relPath, param: name },
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// Per-page starter-logo residue, scanned against the BUILT `_site/<slug>`
|
|
2179
|
+
// output. The starter brand logo (next-logo.png on img.brand-logo) must be
|
|
2180
|
+
// swapped for the campaign's real logo on every page.
|
|
2181
|
+
//
|
|
2182
|
+
// Division of responsibility vs the existing generic residue scan: the packet
|
|
2183
|
+
// path already emits `template_contract.literal_residue` (via
|
|
2184
|
+
// collectGenericTemplateResidueMatches, which includes the next-logo pattern),
|
|
2185
|
+
// but that scan runs against `derived.target_output_dir` — the page-kit SOURCE
|
|
2186
|
+
// dir (src/<slug>) — and is gated on assembly-complete. This check is the
|
|
2187
|
+
// BUILT-output signal: per page, unconditional, over `_site/<slug>`. A logo
|
|
2188
|
+
// that survives the page-kit build into _site (the receipt case) is caught here
|
|
2189
|
+
// even when the source scan didn't run or was a non-blocking source-side note.
|
|
2190
|
+
// The two can both fire for one logo (it lives in source AND built); fixing the
|
|
2191
|
+
// source and rebuilding clears both. See the Shield build QA (#5).
|
|
2192
|
+
export function validateBuiltStarterLogoResidue(content, builtPath, targetRepo, page, issueTarget) {
|
|
2193
|
+
const occurrences = (content.match(/\bnext-logo\.(?:png|svg|webp)\b/gi) || []).length;
|
|
2194
|
+
if (occurrences === 0) return;
|
|
2195
|
+
addIssue(
|
|
2196
|
+
issueTarget,
|
|
2197
|
+
"built_output.starter_logo_residue",
|
|
2198
|
+
`Built page "${page.id}" still references the starter logo next-logo.png (${occurrences} occurrence(s)). Replace the .brand-logo asset with the campaign's real logo before deploy.`,
|
|
2199
|
+
{ page_id: page.id, file: relFromDir(targetRepo, builtPath), occurrences },
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// Pre-checkout pages (presell/landing — SDK page_type "product") must ship the
|
|
2204
|
+
// Campaign Cart bootstrap, not just inert data-next attributes. Without the
|
|
2205
|
+
// loader + next-page-type meta, every SDK feature silently no-ops: conditional
|
|
2206
|
+
// visibility (param.banner/param.seen), utmTransfer (UTM/query carry-through to
|
|
2207
|
+
// checkout — top-of-funnel ad attribution), and SDK analytics. The generic
|
|
2208
|
+
// runtime-marker check above passes on a lone data-next-* attribute, so this
|
|
2209
|
+
// dedicated check guards the pre-checkout boundary. See the Shield build
|
|
2210
|
+
// learnings (A1): base-presell.html / base-landing.html shipped without it.
|
|
2211
|
+
const PRE_CHECKOUT_PAGE_TYPES = new Set([
|
|
2212
|
+
"presell", "advertorial", "listicle", "review",
|
|
2213
|
+
"landing", "lander", "lp", "product",
|
|
2214
|
+
]);
|
|
2215
|
+
|
|
2216
|
+
function sdkLoaderScriptPresent(content) {
|
|
2217
|
+
// Only the campaign-cart loader counts. A loose `loader.js` match would let an
|
|
2218
|
+
// unrelated bundle (analytics/lazy-image loader) falsely satisfy the check and
|
|
2219
|
+
// re-introduce the missing-SDK bug, so the src must identify the campaign-cart
|
|
2220
|
+
// loader specifically. We do not scan for inline ESM imports: that is not a
|
|
2221
|
+
// real bootstrap path for this SDK and is trivially spoofed by a comment or a
|
|
2222
|
+
// JSON <script> blob.
|
|
2223
|
+
for (const tag of content.matchAll(/<script\b[^>]*>/gi)) {
|
|
2224
|
+
const srcMatch = tag[0].match(/\bsrc\s*=\s*["']([^"']+)["']/i);
|
|
2225
|
+
if (!srcMatch) continue;
|
|
2226
|
+
if (/campaign-cart(?:@[^"']*)?\/dist\/loader\.js/i.test(srcMatch[1])) return true;
|
|
2227
|
+
}
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// Order-bump templates (bump-check01/bump-switch01) ship BOTH a per-unit price
|
|
2232
|
+
// row (Option A) and a line-total price row (Option B) behind Liquid guards,
|
|
2233
|
+
// with a "pick ONE" comment. If a build leaves both rendered, the bump shows
|
|
2234
|
+
// doubled prices. The template now defaults to per-unit only, so this guards
|
|
2235
|
+
// the built output against a regression where both rows survive. See the
|
|
2236
|
+
// Shield build learnings (B2). Spurious strikethrough (compare == price) is
|
|
2237
|
+
// covered separately as a polish-gate evidence requirement.
|
|
2238
|
+
const BUMP_BLOCK_PATTERN = /data-component\s*=\s*["']prepurchase-upsell["']/gi;
|
|
2239
|
+
const BUMP_PER_UNIT_DISPLAYS = ["unitPrice", "originalUnitPrice"];
|
|
2240
|
+
const BUMP_LINE_TOTAL_DISPLAYS = ["price", "originalPrice"];
|
|
2241
|
+
|
|
2242
|
+
function bumpDisplaysPresent(block, displays) {
|
|
2243
|
+
return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const CHECKOUT_BUMP_PAGE_TYPES = new Set(["checkout", "select"]);
|
|
2247
|
+
|
|
2248
|
+
export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
|
|
2249
|
+
const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
|
|
2250
|
+
if (!CHECKOUT_BUMP_PAGE_TYPES.has(type)) return;
|
|
2251
|
+
|
|
2252
|
+
// Slice the document into per-bump blocks at each prepurchase-upsell anchor.
|
|
2253
|
+
const anchorOffsets = [...content.matchAll(BUMP_BLOCK_PATTERN)].map((match) => match.index);
|
|
2254
|
+
if (anchorOffsets.length === 0) return;
|
|
2255
|
+
const relPath = relFromDir(targetRepo, builtPath);
|
|
2256
|
+
let doubled = 0;
|
|
2257
|
+
for (let i = 0; i < anchorOffsets.length; i += 1) {
|
|
2258
|
+
const block = content.slice(anchorOffsets[i], anchorOffsets[i + 1] ?? content.length);
|
|
2259
|
+
if (bumpDisplaysPresent(block, BUMP_PER_UNIT_DISPLAYS) && bumpDisplaysPresent(block, BUMP_LINE_TOTAL_DISPLAYS)) {
|
|
2260
|
+
doubled += 1;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
if (doubled > 0) {
|
|
2264
|
+
addIssue(
|
|
2265
|
+
issueTarget,
|
|
2266
|
+
"built_output.bump_double_price",
|
|
2267
|
+
`Built page "${page.id}" renders ${doubled} order bump(s) with BOTH a per-unit price row (Option A) and a line-total price row (Option B). Pick one: pass show_per_unit_price / show_line_total_price to the bump include so a single price row renders (rendering both doubles the displayed price).`,
|
|
2268
|
+
{ page_id: page.id, file: relPath, doubled_bumps: doubled },
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
export function validateBuiltPreCheckoutBootstrap(content, builtPath, targetRepo, page, issueTarget) {
|
|
2274
|
+
const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
|
|
2275
|
+
if (!PRE_CHECKOUT_PAGE_TYPES.has(type)) return;
|
|
2276
|
+
|
|
2277
|
+
const relPath = relFromDir(targetRepo, builtPath);
|
|
2278
|
+
const hasLoader = sdkLoaderScriptPresent(content);
|
|
2279
|
+
const hasPageTypeMeta = isNonEmptyString(extractMetaContent(content, "next-page-type"));
|
|
2280
|
+
if (hasLoader && hasPageTypeMeta) return;
|
|
2281
|
+
|
|
2282
|
+
const missing = [
|
|
2283
|
+
!hasLoader ? "the Campaign Cart loader script (campaign-cart@v{sdk_version}/dist/loader.js)" : null,
|
|
2284
|
+
!hasPageTypeMeta ? 'the <meta name="next-page-type"> tag' : null,
|
|
2285
|
+
].filter(Boolean);
|
|
2286
|
+
addIssue(
|
|
2287
|
+
issueTarget,
|
|
2288
|
+
"built_output.pre_checkout_sdk_bootstrap",
|
|
2289
|
+
`SDK not bootstrapped on pre-checkout page "${page.id}" (type "${type}"): missing ${missing.join(" and ")}. Without it, conditional visibility (param.banner/param.seen), utmTransfer (UTM carry-through to checkout — ad attribution), and SDK analytics silently no-op. Emit the same config.js → loader.js → next-funnel/next-page-type bootstrap that the checkout layout uses.`,
|
|
2290
|
+
{ page_id: page.id, file: relPath, missing: { loader: !hasLoader, page_type_meta: !hasPageTypeMeta } },
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function validateBuiltScriptAssets(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget) {
|
|
2295
|
+
for (const tag of content.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) {
|
|
2296
|
+
const src = tag[1];
|
|
2297
|
+
const resolved = resolveBuiltAssetPath(src, builtPath, targetRepo);
|
|
2298
|
+
if (!resolved || existsSync(resolved)) continue;
|
|
2299
|
+
if (pageKitAssetPathViolation(src, publicRouteSlug)) continue;
|
|
2300
|
+
addIssue(
|
|
2301
|
+
issueTarget,
|
|
2302
|
+
"built_output.script_missing",
|
|
2303
|
+
`Built page "${page.id}" references script "${src}", but the file does not exist in built output.`,
|
|
2304
|
+
{ page_id: page.id, file: relFromDir(targetRepo, builtPath), script: src }
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
export function validateBuiltPageKitAssetPaths(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget) {
|
|
2310
|
+
const hits = collectPageKitAssetPathViolations(content, publicRouteSlug)
|
|
2311
|
+
.filter((hit) => {
|
|
2312
|
+
const resolved = resolveBuiltAssetPath(hit.reference, builtPath, targetRepo);
|
|
2313
|
+
return resolved && !existsSync(resolved);
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
if (!hits.length) return;
|
|
2317
|
+
|
|
2318
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
2319
|
+
const sample = hits
|
|
2320
|
+
.slice(0, 5)
|
|
2321
|
+
.map((hit) => `${hit.reference} (${hit.kind}, line ${hit.line})`)
|
|
2322
|
+
.join("; ");
|
|
2323
|
+
const more = hits.length > 5 ? `; plus ${hits.length - 5} more` : "";
|
|
2324
|
+
const renderedExample = slug ? `/${slug}/config.js` : "/<slug>/config.js";
|
|
2325
|
+
const sourceExample = slug ? `src/${slug}/assets/config.js` : "src/<slug>/assets/config.js";
|
|
2326
|
+
|
|
2327
|
+
addIssue(
|
|
2328
|
+
issueTarget,
|
|
2329
|
+
"built_output.pagekit_asset_path",
|
|
2330
|
+
`Built page "${page.id}" references page-kit asset path(s) that do not exist in built output: ${sample}${more}. next-campaign-page-kit copies ${sourceExample} to "${renderedExample}" (not "/assets/config.js" or "/${slug || "<slug>"}/assets/config.js"). Use "{{ 'config.js' | campaign_asset }}" in page-kit source, or rewrite raw passthrough HTML to the campaign-rooted built URL.`,
|
|
2331
|
+
{
|
|
2332
|
+
page_id: page.id,
|
|
2333
|
+
file: relFromDir(targetRepo, builtPath),
|
|
2334
|
+
references: hits.map((hit) => ({
|
|
2335
|
+
reference: hit.reference,
|
|
2336
|
+
expected: hit.expected,
|
|
2337
|
+
line: hit.line,
|
|
2338
|
+
kind: hit.kind,
|
|
2339
|
+
})),
|
|
2340
|
+
}
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
export function collectPageKitAssetPathViolations(content, publicRouteSlug) {
|
|
2345
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
2346
|
+
const hits = [];
|
|
2347
|
+
const seen = new Set();
|
|
2348
|
+
|
|
2349
|
+
const record = (kind, reference, index) => {
|
|
2350
|
+
const hit = pageKitAssetPathViolation(reference, slug);
|
|
2351
|
+
if (!hit) return;
|
|
2352
|
+
const key = `${kind}:${hit.reference}:${lineNumberAt(content, index || 0)}`;
|
|
2353
|
+
if (seen.has(key)) return;
|
|
2354
|
+
seen.add(key);
|
|
2355
|
+
hits.push({
|
|
2356
|
+
...hit,
|
|
2357
|
+
kind,
|
|
2358
|
+
line: lineNumberAt(content, index || 0),
|
|
2359
|
+
});
|
|
2360
|
+
};
|
|
2361
|
+
|
|
2362
|
+
for (const match of content.matchAll(/\b(src|href)=["']([^"']+)["']/gi)) {
|
|
2363
|
+
record(match[1].toLowerCase(), match[2], match.index);
|
|
2364
|
+
}
|
|
2365
|
+
for (const match of content.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi)) {
|
|
2366
|
+
record("css-url", match[2], match.index);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
return hits;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function pageKitAssetPathViolation(reference, publicRouteSlug) {
|
|
2373
|
+
const raw = String(reference || "").trim();
|
|
2374
|
+
if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
|
|
2375
|
+
|
|
2376
|
+
const clean = raw.replace(/[?#].*$/, "");
|
|
2377
|
+
const slugAssetsPrefix = publicRouteSlug ? `/${publicRouteSlug}/assets/` : null;
|
|
2378
|
+
let assetPath = null;
|
|
2379
|
+
|
|
2380
|
+
if (clean.startsWith("/assets/")) {
|
|
2381
|
+
assetPath = clean.slice("/assets/".length);
|
|
2382
|
+
} else if (slugAssetsPrefix && clean.startsWith(slugAssetsPrefix)) {
|
|
2383
|
+
assetPath = clean.slice(slugAssetsPrefix.length);
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
if (!assetPath || assetPath.startsWith("../") || assetPath.includes("/../")) return null;
|
|
2387
|
+
return {
|
|
2388
|
+
reference: raw,
|
|
2389
|
+
asset_path: assetPath,
|
|
2390
|
+
expected: publicRouteSlug ? `/${publicRouteSlug}/${assetPath}` : `/<slug>/${assetPath}`,
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function resolveBuiltAssetPath(src, builtPath, targetRepo) {
|
|
2395
|
+
if (!isNonEmptyString(src)) return null;
|
|
2396
|
+
const raw = src.trim();
|
|
2397
|
+
if (raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
|
|
2398
|
+
const clean = raw.replace(/[?#].*$/, "");
|
|
2399
|
+
if (!clean || clean.startsWith("#")) return null;
|
|
2400
|
+
if (clean.startsWith("/")) return join(targetRepo, "_site", clean.replace(/^\/+/, ""));
|
|
2401
|
+
return resolve(dirname(builtPath), clean);
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, issueTarget) {
|
|
2405
|
+
const packageRefs = specPackageRefs(spec);
|
|
2406
|
+
const shippingRefs = specShippingRefs(spec);
|
|
2407
|
+
const relPath = relFromDir(targetRepo, builtPath);
|
|
2408
|
+
const badPackages = [...extractRenderedPackageRefs(content)].filter((ref) => packageRefs.size > 0 && !packageRefs.has(ref));
|
|
2409
|
+
const badShipping = [...extractRenderedShippingRefs(content)].filter((ref) => shippingRefs.size > 0 && !shippingRefs.has(ref));
|
|
2410
|
+
|
|
2411
|
+
if (badPackages.length > 0) {
|
|
2412
|
+
addIssue(
|
|
2413
|
+
issueTarget,
|
|
2414
|
+
"built_output.package_ref",
|
|
2415
|
+
`Built page "${page.id}" references package ID(s) not present in CampaignSpec: ${[...new Set(badPackages)].join(", ")}.`,
|
|
2416
|
+
{ page_id: page.id, file: relPath }
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
if (badShipping.length > 0) {
|
|
2420
|
+
addIssue(
|
|
2421
|
+
issueTarget,
|
|
2422
|
+
"built_output.shipping_ref",
|
|
2423
|
+
`Built page "${page.id}" references shipping ID(s) not present in CampaignSpec: ${[...new Set(badShipping)].join(", ")}.`,
|
|
2424
|
+
{ page_id: page.id, file: relPath }
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function extractRenderedPackageRefs(content) {
|
|
2430
|
+
const refs = new Set();
|
|
2431
|
+
for (const match of content.matchAll(/\bdata-next-package-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
|
|
2432
|
+
for (const match of content.matchAll(/\bdata-package-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
|
|
2433
|
+
for (const match of content.matchAll(/["']?packageId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi)) {
|
|
2434
|
+
addRenderedRef(refs, match[1] || match[2] || match[3]);
|
|
2435
|
+
}
|
|
2436
|
+
return refs;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function extractRenderedShippingRefs(content) {
|
|
2440
|
+
const refs = new Set();
|
|
2441
|
+
for (const match of content.matchAll(/\bdata-next-shipping-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
|
|
2442
|
+
for (const match of content.matchAll(/["']?shippingId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi)) {
|
|
2443
|
+
addRenderedRef(refs, match[1] || match[2] || match[3]);
|
|
2444
|
+
}
|
|
2445
|
+
return refs;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function addRenderedRef(refs, value) {
|
|
2449
|
+
const ref = String(value || "").trim();
|
|
2450
|
+
if (/^[A-Za-z0-9_-]+$/.test(ref)) refs.add(ref);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
2454
|
+
if (!targetRepo || !publicRouteSlug) return null;
|
|
2455
|
+
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
|
|
2456
|
+
const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
|
|
2457
|
+
if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
2458
|
+
const clean = route.replace(/^\/+|\/+$/g, "");
|
|
2459
|
+
return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
|
|
2463
|
+
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
2464
|
+
|
|
2465
|
+
const expectedTerminal = terminalRouteSegment(publicRouteForPage(page));
|
|
2466
|
+
const candidates = [];
|
|
2467
|
+
for (const file of collectHtmlFiles(targetOutputDir)) {
|
|
2468
|
+
if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
|
|
2469
|
+
const fullPath = join(targetOutputDir, file.path);
|
|
2470
|
+
const content = readFileSync(fullPath, "utf8");
|
|
2471
|
+
const permalink = extractFrontmatterValue(content, "permalink");
|
|
2472
|
+
if (!isNonEmptyString(permalink)) continue;
|
|
2473
|
+
const relative = stripPublicRoutePrefix(normalizePageKitRoute(permalink), publicRouteSlug);
|
|
2474
|
+
if (terminalRouteSegment(relative) === expectedTerminal) candidates.push(relative);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function extractMetaContent(content, name) {
|
|
2481
|
+
const escaped = escapeRegExp(name);
|
|
2482
|
+
const metaTag = new RegExp(`<meta\\b(?=[^>]*\\bname=["']${escaped}["'])([^>]*)>`, "i").exec(content);
|
|
2483
|
+
if (!metaTag) return null;
|
|
2484
|
+
const contentAttr = /\bcontent=["']([^"']*)["']/i.exec(metaTag[1]);
|
|
2485
|
+
return contentAttr ? contentAttr[1] : "";
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function runtimeRouteForMetaValue(value, publicRouteSlug, routeRoot = null) {
|
|
2489
|
+
if (!isNonEmptyString(value)) return null;
|
|
2490
|
+
const route = value.trim();
|
|
2491
|
+
if (isAbsoluteHttpUrl(route)) return route;
|
|
2492
|
+
if (isRuntimeRootedRoutingMeta(route, publicRouteSlug, routeRoot)) return route;
|
|
2493
|
+
const root = routeRoot || `/${publicRouteSlug}/`;
|
|
2494
|
+
const normalized = runtimeRelativeRouteForSpecValue(route, publicRouteSlug);
|
|
2495
|
+
return normalized ? `${root}${normalized}` : root;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function terminalRouteSegment(route) {
|
|
2499
|
+
const normalized = normalizePageKitRoute(route);
|
|
2500
|
+
const parts = normalized.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
2501
|
+
return parts.length ? parts[parts.length - 1] : "";
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function escapeRegExp(value) {
|
|
2505
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// Declared route_root must be "/" or agree with public_route_slug — any other
|
|
2509
|
+
// prefix would make the packet describe a route surface that contradicts the
|
|
2510
|
+
// slug identity every other check roots on, which is the silent-disarm shape
|
|
2511
|
+
// the c1 negative control exposed for the slug itself.
|
|
2512
|
+
export function validateRouteRootDeclaration(packet, errors, ready) {
|
|
2513
|
+
const declared = packet?.campaign?.route_root;
|
|
2514
|
+
if (declared == null) return;
|
|
2515
|
+
const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2516
|
+
// The packet rule is exact: "/" or the canonical "/<public_route_slug>/"
|
|
2517
|
+
// only, mirroring the schema pattern, and it is the same rule every other
|
|
2518
|
+
// stage reads the packet by. So a near miss ("/example", "//example//") is
|
|
2519
|
+
// blocked here and honoured nowhere — the silent-disarm split this check
|
|
2520
|
+
// exists to close.
|
|
2521
|
+
const honoured = packetRouteRoot(declared, slug);
|
|
2522
|
+
if (honoured === "/") {
|
|
2523
|
+
ready.push(`Campaign is declared root-served (route_root "/"): routing metas and public routes validate against site-root paths; public_route_slug "${slug}" remains identity, not a path prefix`);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
if (honoured) {
|
|
2527
|
+
ready.push(`Campaign route_root "/${slug}/" matches public_route_slug`);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
addIssue(
|
|
2531
|
+
errors,
|
|
2532
|
+
"campaign.route_root",
|
|
2533
|
+
`Packet campaign.route_root ${JSON.stringify(declared)} is invalid. Declare exactly "/" for a root-served campaign or exactly "/${slug || "<public_route_slug>"}/" (leading and trailing slash) for the default slug-prefixed root. Any other value would contradict public_route_slug, which stays the campaign identity and the _site/<public_route_slug>/ built-output directory name.`
|
|
2534
|
+
);
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function isRuntimeRootedRoutingMeta(value, publicRouteSlug, routeRoot = null) {
|
|
2538
|
+
if (isAbsoluteHttpUrl(value)) return true;
|
|
2539
|
+
const route = value.trim();
|
|
2540
|
+
if (!route.startsWith("/")) return false;
|
|
2541
|
+
const root = routeRoot || (publicRouteSlug ? `/${publicRouteSlug}/` : null);
|
|
2542
|
+
// Root-served: every absolute path is rooted at the served surface.
|
|
2543
|
+
if (root === "/") return true;
|
|
2544
|
+
if (!root) return false;
|
|
2545
|
+
const prefix = root.replace(/\/+$/, "");
|
|
2546
|
+
return route === prefix || route.startsWith(`${prefix}/`);
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
export function validateMarketSensitiveCopy(spec, warnings, ready, derived) {
|
|
2550
|
+
return withHtmlScanSnapshot(() => scanMarketSensitiveCopy(spec, warnings, ready, derived), { reuse: true });
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function scanMarketSensitiveCopy(spec, warnings, ready, derived) {
|
|
2554
|
+
const scope = deriveMarketScope(spec);
|
|
2555
|
+
const currencyScope = deriveCurrencyCopyScope(spec);
|
|
2556
|
+
const storePhone = firstNonEmptyString(spec?.campaign?.store_phone, spec?.campaign?.phone);
|
|
2557
|
+
if (!scope.needsCopyReview && !currencyScope.needsCopyReview && !storePhone) return;
|
|
2558
|
+
|
|
2559
|
+
const scanRoots = [];
|
|
2560
|
+
if (derived.source_root && existsSync(derived.source_root) && statSync(derived.source_root).isDirectory()) {
|
|
2561
|
+
scanRoots.push({ label: "source", root: derived.source_root });
|
|
2562
|
+
}
|
|
2563
|
+
if (derived.target_output_dir && existsSync(derived.target_output_dir) && statSync(derived.target_output_dir).isDirectory()) {
|
|
2564
|
+
scanRoots.push({ label: "target", root: derived.target_output_dir });
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
const matches = scope.needsCopyReview ? collectMarketCopyMatches(scanRoots) : [];
|
|
2568
|
+
if (scope.needsCopyReview && !matches.length) {
|
|
2569
|
+
ready.push(`Market-sensitive copy scan found no obvious US-only patterns (${scope.reasons.join(", ")}).`);
|
|
2570
|
+
} else if (matches.length) {
|
|
2571
|
+
const matchSummary = summarizeCopyMatches(matches);
|
|
2572
|
+
addIssue(
|
|
2573
|
+
warnings,
|
|
2574
|
+
"market_copy.us_specific_claims",
|
|
2575
|
+
`Campaign market scope needs copy review (${scope.reasons.join(", ")}), and source/template files contain US-specific starter copy: ${matchSummary}. Confirm or replace this copy; do not remove it automatically.`
|
|
2576
|
+
);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
if (currencyScope.needsCopyReview) {
|
|
2580
|
+
const currencyMatches = collectHardcodedCurrencyMatches(scanRoots);
|
|
2581
|
+
// R2-B2: the assembled/built campaign output is the QA artifact.
|
|
2582
|
+
// Once the build has actually produced output and that output is
|
|
2583
|
+
// currency-clean, residual $ in the *source* HTML is the raw input the build
|
|
2584
|
+
// tokenized — not a live-page defect. Warn on the built output; downgrade
|
|
2585
|
+
// source-only residue to an info note so a correct build stops re-tripping
|
|
2586
|
+
// this warning. Guard on the target containing built HTML, not merely an
|
|
2587
|
+
// (empty) output directory existing — an empty target means the build has
|
|
2588
|
+
// not run yet, so source warnings must still stand.
|
|
2589
|
+
const targetScanRoot = scanRoots.find((scanRoot) => scanRoot.label === "target");
|
|
2590
|
+
const hasBuiltTarget = Boolean(targetScanRoot) && collectHtmlFiles(targetScanRoot.root).length > 0;
|
|
2591
|
+
const targetMatches = currencyMatches.filter((match) => match.surface === "target");
|
|
2592
|
+
const reportableMatches = hasBuiltTarget ? targetMatches : currencyMatches;
|
|
2593
|
+
if (!currencyMatches.length) {
|
|
2594
|
+
ready.push(`Hardcoded currency scan found no obvious static $ amounts (${currencyScope.reasons.join(", ")}).`);
|
|
2595
|
+
} else if (!reportableMatches.length) {
|
|
2596
|
+
ready.push(`Hardcoded currency scan: built output is currency-clean; ${currencyMatches.length} static $ amount(s) remain only in source HTML (raw input tokenized by build).`);
|
|
2597
|
+
} else {
|
|
2598
|
+
addIssue(
|
|
2599
|
+
warnings,
|
|
2600
|
+
"copy.hardcoded_currency_symbol",
|
|
2601
|
+
`Campaign currency scope needs copy review (${currencyScope.reasons.join(", ")}), and ${hasBuiltTarget ? "built campaign output" : "prepared HTML"} contains hardcoded $ amounts outside SDK-bound or skipped regions: ${summarizeCopyMatches(reportableMatches)}. Use SDK display tokens or remove static currency strings.`
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
if (storePhone) {
|
|
2607
|
+
const phoneMatches = collectHardcodedPhoneMatches(scanRoots, storePhone);
|
|
2608
|
+
if (!phoneMatches.length) {
|
|
2609
|
+
ready.push("Hardcoded phone scan found no mismatched static phone numbers.");
|
|
2610
|
+
} else {
|
|
2611
|
+
addIssue(
|
|
2612
|
+
warnings,
|
|
2613
|
+
"copy.hardcoded_phone",
|
|
2614
|
+
`Campaign Store Profile phone is "${storePhone}", but prepared HTML contains different hardcoded phone numbers outside skipped regions: ${summarizeCopyMatches(phoneMatches)}. Use the campaign.store_phone binding or remove static phone strings.`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function deriveMarketScope(spec) {
|
|
2621
|
+
const campaign = spec?.campaign || {};
|
|
2622
|
+
const defaultCurrency = normalizeCurrency(campaign.currency);
|
|
2623
|
+
const currencies = [...new Set([
|
|
2624
|
+
...normalizeCurrencyList(campaign.available_currencies),
|
|
2625
|
+
...normalizeCurrencyList(campaign.additional_currencies),
|
|
2626
|
+
...normalizeCurrencyList(campaign.additionalCurrencies),
|
|
2627
|
+
])];
|
|
2628
|
+
const additionalCurrencies = currencies.filter((currency) => currency && currency !== defaultCurrency);
|
|
2629
|
+
const countries = campaign.available_shipping_countries;
|
|
2630
|
+
const countryList = Array.isArray(countries) ? countries.map((country) => String(country).trim()).filter(Boolean) : [];
|
|
2631
|
+
const nonUsCountries = countryList.filter((country) => !isUsCountryCode(country));
|
|
2632
|
+
const marketMode = String(campaign.market_mode || campaign.marketMode || campaign.market_scope || spec?.market_mode || "").trim();
|
|
2633
|
+
const reasons = [];
|
|
2634
|
+
|
|
2635
|
+
if (additionalCurrencies.length) reasons.push(`additional currencies: ${additionalCurrencies.join(", ")}`);
|
|
2636
|
+
if (countries === "all") reasons.push("available_shipping_countries=all");
|
|
2637
|
+
if (nonUsCountries.length) reasons.push(`non-US shipping countries: ${nonUsCountries.join(", ")}`);
|
|
2638
|
+
if (/country|multi/i.test(marketMode)) reasons.push(`market mode: ${marketMode}`);
|
|
2639
|
+
|
|
2640
|
+
return { needsCopyReview: reasons.length > 0, reasons };
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
function deriveCurrencyCopyScope(spec) {
|
|
2644
|
+
const campaign = spec?.campaign || {};
|
|
2645
|
+
const defaultCurrency = normalizeCurrency(campaign.currency);
|
|
2646
|
+
const currencies = [...new Set([
|
|
2647
|
+
...normalizeCurrencyList(campaign.available_currencies),
|
|
2648
|
+
...normalizeCurrencyList(campaign.additional_currencies),
|
|
2649
|
+
...normalizeCurrencyList(campaign.additionalCurrencies),
|
|
2650
|
+
])];
|
|
2651
|
+
const reasons = [];
|
|
2652
|
+
|
|
2653
|
+
if (currencies.length > 1) reasons.push(`available currencies: ${currencies.join(", ")}`);
|
|
2654
|
+
if (defaultCurrency && defaultCurrency !== "USD") reasons.push(`default currency: ${defaultCurrency}`);
|
|
2655
|
+
|
|
2656
|
+
return { needsCopyReview: reasons.length > 0, reasons };
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function normalizeCurrency(value) {
|
|
2660
|
+
return isNonEmptyString(value) ? value.trim().toUpperCase() : "";
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function normalizeCurrencyList(value) {
|
|
2664
|
+
if (Array.isArray(value)) return value.map(normalizeCurrency).filter(Boolean);
|
|
2665
|
+
if (isNonEmptyString(value)) return value.split(",").map(normalizeCurrency).filter(Boolean);
|
|
2666
|
+
return [];
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function isUsCountryCode(value) {
|
|
2670
|
+
const country = String(value).trim().toUpperCase();
|
|
2671
|
+
return ["US", "USA", "UNITED STATES", "UNITED STATES OF AMERICA"].includes(country);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function collectMarketCopyMatches(scanRoots) {
|
|
2675
|
+
const matches = [];
|
|
2676
|
+
for (const { label: surface, root } of scanRoots) {
|
|
2677
|
+
for (const file of collectHtmlFiles(root)) {
|
|
2678
|
+
const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
|
|
2679
|
+
for (const pattern of US_MARKET_COPY_PATTERNS) {
|
|
2680
|
+
const match = content.match(pattern.regex);
|
|
2681
|
+
if (match) {
|
|
2682
|
+
matches.push({
|
|
2683
|
+
surface,
|
|
2684
|
+
path: file.path,
|
|
2685
|
+
line: lineNumberAt(content, match.index || 0),
|
|
2686
|
+
label: pattern.label,
|
|
2687
|
+
text: match[0],
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
return matches;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
function collectHardcodedCurrencyMatches(scanRoots) {
|
|
2697
|
+
const matches = [];
|
|
2698
|
+
for (const { label: surface, root } of scanRoots) {
|
|
2699
|
+
for (const file of collectHtmlFiles(root)) {
|
|
2700
|
+
const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
|
|
2701
|
+
for (const match of content.matchAll(HARDCODED_CURRENCY_REGEX)) {
|
|
2702
|
+
matches.push({
|
|
2703
|
+
surface,
|
|
2704
|
+
path: file.path,
|
|
2705
|
+
line: lineNumberAt(content, match.index || 0),
|
|
2706
|
+
label: match[0].replace(/\s+/g, " ").trim(),
|
|
2707
|
+
text: match[0],
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
return matches;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function collectHardcodedPhoneMatches(scanRoots, storePhone) {
|
|
2716
|
+
const expected = normalizePhoneNumber(storePhone);
|
|
2717
|
+
if (!expected) return [];
|
|
2718
|
+
|
|
2719
|
+
const matches = [];
|
|
2720
|
+
for (const { label: surface, root } of scanRoots) {
|
|
2721
|
+
for (const file of collectHtmlFiles(root)) {
|
|
2722
|
+
const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
|
|
2723
|
+
for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
|
|
2724
|
+
const found = normalizePhoneNumber(match[0]);
|
|
2725
|
+
if (!found || found === expected) continue;
|
|
2726
|
+
matches.push({
|
|
2727
|
+
surface,
|
|
2728
|
+
path: file.path,
|
|
2729
|
+
line: lineNumberAt(content, match.index || 0),
|
|
2730
|
+
label: match[0].replace(/\s+/g, " ").trim(),
|
|
2731
|
+
text: match[0],
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
return matches;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function maskMarketLintIgnoredRegions(content) {
|
|
2740
|
+
const ignoredElement =
|
|
2741
|
+
/<([A-Za-z][A-Za-z0-9:-]*)(?=[^>]*(?:data-next-display|data-next-bundle-display|data-skip-market-lint\s*=\s*["']true["']))[^>]*>[\s\S]*?<\/\1>/gi;
|
|
2742
|
+
const ignoredTag =
|
|
2743
|
+
/<[^>]*(?:data-next-display|data-next-bundle-display|data-skip-market-lint\s*=\s*["']true["'])[^>]*>/gi;
|
|
2744
|
+
|
|
2745
|
+
return content
|
|
2746
|
+
.replace(ignoredElement, preserveNewlinesMask)
|
|
2747
|
+
.replace(ignoredTag, preserveNewlinesMask);
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
function preserveNewlinesMask(value) {
|
|
2751
|
+
return value.replace(/[^\n]/g, " ");
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
function lineNumberAt(content, index) {
|
|
2755
|
+
return content.slice(0, index).split("\n").length;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function normalizePhoneNumber(value) {
|
|
2759
|
+
const digits = String(value || "").replace(/\D/g, "");
|
|
2760
|
+
if (digits.length === 11 && digits.startsWith("1")) return digits.slice(1);
|
|
2761
|
+
return digits;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function summarizeCopyMatches(matches) {
|
|
2765
|
+
const summary = matches
|
|
2766
|
+
.slice(0, 8)
|
|
2767
|
+
.map((match) => `${match.surface}:${match.path}:${match.line} "${match.label}"`)
|
|
2768
|
+
.join("; ");
|
|
2769
|
+
const more = matches.length > 8 ? `; plus ${matches.length - 8} more` : "";
|
|
2770
|
+
return `${summary}${more}`;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
function validateCampaignsApiKey(packet, spec, warnings, ready) {
|
|
2774
|
+
const apiKey = resolveCampaignsApiKey(packet, spec, process.env);
|
|
2775
|
+
if (apiKey.present) {
|
|
2776
|
+
ready.push(`Campaigns API key available via ${apiKey.source}`);
|
|
2777
|
+
if (apiKey.warning) addIssue(warnings, "campaign.api_key_source", apiKey.warning);
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
if (apiKey.rejected) {
|
|
2781
|
+
// Present but refused is its own finding: the operator fixes a value, not
|
|
2782
|
+
// a missing declaration. Names the source; the value is never printed.
|
|
2783
|
+
addIssue(warnings, "campaign.api_key_rejected", apiKey.warning, { source: apiKey.rejected.source, kind: apiKey.rejected.kind });
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
addIssue(
|
|
2788
|
+
warnings,
|
|
2789
|
+
"campaign.api_key_source",
|
|
2790
|
+
apiKey.warning || "Campaigns API key was not found in the local CampaignSpec, packet, or declared env var. API-side package/shipping/offer confirmation is deferred."
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// The malformed-key description starts mid-sentence ("the Campaigns API key
|
|
2795
|
+
// from …"), which reads wrong at the head of a warning line; the env-name one
|
|
2796
|
+
// starts with an identifier (`api_key_source "env:…"`) that must not be
|
|
2797
|
+
// touched. Only the former is capitalised.
|
|
2798
|
+
function sentenceCase(text) {
|
|
2799
|
+
return typeof text === "string" ? text.replace(/^the /, "The ") : text;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// Doctor's view of the key is a projection of the one resolver the remit
|
|
2803
|
+
// rails use (resolveCampaignsApiKeySource): the same sources in the same
|
|
2804
|
+
// order, the same shape gate, the same refusal vocabulary. A value that is
|
|
2805
|
+
// there but refused on shape is reported as refused — naming the source,
|
|
2806
|
+
// never the value — where doctor used to call any non-empty value present.
|
|
2807
|
+
// What doctor adds is the "nothing usable" explanation, read from the
|
|
2808
|
+
// packet's declared source, since the resolver reports absence without a why.
|
|
2809
|
+
function resolveCampaignsApiKey(packet, spec, env) {
|
|
2810
|
+
const resolved = resolveCampaignsApiKeySource(packet, null, env, { spec });
|
|
2811
|
+
if (resolved.key) {
|
|
2812
|
+
return {
|
|
2813
|
+
present: true,
|
|
2814
|
+
source: resolved.origin,
|
|
2815
|
+
warning: resolved.origin.startsWith("packet.")
|
|
2816
|
+
? "Campaigns API key is stored directly in the Build Packet. This is allowed for local/public-client builds, but shared fixtures may prefer CampaignSpec or env sourcing."
|
|
2817
|
+
: null,
|
|
2818
|
+
};
|
|
2819
|
+
}
|
|
2820
|
+
if (resolved.rejected) {
|
|
2821
|
+
return {
|
|
2822
|
+
present: false,
|
|
2823
|
+
source: resolved.rejected.source,
|
|
2824
|
+
rejected: resolved.rejected,
|
|
2825
|
+
warning: `${sentenceCase(describeCampaignKeyRejection(resolved.rejected))} API-side package/shipping/offer confirmation is deferred.`,
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
const source = packet?.campaign?.api_key_source;
|
|
2830
|
+
if (!isNonEmptyString(source)) {
|
|
2831
|
+
return {
|
|
2832
|
+
present: false,
|
|
2833
|
+
source: null,
|
|
2834
|
+
warning: "No Campaigns API key source is declared, and the local CampaignSpec does not include campaign.campaigns_api_key. API-side package/shipping/offer confirmation is deferred.",
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
if (source.startsWith("env:")) {
|
|
2839
|
+
// A set variable was either accepted (key) or refused (rejected) above,
|
|
2840
|
+
// so reaching here means it is unset.
|
|
2841
|
+
const envName = source.slice("env:".length).trim();
|
|
2842
|
+
return {
|
|
2843
|
+
present: false,
|
|
2844
|
+
source,
|
|
2845
|
+
warning: `Environment variable ${envName} is not set, and the local CampaignSpec does not include campaign.campaigns_api_key. API-side package/shipping/offer confirmation is deferred.`,
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
if (source === "provided-out-of-band") {
|
|
2850
|
+
return {
|
|
2851
|
+
present: false,
|
|
2852
|
+
source,
|
|
2853
|
+
warning: "API key source is declared out-of-band; doctor cannot confirm Campaigns API refs before build.",
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
return {
|
|
2858
|
+
present: false,
|
|
2859
|
+
source,
|
|
2860
|
+
warning: `Unsupported API key source "${source}". Use CampaignSpec campaign.campaigns_api_key, packet campaign.campaigns_api_key, or env:<VAR>.`,
|
|
2861
|
+
};
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
function routeLabel(route) {
|
|
2865
|
+
return route === "" ? "entry route (empty page_url)" : route;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function validateSpecPublicRoutes(spec, errors, ready) {
|
|
2869
|
+
const pages = activeSpecPages(spec);
|
|
2870
|
+
const routeMap = new Map();
|
|
2871
|
+
let routeErrors = 0;
|
|
2872
|
+
|
|
2873
|
+
for (const page of pages) {
|
|
2874
|
+
if (hasHtmlExtensionRoute(page.page_url)) {
|
|
2875
|
+
routeErrors += 1;
|
|
2876
|
+
addIssue(
|
|
2877
|
+
errors,
|
|
2878
|
+
"spec.page_url_html_extension",
|
|
2879
|
+
`Page "${page.label || page.id}" declares page_url "${page.page_url}". CampaignSpec page_url is a Page Kit public route, not a source filename; use "${normalizePageKitRoute(page.page_url) || "(entry route)"}" instead.`
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
const route = publicRouteForPage(page);
|
|
2884
|
+
const prior = routeMap.get(route);
|
|
2885
|
+
if (prior) {
|
|
2886
|
+
routeErrors += 1;
|
|
2887
|
+
addIssue(
|
|
2888
|
+
errors,
|
|
2889
|
+
"spec.route_collision",
|
|
2890
|
+
`Pages "${prior.label || prior.id}" and "${page.label || page.id}" both resolve to ${routeLabel(route)}. Set distinct page_url values before assembly.`
|
|
2891
|
+
);
|
|
2892
|
+
} else {
|
|
2893
|
+
routeMap.set(route, page);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
if (pages.length > 0 && routeErrors === 0) ready.push("CampaignSpec public page routes are Page Kit-compatible");
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
function pageRole(type) {
|
|
2901
|
+
if (["checkout", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
|
|
2902
|
+
return "visual";
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
function summarizeScopePages(pages) {
|
|
2906
|
+
return pages.map((page) => `${page.type || "page"}:${page.page_id}`).join(", ");
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/**
|
|
2910
|
+
* Build a context-aware error message for a CampaignSpec page that has no source mapping.
|
|
2911
|
+
* When `design_source` is set, point the operator at the producing pipeline so the missing
|
|
2912
|
+
* source HTML can be regenerated instead of leaving the operator guessing.
|
|
2913
|
+
*
|
|
2914
|
+
* Per docs/entry-points.md, today's recognized producers:
|
|
2915
|
+
* - figma: run figma-sections-export
|
|
2916
|
+
* - ai-generated: re-run the producing agent (Claude/Codex/etc.)
|
|
2917
|
+
* - hand-authored / template-stock: no design_source set; falls through to generic
|
|
2918
|
+
*
|
|
2919
|
+
* Future producers (Penpot, Sketch, plain Markdown converters, etc.) slot in by adding a
|
|
2920
|
+
* `design_source.type` value here. The fallback path emits a generic "produce the source
|
|
2921
|
+
* HTML for this page" message so unknown types don't crash the operator's UX.
|
|
2922
|
+
*
|
|
2923
|
+
* @param {object} page Active spec page (may carry `design_source`).
|
|
2924
|
+
* @returns {string}
|
|
2925
|
+
*/
|
|
2926
|
+
function coverageErrorMessage(page) {
|
|
2927
|
+
const designSource = page && isObject(page.design_source) ? page.design_source : null;
|
|
2928
|
+
if (designSource) {
|
|
2929
|
+
const fileUrl = optionalString(designSource.file_url);
|
|
2930
|
+
if (designSource.type === "figma" && fileUrl) {
|
|
2931
|
+
return `Active CampaignSpec page "${page.id}" has no source mapping. Design is in Figma at ${fileUrl}; supply the source-html manifest for the page (see docs/design-source-package.md) — the exporter that produced the design emits it — then rerun prepare-build.`;
|
|
2932
|
+
}
|
|
2933
|
+
if (designSource.type === "ai-generated") {
|
|
2934
|
+
const fileUrlHint = fileUrl ? ` (design reference: ${fileUrl})` : "";
|
|
2935
|
+
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. See docs/entry-points.md for the AI-generated entry point contract.`;
|
|
2936
|
+
}
|
|
2937
|
+
if (!fileUrl) {
|
|
2938
|
+
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.`;
|
|
2939
|
+
}
|
|
2940
|
+
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.`;
|
|
2941
|
+
}
|
|
2942
|
+
return `Active CampaignSpec page "${page.id}" has no source mapping.`;
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/**
|
|
2946
|
+
* Build the optional `detail` payload for a source-coverage error. Captures the design_source
|
|
2947
|
+
* pointer when present so downstream agents/UIs can render a clickable link without re-parsing
|
|
2948
|
+
* the message string.
|
|
2949
|
+
*
|
|
2950
|
+
* @param {object} page Active spec page.
|
|
2951
|
+
* @returns {object | null}
|
|
2952
|
+
*/
|
|
2953
|
+
function coverageErrorDetail(page) {
|
|
2954
|
+
const designSource = page && isObject(page.design_source) ? page.design_source : null;
|
|
2955
|
+
// Always carry page_id so downstream consumers (including the prepare-build
|
|
2956
|
+
// gate dedup in addPrepareBuildGateErrors) can match this issue to the page
|
|
2957
|
+
// without re-parsing the message string.
|
|
2958
|
+
return {
|
|
2959
|
+
page_id: page?.id ?? null,
|
|
2960
|
+
...(designSource
|
|
2961
|
+
? {
|
|
2962
|
+
design_source: {
|
|
2963
|
+
type: designSource.type || null,
|
|
2964
|
+
file_url: optionalString(designSource.file_url) || null,
|
|
2965
|
+
},
|
|
2966
|
+
}
|
|
2967
|
+
: {}),
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// A declared out-of-scope page whose scope decision carries `template_stock`
|
|
2972
|
+
// (recorded by prepare-build on dec_page_scope_<page>) is the locked family's
|
|
2973
|
+
// own page: the build stage materialises it, and once its built HTML exists at
|
|
2974
|
+
// the page's route it is a built page like any mapped one — previewable, and
|
|
2975
|
+
// no longer a reason to block runtime QA. Until the build has written it, it
|
|
2976
|
+
// stays out of scope exactly as before, so an unbuilt declaration is unchanged.
|
|
2977
|
+
function templateStockDecision(buildState, pageId) {
|
|
2978
|
+
const decisions = buildState?.report?.decisions;
|
|
2979
|
+
if (!Array.isArray(decisions)) return null;
|
|
2980
|
+
const decision = decisions.find((entry) => entry?.id === `dec_page_scope_${pageId}` && entry?.template_stock === true);
|
|
2981
|
+
return decision || null;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
2985
|
+
const pages = packet.source_html?.pages || [];
|
|
2986
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2987
|
+
const materialisedTemplateStock = [];
|
|
2988
|
+
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
2989
|
+
validateSourceHtmlManifestAtRoot(sourceRoot, {
|
|
2990
|
+
spec,
|
|
2991
|
+
errors,
|
|
2992
|
+
warnings,
|
|
2993
|
+
ready,
|
|
2994
|
+
manifestPath: recordedDesignManifestPath(packet, packetPath),
|
|
2995
|
+
});
|
|
2996
|
+
const active = activeSpecPages(spec);
|
|
2997
|
+
const specPartialScope = spec?.build_scope?.mode === "partial";
|
|
2998
|
+
const specPartialReasons = Array.isArray(spec?.build_scope?.reasons) ? spec.build_scope.reasons.filter(isNonEmptyString) : [];
|
|
2999
|
+
const activeIds = new Set(active.map((page) => page.id));
|
|
3000
|
+
const mappedIds = new Set();
|
|
3001
|
+
const activeById = new Map(active.map((page) => [page.id, page]));
|
|
3002
|
+
const builtPages = [];
|
|
3003
|
+
const outOfScopePages = [];
|
|
3004
|
+
|
|
3005
|
+
for (const page of pages) {
|
|
3006
|
+
if (!isNonEmptyString(page.page_id)) {
|
|
3007
|
+
addIssue(errors, "source_html.pages.page_id", "Every source page mapping needs page_id.");
|
|
3008
|
+
continue;
|
|
3009
|
+
}
|
|
3010
|
+
mappedIds.add(page.page_id);
|
|
3011
|
+
const specPage = activeById.get(page.page_id);
|
|
3012
|
+
if (!activeIds.has(page.page_id)) {
|
|
3013
|
+
addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
|
|
3014
|
+
}
|
|
3015
|
+
if (page.path) {
|
|
3016
|
+
const fullPath = resolve(sourceRoot, page.path);
|
|
3017
|
+
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
|
|
3018
|
+
addIssue(errors, "source_html.pages.path", `Source page file does not exist: ${page.path}`);
|
|
3019
|
+
} else {
|
|
3020
|
+
// Slice 6: drift detection. When the packet carries a manifest-derived
|
|
3021
|
+
// source_hash for this page, compute the on-disk file's actual hash
|
|
3022
|
+
// and warn when they diverge. A mismatch means the file was edited
|
|
3023
|
+
// after the manifest was written, which is the signal that "design
|
|
3024
|
+
// handoff is stale" — operator should re-run the producer or accept
|
|
3025
|
+
// the local edits and regenerate the manifest.
|
|
3026
|
+
//
|
|
3027
|
+
// Silent when source_hash is absent (template-stock, hand-authored,
|
|
3028
|
+
// and producers that haven't adopted Slice 6 yet). Never an error —
|
|
3029
|
+
// drift is a warning so a build can still ship.
|
|
3030
|
+
const expectedHash = optionalString(page.source_hash);
|
|
3031
|
+
if (expectedHash) {
|
|
3032
|
+
const actualHash = sha256File(fullPath);
|
|
3033
|
+
if (actualHash !== expectedHash) {
|
|
3034
|
+
addIssue(
|
|
3035
|
+
warnings,
|
|
3036
|
+
"source_html.pages.source_hash",
|
|
3037
|
+
`Source page "${page.page_id}" hash mismatch — file at ${page.path} has changed since the manifest was written (manifest sha256=${expectedHash.slice(0, 12)}…, on-disk sha256=${actualHash.slice(0, 12)}…). Re-run the producer to refresh the manifest, or accept the local edits.`,
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
if (specPage) {
|
|
3042
|
+
builtPages.push({
|
|
3043
|
+
page_id: specPage.id,
|
|
3044
|
+
type: specPage.type || "page",
|
|
3045
|
+
role: pageRole(specPage.type),
|
|
3046
|
+
route: publicRouteForPage(specPage),
|
|
3047
|
+
source_path: page.path,
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
} else if (!page.skip_reason) {
|
|
3052
|
+
addIssue(errors, "source_html.pages.skip_reason", `Source mapping "${page.page_id}" needs path or skip_reason.`);
|
|
3053
|
+
} else {
|
|
3054
|
+
const stockDecision = specPage ? templateStockDecision(buildState, specPage.id) : null;
|
|
3055
|
+
const builtPath = stockDecision ? builtHtmlPathForPage(derived.target_repo, publicRouteSlug, specPage, derived) : null;
|
|
3056
|
+
if (stockDecision && builtPath && existsSync(builtPath)) {
|
|
3057
|
+
const family = optionalString(stockDecision.template_family) || "selected";
|
|
3058
|
+
builtPages.push({
|
|
3059
|
+
page_id: specPage.id,
|
|
3060
|
+
type: specPage.type || "page",
|
|
3061
|
+
role: pageRole(specPage.type),
|
|
3062
|
+
route: publicRouteForPage(specPage),
|
|
3063
|
+
source_path: null,
|
|
3064
|
+
template_stock: true,
|
|
3065
|
+
template_family: family,
|
|
3066
|
+
});
|
|
3067
|
+
materialisedTemplateStock.push({ page_id: specPage.id, family });
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
const skipped = specPage
|
|
3071
|
+
? {
|
|
3072
|
+
page_id: specPage.id,
|
|
3073
|
+
type: specPage.type || "page",
|
|
3074
|
+
role: pageRole(specPage.type),
|
|
3075
|
+
route: publicRouteForPage(specPage),
|
|
3076
|
+
skip_reason: page.skip_reason,
|
|
3077
|
+
...(stockDecision ? { template_stock: true, template_family: optionalString(stockDecision.template_family) } : {}),
|
|
3078
|
+
}
|
|
3079
|
+
: { page_id: page.page_id, type: "unknown", role: "unknown", route: null, skip_reason: page.skip_reason };
|
|
3080
|
+
outOfScopePages.push(skipped);
|
|
3081
|
+
addIssue(
|
|
3082
|
+
warnings,
|
|
3083
|
+
"source_html.pages.skip_reason",
|
|
3084
|
+
stockDecision
|
|
3085
|
+
? `CampaignSpec page "${page.page_id}" is template stock and not built yet: ${page.skip_reason} Keep it unbuilt unless explicitly opted in. For an opted-in page, the build stage materialises it from the ${optionalString(stockDecision.template_family) || "selected"} family's own page; it joins the previewable routes once its built HTML exists.`
|
|
3086
|
+
: `CampaignSpec page "${page.page_id}" is out of scope for this partial build: ${page.skip_reason}`,
|
|
3087
|
+
);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
if (materialisedTemplateStock.length > 0) {
|
|
3092
|
+
ready.push(`Template-stock page(s) materialised by the build stage: ${materialisedTemplateStock.map((entry) => `${entry.page_id} (${entry.family})`).join(", ")}`);
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
for (const page of active) {
|
|
3096
|
+
if (!mappedIds.has(page.id)) {
|
|
3097
|
+
addIssue(errors, "source_html.pages.coverage", coverageErrorMessage(page), coverageErrorDetail(page));
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
const runtimeBlocked = outOfScopePages.filter((page) => page.role === "runtime");
|
|
3102
|
+
// A CampaignSpec build_scope "partial" declaration is discharged once every
|
|
3103
|
+
// page it took out of scope has been materialised: the declaration named
|
|
3104
|
+
// template-stock pages, and they now exist. With nothing materialised the
|
|
3105
|
+
// declaration stands on its own, as before.
|
|
3106
|
+
const partialScopeOpen = outOfScopePages.length > 0
|
|
3107
|
+
|| (specPartialScope && materialisedTemplateStock.length === 0);
|
|
3108
|
+
derived.scope = {
|
|
3109
|
+
mode: partialScopeOpen ? "partial" : active.length ? "full" : "unknown",
|
|
3110
|
+
built_pages: builtPages,
|
|
3111
|
+
out_of_scope_pages: outOfScopePages,
|
|
3112
|
+
out_of_scope_reasons: specPartialReasons,
|
|
3113
|
+
previewable_routes: builtPages.map((page) => ({ page_id: page.page_id, type: page.type, route: page.route })),
|
|
3114
|
+
blocked_runtime_pages: runtimeBlocked,
|
|
3115
|
+
};
|
|
3116
|
+
|
|
3117
|
+
if (partialScopeOpen) {
|
|
3118
|
+
const reasonSummary = specPartialReasons.length ? ` Reasons: ${specPartialReasons.join("; ")}.` : "";
|
|
3119
|
+
addIssue(
|
|
3120
|
+
warnings,
|
|
3121
|
+
"scope.partial_build",
|
|
3122
|
+
`Partial build scope detected. Built/previewable pages: ${summarizeScopePages(builtPages) || "none"}; out-of-scope pages: ${summarizeScopePages(outOfScopePages) || "declared in CampaignSpec build_scope"}.${reasonSummary}`
|
|
3123
|
+
);
|
|
3124
|
+
const buildScopeRuntimeBlocked = specPartialReasons.some((reason) => /\b(checkout|upsell|downsell|receipt|thankyou|runtime)\b/i.test(reason));
|
|
3125
|
+
if (runtimeBlocked.length > 0 || buildScopeRuntimeBlocked) {
|
|
3126
|
+
addIssue(
|
|
3127
|
+
warnings,
|
|
3128
|
+
"scope.runtime_qa_blocked",
|
|
3129
|
+
`Checkout/runtime launch QA is blocked for out-of-scope pages: ${summarizeScopePages(runtimeBlocked) || "declared in CampaignSpec build_scope"}. Preview QA should cover only the built routes.`
|
|
3130
|
+
);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
if (active.length > 0 && active.every((page) => mappedIds.has(page.id))) {
|
|
3135
|
+
ready.push(partialScopeOpen
|
|
3136
|
+
? "Source mappings cover active CampaignSpec pages with explicit partial-scope skip reasons"
|
|
3137
|
+
: "Source mappings cover active CampaignSpec pages");
|
|
3138
|
+
}
|
|
3139
|
+
if (builtPages.length > 0) {
|
|
3140
|
+
ready.push(partialScopeOpen
|
|
3141
|
+
? `Partial build previewable routes: ${builtPages.map((page) => routeLabel(page.route)).join(", ")}`
|
|
3142
|
+
: "All mapped CampaignSpec pages are build candidates");
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
// Source preparation check (#262). Runs at every doctor evaluation — start and
|
|
3147
|
+
// build embed doctor, so this is the start preflight the issue asks for while
|
|
3148
|
+
// staying re-checkable after source edits. Blocking findings surface as doctor
|
|
3149
|
+
// errors, which drive status "blocked" and a doctor-blocked / prepare-build next stage exactly
|
|
3150
|
+
// like other unprepared-input states. Detection and severity policy live in
|
|
3151
|
+
// src/source-prep.mjs; the codes and fixes are documented in
|
|
3152
|
+
// docs/source-adapters.md "Source preparation check".
|
|
3153
|
+
function validateSourcePreparation(packet, packetPath, errors, warnings, ready, derived = {}) {
|
|
3154
|
+
const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
|
|
3155
|
+
if (!sourceRoot || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) return;
|
|
3156
|
+
const pages = Array.isArray(packet.source_html?.pages) ? packet.source_html.pages : [];
|
|
3157
|
+
const result = evaluateSourcePreparation({
|
|
3158
|
+
sourceRoot,
|
|
3159
|
+
pages,
|
|
3160
|
+
wrapperPolicy: packet.source_html?.adapter_contract?.wrapper_policy,
|
|
3161
|
+
});
|
|
3162
|
+
derived.source_preparation = {
|
|
3163
|
+
checked_page_count: result.checked_page_count,
|
|
3164
|
+
finding_codes: result.findings.map((finding) => finding.code),
|
|
3165
|
+
};
|
|
3166
|
+
for (const finding of result.findings) {
|
|
3167
|
+
addIssue(
|
|
3168
|
+
finding.severity === "error" ? errors : warnings,
|
|
3169
|
+
finding.code,
|
|
3170
|
+
finding.message,
|
|
3171
|
+
{ pages: finding.pages, docs: finding.docs }
|
|
3172
|
+
);
|
|
3173
|
+
}
|
|
3174
|
+
if (result.checked_page_count > 0 && result.findings.length === 0) {
|
|
3175
|
+
ready.push("Mapped source pages pass the page-kit preparation check (document wrappers stripped, no leftover frontmatter, no source-file internal links)");
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// The manifest prepare-build read is recorded on the Design Source Package
|
|
3180
|
+
// (html-funnel contribution, provenance.manifest_path, relative to the package
|
|
3181
|
+
// file). Doctor reads the same file back, so a manifest supplied through
|
|
3182
|
+
// --design-manifest from outside the source root is still the one doctor
|
|
3183
|
+
// validates; with nothing recorded, the default path under the source root
|
|
3184
|
+
// stands.
|
|
3185
|
+
function recordedDesignManifestPath(packet, packetPath) {
|
|
3186
|
+
const packagePath = resolveFromFile(packetPath, packet?.design_source_package?.path);
|
|
3187
|
+
if (!packagePath || !existsSync(packagePath) || !statSync(packagePath).isFile()) return null;
|
|
3188
|
+
let value;
|
|
3189
|
+
try {
|
|
3190
|
+
value = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
3191
|
+
} catch {
|
|
3192
|
+
return null;
|
|
3193
|
+
}
|
|
3194
|
+
const htmlFunnel = (Array.isArray(value?.contributions) ? value.contributions : [])
|
|
3195
|
+
.find((contribution) => contribution?.kind === "html_funnel");
|
|
3196
|
+
const recorded = optionalString(htmlFunnel?.provenance?.manifest_path);
|
|
3197
|
+
return recorded ? resolve(dirname(packagePath), recorded) : null;
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings, ready, manifestPath = null } = {}) {
|
|
3201
|
+
if (!isNonEmptyString(sourceRoot) || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) return;
|
|
3202
|
+
const result = readSourceHtmlManifestFile(sourceRoot, { manifestPath });
|
|
3203
|
+
if (!result.path) return;
|
|
3204
|
+
if (result.validation && !result.validation.ok) {
|
|
3205
|
+
const detail = result.validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
3206
|
+
addIssue(warnings, "source_html.manifest", `Source-html manifest failed ${SOURCE_HTML_MANIFEST_SCHEMA} validation: ${detail}. Re-run or fix the producer before relying on manifest-derived page mappings.`);
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
if (result.warning) {
|
|
3210
|
+
addIssue(warnings, "source_html.manifest", result.warning);
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
for (const warning of result.warnings || []) {
|
|
3214
|
+
addIssue(warnings, "source_html.manifest", warning);
|
|
3215
|
+
}
|
|
3216
|
+
validateSourceProducerProvenance(result.manifest, { spec, errors, warnings, ready });
|
|
3217
|
+
ready.push(`Source-html manifest ${SOURCE_HTML_MANIFEST_SCHEMA} validated`);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
function validateSourceProducerProvenance(manifest, { spec, errors, warnings, ready }) {
|
|
3221
|
+
const generator = optionalString(manifest?.generator) || "";
|
|
3222
|
+
const rawProvenance = manifest?.producer_provenance;
|
|
3223
|
+
const provenance = isObject(rawProvenance) ? rawProvenance : {};
|
|
3224
|
+
const expectsFigma = generator.startsWith("figma-sections-export@") || activeSpecPages(spec).some(hasFigmaDesignSource);
|
|
3225
|
+
if (!expectsFigma) return;
|
|
3226
|
+
|
|
3227
|
+
if (!rawProvenance) {
|
|
3228
|
+
addIssue(
|
|
3229
|
+
errors,
|
|
3230
|
+
"source_html.producer_provenance",
|
|
3231
|
+
"Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
if (provenance.source_type !== "semantic_figma_export") {
|
|
3236
|
+
addIssue(
|
|
3237
|
+
errors,
|
|
3238
|
+
"source_html.producer_provenance.source_type",
|
|
3239
|
+
`Figma source manifest source_type is "${provenance.source_type || "missing"}"; expected "semantic_figma_export". Screenshot or hand-authored fallback output cannot satisfy the Figma provenance gate.`
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
if (provenance.screenshot_fallback_used !== false) {
|
|
3243
|
+
addIssue(
|
|
3244
|
+
errors,
|
|
3245
|
+
"source_html.producer_provenance.screenshot_fallback_used",
|
|
3246
|
+
"Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
|
|
3250
|
+
addIssue(
|
|
3251
|
+
errors,
|
|
3252
|
+
"source_html.producer_provenance.semantic_section_count",
|
|
3253
|
+
"Figma source manifest must report semantic_section_count > 0."
|
|
3254
|
+
);
|
|
3255
|
+
}
|
|
3256
|
+
if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
|
|
3257
|
+
addIssue(
|
|
3258
|
+
errors,
|
|
3259
|
+
"source_html.producer_provenance.material_fingerprint",
|
|
3260
|
+
"Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
|
|
3261
|
+
);
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
const files = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
3265
|
+
if (!files.some((entry) => entry.role === "partial")) {
|
|
3266
|
+
addIssue(errors, "source_html.files.partial", "Figma source manifest files[] must include section partials.");
|
|
3267
|
+
}
|
|
3268
|
+
if (!files.some((entry) => entry.role === "asset")) {
|
|
3269
|
+
addIssue(errors, "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
|
|
3273
|
+
if (!sectionExports.length) {
|
|
3274
|
+
addIssue(errors, "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
|
|
3275
|
+
} else {
|
|
3276
|
+
const withoutNodeIds = sectionExports
|
|
3277
|
+
.filter((entry) => {
|
|
3278
|
+
const nodeIds = isObject(entry?.node_ids) ? entry.node_ids : null;
|
|
3279
|
+
return !nodeIds || !Object.keys(nodeIds).length;
|
|
3280
|
+
})
|
|
3281
|
+
.map((entry) => entry?.section || "unknown");
|
|
3282
|
+
if (withoutNodeIds.length) {
|
|
3283
|
+
addIssue(
|
|
3284
|
+
warnings,
|
|
3285
|
+
"source_html.producer_provenance.section_exports.node_ids",
|
|
3286
|
+
`Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
|
|
3287
|
+
);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
if (errors.every((issue) => !String(issue.code || "").startsWith("source_html.producer_provenance") && !["source_html.files.partial", "source_html.files.asset"].includes(issue.code))) {
|
|
3292
|
+
ready.push("Figma producer provenance gate passed: semantic_figma_export with package fingerprint");
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
function hasFigmaDesignSource(page) {
|
|
3297
|
+
const designSource = page && isObject(page.design_source) ? page.design_source : null;
|
|
3298
|
+
return Boolean(designSource && (String(designSource.type || "").toLowerCase() === "figma" || /figma\.com\//i.test(optionalString(designSource.file_url))));
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
// Helpers ported from the private build-packet doctor (ADR-003 step 2) so the
|
|
3302
|
+
// shared-concern template-contract checks live here in the public doctor too.
|
|
3303
|
+
function frontmatterList(contract, key) {
|
|
3304
|
+
const value = contract?.frontmatter?.[key];
|
|
3305
|
+
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
3306
|
+
}
|
|
3307
|
+
function contractMentions(contract, pattern, keys = ["requiredWhenCloning", "replaceFromSpecOrApi"]) {
|
|
3308
|
+
return keys.flatMap((key) => frontmatterList(contract, key)).some((value) => pattern.test(value));
|
|
3309
|
+
}
|
|
3310
|
+
function packageRefsFromEntries(entries) {
|
|
3311
|
+
if (!Array.isArray(entries)) return [];
|
|
3312
|
+
return entries
|
|
3313
|
+
.map((entry) => entry?.ref_id ?? entry?.package_ref_id ?? entry?.package_id ?? entry?.id)
|
|
3314
|
+
.filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
|
|
3315
|
+
.map((value) => String(value));
|
|
3316
|
+
}
|
|
3317
|
+
function offerRefsFromEntries(entries) {
|
|
3318
|
+
if (!Array.isArray(entries)) return [];
|
|
3319
|
+
return entries
|
|
3320
|
+
.flatMap((entry) => [entry?.package_ref_id, entry?.package_id, entry?.ref_id, entry?.code])
|
|
3321
|
+
.filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
|
|
3322
|
+
.map((value) => String(value));
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
function isAutomatableTemplateFamily(family) {
|
|
3326
|
+
return isNonEmptyString(family) && family !== "undecided" && family !== "custom";
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
// One resolution of the family brand contract per doctor run, keyed by the
|
|
3330
|
+
// run's `derived` — the in-process bag every check reads. The JSON-visible
|
|
3331
|
+
// summary lands on derived.brand_contract: `state` (no_family, no_contract,
|
|
3332
|
+
// no_palette_checks, inspected, defect), `family`, and for a defect the
|
|
3333
|
+
// loader's code and a one-line detail — the shape the `next` advisories read.
|
|
3334
|
+
// The contract object itself stays in process. A defect is reported once, by
|
|
3335
|
+
// the first check that reports it, at that check's severity: before this, a
|
|
3336
|
+
// standard packet with an unreadable contract carried the same finding twice
|
|
3337
|
+
// (an error from the catalog check and a warning from the pricing scan).
|
|
3338
|
+
const BRAND_CONTRACT_RESOLUTIONS = new WeakMap();
|
|
3339
|
+
|
|
3340
|
+
// The one resolution: the contract object (or the loader's error) and the
|
|
3341
|
+
// summary every consumer reads. null is "resolved to no contract", never
|
|
3342
|
+
// "something went wrong": no public contract file AND no private fragment
|
|
3343
|
+
// carrying a brandContract, for which QA emits no residue rows. A defect
|
|
3344
|
+
// throws instead; the two must not be collapsed.
|
|
3345
|
+
function resolveBrandContract(family) {
|
|
3346
|
+
const label = optionalString(family);
|
|
3347
|
+
if (!label) return { contract: null, error: null, summary: { state: "no_family", family: null } };
|
|
3348
|
+
try {
|
|
3349
|
+
const contract = resolveTemplateBrandContract(label);
|
|
3350
|
+
const state = contract ? (contractHasPaletteResidueChecks(contract) ? "inspected" : "no_palette_checks") : "no_contract";
|
|
3351
|
+
return { contract, error: null, summary: { state, family: label } };
|
|
3352
|
+
} catch (error) {
|
|
3353
|
+
return {
|
|
3354
|
+
contract: null,
|
|
3355
|
+
error,
|
|
3356
|
+
summary: {
|
|
3357
|
+
state: "defect",
|
|
3358
|
+
family: label,
|
|
3359
|
+
code: safeBrandContractCode(error?.code),
|
|
3360
|
+
detail: singleLineDetail(error instanceof Error ? error.message : error),
|
|
3361
|
+
},
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
function resolveBrandContractOnce(derived, family) {
|
|
3367
|
+
if (BRAND_CONTRACT_RESOLUTIONS.has(derived)) return BRAND_CONTRACT_RESOLUTIONS.get(derived);
|
|
3368
|
+
const { contract, error, summary } = resolveBrandContract(family);
|
|
3369
|
+
derived.brand_contract = summary;
|
|
3370
|
+
const resolution = { contract, error, reported: false };
|
|
3371
|
+
BRAND_CONTRACT_RESOLUTIONS.set(derived, resolution);
|
|
3372
|
+
return resolution;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
function reportBrandContractDefectOnce(resolution, collection, family) {
|
|
3376
|
+
if (!resolution.error || resolution.reported) return;
|
|
3377
|
+
resolution.reported = true;
|
|
3378
|
+
addIssue(
|
|
3379
|
+
collection,
|
|
3380
|
+
"template_contract.brand_contract",
|
|
3381
|
+
`Template brand contract for "${family}" failed to load: ${resolution.error.message}`,
|
|
3382
|
+
templateBrandContractErrorDetail(resolution.error, family),
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
function loadTemplateFamilyBrandContract(family, errors, warnings, derived, { required = false } = {}) {
|
|
3387
|
+
const resolution = resolveBrandContractOnce(derived, family);
|
|
3388
|
+
if (resolution.error) {
|
|
3389
|
+
reportBrandContractDefectOnce(resolution, required ? errors : warnings, family);
|
|
3390
|
+
return null;
|
|
3391
|
+
}
|
|
3392
|
+
if (!resolution.contract && required) {
|
|
3393
|
+
addIssue(
|
|
3394
|
+
errors,
|
|
3395
|
+
"template_contract.brand_contract",
|
|
3396
|
+
`Template family "${family}" has no brand/residue/pricing contract at contracts/template-brand-contract.${family}.v0.json. Add the contract before treating this family as promoted/agent-ready.`,
|
|
3397
|
+
{
|
|
3398
|
+
template_family: family,
|
|
3399
|
+
reason: "missing_file",
|
|
3400
|
+
contract_path: `contracts/template-brand-contract.${family}.v0.json`,
|
|
3401
|
+
},
|
|
3402
|
+
);
|
|
3403
|
+
}
|
|
3404
|
+
return resolution.contract;
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
function templateBrandContractErrorDetail(error, family) {
|
|
3408
|
+
return {
|
|
3409
|
+
template_family: family || null,
|
|
3410
|
+
reason: typeof error?.code === "string" ? error.code : "load_error",
|
|
3411
|
+
message: error instanceof Error ? error.message : String(error),
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
export function validateCommerceCatalog(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
|
|
3416
|
+
const family = packet.assembly?.template_family;
|
|
3417
|
+
// Match the private doctor (build-packet.js): the ported template_contract.*
|
|
3418
|
+
// checks below do not apply to non-automatable families. The pre-existing
|
|
3419
|
+
// agentContract / demo_ref / shipping checks keep running for all families.
|
|
3420
|
+
const familyAutomatable = isAutomatableTemplateFamily(family);
|
|
3421
|
+
const catalogInfo = packet.assembly?.commerce_catalog || {};
|
|
3422
|
+
if (catalogInfo.required !== true) return;
|
|
3423
|
+
const catalogResolution = resolvePacketCommerceCatalogPath(packetPath, catalogInfo);
|
|
3424
|
+
const catalogPath = catalogResolution.path;
|
|
3425
|
+
if (!catalogPath || !existsSync(catalogPath)) {
|
|
3426
|
+
addIssue(errors, "assembly.commerce_catalog.path", "Commerce catalog is required but not found.");
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
if (catalogResolution.source === "stale_packet_path") {
|
|
3430
|
+
// Not a warning: the catalog resolved and nothing about the build changes.
|
|
3431
|
+
// The line tells the operator the packet still names one machine's
|
|
3432
|
+
// checkout, and that a re-prepare records the toolkit default (null).
|
|
3433
|
+
ready.push(
|
|
3434
|
+
`Commerce catalog resolved to the running toolkit's copy; the packet's recorded path ${catalogResolution.recorded} ` +
|
|
3435
|
+
"does not exist here (it names the checkout that ran prepare-build). Re-run prepare-build to clear the machine-local path.",
|
|
3436
|
+
);
|
|
3437
|
+
}
|
|
3438
|
+
const catalog = resolveCommerceCatalog(catalogPath);
|
|
3439
|
+
if (familyAutomatable && catalog.agentContractVersion !== 1) {
|
|
3440
|
+
addIssue(warnings, "template_contract.catalog_version", "Commerce surface catalog agentContractVersion is not 1; verify contract semantics before build.");
|
|
3441
|
+
}
|
|
3442
|
+
if (!isObject(catalog.sharedFrontmatterVocabulary)) {
|
|
3443
|
+
addIssue(errors, "catalog.sharedFrontmatterVocabulary", "Commerce catalog is missing sharedFrontmatterVocabulary.");
|
|
3444
|
+
} else {
|
|
3445
|
+
ready.push("Commerce catalog sharedFrontmatterVocabulary loaded");
|
|
3446
|
+
}
|
|
3447
|
+
const contract = catalog.families?.[family]?.agentContract;
|
|
3448
|
+
if (!contract) {
|
|
3449
|
+
addIssue(errors, "template_contract.agentContract", `Template family "${family}" has no agentContract.`);
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
ready.push(`Template agentContract loaded for ${family}`);
|
|
3453
|
+
const brandContract = loadTemplateFamilyBrandContract(family, errors, warnings, derived, { required: familyAutomatable });
|
|
3454
|
+
if (brandContract) {
|
|
3455
|
+
ready.push(`Template brand/residue/pricing contract loaded for ${family}`);
|
|
3456
|
+
validateTemplateFamilyInventory(brandContract, errors, ready);
|
|
3457
|
+
}
|
|
3458
|
+
if (familyAutomatable && contract.status && contract.status !== "agent-ready") {
|
|
3459
|
+
addIssue(warnings, "template_contract.status", `Template family "${family}" contract status is "${contract.status}"; treat this as guided assembly, not full automation.`);
|
|
3460
|
+
}
|
|
3461
|
+
const assemblyComplete = isStageComplete(buildState.report, "assembly");
|
|
3462
|
+
if (assemblyComplete) {
|
|
3463
|
+
validateBuiltContractResidue(contract, warnings, ready, derived, spec);
|
|
3464
|
+
// H3.1/H3.2: pre-QA warnings off the family brand contract. Doctor warns
|
|
3465
|
+
// (the fix happens during build/polish); browser QA enforces the same
|
|
3466
|
+
// placeholder-text terms as a blocker in the verdict.
|
|
3467
|
+
validateBuiltPlaceholderTextResidue(brandContract, warnings, ready, derived, { report: buildState.report });
|
|
3468
|
+
validateBuiltDemoAssetFidelity(brandContract, warnings, ready, derived);
|
|
3469
|
+
} else {
|
|
3470
|
+
for (const value of contract.frontmatter?.demoOnlyValues || []) {
|
|
3471
|
+
addIssue(warnings, "frontmatter.demoOnlyValues", `Replace demo-only starter value before launch: ${value}`);
|
|
3472
|
+
}
|
|
3473
|
+
for (const value of contract.frontmatter?.replaceFromSpecOrApi || []) {
|
|
3474
|
+
addIssue(warnings, "frontmatter.replaceFromSpecOrApi", `Must be replaced from CampaignSpec/API: ${value}`);
|
|
3475
|
+
}
|
|
3476
|
+
for (const value of contract.frontmatter?.removeWhenUnsupported || []) {
|
|
3477
|
+
addIssue(warnings, "frontmatter.removeWhenUnsupported", `Remove when unsupported by target campaign: ${value}`);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
const consumesExplicitShipping = contractMentionsShipping(contract);
|
|
3481
|
+
if (family === "shop-three-step") {
|
|
3482
|
+
ready.push("shop-three-step uses dynamic shipping via window.next.getShippingMethods(); do not copy Olympus-style shipping_methods frontmatter into it.");
|
|
3483
|
+
}
|
|
3484
|
+
if (!consumesExplicitShipping) {
|
|
3485
|
+
validateUnsupportedShippingFrontmatter(packet, packetPath, family, warnings, ready, derived);
|
|
3486
|
+
} else if (spec && !Array.isArray(spec.shipping_methods)) {
|
|
3487
|
+
addIssue(errors, "template_contract.shipping_methods", `${family} contract references shipping_methods but CampaignSpec has no shipping_methods array.`);
|
|
3488
|
+
}
|
|
3489
|
+
if (spec) {
|
|
3490
|
+
const demoRefHits = collectDemoRefHits(spec, catalog.sharedFrontmatterVocabulary);
|
|
3491
|
+
for (const hit of demoRefHits) {
|
|
3492
|
+
addIssue(
|
|
3493
|
+
warnings,
|
|
3494
|
+
"template_contract.demo_ref",
|
|
3495
|
+
`CampaignSpec contains a starter-looking demo ref "${hit.value}" at ${hit.path}. Confirm it came from the actual Campaigns API or attach _provenance.api/source metadata.`
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
// ADR-003 step 2: template-contract checks ported from the private doctor.
|
|
3500
|
+
const specPages = activeSpecPages(spec);
|
|
3501
|
+
|
|
3502
|
+
const mismatchedFamilies = specPages.filter(
|
|
3503
|
+
(page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
|
|
3504
|
+
);
|
|
3505
|
+
if (familyAutomatable && mismatchedFamilies.length > 0) {
|
|
3506
|
+
addIssue(
|
|
3507
|
+
errors,
|
|
3508
|
+
"template_contract.spec_family",
|
|
3509
|
+
`CampaignSpec sdk_hints.template_family disagrees with packet template family on pages: ${mismatchedFamilies.map((page) => `${page.id}=${page.sdk_hints.template_family}`).join(", ")}.`
|
|
3510
|
+
);
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
const checkoutPackageRefs = specPages
|
|
3514
|
+
.filter((page) => page.type === "checkout" || page.type === "select")
|
|
3515
|
+
.flatMap((page) => packageRefsFromEntries(page.packages));
|
|
3516
|
+
if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
|
|
3517
|
+
addIssue(
|
|
3518
|
+
errors,
|
|
3519
|
+
"template_contract.packages",
|
|
3520
|
+
`Template family "${family}" requires checkout package frontmatter, but the active CampaignSpec checkout/select pages have no package refs.`
|
|
3521
|
+
);
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
const upsellPages = specPages.filter((page) => page.type === "upsell" || page.type === "downsell");
|
|
3525
|
+
const upsellPackageRefs = upsellPages.flatMap((page) => [
|
|
3526
|
+
...packageRefsFromEntries(page.packages),
|
|
3527
|
+
...offerRefsFromEntries(page.offers),
|
|
3528
|
+
]);
|
|
3529
|
+
if (
|
|
3530
|
+
familyAutomatable &&
|
|
3531
|
+
contractMentions(contract, /\b(upsell_offer|upsell_bundle_tiers|inline upsell)\b/, ["optionalWhenSupported", "replaceFromSpecOrApi", "demoOnlyValues"]) &&
|
|
3532
|
+
upsellPages.length > 0 &&
|
|
3533
|
+
upsellPackageRefs.length === 0
|
|
3534
|
+
) {
|
|
3535
|
+
addIssue(
|
|
3536
|
+
errors,
|
|
3537
|
+
"template_contract.upsell_refs",
|
|
3538
|
+
`Template family "${family}" exposes upsell frontmatter, but active upsell/downsell pages have no package or offer refs to replace demo values.`
|
|
3539
|
+
);
|
|
3540
|
+
}
|
|
3541
|
+
validateCodeLessUpsellOfferBinding({ familyAutomatable, contract, family, upsellPages, errors, ready });
|
|
3542
|
+
validateExitPopContract(brandContract, spec, family, warnings, ready, derived, buildState);
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
function validateCodeLessUpsellOfferBinding({ familyAutomatable, contract, family, upsellPages, errors, ready }) {
|
|
3547
|
+
if (!familyAutomatable || !Array.isArray(upsellPages) || upsellPages.length === 0) return;
|
|
3548
|
+
const usesVoucherJson = contractMentions(contract, /\bvouchers_json\b/, ["replaceFromSpecOrApi", "demoOnlyValues", "optionalWhenSupported"]);
|
|
3549
|
+
const supportsOfferRef = contractMentions(contract, /\boffer_ref(?:_id)?\b/, ["replaceFromSpecOrApi", "demoOnlyValues", "optionalWhenSupported", "requiredWhenCloning"]);
|
|
3550
|
+
if (!usesVoucherJson || supportsOfferRef) return;
|
|
3551
|
+
|
|
3552
|
+
const codeLessDiscountOffers = [];
|
|
3553
|
+
for (const page of upsellPages) {
|
|
3554
|
+
for (const offer of Array.isArray(page.offers) ? page.offers : []) {
|
|
3555
|
+
if (!isCodeLessDiscountOffer(offer)) continue;
|
|
3556
|
+
codeLessDiscountOffers.push({
|
|
3557
|
+
page_id: page.id,
|
|
3558
|
+
label: page.label || null,
|
|
3559
|
+
offer_ref_id: offer.ref_id || offer.id || null,
|
|
3560
|
+
benefit_type: offer.benefit?.type || null,
|
|
3561
|
+
benefit_value: offer.benefit?.value || null,
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
if (!codeLessDiscountOffers.length) {
|
|
3567
|
+
ready.push(`${family} upsell offers have code-backed discounts or no code-less discount offer binding requirement`);
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
addIssue(
|
|
3572
|
+
errors,
|
|
3573
|
+
"template_contract.upsell_offer_binding",
|
|
3574
|
+
`Template family "${family}" exposes post-purchase upsell vouchers_json but CampaignSpec has code-less discount offer refs: ${codeLessDiscountOffers.map((offer) => `${offer.page_id}:${offer.offer_ref_id || "unknown"}`).join(", ")}. Add a supported offer-ref binding to the template/SDK adapter, use a code-backed offer, or block assembly; otherwise accepted upsells can commit at full price.`,
|
|
3575
|
+
{ offers: codeLessDiscountOffers, template_family: family }
|
|
3576
|
+
);
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
function isCodeLessDiscountOffer(offer) {
|
|
3580
|
+
if (!isObject(offer)) return false;
|
|
3581
|
+
if (isNonEmptyString(offer.code)) return false;
|
|
3582
|
+
if (offer.ref_id === undefined && offer.id === undefined) return false;
|
|
3583
|
+
const benefit = isObject(offer.benefit) ? offer.benefit : null;
|
|
3584
|
+
const benefitType = String(benefit?.type || "").toLowerCase();
|
|
3585
|
+
const value = benefit?.value;
|
|
3586
|
+
if (!benefit || !/(percentage|percent|discount|fixed|amount)/.test(benefitType)) return false;
|
|
3587
|
+
if (value === undefined || value === null) return false;
|
|
3588
|
+
return String(value).trim().length > 0 && Number.isFinite(Number(value));
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
export function validateTemplateFamilyInventory(contract, errors, ready) {
|
|
3592
|
+
const inventory = contract.family_inventory;
|
|
3593
|
+
if (!isObject(inventory)) {
|
|
3594
|
+
addIssue(errors, "template_contract.family_inventory", `Template brand contract for "${contract.family}" is missing family_inventory.`);
|
|
3595
|
+
return;
|
|
3596
|
+
}
|
|
3597
|
+
const required = [
|
|
3598
|
+
"supported_pages",
|
|
3599
|
+
"required_sdk_anchors",
|
|
3600
|
+
"theme_insertion_point",
|
|
3601
|
+
"default_color_residue",
|
|
3602
|
+
"pricing_presentation",
|
|
3603
|
+
"bundle_picker",
|
|
3604
|
+
"order_bump",
|
|
3605
|
+
"upsell_downsell",
|
|
3606
|
+
"exit_pop",
|
|
3607
|
+
"qa_selectors",
|
|
3608
|
+
];
|
|
3609
|
+
const missing = required.filter((key) => !hasPopulatedInventoryValue(inventory[key]));
|
|
3610
|
+
if (missing.length) {
|
|
3611
|
+
addIssue(errors, "template_contract.family_inventory", `Template brand contract for "${contract.family}" family_inventory is missing or empty: ${missing.join(", ")}.`);
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
ready.push(`Template family inventory matrix loaded for ${contract.family}`);
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
function hasPopulatedInventoryValue(value) {
|
|
3618
|
+
if (value === undefined || value === null) return false;
|
|
3619
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
3620
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
3621
|
+
if (isObject(value)) return Object.values(value).some((entry) => hasPopulatedInventoryValue(entry));
|
|
3622
|
+
return true;
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
export function validateExitPopContract(contract, spec, family, warnings, ready, derived, buildState = {}) {
|
|
3626
|
+
const exitPop = contract?.exit_pop;
|
|
3627
|
+
if (!exitPop || !spec) return;
|
|
3628
|
+
const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
|
|
3629
|
+
page?.type === "checkout" && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
|
|
3630
|
+
));
|
|
3631
|
+
if (hasGovernedOfferSurface) {
|
|
3632
|
+
ready.push(`${family} exit-pop/promo-code behavior is governed by CampaignSpec offer-surface fields`);
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
const inventoryExitPop = contract.family_inventory?.exit_pop;
|
|
3637
|
+
if (!isStageComplete(buildState.report, "assembly")) {
|
|
3638
|
+
if (inventoryExitPop?.default_included === true) {
|
|
3639
|
+
addIssue(
|
|
3640
|
+
warnings,
|
|
3641
|
+
"template_contract.exit_pop",
|
|
3642
|
+
`Template family "${family}" includes an exit-pop by default, but active CampaignSpec checkout pages do not define exit_intent or promo_code_input. Strip the widget or wire a mapped offer/code through the SDK coupon path during build.`
|
|
3643
|
+
);
|
|
3644
|
+
}
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
const targetOutputDir = derived.target_output_dir;
|
|
3649
|
+
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return;
|
|
3650
|
+
const residueHits = collectLiteralMatches(targetOutputDir, exitPop.residue_literals || []);
|
|
3651
|
+
if (residueHits.length) {
|
|
3652
|
+
addIssue(
|
|
3653
|
+
warnings,
|
|
3654
|
+
"template_contract.exit_pop_residue",
|
|
3655
|
+
`Assembly is recorded complete, but target output contains exit-pop/template offer residue while CampaignSpec has no checkout exit_intent or promo_code_input: ${summarizeCopyMatches(residueHits)}. Strip it or add a mapped offer surface.`
|
|
3656
|
+
);
|
|
3657
|
+
} else {
|
|
3658
|
+
ready.push(`Built target output has no ungoverned ${family} exit-pop residue`);
|
|
3659
|
+
}
|
|
3660
|
+
const blankHits = collectLiteralMatches(targetOutputDir, exitPop.blank_widget_literals || []);
|
|
3661
|
+
if (blankHits.length) {
|
|
3662
|
+
addIssue(
|
|
3663
|
+
warnings,
|
|
3664
|
+
"template_contract.exit_pop_blank_widget",
|
|
3665
|
+
`Assembly is recorded complete, but target output still contains default/blank exit-pop widget copy or coupon placeholders: ${summarizeCopyMatches(blankHits)}.`
|
|
3666
|
+
);
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
function validateBuiltContractResidue(contract, warnings, ready, derived, spec = null) {
|
|
3671
|
+
const targetOutputDir = derived.target_output_dir;
|
|
3672
|
+
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) {
|
|
3673
|
+
addIssue(warnings, "frontmatter.build_state", "Assembly is recorded complete, but doctor cannot scan the target output directory for remaining starter contract residue.");
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
const literalValues = [
|
|
3678
|
+
...(contract.frontmatter?.demoOnlyValues || []),
|
|
3679
|
+
...(contract.frontmatter?.removeWhenUnsupported || []),
|
|
3680
|
+
].filter((value) => isNonEmptyString(String(value)) && !String(value).includes("."));
|
|
3681
|
+
const hits = collectLiteralMatches(targetOutputDir, literalValues);
|
|
3682
|
+
if (!hits.length) {
|
|
3683
|
+
ready.push("Built target output has no obvious demo-only or unsupported starter contract residue");
|
|
3684
|
+
} else {
|
|
3685
|
+
addIssue(
|
|
3686
|
+
warnings,
|
|
3687
|
+
"frontmatter.build_residue",
|
|
3688
|
+
`Assembly is recorded complete, but target output still contains starter contract residue: ${summarizeCopyMatches(hits)}.`
|
|
3689
|
+
);
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
const genericHits = collectGenericTemplateResidueMatches(targetOutputDir);
|
|
3693
|
+
if (genericHits.length) {
|
|
3694
|
+
addIssue(
|
|
3695
|
+
warnings,
|
|
3696
|
+
"template_contract.literal_residue",
|
|
3697
|
+
`Assembly is recorded complete, but built output still contains generic starter/template placeholders: ${summarizeCopyMatches(genericHits)}. Replace these from CampaignSpec/API or remove dead template references before QA.`
|
|
3698
|
+
);
|
|
3699
|
+
} else {
|
|
3700
|
+
ready.push("Built target output has no generic starter placeholder or promo-code residue");
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
const maxDiscount = maxSpecDiscountPercent(spec);
|
|
3704
|
+
if (maxDiscount !== null) {
|
|
3705
|
+
const discountHits = collectOverstatedDiscountClaimMatches(targetOutputDir, maxDiscount);
|
|
3706
|
+
if (discountHits.length) {
|
|
3707
|
+
addIssue(
|
|
3708
|
+
warnings,
|
|
3709
|
+
"template_contract.discount_claim_residue",
|
|
3710
|
+
`Assembly is recorded complete, but built output claims discount percentages above the CampaignSpec maximum (${formatPercent(maxDiscount)}): ${summarizeCopyMatches(discountHits)}. Generate promo/banner/timer copy from actual offers and vouchers.`
|
|
3711
|
+
);
|
|
3712
|
+
} else {
|
|
3713
|
+
ready.push(`Built target output has no promo discount claims above CampaignSpec max (${formatPercent(maxDiscount)})`);
|
|
3714
|
+
}
|
|
3715
|
+
} else {
|
|
3716
|
+
const discountClaims = collectDiscountClaimMatches(targetOutputDir);
|
|
3717
|
+
if (discountClaims.length) {
|
|
3718
|
+
addIssue(
|
|
3719
|
+
warnings,
|
|
3720
|
+
"template_contract.discount_claim_unverified",
|
|
3721
|
+
`Assembly is recorded complete, but built output contains promo discount percentage claims without explicit CampaignSpec percentage discount values: ${summarizeCopyMatches(discountClaims)}. Confirm the intended business logic with the build request/merchant notes or remove the claims before launch.`
|
|
3722
|
+
);
|
|
3723
|
+
} else {
|
|
3724
|
+
ready.push("Built target output has no promo discount percentage claims requiring CampaignSpec verification");
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
// H3.1 (doctor surface): literal placeholder TEXT in built HTML. Word-boundary
|
|
3730
|
+
// matched off the family brand contract's placeholder_text_residue.terms, so
|
|
3731
|
+
// the doctor warning and the browser QA blocker key off one declared term set.
|
|
3732
|
+
// Scans the VISIBLE text of each page (not the includes/layouts the family
|
|
3733
|
+
// ships), the same surface the browser gate reads: an `<input
|
|
3734
|
+
// placeholder="Placeholder">` hint, a data-* hook, a comment or a script
|
|
3735
|
+
// string is not rendered copy and must not warn where QA would pass.
|
|
3736
|
+
//
|
|
3737
|
+
// `report`: once QA has recorded this gate as passed on the current build
|
|
3738
|
+
// (qaGatePassedForCurrentBuild), the browser's verdict outranks this static
|
|
3739
|
+
// approximation — any remaining static hit demotes to a ready line instead of
|
|
3740
|
+
// a warning, so `next` stops asking for a fix QA already cleared. A rebuild
|
|
3741
|
+
// changes the fingerprint and the warning returns until QA runs again.
|
|
3742
|
+
export function validateBuiltPlaceholderTextResidue(brandContract, warnings, ready, derived, { report = null } = {}) {
|
|
3743
|
+
const config = placeholderTextResidueConfig(brandContract);
|
|
3744
|
+
if (!config) return;
|
|
3745
|
+
const targetOutputDir = derived.target_output_dir;
|
|
3746
|
+
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return;
|
|
3747
|
+
const hits = collectPlaceholderTextResidueMatches(targetOutputDir, config.terms);
|
|
3748
|
+
if (!hits.length) {
|
|
3749
|
+
ready.push("Built target output has no literal template placeholder text");
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
const terms = [...new Set(hits.map((hit) => hit.label))].join(", ");
|
|
3753
|
+
if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report) })) {
|
|
3754
|
+
ready.push(`Static scan still sees placeholder-term text (${terms}: ${summarizeCopyMatches(hits)}), but the browser residue gate passed on this build; QA's rendered-text verdict stands.`);
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3757
|
+
addIssue(
|
|
3758
|
+
warnings,
|
|
3759
|
+
"template_contract.placeholder_text_residue",
|
|
3760
|
+
`Assembly is recorded complete, but built output still contains literal template placeholder text (${terms}): ${summarizeCopyMatches(hits)}. Replace with CampaignSpec/design copy; browser QA blocks on these terms.`,
|
|
3761
|
+
);
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
function collectPlaceholderTextResidueMatches(root, terms) {
|
|
3765
|
+
const matches = [];
|
|
3766
|
+
for (const file of collectHtmlFiles(root)) {
|
|
3767
|
+
if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
|
|
3768
|
+
const content = visibleText(readHtmlScanText(join(root, file.path)), { keepLines: true });
|
|
3769
|
+
for (const match of placeholderTextResidueMatches(content, terms)) {
|
|
3770
|
+
matches.push({
|
|
3771
|
+
surface: "target",
|
|
3772
|
+
path: file.path,
|
|
3773
|
+
line: lineNumberAt(content, match.index || 0),
|
|
3774
|
+
label: match.term,
|
|
3775
|
+
text: match.match,
|
|
3776
|
+
});
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
return matches;
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
// Rendered-output content-residue scan (assembly-surfaces prototype): scans
|
|
3783
|
+
// BUILT pages for the needs-merchant-input marker and urgency chrome rendered
|
|
3784
|
+
// without verified offer urgency (blockers), plus demo/placeholder residue and
|
|
3785
|
+
// generic content anti-pattern hits (warnings feeding the review/attestation
|
|
3786
|
+
// queue). Scans _site output, not frontmatter — layout/script-rendered chrome
|
|
3787
|
+
// only exists after the build.
|
|
3788
|
+
export function validateBuiltContentResidue(packet, errors, warnings, ready, derived, buildState = {}) {
|
|
3789
|
+
if (!isStageComplete(buildState.report, "assembly")) return;
|
|
3790
|
+
// Scan the RENDERED _site output, not the campaign source dir: the Liquid
|
|
3791
|
+
// guards ({% if countdown_label %}) only resolve at build time, and layout/
|
|
3792
|
+
// script-rendered chrome only exists in the built pages.
|
|
3793
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
3794
|
+
const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
|
|
3795
|
+
if (!siteRoot || !existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
|
|
3796
|
+
const brief = loadBriefPayload(derived.target_repo);
|
|
3797
|
+
const urgencyVerified = briefUrgencyVerified(brief?.payload);
|
|
3798
|
+
const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
|
|
3799
|
+
if (!findings.length) {
|
|
3800
|
+
ready.push("Built output carries no needs-input markers, unverified urgency chrome, or content-residue hits");
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3803
|
+
// One issue per finding id, carrying the FULL file inventory: the id keeps
|
|
3804
|
+
// the issue list readable (a demo term on every page is one problem, not
|
|
3805
|
+
// five), while the file list tells the operator everything to fix.
|
|
3806
|
+
const byId = new Map();
|
|
3807
|
+
for (const finding of findings) {
|
|
3808
|
+
const group = byId.get(finding.id) || { first: finding, files: new Set() };
|
|
3809
|
+
group.files.add(finding.file);
|
|
3810
|
+
byId.set(finding.id, group);
|
|
3811
|
+
}
|
|
3812
|
+
const describeFiles = (files) => {
|
|
3813
|
+
const list = [...files].sort();
|
|
3814
|
+
const shown = list.slice(0, 5).join(", ");
|
|
3815
|
+
return list.length > 5 ? `${list.length} pages: ${shown}, …` : shown;
|
|
3816
|
+
};
|
|
3817
|
+
// The AI-assembled path is "a READABLE brief payload exists" — an unreadable
|
|
3818
|
+
// one already blocks via proof_attestation.unreadable, and pointing the
|
|
3819
|
+
// operator at offer.urgency.verified inside a file that cannot be parsed
|
|
3820
|
+
// would be wrong remediation copy.
|
|
3821
|
+
const briefReadable = Boolean(brief && !brief.error && brief.payload);
|
|
3822
|
+
for (const [id, group] of byId) {
|
|
3823
|
+
const finding = group.first;
|
|
3824
|
+
const where = describeFiles(group.files);
|
|
3825
|
+
if (id === "needs_merchant_input_marker") {
|
|
3826
|
+
addIssue(
|
|
3827
|
+
errors,
|
|
3828
|
+
"content_residue.needs_merchant_input",
|
|
3829
|
+
`Built output still renders needs-merchant-input marker(s) (${where}; e.g. "${finding.excerpt}"). Collect the missing merchant input (attestation lane) before publish.`,
|
|
3830
|
+
);
|
|
3831
|
+
} else if (id === "unverified_urgency_countdown") {
|
|
3832
|
+
// The scanner emits this only when the brief does not verify urgency
|
|
3833
|
+
// (urgencyVerified=false). Severity keys on the path: with a readable
|
|
3834
|
+
// brief payload (AI-assembled) it blocks; a designed-source campaign
|
|
3835
|
+
// with no brief payload may carry an intentional countdown — warning.
|
|
3836
|
+
if (briefReadable) {
|
|
3837
|
+
addIssue(
|
|
3838
|
+
errors,
|
|
3839
|
+
"content_residue.unverified_urgency",
|
|
3840
|
+
`Built output renders countdown chrome without verified offer urgency (${where}). Set offer.urgency.verified in ${BRIEF_PAYLOAD_REL_PATH} from a real promotion window, or blank the urgency slots.`,
|
|
3841
|
+
);
|
|
3842
|
+
} else if (brief?.error) {
|
|
3843
|
+
// Unreadable brief payload: the run is already blocked by
|
|
3844
|
+
// proof_attestation.unreadable with the right remediation (repair the
|
|
3845
|
+
// brief). Urgency is re-evaluated on the next doctor run once the
|
|
3846
|
+
// brief parses — "confirm the urgency is real" copy here would point
|
|
3847
|
+
// the operator at the wrong fix.
|
|
3848
|
+
} else {
|
|
3849
|
+
addIssue(
|
|
3850
|
+
warnings,
|
|
3851
|
+
"content_residue.urgency_unattested",
|
|
3852
|
+
`Built output renders countdown chrome and no brief payload attests the promotion window (${where}). Confirm the urgency is real (a genuine offer window or live inventory) before launch.`,
|
|
3853
|
+
);
|
|
3854
|
+
}
|
|
3855
|
+
} else if (id === "demo_residue_term" || id === "bracket_placeholder_stub") {
|
|
3856
|
+
addIssue(
|
|
3857
|
+
warnings,
|
|
3858
|
+
"content_residue.demo_residue",
|
|
3859
|
+
`Built output carries template demo/placeholder residue (${where}; e.g. "${finding.excerpt}"). Fill or blank the slot — demo values must never ship.`,
|
|
3860
|
+
);
|
|
3861
|
+
} else {
|
|
3862
|
+
addIssue(
|
|
3863
|
+
warnings,
|
|
3864
|
+
"content_residue.anti_pattern",
|
|
3865
|
+
`Built output matches content anti-pattern "${id}" (${where}; e.g. "${finding.excerpt}"). ${finding.rule || "Remove it or route it through brief-sourced proof."} This is a review warning and nothing downstream blocks on it: the claim is the operator's and the client's responsibility — remove or evidence it, never make it more plausible.`,
|
|
3866
|
+
);
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3871
|
+
// Proof-attestation gate over the brief payload's proof_assets (click-wrap
|
|
3872
|
+
// lane). Usable = verified:true OR attestation_status:"accepted". Enforcement
|
|
3873
|
+
// keys on whether a non-usable asset's content actually SHIPPED in built
|
|
3874
|
+
// output: shipped pending/non-attestable content blocks (collect-inputs);
|
|
3875
|
+
// non-shipped, non-usable assets stay warnings (the attestation queue), so a
|
|
3876
|
+
// producer that correctly excluded them is not blocked.
|
|
3877
|
+
export function validateProofAttestation(packet, errors, warnings, ready, derived, buildState = {}) {
|
|
3878
|
+
const brief = loadBriefPayload(derived.target_repo);
|
|
3879
|
+
if (!brief) return; // not an AI-assembled run — no brief payload, no gate
|
|
3880
|
+
if (brief.error) {
|
|
3881
|
+
// Fail closed: the brief payload is the source of attestation state. If
|
|
3882
|
+
// it exists but cannot be read, unapproved proof could ship unevaluated.
|
|
3883
|
+
addIssue(errors, "proof_attestation.unreadable", `Brief payload at ${BRIEF_PAYLOAD_REL_PATH} failed to parse: ${brief.error}. Repair or regenerate it — the attestation gate cannot evaluate proof states.`);
|
|
3884
|
+
return;
|
|
3885
|
+
}
|
|
3886
|
+
const proofAssets = brief.payload?.proof_assets;
|
|
3887
|
+
if (!Array.isArray(proofAssets) || proofAssets.length === 0) {
|
|
3888
|
+
ready.push("Brief payload declares no proof assets (scenario-framing fallback applies)");
|
|
3889
|
+
return;
|
|
3890
|
+
}
|
|
3891
|
+
const assemblyComplete = isStageComplete(buildState.report, "assembly");
|
|
3892
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
3893
|
+
const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
|
|
3894
|
+
let renderedText = "";
|
|
3895
|
+
if (assemblyComplete && siteRoot && existsSync(siteRoot)) {
|
|
3896
|
+
renderedText = collectRenderedHtmlFiles(siteRoot)
|
|
3897
|
+
.map((file) => readFileSync(file, "utf8"))
|
|
3898
|
+
.join("\n");
|
|
3899
|
+
}
|
|
3900
|
+
const findings = evaluateProofAssets(proofAssets, renderedText);
|
|
3901
|
+
const usable = findings.filter((f) => f.state === "verified" || f.state === "accepted").length;
|
|
3902
|
+
const pending = findings.filter((f) => f.state === "pending");
|
|
3903
|
+
const nonAttestable = findings.filter((f) => f.state === "non_attestable");
|
|
3904
|
+
if (!assemblyComplete) {
|
|
3905
|
+
// Shipped-content evaluation needs built output; before assembly there is
|
|
3906
|
+
// nothing to judge and a "none shipped" warning would be both misleading
|
|
3907
|
+
// and noisy on every pre-build doctor run.
|
|
3908
|
+
ready.push(
|
|
3909
|
+
`Brief payload declares ${proofAssets.length} proof asset(s) (${usable} usable, ${pending.length} pending attestation, ${nonAttestable.length} non-attestable); shipped-content evaluation runs after assembly.`,
|
|
3910
|
+
);
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
const { shippedNonAttestable, shippedPending } = attestationBlockers(findings);
|
|
3914
|
+
for (const finding of shippedNonAttestable) {
|
|
3915
|
+
addIssue(
|
|
3916
|
+
errors,
|
|
3917
|
+
"proof_attestation.non_attestable_shipped",
|
|
3918
|
+
`Built output ships non-attestable proof asset ${finding.assetId ?? "(unidentified)"} (${finding.modality ?? "unknown modality"}). This proof class is never attestable — remove it from the content.`,
|
|
3919
|
+
);
|
|
3920
|
+
}
|
|
3921
|
+
for (const finding of shippedPending) {
|
|
3922
|
+
addIssue(
|
|
3923
|
+
errors,
|
|
3924
|
+
"proof_attestation.pending_shipped",
|
|
3925
|
+
`Built output ships proof asset ${finding.assetId ?? "(unidentified)"} (${finding.modality ?? "unknown modality"}) whose attestation is not accepted. Collect the merchant's click-wrap attestation or remove the claim.`,
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
if (!shippedNonAttestable.length && !shippedPending.length && (pending.length || nonAttestable.length)) {
|
|
3929
|
+
addIssue(
|
|
3930
|
+
warnings,
|
|
3931
|
+
"proof_attestation.queue",
|
|
3932
|
+
`Brief payload carries ${pending.length} attestation-pending and ${nonAttestable.length} non-attestable proof asset(s); none shipped in built output. Pending items are the merchant attestation checklist.`,
|
|
3933
|
+
);
|
|
3934
|
+
}
|
|
3935
|
+
if (usable) ready.push(`${usable} proof asset(s) usable (verified or attestation accepted)`);
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
// H3.2 (doctor surface): the family's own demo placeholder assets surviving
|
|
3939
|
+
// into built output. Reuses the literal-match infra over the demo-asset
|
|
3940
|
+
// basenames declared in the brand contract. Warning only — the agent re-skins.
|
|
3941
|
+
export function validateBuiltDemoAssetFidelity(brandContract, warnings, ready, derived) {
|
|
3942
|
+
const config = demoAssetConfig(brandContract);
|
|
3943
|
+
if (!config || !config.assetBasenames.length) return;
|
|
3944
|
+
const targetOutputDir = derived.target_output_dir;
|
|
3945
|
+
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return;
|
|
3946
|
+
const hits = collectLiteralMatches(targetOutputDir, config.assetBasenames);
|
|
3947
|
+
if (hits.length) {
|
|
3948
|
+
const assets = [...new Set(hits.map((hit) => hit.label))].join(", ");
|
|
3949
|
+
addIssue(
|
|
3950
|
+
warnings,
|
|
3951
|
+
"template_contract.demo_asset_residue",
|
|
3952
|
+
`Assembly is recorded complete, but built output still references template demo assets (${assets}): ${summarizeCopyMatches(hits)}. Re-skin to the campaign's real assets rather than shipping template placeholders.`,
|
|
3953
|
+
);
|
|
3954
|
+
} else {
|
|
3955
|
+
ready.push("Built target output references no template demo placeholder assets");
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
function collectLiteralMatches(root, values) {
|
|
3960
|
+
if (!values.length) return [];
|
|
3961
|
+
const escaped = values.map((value) => escapeRegExp(String(value))).join("|");
|
|
3962
|
+
const regex = new RegExp(escaped, "g");
|
|
3963
|
+
const matches = [];
|
|
3964
|
+
for (const file of collectHtmlFiles(root)) {
|
|
3965
|
+
if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
|
|
3966
|
+
const content = readHtmlScanText(join(root, file.path));
|
|
3967
|
+
for (const match of content.matchAll(regex)) {
|
|
3968
|
+
matches.push({
|
|
3969
|
+
surface: "target",
|
|
3970
|
+
path: file.path,
|
|
3971
|
+
line: lineNumberAt(content, match.index || 0),
|
|
3972
|
+
label: match[0],
|
|
3973
|
+
text: match[0],
|
|
3974
|
+
});
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
return matches;
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
const GENERIC_TEMPLATE_RESIDUE_PATTERNS = [
|
|
3981
|
+
{ id: "promo_code_placeholder", label: "XXCODE", pattern: /\bXXCODE\b/gi },
|
|
3982
|
+
{ id: "package_title_placeholder", label: "Package Title", pattern: /\bPackage Title\b/g },
|
|
3983
|
+
{ id: "product_title_placeholder", label: "Product Title", pattern: /\bProduct Title\b/g },
|
|
3984
|
+
{ id: "spec_ref_placeholder", label: "SPEC_*_REF", pattern: /\bSPEC_[A-Z0-9_]*_REF\b/g },
|
|
3985
|
+
{ id: "starter_logo_asset", label: "next-logo.png", pattern: /\bnext-logo\.(?:png|svg|webp)\b/g },
|
|
3986
|
+
];
|
|
3987
|
+
|
|
3988
|
+
function collectGenericTemplateResidueMatches(root) {
|
|
3989
|
+
return collectPatternMatches(root, GENERIC_TEMPLATE_RESIDUE_PATTERNS);
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
function collectPatternMatches(root, patterns) {
|
|
3993
|
+
if (!patterns.length) return [];
|
|
3994
|
+
const matches = [];
|
|
3995
|
+
const compiledPatterns = patterns.map((entry) => {
|
|
3996
|
+
const flags = entry.pattern.flags.includes("g") ? entry.pattern.flags : `${entry.pattern.flags}g`;
|
|
3997
|
+
return { ...entry, regex: new RegExp(entry.pattern.source, flags) };
|
|
3998
|
+
});
|
|
3999
|
+
for (const file of collectBuiltTextFiles(root)) {
|
|
4000
|
+
const content = readHtmlScanText(join(root, file.path));
|
|
4001
|
+
for (const entry of compiledPatterns) {
|
|
4002
|
+
entry.regex.lastIndex = 0;
|
|
4003
|
+
for (const match of content.matchAll(entry.regex)) {
|
|
4004
|
+
matches.push({
|
|
4005
|
+
surface: "target",
|
|
4006
|
+
path: file.path,
|
|
4007
|
+
line: lineNumberAt(content, match.index || 0),
|
|
4008
|
+
label: entry.label,
|
|
4009
|
+
text: match[0],
|
|
4010
|
+
kind: entry.id,
|
|
4011
|
+
});
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
return matches;
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
function maxSpecDiscountPercent(spec) {
|
|
4019
|
+
if (!spec || typeof spec !== "object") return null;
|
|
4020
|
+
const values = [];
|
|
4021
|
+
|
|
4022
|
+
function visit(value, key = "") {
|
|
4023
|
+
if (Array.isArray(value)) {
|
|
4024
|
+
for (const entry of value) visit(entry, key);
|
|
4025
|
+
return;
|
|
4026
|
+
}
|
|
4027
|
+
if (!value || typeof value !== "object") return;
|
|
4028
|
+
|
|
4029
|
+
if (isPercentDiscountBenefit(value, key)) {
|
|
4030
|
+
addPercentValue(values, value.value);
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
4034
|
+
if (isDiscountPercentKey(entryKey)) addPercentValue(values, entryValue);
|
|
4035
|
+
visit(entryValue, entryKey);
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
visit(spec);
|
|
4040
|
+
return values.length ? Math.max(...values) : null;
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
function isPercentDiscountBenefit(value, key) {
|
|
4044
|
+
if (!isObject(value) || !Object.hasOwn(value, "value")) return false;
|
|
4045
|
+
const type = normalizeSpecKey(value.type || "");
|
|
4046
|
+
if (!/(?:^|_)(?:percent|percentage)(?:_|$)/.test(type)) return false;
|
|
4047
|
+
const context = normalizeSpecKey(key);
|
|
4048
|
+
if (context === "benefit") return true;
|
|
4049
|
+
return /(?:^|_)(?:discount|saving|savings|save|off|offer|promo|coupon|voucher|package)(?:_|$)/.test(type);
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
function isDiscountPercentKey(key) {
|
|
4053
|
+
const normalized = normalizeSpecKey(key);
|
|
4054
|
+
const mentionsPercent = /(?:^|_)(?:percent|percentage)(?:_|$)/.test(normalized);
|
|
4055
|
+
const mentionsOffer = /(?:^|_)(?:discount|saving|savings|save|off|offer|promo|coupon|voucher)(?:_|$)/.test(normalized);
|
|
4056
|
+
return mentionsPercent && mentionsOffer;
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
function normalizeSpecKey(value) {
|
|
4060
|
+
return String(value || "")
|
|
4061
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
4062
|
+
.replace(/[^a-z0-9]+/gi, "_")
|
|
4063
|
+
.replace(/^_+|_+$/g, "")
|
|
4064
|
+
.toLowerCase();
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
function addPercentValue(values, value) {
|
|
4068
|
+
const parsed = Number.parseFloat(String(value ?? "").replace("%", "").trim());
|
|
4069
|
+
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) values.push(parsed);
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
function collectDiscountClaimMatches(root) {
|
|
4073
|
+
const claimPattern = /\b(?:save(?:\s+up\s+to)?\s+(\d{1,3}(?:\.\d+)?)\s*(?:%|\bpercent\b)|(\d{1,3}(?:\.\d+)?)\s*(?:%|\bpercent\b)\s*(?:off|discount)\b)/gi;
|
|
4074
|
+
const matches = [];
|
|
4075
|
+
for (const file of collectBuiltTextFiles(root)) {
|
|
4076
|
+
const content = readHtmlScanText(join(root, file.path));
|
|
4077
|
+
for (const match of content.matchAll(claimPattern)) {
|
|
4078
|
+
const claimed = Number.parseFloat(match[1] || match[2]);
|
|
4079
|
+
if (!Number.isFinite(claimed)) continue;
|
|
4080
|
+
matches.push({
|
|
4081
|
+
surface: "target",
|
|
4082
|
+
path: file.path,
|
|
4083
|
+
line: lineNumberAt(content, match.index || 0),
|
|
4084
|
+
label: `${formatPercent(claimed)} claim`,
|
|
4085
|
+
text: match[0],
|
|
4086
|
+
claimed_percent: claimed,
|
|
4087
|
+
});
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
return matches;
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
function collectOverstatedDiscountClaimMatches(root, maxDiscount) {
|
|
4094
|
+
return collectDiscountClaimMatches(root)
|
|
4095
|
+
.filter((match) => match.claimed_percent > maxDiscount + DISCOUNT_CLAIM_TOLERANCE)
|
|
4096
|
+
.map((match) => ({ ...match, max_spec_percent: maxDiscount }));
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
function formatPercent(value) {
|
|
4100
|
+
const rounded = Math.round(value * 100) / 100;
|
|
4101
|
+
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(2)}%`;
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
function contractMentionsShipping(contract) {
|
|
4105
|
+
return Object.values(contract.frontmatter || {})
|
|
4106
|
+
.flatMap((value) => Array.isArray(value) ? value : [])
|
|
4107
|
+
.some((value) => String(value).includes("shipping_methods") || String(value).includes("shipping_method"));
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
function validateUnsupportedShippingFrontmatter(packet, packetPath, family, warnings, ready, derived) {
|
|
4111
|
+
const hits = collectShippingFrontmatterHits(packet, packetPath, derived);
|
|
4112
|
+
if (hits.length === 0) {
|
|
4113
|
+
ready.push(`${family} contract has no explicit shipping frontmatter residue in currently available mapped source/target pages`);
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
4116
|
+
addIssue(
|
|
4117
|
+
warnings,
|
|
4118
|
+
"template_contract.shipping_unused",
|
|
4119
|
+
`${family} does not consume explicit shipping frontmatter, but mapped page frontmatter still declares ${summarizeShippingFrontmatterHits(hits)}. Remove copied shipping_methods/shipping_method values and let the family resolve shipping through its own SDK/runtime surface.`
|
|
4120
|
+
);
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
function collectShippingFrontmatterHits(packet, packetPath, derived = {}) {
|
|
4124
|
+
const hits = [];
|
|
4125
|
+
const seen = new Set();
|
|
4126
|
+
const addFile = (surface, root, relPath) => {
|
|
4127
|
+
if (!root || !relPath) return;
|
|
4128
|
+
const filePath = resolve(root, relPath);
|
|
4129
|
+
const key = `${surface}:${filePath}`;
|
|
4130
|
+
if (seen.has(key) || !existsSync(filePath) || !statSync(filePath).isFile()) return;
|
|
4131
|
+
seen.add(key);
|
|
4132
|
+
const frontmatter = extractYamlFrontmatter(readFileSync(filePath, "utf8"));
|
|
4133
|
+
if (!frontmatter) return;
|
|
4134
|
+
for (const hit of shippingFrontmatterKeys(frontmatter)) {
|
|
4135
|
+
hits.push({ surface, path: relFromDir(root, filePath), key: hit.key, line: hit.line });
|
|
4136
|
+
}
|
|
4137
|
+
};
|
|
4138
|
+
|
|
4139
|
+
const sourceRoot = derived.source_root || resolveFromFile(packetPath, packet.source_html?.root);
|
|
4140
|
+
for (const page of packet.source_html?.pages || []) addFile("source", sourceRoot, page.path);
|
|
4141
|
+
|
|
4142
|
+
const targetOutputDir = derived.target_output_dir;
|
|
4143
|
+
if (targetOutputDir && existsSync(targetOutputDir) && statSync(targetOutputDir).isDirectory()) {
|
|
4144
|
+
for (const file of collectHtmlFiles(targetOutputDir)) addFile("target", targetOutputDir, file.path);
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
return hits;
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
function extractYamlFrontmatter(content) {
|
|
4151
|
+
const match = String(content || "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
4152
|
+
return match ? match[1] : "";
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
function shippingFrontmatterKeys(frontmatter) {
|
|
4156
|
+
const hits = [];
|
|
4157
|
+
const lines = String(frontmatter || "").split(/\r?\n/);
|
|
4158
|
+
lines.forEach((line, index) => {
|
|
4159
|
+
const match = line.match(/^\s*(shipping_methods|shipping_method)\s*:/);
|
|
4160
|
+
if (match) hits.push({ key: match[1], line: index + 2 });
|
|
4161
|
+
});
|
|
4162
|
+
return hits;
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
function summarizeShippingFrontmatterHits(hits) {
|
|
4166
|
+
const limit = 4;
|
|
4167
|
+
const summary = hits.slice(0, limit).map((hit) => `${hit.surface}:${hit.path}:${hit.line} (${hit.key})`);
|
|
4168
|
+
const more = hits.length > limit ? ` and ${hits.length - limit} more` : "";
|
|
4169
|
+
return `${summary.join(", ")}${more}`;
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
export function collectDemoRefHits(spec, vocab) {
|
|
4173
|
+
const demoValues = new Set(
|
|
4174
|
+
Object.values(vocab || {})
|
|
4175
|
+
.flatMap((entry) => Array.isArray(entry.demoOnlyValues) ? entry.demoOnlyValues : [])
|
|
4176
|
+
.map((value) => String(value))
|
|
4177
|
+
);
|
|
4178
|
+
if (demoValues.size === 0) return [];
|
|
4179
|
+
// R2-B1: a ref whose value matches a real commerce entity the
|
|
4180
|
+
// spec declares (package/offer/shipping_method) is legitimate, not a
|
|
4181
|
+
// starter placeholder. The Map exporter does not stamp `_provenance.api`
|
|
4182
|
+
// down to the ref level, so provenance alone was missing it and flagging
|
|
4183
|
+
// valid low-integer API refs (e.g. "1"/"2"). Suppressing declared refs
|
|
4184
|
+
// kills that noise while still flagging refs that point at nothing the
|
|
4185
|
+
// spec defines.
|
|
4186
|
+
const declaredRefs = specDeclaredCommerceRefs(spec);
|
|
4187
|
+
const hits = new Set();
|
|
4188
|
+
const results = [];
|
|
4189
|
+
const visit = (value, key = "", path = [], provenanceStack = []) => {
|
|
4190
|
+
if (Array.isArray(value)) {
|
|
4191
|
+
value.forEach((item, index) => visit(item, key, [...path, String(index)], provenanceStack));
|
|
4192
|
+
} else if (isObject(value)) {
|
|
4193
|
+
const nextStack = [...provenanceStack, value._provenance].filter(Boolean);
|
|
4194
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
4195
|
+
if (childKey === "_provenance") continue;
|
|
4196
|
+
visit(childValue, childKey, [...path, childKey], nextStack);
|
|
4197
|
+
}
|
|
4198
|
+
} else if (["ref_id", "package_id", "package_ref_id", "shipping_method"].includes(key) && demoValues.has(String(value))) {
|
|
4199
|
+
const hitKey = `${path.join(".")}:${String(value)}`;
|
|
4200
|
+
if (!hits.has(hitKey) && !isApiSourcedProvenance(provenanceStack) && !declaredRefs.has(String(value))) {
|
|
4201
|
+
hits.add(hitKey);
|
|
4202
|
+
results.push({ value: String(value), path: path.join(".") || key });
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
};
|
|
4206
|
+
visit(spec);
|
|
4207
|
+
return results;
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
function isApiSourcedProvenance(provenanceStack) {
|
|
4211
|
+
return provenanceStack.some((provenance) => {
|
|
4212
|
+
if (!isObject(provenance)) return false;
|
|
4213
|
+
if (provenance.api === true || provenance.api_sourced === true) return true;
|
|
4214
|
+
const source = String(provenance.source || provenance.origin || "").toLowerCase();
|
|
4215
|
+
return source.includes("api") || source.includes("campaigns");
|
|
4216
|
+
});
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
function isStageComplete(report, stage) {
|
|
4220
|
+
const status = report?.stages?.[stage]?.status;
|
|
4221
|
+
return isNonEmptyString(status) && status.startsWith("completed");
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
function validateContext(context, errors, warnings, ready, derived) {
|
|
4225
|
+
if (context.schema_version !== CONTEXT_SCHEMA) addIssue(warnings, "context.schema_version", `Context schema should be ${CONTEXT_SCHEMA}.`);
|
|
4226
|
+
else ready.push(`Build context schema ${CONTEXT_SCHEMA}`);
|
|
4227
|
+
if (context.source_adapter !== "html_funnel") {
|
|
4228
|
+
addIssue(warnings, "context.source_adapter", "Only html_funnel is supported in the current release.");
|
|
4229
|
+
}
|
|
4230
|
+
if (Array.isArray(context.prompts_required) && context.prompts_required.length > 0) {
|
|
4231
|
+
for (const prompt of context.prompts_required) {
|
|
4232
|
+
addIssue(warnings, `context.prompts_required.${prompt.code || "prompt"}`, prompt.message || "Context has unresolved prompts.");
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
validateCommerceZoneFindings(context.commerce_zone_findings, warnings, ready);
|
|
4236
|
+
validateAdapterDecisionShape(context.adapter_decisions, "context.adapter_decisions", warnings, ready, { addIssue });
|
|
4237
|
+
if (context.scaffold?.required === true) {
|
|
4238
|
+
derived.scaffold_required = true;
|
|
4239
|
+
derived.scaffold_reason = context.scaffold.reason || "Build context says setup is required.";
|
|
4240
|
+
}
|
|
4241
|
+
const themeResult = validateThemeContextBlock(context.theme);
|
|
4242
|
+
for (const error of themeResult.errors) errors.push(error);
|
|
4243
|
+
for (const warning of themeResult.warnings) warnings.push(warning);
|
|
4244
|
+
ready.push(...themeResult.ready);
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
export function validateCommerceZoneFindings(findings, warnings, ready) {
|
|
4248
|
+
if (!Array.isArray(findings) || findings.length === 0) return;
|
|
4249
|
+
const shellRequired = findings.filter((finding) => finding?.requires_template_shell === true);
|
|
4250
|
+
if (shellRequired.length === 0) {
|
|
4251
|
+
ready.push("Source commerce zones inspected; no SDK-owned commerce shell placeholders declared");
|
|
4252
|
+
return;
|
|
4253
|
+
}
|
|
4254
|
+
for (const finding of shellRequired) {
|
|
4255
|
+
const zones = Array.isArray(finding.commerce_zones) && finding.commerce_zones.length
|
|
4256
|
+
? finding.commerce_zones.join(", ")
|
|
4257
|
+
: finding.zones?.filter((zone) => zone !== "sdk_owned_declared").join(", ") || "commerce zone";
|
|
4258
|
+
addIssue(
|
|
4259
|
+
warnings,
|
|
4260
|
+
"source_html.commerce_shell_required",
|
|
4261
|
+
`Source page "${finding.path}" declares SDK-owned commerce zone(s): ${zones}. During build, adopt the selected starter-template family shell for these zones; do not wrap borrowed partials in a custom checkout/upsell structure. Browser QA will verify rendered commerce structure when the family contract declares it.`
|
|
4262
|
+
);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
function validateAssemblyReportShape(report, errors, warnings, ready) {
|
|
4267
|
+
// Doctor already reports the source-package freshness finding from
|
|
4268
|
+
// derived.polish_gate, so the shape check leaves it out here and the same
|
|
4269
|
+
// finding is not listed twice in one doctor run. The standalone
|
|
4270
|
+
// `validate-assembly-report` has no gate behind it, so it keeps the check.
|
|
4271
|
+
const result = validateAssemblyReport(report, { checkSourcePackageFreshness: false });
|
|
4272
|
+
for (const error of result.errors) errors.push(error);
|
|
4273
|
+
for (const warning of result.warnings) warnings.push(warning);
|
|
4274
|
+
ready.push(...result.ready);
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
function validateAssemblyReport(report, { checkSourcePackageFreshness = true } = {}) {
|
|
4278
|
+
const errors = [];
|
|
4279
|
+
const warnings = [];
|
|
4280
|
+
const ready = [];
|
|
4281
|
+
if (!isObject(report)) {
|
|
4282
|
+
addIssue(errors, "report.type", "Assembly report must be a JSON object.");
|
|
4283
|
+
return { ok: false, status: "blocked", errors, warnings, ready };
|
|
4284
|
+
}
|
|
4285
|
+
if (report.schema_version !== REPORT_SCHEMA) addIssue(errors, "schema_version", `Expected ${REPORT_SCHEMA}.`);
|
|
4286
|
+
else ready.push(`Assembly report schema ${REPORT_SCHEMA}`);
|
|
4287
|
+
for (const path of ["run_id", "generated_at", "status", "identity.map_id", "identity.public_route_slug", "inputs.packet_path", "template_family.value"]) {
|
|
4288
|
+
if (path === "identity.map_id") {
|
|
4289
|
+
if (!resolveCampaignIdentity(report.identity)) addIssue(errors, "identity.map_id", "Assembly Report requires exactly one valid map_id or local_spec_id.");
|
|
4290
|
+
} else requireString(report, errors, path);
|
|
4291
|
+
}
|
|
4292
|
+
if (report.identity?.local_spec_id != null && !/^sha256:[0-9a-f]{64}$/.test(report.identity.spec_material_hash || "")) {
|
|
4293
|
+
addIssue(errors, "identity.spec_material_hash", "Local-spec reports require a current SHA-256 material spec hash.");
|
|
4294
|
+
}
|
|
4295
|
+
const stages = report.stages;
|
|
4296
|
+
if (!isObject(stages)) {
|
|
4297
|
+
addIssue(errors, "stages", "stages object is required.");
|
|
4298
|
+
} else {
|
|
4299
|
+
for (const stage of ASSEMBLY_REPORT_STAGE_KEYS) {
|
|
4300
|
+
if (!isObject(stages[stage])) {
|
|
4301
|
+
addIssue(errors, `stages.${stage}`, `${stage} stage is required.`);
|
|
4302
|
+
continue;
|
|
4303
|
+
}
|
|
4304
|
+
requireString(report, errors, `stages.${stage}.status`);
|
|
4305
|
+
for (const field of ["inputs", "outputs", "commands", "blockers", "warnings"]) {
|
|
4306
|
+
if (stages[stage][field] !== undefined && !Array.isArray(stages[stage][field])) {
|
|
4307
|
+
addIssue(errors, `stages.${stage}.${field}`, `stages.${stage}.${field} must be an array.`);
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
for (const field of ["decisions", "evidence", "blockers", "warnings"]) {
|
|
4313
|
+
if (!Array.isArray(report[field])) addIssue(errors, field, `${field} must be an array.`);
|
|
4314
|
+
}
|
|
4315
|
+
const themeResult = validateAssemblyReportThemeBlock(report.theme);
|
|
4316
|
+
for (const error of themeResult.errors) errors.push(error);
|
|
4317
|
+
for (const warning of themeResult.warnings) warnings.push(warning);
|
|
4318
|
+
ready.push(...themeResult.ready);
|
|
4319
|
+
validateAdapterDecisionShape(report.adapter_decisions, "report.adapter_decisions", warnings, ready, { addIssue });
|
|
4320
|
+
validateAssemblyProofPolicy(report.proof_policy, warnings, ready);
|
|
4321
|
+
if (checkSourcePackageFreshness) validateAssemblySourcePackageFreshness(report, errors);
|
|
4322
|
+
const status = errors.length ? "blocked" : warnings.length ? "ready_with_warnings" : "ready";
|
|
4323
|
+
return { ok: errors.length === 0, status, errors, warnings, ready };
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
// The two source-freshness conditions the polish gate blocks on that a
|
|
4327
|
+
// standalone report validation can answer from the report alone. Both read the
|
|
4328
|
+
// gate's own helpers, and the waiver records are scanned once for both. The
|
|
4329
|
+
// order mirrors the gate: a malformed waiver record is reported on its own and
|
|
4330
|
+
// the freshness question is not asked until it is fixed, so the validator and
|
|
4331
|
+
// the gate name the same single finding for that report.
|
|
4332
|
+
function validateAssemblySourcePackageFreshness(report, errors) {
|
|
4333
|
+
const waiverAssessment = assessAssemblySourcePackageFreshnessWaivers(report);
|
|
4334
|
+
if (waiverAssessment.invalid.length) {
|
|
4335
|
+
const invalid = waiverAssessment.invalid[0];
|
|
4336
|
+
addIssue(
|
|
4337
|
+
errors,
|
|
4338
|
+
"stages.assembly.waiver_expires_at_invalid",
|
|
4339
|
+
`Source freshness waiver has an unparseable expires_at (${JSON.stringify(invalid.expires_at)}). Record a valid ISO 8601 timestamp, or remove the malformed waiver record: the polish gate refuses to honor it either way.`,
|
|
4340
|
+
);
|
|
4341
|
+
return;
|
|
4342
|
+
}
|
|
4343
|
+
if (assemblySourcePackageFingerprintMissing(report, Date.now(), waiverAssessment)) {
|
|
4344
|
+
addIssue(
|
|
4345
|
+
errors,
|
|
4346
|
+
"stages.assembly.source_package_material_fingerprint",
|
|
4347
|
+
"Report declares a Design Source Package material fingerprint, so stages.assembly.source_package_material_fingerprint is required: without it nothing proves the build consumed the current source context. Re-run Build against the current Design Source Package, or record a source freshness waiver.",
|
|
4348
|
+
);
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
function validateAssemblyProofPolicy(policy, warnings, ready) {
|
|
4353
|
+
if (policy == null) {
|
|
4354
|
+
addIssue(warnings, "report.proof_policy", "report.proof_policy is missing. Assembly Reports should mirror qa.proof_policy so browser QA, typed-card depth, SDK origin allowlist state, order path depth, and approval state are inspectable.");
|
|
4355
|
+
return;
|
|
4356
|
+
}
|
|
4357
|
+
validateProofPolicyObject(policy, "report.proof_policy", warnings, ready);
|
|
4358
|
+
}
|
|
4359
|
+
const BRAND_CONTRACT_ERROR_CODES = new Set([
|
|
4360
|
+
"parse_error",
|
|
4361
|
+
"schema_mismatch",
|
|
4362
|
+
"extends_cycle",
|
|
4363
|
+
"extends_missing_parent",
|
|
4364
|
+
"family_mismatch",
|
|
4365
|
+
]);
|
|
4366
|
+
|
|
4367
|
+
export function safeBrandContractCode(code) {
|
|
4368
|
+
const value = optionalString(code);
|
|
4369
|
+
return value && BRAND_CONTRACT_ERROR_CODES.has(value) ? value : "unknown";
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
function requireString(object, errors, path) {
|
|
4373
|
+
if (!isNonEmptyString(getPath(object, path))) addIssue(errors, path, `${path} is required.`);
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
function requireBoolean(object, errors, path) {
|
|
4377
|
+
if (typeof getPath(object, path) !== "boolean") addIssue(errors, path, `${path} must be boolean.`);
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
function requireArray(object, errors, path) {
|
|
4381
|
+
if (!Array.isArray(getPath(object, path))) addIssue(errors, path, `${path} must be an array.`);
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
function getPath(object, path) {
|
|
4385
|
+
return path.split(".").reduce((cursor, part) => {
|
|
4386
|
+
if (!isObject(cursor) && !Array.isArray(cursor)) return undefined;
|
|
4387
|
+
return cursor[part];
|
|
4388
|
+
}, object);
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
export {
|
|
4392
|
+
PACKET_SCHEMA,
|
|
4393
|
+
CONTEXT_SCHEMA,
|
|
4394
|
+
REPORT_SCHEMA,
|
|
4395
|
+
certifiedTemplateFamilies,
|
|
4396
|
+
isCertifiedTemplateFamily,
|
|
4397
|
+
LOCAL_SERVE_DEPLOY_TARGET,
|
|
4398
|
+
activeSpecPages,
|
|
4399
|
+
collectHtmlFiles,
|
|
4400
|
+
runDoctorChecks,
|
|
4401
|
+
ARTIFACT_DOCTOR_CHECKS,
|
|
4402
|
+
validatePacket,
|
|
4403
|
+
orderPathDepthDrift,
|
|
4404
|
+
recordUpsellSelectorScopeGate,
|
|
4405
|
+
collectBuiltPageIdentityInputs,
|
|
4406
|
+
recordCampaignIdentityGate,
|
|
4407
|
+
recordScriptSyntaxGate,
|
|
4408
|
+
recordSdkMarkupGate,
|
|
4409
|
+
summarizeCopyMatches,
|
|
4410
|
+
resolveBrandContract,
|
|
4411
|
+
resolveBrandContractOnce,
|
|
4412
|
+
reportBrandContractDefectOnce,
|
|
4413
|
+
collectGenericTemplateResidueMatches,
|
|
4414
|
+
validateAssemblyReport,
|
|
4415
|
+
};
|