@nextcommerce/campaigns-os 1.50.0 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
package/src/doctor/checks.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Doctor checks: the check registries, validatePacket and the validators they run.
|
|
2
2
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
3
|
import { withHtmlScanSnapshot, readHtmlScanText } from "../html-scan.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
6
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
6
7
|
import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
|
|
7
8
|
import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthsDisagree } from "../proof-policy.mjs";
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
|
|
45
46
|
LOCAL_PROOF_NEVER_EDIT_RULE,
|
|
46
47
|
LOCAL_PROOF_PARITY_COMMAND,
|
|
48
|
+
LOCAL_PROOF_RECORD_BUILD_COMMAND,
|
|
47
49
|
LOCAL_PROOF_PARITY_FIELD,
|
|
48
50
|
LOCAL_PROOF_PARITY_SCOPE,
|
|
49
51
|
recordedBuildEnvironment,
|
|
@@ -84,8 +86,12 @@ import {
|
|
|
84
86
|
import { CAMPAIGN_IDENTITY, evaluateCampaignIdentity, externalScriptSources } from "../campaign-identity.mjs";
|
|
85
87
|
import { SDK_MARKUP, evaluateSdkMarkup } from "../sdk-markup.mjs";
|
|
86
88
|
import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSyntax } from "../built-script-syntax.mjs";
|
|
89
|
+
import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
|
|
90
|
+
import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
|
|
91
|
+
import { recordQcResults } from "../qc-results.mjs";
|
|
87
92
|
import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
|
|
88
93
|
import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
|
|
94
|
+
import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
|
|
89
95
|
import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
90
96
|
import {
|
|
91
97
|
assemblySourcePackageFingerprintMissing,
|
|
@@ -104,6 +110,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
|
|
|
104
110
|
// so a fresh install (including the git-ref consumer) always has dist.
|
|
105
111
|
import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
106
112
|
import { cmd, asInvocation } from "../install-invocation.mjs";
|
|
113
|
+
import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
|
|
107
114
|
import {
|
|
108
115
|
isObject,
|
|
109
116
|
isNonEmptyString,
|
|
@@ -428,7 +435,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
428
435
|
{
|
|
429
436
|
id: CAMPAIGN_IDENTITY,
|
|
430
437
|
phase: "built-output",
|
|
431
|
-
run: ({ packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived),
|
|
438
|
+
run: ({ spec, packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived, spec),
|
|
432
439
|
},
|
|
433
440
|
{
|
|
434
441
|
id: SDK_MARKUP,
|
|
@@ -440,6 +447,16 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
440
447
|
phase: "built-output",
|
|
441
448
|
run: ({ packet, errors, warnings, ready, derived }) => validateBuiltScriptSyntax(packet, errors, warnings, ready, derived),
|
|
442
449
|
},
|
|
450
|
+
{
|
|
451
|
+
id: CART_PLACEHOLDERS,
|
|
452
|
+
phase: "built-output",
|
|
453
|
+
run: ({ packet, warnings, ready, derived }) => validateCartPlaceholders(packet, warnings, ready, derived),
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: SMOKE_QC,
|
|
457
|
+
phase: "built-output",
|
|
458
|
+
run: ({ packet, warnings, ready, derived, buildState }) => validateSmokeQc(packet, warnings, ready, derived, buildState),
|
|
459
|
+
},
|
|
443
460
|
{
|
|
444
461
|
id: "built_output.sdk_meta_tags",
|
|
445
462
|
phase: "built-output",
|
|
@@ -496,7 +513,10 @@ const PACKET_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
496
513
|
{
|
|
497
514
|
id: "build_brief.artifact",
|
|
498
515
|
phase: "brief",
|
|
499
|
-
run: ({ packet, packetPath, spec, context, errors, warnings, ready }) =>
|
|
516
|
+
run: ({ packet, packetPath, spec, context, report, errors, warnings, ready, derived }) => {
|
|
517
|
+
validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready);
|
|
518
|
+
validateInputCurrency({ packet, packetPath, spec, report, warnings, derived });
|
|
519
|
+
},
|
|
500
520
|
},
|
|
501
521
|
], { registryId: "packet.always" });
|
|
502
522
|
|
|
@@ -716,14 +736,22 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
716
736
|
}
|
|
717
737
|
buildState.specStatus = specStatus;
|
|
718
738
|
if (specStatus === "ok") {
|
|
719
|
-
const
|
|
720
|
-
if (
|
|
721
|
-
|
|
722
|
-
&& !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
|
|
723
|
-
const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
|
|
724
|
-
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" });
|
|
739
|
+
const identityKind = specIdentityMismatch(spec, packet.spec);
|
|
740
|
+
if (identityKind) {
|
|
741
|
+
addIssue(errors, identityKind === "local_spec" ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: identityKind });
|
|
725
742
|
}
|
|
726
743
|
ready.push("Local CampaignSpec parsed");
|
|
744
|
+
// The spec no longer has the material hash the Assembly Report binds:
|
|
745
|
+
// a local spec, or the copy a saved-Map or gateway packet fetched.
|
|
746
|
+
// Doctor (and next, which prints doctor's warnings) name it at the
|
|
747
|
+
// first read.
|
|
748
|
+
const boundMaterialHash = buildState.report?.identity?.spec_material_hash;
|
|
749
|
+
if (isNonEmptyString(boundMaterialHash)) {
|
|
750
|
+
const currentMaterialHash = specMaterialHash(spec);
|
|
751
|
+
if (!specHashesMatch(boundMaterialHash, currentMaterialHash)) {
|
|
752
|
+
addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { spec_path: localSpecPath, bound_material_hash: boundMaterialHash, current_material_hash: currentMaterialHash });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
727
755
|
runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
|
|
728
756
|
} else {
|
|
729
757
|
runDoctorChecks(
|
|
@@ -737,6 +765,9 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
737
765
|
if (synthesizedBuiltSite) {
|
|
738
766
|
ready.push("Packet source/proof checks skipped for synthesized built-site packet");
|
|
739
767
|
} else {
|
|
768
|
+
// Computed once, before the checks that read it (the QA gate pass the
|
|
769
|
+
// built-output residue scan defers to) and the input warnings below.
|
|
770
|
+
derived.input_currency = deriveInputCurrency({ packet, packetPath, report: buildState.report, spec: isObject(spec) ? spec : null });
|
|
740
771
|
runDoctorChecks(PACKET_DOCTOR_CHECKS, { packet, packetPath, spec, context: buildState.context, report: buildState.report, errors, warnings, ready, derived, buildState });
|
|
741
772
|
}
|
|
742
773
|
|
|
@@ -756,6 +787,25 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
756
787
|
// stage progression.
|
|
757
788
|
}
|
|
758
789
|
|
|
790
|
+
// A stage's spec stamp, as record build, record polish and the QA write store it.
|
|
791
|
+
const SPEC_STAMP = /^sha256:[0-9a-f]{64}$/;
|
|
792
|
+
const SPEC_MATERIAL_STALE_MESSAGE = "CampaignSpec material changed since it was bound; run `record spec` (keeps history). `prepare-build --force` resets every stage and archives their records.";
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Whether a parsed CampaignSpec names another campaign than `bound` (the
|
|
796
|
+
* packet's spec block, or an Assembly Report identity): null when they
|
|
797
|
+
* agree, otherwise the kind of identity that differs ("local_spec" when
|
|
798
|
+
* either side carries a local spec id, else "saved_map").
|
|
799
|
+
*/
|
|
800
|
+
export function specIdentityMismatch(spec, bound) {
|
|
801
|
+
const specMapId = spec?.spec_identity?.map_id || spec?.map_id;
|
|
802
|
+
const localIdentity = spec?.spec_identity?.local_spec_id != null || bound?.local_spec_id != null;
|
|
803
|
+
if ((specMapId && specMapId !== bound?.map_id) || (localIdentity && !campaignIdentitiesMatch(campaignSpecIdentity(spec), bound))) {
|
|
804
|
+
return localIdentity ? "local_spec" : "saved_map";
|
|
805
|
+
}
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
759
809
|
function validateCampaignSpecRuleRegistry(spec, errors, warnings) {
|
|
760
810
|
// ADR-003: run the shared campaign-spec rule registry — the single public
|
|
761
811
|
// source of CampaignSpec validation. specOnlyRules is the right preset here:
|
|
@@ -851,8 +901,18 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
|
|
|
851
901
|
return;
|
|
852
902
|
}
|
|
853
903
|
|
|
854
|
-
|
|
855
|
-
|
|
904
|
+
let brief;
|
|
905
|
+
try {
|
|
906
|
+
brief = readJson(resolvedPath);
|
|
907
|
+
} catch {
|
|
908
|
+
addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not valid JSON: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (!isObject(brief)) {
|
|
912
|
+
addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not a valid brief object: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const result = validateCampaignBuildBriefArtifact(brief, { spec, normalizedPath });
|
|
856
916
|
for (const issue of result.errors) errors.push(issue);
|
|
857
917
|
for (const issue of result.warnings) warnings.push(issue);
|
|
858
918
|
ready.push(...result.ready);
|
|
@@ -862,6 +922,86 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
|
|
|
862
922
|
}
|
|
863
923
|
}
|
|
864
924
|
|
|
925
|
+
// derived.input_currency, computed once per doctor read, and the warnings it
|
|
926
|
+
// gives: a stage owed on the brief or on the CampaignSpec, a completed stage
|
|
927
|
+
// with no valid brief or spec stamp, or read against a brief or CampaignSpec
|
|
928
|
+
// that cannot be read, a brief file edited (or unreadable) since it was saved,
|
|
929
|
+
// and the build replay after an input change (owed, or kept by the
|
|
930
|
+
// operator's recorded decision). None is an error; `next` routes to the owed
|
|
931
|
+
// stage itself.
|
|
932
|
+
function validateInputCurrency({ packet, packetPath, spec, report, warnings, derived }) {
|
|
933
|
+
const currency = derived.input_currency || deriveInputCurrency({ packet, packetPath, report, spec: isObject(spec) ? spec : null });
|
|
934
|
+
derived.input_currency = currency;
|
|
935
|
+
if (!isObject(report?.stages)) return;
|
|
936
|
+
// A stage stamped with other spec content than the file holds now, while
|
|
937
|
+
// the report identity already names the file's content (an identity edited
|
|
938
|
+
// by hand): the same warning the identity comparison gives.
|
|
939
|
+
const owedOnSpec = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "spec_material_changed");
|
|
940
|
+
if (owedOnSpec.length && !warnings.some((issue) => issue.code === "spec.material_stale")) {
|
|
941
|
+
addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { stages: owedOnSpec, bound_material_hash: currency.spec.bound, current_material_hash: currency.spec.current });
|
|
942
|
+
}
|
|
943
|
+
const specUnstamped = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown" && !SPEC_STAMP.test(String(report.stages[key]?.source_spec_material_hash ?? "")));
|
|
944
|
+
if (specUnstamped.length) {
|
|
945
|
+
addIssue(warnings, "spec.binding_unknown", `Recorded ${specUnstamped.join(", ")} ${specUnstamped.length === 1 ? "does" : "do"} not say which CampaignSpec content ${specUnstamped.length === 1 ? "it was" : "they were"} made against, so ${specUnstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${specUnstamped.join(", ")} again (${specUnstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: specUnstamped });
|
|
946
|
+
}
|
|
947
|
+
const owedOnBrief = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "brief_material_changed");
|
|
948
|
+
if (owedOnBrief.length) {
|
|
949
|
+
addIssue(warnings, "build_brief.material_changed", `The Campaign Build Brief's content changed since ${owedOnBrief.join(", ")} ${owedOnBrief.length === 1 ? "was" : "were"} recorded, so ${owedOnBrief.length === 1 ? "it is" : "they are"} owed again. Save the brief with ${cmd("record")} brief --packet <packet> (it keeps stage history), then record ${owedOnBrief.join(", ")} again.`, { stages: owedOnBrief });
|
|
950
|
+
}
|
|
951
|
+
const unstamped = Object.keys(currency.stages).filter((key) => {
|
|
952
|
+
const stage = report.stages[key];
|
|
953
|
+
if (currency.reasons[key] !== "input_binding_unknown") return false;
|
|
954
|
+
const stamp = stage?.source_brief_material;
|
|
955
|
+
return !wellFormedBriefMaterial(stamp);
|
|
956
|
+
});
|
|
957
|
+
if (unstamped.length) {
|
|
958
|
+
addIssue(warnings, "build_brief.binding_unknown", `Recorded ${unstamped.join(", ")} ${unstamped.length === 1 ? "does" : "do"} not say which Campaign Build Brief content ${unstamped.length === 1 ? "it was" : "they were"} made against, so ${unstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${unstamped.join(", ")} again (${unstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: unstamped });
|
|
959
|
+
}
|
|
960
|
+
// A completed stage with a well-formed stamp also reads unknown when the
|
|
961
|
+
// current brief or CampaignSpec cannot be read: there is nothing to compare
|
|
962
|
+
// that stamp with. The warning names the file to restore; a stage whose
|
|
963
|
+
// stamp is missing or malformed is named by the stamp warning above only.
|
|
964
|
+
const unknownStages = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown");
|
|
965
|
+
const unconfirmed = (stages) => `recorded ${stages.join(", ")} ${stages.length === 1 ? "reads" : "read"} unconfirmed, not current`;
|
|
966
|
+
const briefUnreadable = unknownStages.filter((key) => !unstamped.includes(key));
|
|
967
|
+
if (briefUnreadable.length && currency.brief.current === null) {
|
|
968
|
+
const normalizedPath = optionalString(packet?.build_brief?.normalized_path);
|
|
969
|
+
addIssue(warnings, "build_brief.binding_unknown", `${normalizedPath ? `The normalized Campaign Build Brief ${normalizedPath} cannot be read as a brief object` : "The Build Packet names no build_brief.normalized_path, so the current Campaign Build Brief cannot be read"}, so ${unconfirmed(briefUnreadable)}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`, { stages: briefUnreadable, ...(normalizedPath ? { normalized_path: normalizedPath } : {}) });
|
|
970
|
+
}
|
|
971
|
+
const specPath = optionalString(packet?.spec?.local_path);
|
|
972
|
+
const specUnreadable = unknownStages.filter((key) => !specUnstamped.includes(key));
|
|
973
|
+
if (specUnreadable.length && currency.spec.current === null && specPath) {
|
|
974
|
+
addIssue(warnings, "spec.binding_unknown", `The CampaignSpec ${specPath} cannot be read as a JSON object, so ${unconfirmed(specUnreadable)}. Restore it, then run doctor again.`, { stages: specUnreadable, spec_path: specPath });
|
|
975
|
+
}
|
|
976
|
+
const inputPath = optionalString(packet?.build_brief?.input_path);
|
|
977
|
+
const savedSha = optionalString(report.build_brief?.input_sha256);
|
|
978
|
+
const briefFile = inputPath ? resolveFromFile(packetPath, inputPath) : null;
|
|
979
|
+
if (briefFile && savedSha && existsSync(briefFile)) {
|
|
980
|
+
// Read only a readable regular file: a pipe would block, a directory throws.
|
|
981
|
+
const unusable = briefFileUnusable(briefFile);
|
|
982
|
+
let bytes = null;
|
|
983
|
+
if (!unusable) {
|
|
984
|
+
try {
|
|
985
|
+
bytes = `sha256:${createHash("sha256").update(readFileSync(briefFile)).digest("hex")}`;
|
|
986
|
+
} catch {
|
|
987
|
+
bytes = null;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (bytes === null) {
|
|
991
|
+
addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} ${unusable || "cannot be read"}, so doctor cannot tell whether it changed since it was saved; the build reads the saved brief. Restore it, or save a readable brief file with ${cmd("record")} brief --packet <packet> --brief <file>.`, { input_path: inputPath });
|
|
992
|
+
} else if (bytes !== savedSha) {
|
|
993
|
+
addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} changed since it was saved. Save it with ${cmd("record")} brief --packet <packet>; until then the build reads the saved brief.`, { input_path: inputPath });
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (currency.reasons.assembly === "output_unchanged_after_input_change") {
|
|
997
|
+
addIssue(warnings, "assembly.output_unchanged_after_input_change", `The build output is unchanged since the brief or CampaignSpec change was recorded, so the change has not reached the pages and build is owed again. Rebuild, or, only on the operator's explicit decision that the change needs no output change, record build with --deviation-reason "<the operator's reason>".`);
|
|
998
|
+
}
|
|
999
|
+
const keptReason = report.stages.assembly?.unchanged_output_reason;
|
|
1000
|
+
if (currency.stages.assembly === "current" && isObject(keptReason) && typeof keptReason.text === "string") {
|
|
1001
|
+
addIssue(warnings, "assembly.output_unchanged_by_operator_decision", `The build output was kept unchanged after an input change by the operator's recorded decision: "${keptReason.text}".`, { reason: keptReason.text });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
865
1005
|
function validateProofPolicyObject(policy, location, warnings, ready, { requireBrowserQa = false } = {}) {
|
|
866
1006
|
if (!isObject(policy)) {
|
|
867
1007
|
addIssue(warnings, location, `${location} must be an object when present.`);
|
|
@@ -1129,7 +1269,7 @@ export function isLocalhostDevelopmentOrigin(value) {
|
|
|
1129
1269
|
// Both facts are read from the assembly stage's free-form evidence.
|
|
1130
1270
|
function validateLocalProof(packet, report, errors, warnings, ready) {
|
|
1131
1271
|
if (!stageIsTerminal(report?.stages?.assembly?.status)) {
|
|
1132
|
-
ready.push(`Local proof mode: the build stage renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}; polish capture and QA run against that served output, and ${asInvocation(LOCAL_PROOF_PARITY_COMMAND)} proves the production render before commit.`);
|
|
1272
|
+
ready.push(`Local proof mode: the build stage renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}; polish capture and QA run against that served output, and ${asInvocation(LOCAL_PROOF_PARITY_COMMAND)} proves the production render before commit.`);
|
|
1133
1273
|
return;
|
|
1134
1274
|
}
|
|
1135
1275
|
const environment = recordedBuildEnvironment(report);
|
|
@@ -1137,8 +1277,8 @@ function validateLocalProof(packet, report, errors, warnings, ready) {
|
|
|
1137
1277
|
ready.push(`Local proof mode: the built _site/ is recorded as a ${LOCAL_PROOF_BUILD_ENVIRONMENT} render (${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}); vendor loaders are environment-gated out, SDK dl_* events still fire.`);
|
|
1138
1278
|
} else {
|
|
1139
1279
|
addIssue(warnings, LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE, environment
|
|
1140
|
-
? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}" under deploy.target local-serve. A production build served over plain HTTP fails polish capture unwaivably on its protocol-relative vendor loaders (//host/...). Rebuild with ${LOCAL_PROOF_BUILD_COMMAND}, record
|
|
1141
|
-
: `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded under deploy.target local-serve. The build stage renders the development environment for local proof (${LOCAL_PROOF_BUILD_COMMAND}) and records it
|
|
1280
|
+
? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}" under deploy.target local-serve. A production build served over plain HTTP fails polish capture unwaivably on its protocol-relative vendor loaders (//host/...). Rebuild with ${LOCAL_PROOF_BUILD_COMMAND}, record it with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}, and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`
|
|
1281
|
+
: `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded under deploy.target local-serve. The build stage renders the development environment for local proof (${LOCAL_PROOF_BUILD_COMMAND}) and records it with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}; without the record doctor cannot tell a development render from a production build that will fail polish capture over plain HTTP. ${LOCAL_PROOF_NEVER_EDIT_RULE}`);
|
|
1142
1282
|
}
|
|
1143
1283
|
const parity = recordedProductionParity(report);
|
|
1144
1284
|
const parityCommand = asInvocation(LOCAL_PROOF_PARITY_COMMAND);
|
|
@@ -1983,17 +2123,30 @@ function recordUpsellSelectorScopeGate({ subject, pages, waivers, errors, warnin
|
|
|
1983
2123
|
// carries another funnel's key or tag arrives in a later edit round as often
|
|
1984
2124
|
// as at first assembly. Enumerates from the filesystem so both doctor paths
|
|
1985
2125
|
// scan the same pages, and stays blocking regardless of stage status.
|
|
1986
|
-
|
|
2126
|
+
//
|
|
2127
|
+
// With a CampaignSpec, each built page is marked by whether some active spec
|
|
2128
|
+
// page builds to it (the same path route drift claims), so a finding on a
|
|
2129
|
+
// file no spec page produces names it as leftover output.
|
|
2130
|
+
export function validateCampaignIdentity(packet, errors, ready, derived, spec = null) {
|
|
1987
2131
|
const targetRepo = derived.target_repo;
|
|
1988
2132
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1989
2133
|
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
1990
2134
|
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
2135
|
+
const pages = scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [];
|
|
2136
|
+
const specPages = isObject(spec) ? activeSpecPages(spec) : [];
|
|
2137
|
+
if (specPages.length) {
|
|
2138
|
+
const claimed = new Set(specPages
|
|
2139
|
+
.map((page) => builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived))
|
|
2140
|
+
.filter(Boolean)
|
|
2141
|
+
.map((path) => resolve(path)));
|
|
2142
|
+
for (const page of pages) page.spec_page = claimed.has(resolve(targetRepo, page.file));
|
|
2143
|
+
}
|
|
1991
2144
|
recordCampaignIdentityGate({
|
|
1992
2145
|
subject: {
|
|
1993
2146
|
public_route_slug: publicRouteSlug || null,
|
|
1994
2147
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
1995
2148
|
},
|
|
1996
|
-
pages
|
|
2149
|
+
pages,
|
|
1997
2150
|
errors,
|
|
1998
2151
|
ready,
|
|
1999
2152
|
derived,
|
|
@@ -2009,7 +2162,24 @@ function validateCampaignIdentity(packet, errors, ready, derived) {
|
|
|
2009
2162
|
// `/<slug>/config.js`), then the campaign directory (a root-served campaign
|
|
2010
2163
|
// emits `/config.js`); relative srcs resolve against the page. Remote and
|
|
2011
2164
|
// missing scripts contribute nothing.
|
|
2012
|
-
|
|
2165
|
+
//
|
|
2166
|
+
// The bounded form ({ pages, bounds }) takes pages the caller already read
|
|
2167
|
+
// ({file, content?}, as collectCartPlaceholderPages gives them) and adds each
|
|
2168
|
+
// page's `scripts` as the smoke check's anchor script hint reads them
|
|
2169
|
+
// (boundedPageScripts), listed from the page's bounded parse5 tree
|
|
2170
|
+
// (pageScriptSources: any attribute quoting, spacing or case; never a
|
|
2171
|
+
// non-JavaScript type or a script in <template> or <noscript>). A page with
|
|
2172
|
+
// no content, or one that cannot be parsed, is returned as given. The smoke
|
|
2173
|
+
// check itself hands boundedPageScripts the srcs its own parse lists, so a
|
|
2174
|
+
// page is parsed once.
|
|
2175
|
+
function collectBuiltPageIdentityInputs(scope, targetRepo, { pages = null, bounds = null } = {}) {
|
|
2176
|
+
if (pages && bounds) {
|
|
2177
|
+
const scriptsOf = boundedPageScripts(scope.site_root, targetRepo, bounds);
|
|
2178
|
+
return pages.map((page) => {
|
|
2179
|
+
const sources = typeof page?.content === "string" ? boundedScriptSources(page.content) : null;
|
|
2180
|
+
return sources ? { ...page, scripts: scriptsOf(sources, join(targetRepo, page.file)) } : page;
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2013
2183
|
const scriptCache = new Map();
|
|
2014
2184
|
const readScript = (path) => {
|
|
2015
2185
|
if (!scriptCache.has(path)) {
|
|
@@ -2023,23 +2193,11 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
|
|
|
2023
2193
|
}
|
|
2024
2194
|
return scriptCache.get(path);
|
|
2025
2195
|
};
|
|
2026
|
-
const resolveLocalScript = (src, builtPath) => {
|
|
2027
|
-
const raw = String(src || "").trim();
|
|
2028
|
-
if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
|
|
2029
|
-
const clean = raw.replace(/[?#].*$/, "");
|
|
2030
|
-
if (!clean) return null;
|
|
2031
|
-
if (clean.startsWith("/")) {
|
|
2032
|
-
const rel = clean.replace(/^\/+/, "");
|
|
2033
|
-
const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
|
|
2034
|
-
return candidates.find((candidate) => existsSync(candidate)) || null;
|
|
2035
|
-
}
|
|
2036
|
-
return resolve(dirname(builtPath), clean);
|
|
2037
|
-
};
|
|
2038
2196
|
return scope.pages.map((page) => {
|
|
2039
2197
|
const content = readFileSync(page.built_path, "utf8");
|
|
2040
2198
|
const scripts = [];
|
|
2041
2199
|
for (const src of externalScriptSources(content)) {
|
|
2042
|
-
const path =
|
|
2200
|
+
const path = builtLocalScriptPath(scope, src, page.built_path);
|
|
2043
2201
|
const scriptContent = path ? readScript(path) : null;
|
|
2044
2202
|
if (scriptContent == null) continue;
|
|
2045
2203
|
scripts.push({ src, file: relFromDir(targetRepo, path), content: scriptContent });
|
|
@@ -2054,6 +2212,84 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
|
|
|
2054
2212
|
});
|
|
2055
2213
|
}
|
|
2056
2214
|
|
|
2215
|
+
// Where a page's local `<script src>` lives, or null for a remote, data: or
|
|
2216
|
+
// empty src. An absolute src is the first of its site-root and campaign
|
|
2217
|
+
// candidates that exists, otherwise null.
|
|
2218
|
+
function builtLocalScriptPath(scope, src, builtPath) {
|
|
2219
|
+
const raw = String(src || "").trim();
|
|
2220
|
+
if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
|
|
2221
|
+
const clean = raw.replace(/[?#].*$/, "");
|
|
2222
|
+
if (!clean) return null;
|
|
2223
|
+
if (clean.startsWith("/")) {
|
|
2224
|
+
const rel = clean.replace(/^\/+/, "");
|
|
2225
|
+
const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
|
|
2226
|
+
return candidates.find((candidate) => existsSync(candidate)) || null;
|
|
2227
|
+
}
|
|
2228
|
+
return resolve(dirname(builtPath), clean);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// The srcs of the scripts a page loads, or null when it cannot be parsed.
|
|
2232
|
+
function boundedScriptSources(content) {
|
|
2233
|
+
try {
|
|
2234
|
+
return pageScriptSources(parseBuiltPage(content));
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
if (!isBuiltPageReadFailure(error)) throw error;
|
|
2237
|
+
return null;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// The smoke check's script reader: given the srcs a page loads and the
|
|
2242
|
+
// page's file, every local script, read or not. The first `bounds.scripts`
|
|
2243
|
+
// are read when their real path lies inside the site root and they hold at
|
|
2244
|
+
// most `bounds.script_bytes` bytes ({src, file, content}); any other reads
|
|
2245
|
+
// {src, file, unread} with `missing`, `outside_site`, `too_large`,
|
|
2246
|
+
// `unreadable` or `script_cap`, or {src, unread: "unmappable"} when its src
|
|
2247
|
+
// names no file. Each src maps to its file through builtFileOf: the URL
|
|
2248
|
+
// parser resolves it against the page's URL under the site root (a
|
|
2249
|
+
// root-relative src starts at the site root, never the campaign directory),
|
|
2250
|
+
// and its path segments are percent-decoded. Each script file is read at most
|
|
2251
|
+
// once per run.
|
|
2252
|
+
function boundedPageScripts(siteRoot, targetRepo, bounds) {
|
|
2253
|
+
const realSiteRoot = realPathOf(siteRoot);
|
|
2254
|
+
const cache = new Map();
|
|
2255
|
+
const read = (path) => {
|
|
2256
|
+
if (!cache.has(path)) cache.set(path, readBoundedScript(realSiteRoot, path, bounds.script_bytes));
|
|
2257
|
+
return cache.get(path);
|
|
2258
|
+
};
|
|
2259
|
+
return (sources, builtPath) => {
|
|
2260
|
+
const scripts = [];
|
|
2261
|
+
for (const src of sources) {
|
|
2262
|
+
const mapped = builtFileOf(src, builtPath, siteRoot);
|
|
2263
|
+
if (!mapped) continue;
|
|
2264
|
+
if (mapped.unmappable) {
|
|
2265
|
+
scripts.push({ src, unread: "unmappable" });
|
|
2266
|
+
continue;
|
|
2267
|
+
}
|
|
2268
|
+
const path = mapped.path;
|
|
2269
|
+
const file = relFromDir(targetRepo, path);
|
|
2270
|
+
scripts.push(scripts.length < bounds.scripts ? { src, file, ...read(path) } : { src, file, unread: "script_cap" });
|
|
2271
|
+
}
|
|
2272
|
+
return scripts;
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// { content } or { unread }. A file-system read failure reads unread; any
|
|
2277
|
+
// other error is a defect and throws.
|
|
2278
|
+
function readBoundedScript(realSiteRoot, path, maxBytes) {
|
|
2279
|
+
try {
|
|
2280
|
+
const real = realpathSync(path);
|
|
2281
|
+
if (!realSiteRoot || !insideRoot(realSiteRoot, real)) return { unread: "outside_site" };
|
|
2282
|
+
const stat = statSync(real);
|
|
2283
|
+
if (!stat.isFile()) return { unread: "unreadable" };
|
|
2284
|
+
if (stat.size > maxBytes) return { unread: "too_large" };
|
|
2285
|
+
const bytes = readFileSync(real);
|
|
2286
|
+
return bytes.length > maxBytes ? { unread: "too_large" } : { content: bytes.toString("utf8") };
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
if (!isFileReadFailure(error)) throw error;
|
|
2289
|
+
return { unread: error.code === "ENOENT" || error.code === "ENOTDIR" ? "missing" : "unreadable" };
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2057
2293
|
function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
|
|
2058
2294
|
const gate = evaluateCampaignIdentity({ subject, pages });
|
|
2059
2295
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
@@ -2143,6 +2379,148 @@ function recordScriptSyntaxGate({ subject, inputs, errors, warnings, ready, deri
|
|
|
2143
2379
|
return gate;
|
|
2144
2380
|
}
|
|
2145
2381
|
|
|
2382
|
+
// Raw cart placeholders. Every doctor invocation, both entry
|
|
2383
|
+
// points, filesystem enumeration, like the gates above; but a QC check, not a
|
|
2384
|
+
// checkpoint gate: its results land in derived.qc_results (warning and review
|
|
2385
|
+
// also in warnings[]), never in errors[] or derived.checkpoint_gates, so no
|
|
2386
|
+
// blocker elsewhere can withhold them and checkpoint waive never sees them.
|
|
2387
|
+
function validateCartPlaceholders(packet, warnings, ready, derived) {
|
|
2388
|
+
const targetRepo = derived.target_repo;
|
|
2389
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2390
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2391
|
+
recordCartPlaceholders({
|
|
2392
|
+
subject: {
|
|
2393
|
+
public_route_slug: publicRouteSlug || null,
|
|
2394
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2395
|
+
},
|
|
2396
|
+
pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
|
|
2397
|
+
warnings,
|
|
2398
|
+
ready,
|
|
2399
|
+
derived,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// Every built page under the campaign directory, in path order, including
|
|
2404
|
+
// every symbolic link that may hold a page (an .html link, a directory link,
|
|
2405
|
+
// or a link whose target cannot be inspected; each one entry, read as
|
|
2406
|
+
// unreadable, never followed). Pages within the page cap carry their HTML,
|
|
2407
|
+
// read one page at a time, unless they are over the size cap or cannot be
|
|
2408
|
+
// read; pages past it carry only their path.
|
|
2409
|
+
function collectCartPlaceholderPages(targetRepo, slug) {
|
|
2410
|
+
const scope = resolveBuiltSiteScope(targetRepo, { slug, includeLinkedPages: true });
|
|
2411
|
+
const linked = new Set((scope.linked_pages || []).map((page) => page.built_path));
|
|
2412
|
+
const all = [...(scope.ok ? scope.pages : []), ...(scope.linked_pages || [])]
|
|
2413
|
+
.sort((a, b) => (a.built_path < b.built_path ? -1 : a.built_path > b.built_path ? 1 : 0));
|
|
2414
|
+
const fileOf = (page) => builtPageFile(targetRepo, page.built_path);
|
|
2415
|
+
return [
|
|
2416
|
+
...all.slice(0, CART_PLACEHOLDERS_LIMITS.pages).map((page) => readCartPlaceholderPage(page.built_path, fileOf(page), linked.has(page.built_path))),
|
|
2417
|
+
...all.slice(CART_PLACEHOLDERS_LIMITS.pages).map((page) => ({ file: fileOf(page) })),
|
|
2418
|
+
];
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// One page within the page cap: its HTML, its size when over the size cap, or
|
|
2422
|
+
// unreadable. A file-system read failure, before or after the readability
|
|
2423
|
+
// precheck (EIO, EACCES, EISDIR ...), reads it unreadable; any other error is
|
|
2424
|
+
// a defect and throws.
|
|
2425
|
+
function readCartPlaceholderPage(path, file, linked) {
|
|
2426
|
+
if (linked) return { file, unreadable: true };
|
|
2427
|
+
try {
|
|
2428
|
+
const bytes = statSync(path).size;
|
|
2429
|
+
if (!isReadableFile(path)) return { file, unreadable: true };
|
|
2430
|
+
if (bytes > CART_PLACEHOLDERS_LIMITS.page_bytes) return { file, bytes };
|
|
2431
|
+
return { file, content: readFileSync(path, "utf8") };
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
if (!isFileReadFailure(error)) throw error;
|
|
2434
|
+
return { file, unreadable: true };
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// A built page's path relative to the doctor target, "/"-separated and with no
|
|
2439
|
+
// "./" prefix ("_site/<slug>/index.html"). The directory is resolved, the file
|
|
2440
|
+
// name never is, so a symlinked page keeps its own name.
|
|
2441
|
+
function builtPageFile(targetRepo, path) {
|
|
2442
|
+
const dir = relFromDir(targetRepo, dirname(path)).replace(/^\.(?:\/|$)/, "");
|
|
2443
|
+
return [...(dir ? dir.split(sep) : []), basename(path)].join("/");
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// Whether the page can be opened for reading. Only a file-system read failure
|
|
2447
|
+
// says it cannot; any other error is a defect and throws.
|
|
2448
|
+
function isReadableFile(path) {
|
|
2449
|
+
try {
|
|
2450
|
+
accessSync(path, fsConstants.R_OK);
|
|
2451
|
+
return true;
|
|
2452
|
+
} catch (error) {
|
|
2453
|
+
if (!isFileReadFailure(error)) throw error;
|
|
2454
|
+
return false;
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function recordCartPlaceholders({ subject, pages, warnings, ready, derived }) {
|
|
2459
|
+
const results = evaluateCartPlaceholders({ subject, pages });
|
|
2460
|
+
recordQcResults({ derived, warnings, results });
|
|
2461
|
+
if (!pages.length) {
|
|
2462
|
+
ready.push("Cart placeholder check not applicable: no built page to scan yet.");
|
|
2463
|
+
return results;
|
|
2464
|
+
}
|
|
2465
|
+
// A ready line only when no page went unexercised.
|
|
2466
|
+
if (results.some((row) => row.result === "unexercised")) return results;
|
|
2467
|
+
const passed = results.filter((row) => row.result === "pass").length;
|
|
2468
|
+
const flagged = results.filter((row) => row.result === "warning" || row.result === "review").length;
|
|
2469
|
+
ready.push(`Cart placeholder check on ${pages.length} built page(s): ${passed} page(s) pass, ${flagged} warning or review result(s)`);
|
|
2470
|
+
return results;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// Built-output smoke checks. Same placement and the same QC wiring as the
|
|
2474
|
+
// cart placeholder check above, over the same built pages. The build
|
|
2475
|
+
// environment is the one the Assembly Report records (nothing measures it),
|
|
2476
|
+
// and the deploy URLs give the base an absolute og:image maps from.
|
|
2477
|
+
function validateSmokeQc(packet, warnings, ready, derived, buildState = {}) {
|
|
2478
|
+
const targetRepo = derived.target_repo;
|
|
2479
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2480
|
+
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2481
|
+
recordSmokeQc({
|
|
2482
|
+
subject: {
|
|
2483
|
+
public_route_slug: publicRouteSlug || null,
|
|
2484
|
+
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2485
|
+
},
|
|
2486
|
+
targetRepo,
|
|
2487
|
+
pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
|
|
2488
|
+
environment: recordedBuildEnvironment(buildState?.report),
|
|
2489
|
+
deployBase: [packet?.deploy?.preview_url, packet?.deploy?.production_url],
|
|
2490
|
+
warnings,
|
|
2491
|
+
ready,
|
|
2492
|
+
derived,
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, warnings, ready, derived }) {
|
|
2497
|
+
// The site root is the scope's, whichever form of target doctor was given
|
|
2498
|
+
// (the repo, its _site/ or a campaign directory). The anchor script hint
|
|
2499
|
+
// reads each page's local scripts through boundedPageScripts, from the srcs
|
|
2500
|
+
// the gate's own parse lists. With no scope to resolve them in, a page's
|
|
2501
|
+
// local scripts read unread.
|
|
2502
|
+
const scope = targetRepo && pages.length ? resolveBuiltSiteScope(targetRepo, { slug: subject?.public_route_slug || null }) : null;
|
|
2503
|
+
const siteRoot = scope?.site_root || null;
|
|
2504
|
+
const results = evaluateSmokeQc({
|
|
2505
|
+
pages,
|
|
2506
|
+
environment,
|
|
2507
|
+
siteRoot,
|
|
2508
|
+
targetDir: targetRepo || null,
|
|
2509
|
+
readScripts: siteRoot ? boundedPageScripts(siteRoot, targetRepo, SMOKE_QC_LIMITS) : null,
|
|
2510
|
+
deployBase,
|
|
2511
|
+
});
|
|
2512
|
+
recordQcResults({ derived, warnings, results });
|
|
2513
|
+
if (!pages.length) {
|
|
2514
|
+
ready.push("Smoke checks not applicable: no built page to scan yet.");
|
|
2515
|
+
return results;
|
|
2516
|
+
}
|
|
2517
|
+
// A ready line only when no result went unexercised (contract 1.0).
|
|
2518
|
+
if (results.some((row) => row.result === "unexercised")) return results;
|
|
2519
|
+
const count = (...values) => results.filter((row) => values.includes(row.result)).length;
|
|
2520
|
+
ready.push(`Smoke checks on ${pages.length} built page(s): ${count("pass")} pass, ${count("warning", "review")} warning or review result(s)`);
|
|
2521
|
+
return results;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2146
2524
|
function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
|
|
2147
2525
|
const gate = evaluateSdkMarkup({ subject, pages });
|
|
2148
2526
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
@@ -2565,7 +2943,7 @@ function pageKitAssetPathViolation(reference, publicRouteSlug) {
|
|
|
2565
2943
|
};
|
|
2566
2944
|
}
|
|
2567
2945
|
|
|
2568
|
-
function resolveBuiltAssetPath(src, builtPath, targetRepo) {
|
|
2946
|
+
export function resolveBuiltAssetPath(src, builtPath, targetRepo) {
|
|
2569
2947
|
if (!isNonEmptyString(src)) return null;
|
|
2570
2948
|
const raw = src.trim();
|
|
2571
2949
|
if (raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
|
|
@@ -3090,7 +3468,7 @@ function coverageErrorMessage(page, { figmaGate = false } = {}) {
|
|
|
3090
3468
|
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="ai-generated"${fileUrlHint} — re-run the producing agent so the source HTML and source-html manifest land in the source root, then rerun prepare-build. ${sourceManifestHint(page, figmaGate)} See docs/entry-points.md for the AI-generated entry point contract.`;
|
|
3091
3469
|
}
|
|
3092
3470
|
if (!fileUrl) {
|
|
3093
|
-
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source is set but file_url is missing — add file_url to the spec before requesting a build
|
|
3471
|
+
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source is set but file_url is missing — add file_url to the spec before requesting a build, or, for hand-written or template HTML, remove design_source from the page and map it in the source-html manifest. ${sourceManifestHint(page, figmaGate)}`;
|
|
3094
3472
|
}
|
|
3095
3473
|
return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="${designSource.type}" at ${fileUrl}; produce the source HTML for this page (or update design_source.type to a recognized producer — see docs/entry-points.md) before rerunning prepare-build. ${sourceManifestHint(page, figmaGate)}`;
|
|
3096
3474
|
}
|
|
@@ -3329,6 +3707,7 @@ function validateSourcePreparation(packet, packetPath, errors, warnings, ready,
|
|
|
3329
3707
|
sourceRoot,
|
|
3330
3708
|
pages,
|
|
3331
3709
|
wrapperPolicy: packet.source_html?.adapter_contract?.wrapper_policy,
|
|
3710
|
+
targetRoot: resolveFromFile(packetPath, packet.assembly?.target_repo),
|
|
3332
3711
|
});
|
|
3333
3712
|
derived.source_preparation = {
|
|
3334
3713
|
checked_page_count: result.checked_page_count,
|
|
@@ -3993,7 +4372,7 @@ export function validateBuiltPlaceholderTextResidue(brandContract, warnings, rea
|
|
|
3993
4372
|
return;
|
|
3994
4373
|
}
|
|
3995
4374
|
const terms = [...new Set(hits.map((hit) => hit.label))].join(", ");
|
|
3996
|
-
if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report) })) {
|
|
4375
|
+
if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report), qaCurrency: derived.input_currency?.stages?.qa ?? "unknown" })) {
|
|
3997
4376
|
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.`);
|
|
3998
4377
|
return;
|
|
3999
4378
|
}
|
|
@@ -4636,6 +5015,9 @@ export {
|
|
|
4636
5015
|
recordCampaignIdentityGate,
|
|
4637
5016
|
recordScriptSyntaxGate,
|
|
4638
5017
|
recordSdkMarkupGate,
|
|
5018
|
+
recordCartPlaceholders,
|
|
5019
|
+
collectCartPlaceholderPages,
|
|
5020
|
+
recordSmokeQc,
|
|
4639
5021
|
summarizeCopyMatches,
|
|
4640
5022
|
resolveBrandContract,
|
|
4641
5023
|
resolveBrandContractOnce,
|