@nextcommerce/campaigns-os 1.60.0 → 1.62.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/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +189 -10
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/built-site-scope.mjs
CHANGED
|
@@ -96,14 +96,14 @@ function listHtmlFiles(root, links = null) {
|
|
|
96
96
|
return files.sort();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function routeForFile(campaignDir, file) {
|
|
99
|
+
export function routeForFile(campaignDir, file) {
|
|
100
100
|
const rel = relative(campaignDir, file).split(sep).join("/");
|
|
101
101
|
if (rel === "index.html") return "";
|
|
102
102
|
if (rel.endsWith("/index.html")) return rel.slice(0, -"/index.html".length);
|
|
103
103
|
return rel.replace(/\.html$/i, "");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function pageIdForRoute(route) {
|
|
106
|
+
export function pageIdForRoute(route) {
|
|
107
107
|
if (!route) return "index";
|
|
108
108
|
return route.replace(/\//g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "index";
|
|
109
109
|
}
|
|
@@ -2,21 +2,86 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Two roots matter. The TARGET REPO (`packet.assembly.target_repo` resolved
|
|
4
4
|
// against the packet's directory, else that directory) carries the build
|
|
5
|
-
// sidecars under
|
|
6
|
-
// `qa-output/`; the packet's own directory (`baseDir`) carries what is
|
|
7
|
-
// beside the packet (run records, the committed QA verdict sidecar).
|
|
8
|
-
// coincide for a packet at the target root and diverge for
|
|
9
|
-
// --out` elsewhere — exactly when a stage spelling the rule for
|
|
5
|
+
// sidecars under the funnel's runtime directory and the local QA verdicts
|
|
6
|
+
// under `qa-output/`; the packet's own directory (`baseDir`) carries what is
|
|
7
|
+
// written beside the packet (run records, the committed QA verdict sidecar).
|
|
8
|
+
// They coincide for a packet at the target root and diverge for
|
|
9
|
+
// `prepare-build --out` elsewhere — exactly when a stage spelling the rule for
|
|
10
|
+
// itself drifts.
|
|
11
|
+
//
|
|
12
|
+
// One Page Kit project can hold several funnels. Each funnel has its own
|
|
13
|
+
// packet and its own runtime directory, so setting one up never touches
|
|
14
|
+
// another's records (#719). The first funnel set up in a project keeps the
|
|
15
|
+
// project-level `.campaign-runtime/`, which is also where every packet that
|
|
16
|
+
// records no `assembly.runtime_dir` (all packets before per-funnel records)
|
|
17
|
+
// keeps its records. Every later funnel gets `.campaign-runtime/funnels/<public
|
|
18
|
+
// route slug>/`, recorded on its packet as `assembly.runtime_dir`, and its
|
|
19
|
+
// packet is named `campaign-runtime-<public route slug>.build.json` beside the
|
|
20
|
+
// default `campaign-runtime.build.json`.
|
|
10
21
|
// A leaf: node built-ins and the sidecar leaves only.
|
|
11
22
|
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
13
|
-
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
14
25
|
import { DOCTOR_SIDECAR_REL_PATH } from "./doctor-sidecar.mjs";
|
|
15
26
|
import { absentOrMalformed, sameFile } from "./fs-identity.mjs";
|
|
27
|
+
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
16
28
|
|
|
29
|
+
export const RUNTIME_DIR_REL_PATH = ".campaign-runtime";
|
|
30
|
+
export const FUNNEL_RUNTIME_PARENT_REL_PATH = ".campaign-runtime/funnels";
|
|
17
31
|
export const BUILD_CONTEXT_REL_PATH = ".campaign-runtime/build-context.json";
|
|
18
32
|
export const ASSEMBLY_REPORT_REL_PATH = ".campaign-runtime/assembly-report.json";
|
|
19
33
|
export const QA_OUTPUT_REL_PATH = "qa-output";
|
|
34
|
+
export const DEFAULT_PACKET_FILENAME = "campaign-runtime.build.json";
|
|
35
|
+
// A project's other root-level packets, one per additional funnel. The same
|
|
36
|
+
// shape readback discovers (src/readback.mjs PACKET_CANDIDATE_PATTERN).
|
|
37
|
+
export const FUNNEL_PACKET_PATTERN = /^campaign-runtime-.+\.build\.json$/;
|
|
38
|
+
|
|
39
|
+
// A route slug that can name a directory and a file: the characters the packet
|
|
40
|
+
// schema allows in route_root, not starting with a dot (so never `.` or `..`).
|
|
41
|
+
const ROUTE_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
42
|
+
|
|
43
|
+
function routeComponent(routeSlug) {
|
|
44
|
+
const slug = normalizePublicRouteSlug(routeSlug);
|
|
45
|
+
if (!ROUTE_COMPONENT.test(slug)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Public route slug ${JSON.stringify(routeSlug)} cannot name a funnel's runtime directory or packet file: `
|
|
48
|
+
+ "use letters, digits, dots, underscores and hyphens, not starting with a dot.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return slug;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `.campaign-runtime/funnels/<route>`: a funnel's runtime directory when it is
|
|
55
|
+
// not the project's first, relative to the target repo.
|
|
56
|
+
export function funnelRuntimeRelPath(routeSlug) {
|
|
57
|
+
return `${FUNNEL_RUNTIME_PARENT_REL_PATH}/${routeComponent(routeSlug)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// `campaign-runtime-<route>.build.json`: the packet of a funnel that is not
|
|
61
|
+
// the project's first.
|
|
62
|
+
export function funnelPacketFilename(routeSlug) {
|
|
63
|
+
return `campaign-runtime-${routeComponent(routeSlug)}.build.json`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The runtime directory a packet's records live under, relative to its target
|
|
67
|
+
// repo (and, for the QA verdict sidecar and Run Records, to the packet's own
|
|
68
|
+
// directory). A packet that records no `assembly.runtime_dir` uses the
|
|
69
|
+
// project-level `.campaign-runtime`, as every packet did before per-funnel
|
|
70
|
+
// records, so an existing project reads its records where they already are.
|
|
71
|
+
// A recorded value must be one of the two shapes for this packet's route:
|
|
72
|
+
// anything else could point one funnel at another's records, so it is refused
|
|
73
|
+
// rather than followed.
|
|
74
|
+
export function packetRuntimeRelPath(packet) {
|
|
75
|
+
const declared = packet?.assembly?.runtime_dir;
|
|
76
|
+
if (declared === undefined || declared === null || declared === RUNTIME_DIR_REL_PATH) return RUNTIME_DIR_REL_PATH;
|
|
77
|
+
const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
78
|
+
if (typeof declared === "string" && ROUTE_COMPONENT.test(slug) && declared === `${FUNNEL_RUNTIME_PARENT_REL_PATH}/${slug}`) return declared;
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Build Packet assembly.runtime_dir ${JSON.stringify(declared)} is not this funnel's runtime directory: `
|
|
81
|
+
+ `it must be "${RUNTIME_DIR_REL_PATH}" or "${FUNNEL_RUNTIME_PARENT_REL_PATH}/<campaign.public_route_slug>"`
|
|
82
|
+
+ `${slug ? ` ("${FUNNEL_RUNTIME_PARENT_REL_PATH}/${slug}")` : ""}. Restore the value prepare-build wrote, or re-run prepare-build.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
20
85
|
|
|
21
86
|
// The target repo a packet builds into.
|
|
22
87
|
export function targetRepoFor(packetPath, packet) {
|
|
@@ -25,12 +90,16 @@ export function targetRepoFor(packetPath, packet) {
|
|
|
25
90
|
return typeof declared === "string" && declared.trim() ? resolve(packetDir, declared) : packetDir;
|
|
26
91
|
}
|
|
27
92
|
|
|
28
|
-
// The default location of every build sidecar a
|
|
29
|
-
|
|
93
|
+
// The default location of every build sidecar a funnel carries: under the
|
|
94
|
+
// target repo's project-level `.campaign-runtime/` unless the packet records
|
|
95
|
+
// its own runtime directory (packetRuntimeRelPath).
|
|
96
|
+
export function campaignSidecarPaths(targetRepo, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
97
|
+
const runtimeDir = join(targetRepo, runtimeRelPath);
|
|
30
98
|
return {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
99
|
+
runtimeDir,
|
|
100
|
+
contextPath: join(runtimeDir, basename(BUILD_CONTEXT_REL_PATH)),
|
|
101
|
+
reportPath: join(runtimeDir, basename(ASSEMBLY_REPORT_REL_PATH)),
|
|
102
|
+
doctorOutPath: join(runtimeDir, basename(DOCTOR_SIDECAR_REL_PATH)),
|
|
34
103
|
qaOutputDir: join(targetRepo, QA_OUTPUT_REL_PATH),
|
|
35
104
|
};
|
|
36
105
|
}
|
|
@@ -44,9 +113,11 @@ export function campaignSidecarPaths(targetRepo) {
|
|
|
44
113
|
// report IS that default, so the printed command stays short; the path when
|
|
45
114
|
// it is not, or when the target repo is unknown and the default cannot be
|
|
46
115
|
// ruled out. Doctor's report and the QA runner's both print through this.
|
|
47
|
-
|
|
116
|
+
// `runtimeRelPath` is the packet's runtime directory (packetRuntimeRelPath),
|
|
117
|
+
// since that is where those commands infer the report for that packet.
|
|
118
|
+
export function explicitReportPath(reportPath, targetRepo, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
48
119
|
if (typeof reportPath !== "string" || !reportPath) return null;
|
|
49
|
-
const inferred = typeof targetRepo === "string" && targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null;
|
|
120
|
+
const inferred = typeof targetRepo === "string" && targetRepo ? campaignSidecarPaths(targetRepo, runtimeRelPath).reportPath : null;
|
|
50
121
|
return reportPath !== inferred ? reportPath : null;
|
|
51
122
|
}
|
|
52
123
|
|
|
@@ -99,7 +170,8 @@ export function resolveCampaignWorkspace(packetPath, {
|
|
|
99
170
|
const loadedPacket = packet === undefined ? JSON.parse(readFileSync(absolutePacketPath, "utf8")) : packet;
|
|
100
171
|
const baseDir = dirname(absolutePacketPath);
|
|
101
172
|
const targetRepo = targetRepoFor(absolutePacketPath, loadedPacket);
|
|
102
|
-
const
|
|
173
|
+
const runtimeRelPath = packetRuntimeRelPath(loadedPacket);
|
|
174
|
+
const defaults = campaignSidecarPaths(targetRepo, runtimeRelPath);
|
|
103
175
|
const resolvedContextPath = contextPath === undefined ? defaults.contextPath : contextPath;
|
|
104
176
|
const context = followContextPointer ? readContextForBinding(resolvedContextPath) : null;
|
|
105
177
|
const recorded = typeof context?.report_path === "string" && context.report_path.trim() ? context.report_path.trim() : null;
|
|
@@ -118,6 +190,11 @@ export function resolveCampaignWorkspace(packetPath, {
|
|
|
118
190
|
packetPath: absolutePacketPath,
|
|
119
191
|
baseDir,
|
|
120
192
|
targetRepo,
|
|
193
|
+
// The funnel's runtime directory under the target repo, and the same
|
|
194
|
+
// directory beside the packet (QA verdict sidecar, Run Records).
|
|
195
|
+
runtimeRelPath,
|
|
196
|
+
runtimeDir: defaults.runtimeDir,
|
|
197
|
+
packetRuntimeDir: join(baseDir, runtimeRelPath),
|
|
121
198
|
contextPath: resolvedContextPath,
|
|
122
199
|
reportPath: resolvedReportPath,
|
|
123
200
|
defaultReportPath: defaults.reportPath,
|
|
@@ -125,3 +202,199 @@ export function resolveCampaignWorkspace(packetPath, {
|
|
|
125
202
|
qaOutputDir: defaults.qaOutputDir,
|
|
126
203
|
};
|
|
127
204
|
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Several funnels in one project.
|
|
208
|
+
|
|
209
|
+
function readJsonState(path) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
212
|
+
return { state: "present", value: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error?.code === "ENOENT") return { state: "absent", value: null };
|
|
215
|
+
if (absentOrMalformed(error)) return { state: "present", value: null };
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function routeOf(packet) {
|
|
221
|
+
return normalizePublicRouteSlug(packet?.campaign?.public_route_slug) || null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The root-level Build Packets of a project, default name first, then one per
|
|
225
|
+
// additional funnel by name, each with the route it names (null when the
|
|
226
|
+
// packet names none), its generated_at instant in milliseconds (null when it
|
|
227
|
+
// records none it can parse) and whether it could be read at all.
|
|
228
|
+
export function projectFunnelPackets(root) {
|
|
229
|
+
const projectRoot = resolve(root);
|
|
230
|
+
let names = [];
|
|
231
|
+
try {
|
|
232
|
+
names = readdirSync(projectRoot);
|
|
233
|
+
} catch {
|
|
234
|
+
names = [];
|
|
235
|
+
}
|
|
236
|
+
const ordered = [
|
|
237
|
+
...(names.includes(DEFAULT_PACKET_FILENAME) ? [DEFAULT_PACKET_FILENAME] : []),
|
|
238
|
+
...names.filter((name) => FUNNEL_PACKET_PATTERN.test(name)).sort(),
|
|
239
|
+
];
|
|
240
|
+
const packets = [];
|
|
241
|
+
for (const name of ordered) {
|
|
242
|
+
const path = join(projectRoot, name);
|
|
243
|
+
try {
|
|
244
|
+
if (!statSync(path).isFile()) continue;
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const { value } = readJsonState(path);
|
|
249
|
+
const generated = typeof value?.generated_at === "string" ? Date.parse(value.generated_at) : Number.NaN;
|
|
250
|
+
packets.push({ name, path, route: routeOf(value), readable: Boolean(value), generatedAt: Number.isFinite(generated) ? generated : null });
|
|
251
|
+
}
|
|
252
|
+
return packets;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The packet a command acts on when it was given no --packet, chosen the way
|
|
256
|
+
// readback discovers one: null when there is none, the only readable packet,
|
|
257
|
+
// or among packets that all name one route (later runs of one funnel) the
|
|
258
|
+
// newest by generated_at, the default name first on a tie. Packets naming
|
|
259
|
+
// different routes are different funnels, and picking one would read or write
|
|
260
|
+
// another funnel's records, so that is refused with every packet named.
|
|
261
|
+
export function soleProjectPacket(root, { command = "this command" } = {}) {
|
|
262
|
+
const packets = projectFunnelPackets(root);
|
|
263
|
+
const readable = packets.filter((packet) => packet.readable);
|
|
264
|
+
if (!readable.length) return packets[0]?.path || null;
|
|
265
|
+
if (new Set(readable.map((packet) => packet.route)).size > 1) {
|
|
266
|
+
const listed = readable.map((packet) => `${packet.name} (route ${packet.route || "unnamed"})`).join(", ");
|
|
267
|
+
throw Object.assign(new Error(
|
|
268
|
+
`${resolve(root)} holds Build Packets for ${new Set(readable.map((packet) => packet.route)).size} funnels: ${listed}. `
|
|
269
|
+
+ `Pass --packet <packet> to ${command} to name the funnel to act on.`,
|
|
270
|
+
), { code: "MULTIPLE_FUNNEL_PACKETS", packets: readable.map((packet) => packet.path) });
|
|
271
|
+
}
|
|
272
|
+
const newest = readable.reduce((best, packet) => ((packet.generatedAt ?? -Infinity) > (best.generatedAt ?? -Infinity) ? packet : best));
|
|
273
|
+
return newest.path;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The route the project-level records belong to: undefined when there are
|
|
277
|
+
// none, the route the Assembly Report (else the Build Context's packet) names,
|
|
278
|
+
// or null when records exist but name no route anyone can read.
|
|
279
|
+
function projectRecordsRoute(targetRepo) {
|
|
280
|
+
const defaults = campaignSidecarPaths(targetRepo);
|
|
281
|
+
const report = readJsonState(defaults.reportPath);
|
|
282
|
+
const context = readJsonState(defaults.contextPath);
|
|
283
|
+
if (report.state === "absent" && context.state === "absent") return undefined;
|
|
284
|
+
const reported = normalizePublicRouteSlug(report.value?.identity?.public_route_slug);
|
|
285
|
+
if (reported) return reported;
|
|
286
|
+
const named = typeof context.value?.packet_path === "string" && context.value.packet_path.trim()
|
|
287
|
+
? resolve(targetRepo, context.value.packet_path.trim())
|
|
288
|
+
: null;
|
|
289
|
+
return named ? routeOf(readJsonState(named).value) : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Where prepare-build puts one funnel's packet and records.
|
|
294
|
+
*
|
|
295
|
+
* Packet: an explicit `--out` wins. Otherwise the default
|
|
296
|
+
* `campaign-runtime.build.json` when it is this route's, else the funnel's own
|
|
297
|
+
* `campaign-runtime-<route>.build.json` when it already exists, else the
|
|
298
|
+
* default when it is absent, else the funnel's own name. A Build Packet that
|
|
299
|
+
* exists at the chosen path for another route, or one whose route cannot be
|
|
300
|
+
* read, is replaced only with `force`: that is the operator saying this
|
|
301
|
+
* funnel takes the other's place (renaming a funnel's route), never a side
|
|
302
|
+
* effect of naming a path.
|
|
303
|
+
*
|
|
304
|
+
* Records: a packet already at that path for this route keeps the runtime
|
|
305
|
+
* directory it records (the project-level one when it records none and those
|
|
306
|
+
* records are this route's or absent). A packet replaced with `force` whose records are the
|
|
307
|
+
* project-level ones hands those to this funnel, so renaming a project's first
|
|
308
|
+
* funnel keeps the default layout. Otherwise the funnel's own directory when
|
|
309
|
+
* it already exists, else the project-level `.campaign-runtime` when it holds
|
|
310
|
+
* no records or this route's, else the funnel's own directory. Records another
|
|
311
|
+
* route owns, or records whose route cannot be read, are never replaced.
|
|
312
|
+
*
|
|
313
|
+
* Returns `{ packetPath, runtimeRelPath, scoped, addedBeside }`; `scoped` is
|
|
314
|
+
* true when the packet must record `assembly.runtime_dir`, and `addedBeside`
|
|
315
|
+
* names the default packet and its route when this run sets the funnel up as
|
|
316
|
+
* a new one beside it (null otherwise).
|
|
317
|
+
*/
|
|
318
|
+
export function planFunnelWorkspace(targetRepo, routeSlug, { packetPath: explicitPacketPath = null, force = false } = {}) {
|
|
319
|
+
const repo = resolve(targetRepo);
|
|
320
|
+
const slug = normalizePublicRouteSlug(routeSlug);
|
|
321
|
+
const nameable = ROUTE_COMPONENT.test(slug);
|
|
322
|
+
const rootPacket = join(repo, DEFAULT_PACKET_FILENAME);
|
|
323
|
+
const ownPacket = nameable ? join(repo, funnelPacketFilename(slug)) : null;
|
|
324
|
+
const rootPacketState = readJsonState(rootPacket);
|
|
325
|
+
const rootPacketRoute = routeOf(rootPacketState.value);
|
|
326
|
+
|
|
327
|
+
let packetPath = explicitPacketPath ? resolve(explicitPacketPath) : null;
|
|
328
|
+
if (!packetPath) {
|
|
329
|
+
const rootPacketFree = rootPacketState.state === "absent" || rootPacketRoute === slug;
|
|
330
|
+
packetPath = rootPacketRoute === slug ? rootPacket
|
|
331
|
+
: ownPacket && existsSync(ownPacket) ? ownPacket
|
|
332
|
+
: rootPacketFree || !ownPacket ? rootPacket
|
|
333
|
+
: ownPacket;
|
|
334
|
+
if (!rootPacketFree && packetPath === rootPacket && !force) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`${rootPacket} belongs to route ${rootPacketRoute || "(unreadable)"}, and route ${JSON.stringify(slug)} cannot name a packet of its own. `
|
|
337
|
+
+ "Pass --out <packet> to keep this funnel's packet elsewhere, or set a public route slug of letters, digits, dots, underscores and hyphens.",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const existingState = readJsonState(packetPath);
|
|
343
|
+
const existing = existingState.value;
|
|
344
|
+
const existingRoute = routeOf(existing);
|
|
345
|
+
const replacing = isBuildPacketAt(packetPath, existingState) && existingRoute !== slug;
|
|
346
|
+
if (replacing && !force) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`${packetPath} is the Build Packet of route ${existingRoute || "(unreadable)"}, not ${JSON.stringify(slug)}; replacing it would leave that funnel's records with no packet. `
|
|
349
|
+
+ "Pass a different --out to set this funnel up beside it, or --force to replace that funnel with this one (renaming its route).",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const addedBeside = !replacing && existingState.state === "absent" && packetPath !== rootPacket
|
|
353
|
+
&& rootPacketState.state === "present" && rootPacketRoute !== slug
|
|
354
|
+
? { packetPath: rootPacket, route: rootPacketRoute }
|
|
355
|
+
: null;
|
|
356
|
+
const plan = (runtimeRelPath) => ({ packetPath, runtimeRelPath, scoped: runtimeRelPath !== RUNTIME_DIR_REL_PATH, addedBeside });
|
|
357
|
+
|
|
358
|
+
const owner = projectRecordsRoute(repo);
|
|
359
|
+
if (existing && existingRoute === slug) {
|
|
360
|
+
if (existing.assembly?.runtime_dir != null) return plan(packetRuntimeRelPath(existing));
|
|
361
|
+
if (owner === undefined || owner === slug) return plan(RUNTIME_DIR_REL_PATH);
|
|
362
|
+
}
|
|
363
|
+
if (replacing && existing && replacedRuntimeRelPath(existing) === RUNTIME_DIR_REL_PATH
|
|
364
|
+
&& (owner === undefined || owner === null || owner === existingRoute || owner === slug)) {
|
|
365
|
+
return plan(RUNTIME_DIR_REL_PATH);
|
|
366
|
+
}
|
|
367
|
+
const ownRuntime = nameable ? funnelRuntimeRelPath(slug) : null;
|
|
368
|
+
if (ownRuntime && existsSync(join(repo, ownRuntime))) return plan(ownRuntime);
|
|
369
|
+
if (owner === undefined || owner === slug) return plan(RUNTIME_DIR_REL_PATH);
|
|
370
|
+
if (!ownRuntime) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`${join(repo, RUNTIME_DIR_REL_PATH)} holds the records of route ${owner || "(unreadable)"}, and route ${JSON.stringify(slug)} cannot name a runtime directory of its own. `
|
|
373
|
+
+ "Set a public route slug of letters, digits, dots, underscores and hyphens.",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return plan(ownRuntime);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Whether the file at `path` is a Build Packet another funnel may own: a
|
|
380
|
+
// packet by its contents, or a file at a packet's name that cannot be read.
|
|
381
|
+
// Any other file there is left to prepare-build's output collision checks.
|
|
382
|
+
function isBuildPacketAt(path, { state, value }) {
|
|
383
|
+
if (state !== "present") return false;
|
|
384
|
+
if (value) {
|
|
385
|
+
return (typeof value.schema_version === "string" && value.schema_version.startsWith("campaign-runtime-build-packet/"))
|
|
386
|
+
|| (value.campaign !== null && typeof value.campaign === "object" && !Array.isArray(value.campaign));
|
|
387
|
+
}
|
|
388
|
+
const name = basename(path);
|
|
389
|
+
return name === DEFAULT_PACKET_FILENAME || FUNNEL_PACKET_PATTERN.test(name);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// The runtime directory a packet being replaced keeps its records in, or null
|
|
393
|
+
// when its recorded value is not one prepare-build writes.
|
|
394
|
+
function replacedRuntimeRelPath(packet) {
|
|
395
|
+
try {
|
|
396
|
+
return packetRuntimeRelPath(packet);
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Change scope: what a change to built output owes.
|
|
2
|
+
//
|
|
3
|
+
// A change that touches only some pages' copy, images or styling owes QA on
|
|
4
|
+
// those pages; Polish is advisory for it. A change that touches commerce owes
|
|
5
|
+
// the full Polish and QA. Commerce here is what a page sends to the Campaign
|
|
6
|
+
// Cart SDK or the funnel: the campaign's Page Kit config entry (SDK pin,
|
|
7
|
+
// analytics ids, store fields), package, offer and shipping refs, the upsell
|
|
8
|
+
// order and routes, and scripts. A file that cannot be mapped to one page
|
|
9
|
+
// counts as commerce, and so does any change this module cannot classify.
|
|
10
|
+
//
|
|
11
|
+
// `record build` keeps a commerce signature beside the build manifest: one
|
|
12
|
+
// hash per built page over the page's commerce-bearing markup, plus one hash
|
|
13
|
+
// of the campaign's config entry. A later change compares the pages that
|
|
14
|
+
// differ against those hashes. It also keeps `build_changes`, one entry per
|
|
15
|
+
// recorded rebuild, so a stage that reviewed an older build can still read
|
|
16
|
+
// which pages changed since then after the manifest has moved on.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { parse } from "parse5";
|
|
22
|
+
|
|
23
|
+
import { inferPageType, pageIdForRoute, routeForFile } from "./built-site-scope.mjs";
|
|
24
|
+
import { loadPageKitCampaignEntry } from "./page-kit-campaign-config.mjs";
|
|
25
|
+
|
|
26
|
+
export const COMMERCE_SIGNATURE_ALGORITHM = "commerce-signature/v1";
|
|
27
|
+
export const CHANGE_KINDS = Object.freeze(["none", "page_only", "commerce_affecting", "unknown"]);
|
|
28
|
+
// The reasons a change counts as commerce, closed so readers can switch on them.
|
|
29
|
+
export const COMMERCE_REASONS = Object.freeze([
|
|
30
|
+
"unmapped_file",
|
|
31
|
+
"page_added",
|
|
32
|
+
"page_removed",
|
|
33
|
+
"checkout_page",
|
|
34
|
+
"commerce_markup_changed",
|
|
35
|
+
"commerce_signature_unavailable",
|
|
36
|
+
"campaign_config_changed",
|
|
37
|
+
"campaign_config_unavailable",
|
|
38
|
+
"change_history_unavailable",
|
|
39
|
+
]);
|
|
40
|
+
// How many rebuilds `record build` keeps in build_changes, and how many paths
|
|
41
|
+
// one entry lists per list. A stage that reviewed a build older than the
|
|
42
|
+
// kept history reads its change as unknown, and so owes the full stages.
|
|
43
|
+
export const BUILD_CHANGES_LIMIT = 25;
|
|
44
|
+
export const CHANGE_PATH_LIMIT = 20;
|
|
45
|
+
// CPK page types that take payment or render the checkout surface.
|
|
46
|
+
const CHECKOUT_PAGE_TYPES = new Set(["checkout", "checkout_step", "select"]);
|
|
47
|
+
// Attribute names that carry package, offer, shipping, upsell or checkout
|
|
48
|
+
// identity on built pages: data-next-*, the older os-checkout-* bindings and
|
|
49
|
+
// other data-* names for those refs. Inline event handlers (onclick and the
|
|
50
|
+
// like) can call the SDK, so they count too.
|
|
51
|
+
const COMMERCE_ATTRIBUTE = /^data-next-|^data-[a-z0-9-]*(?:package|offer|shipping|upsell|voucher|coupon)[a-z0-9-]*$|^data-(?:next-)?[a-z0-9-]*template-id$|^os-checkout(?:-|$)|^on[a-z]+$/;
|
|
52
|
+
// Attributes that render the checkout surface or take payment. A page that
|
|
53
|
+
// carries one is a checkout page whatever its type or route says.
|
|
54
|
+
const CHECKOUT_ATTRIBUTE = /^data-next-(?:checkout|payment|express-checkout)(?:-|$)|^os-checkout(?:-|$)/;
|
|
55
|
+
|
|
56
|
+
const sha256 = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
57
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
58
|
+
|
|
59
|
+
function textOf(node) {
|
|
60
|
+
const children = node.content ? node.content.childNodes : node.childNodes;
|
|
61
|
+
return (children || []).map((child) => (child.nodeName === "#text" ? child.value || "" : "")).join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The commerce-bearing facts of one built page, in document order: every
|
|
65
|
+
// SDK attribute and inline event handler, every next-* meta tag, every
|
|
66
|
+
// script (src, or the inline body), every link target and form action. Copy,
|
|
67
|
+
// images and styling are left out, so a copy edit keeps the signature.
|
|
68
|
+
// `checkout` is true when the page carries checkout or payment markup.
|
|
69
|
+
export function pageCommerceFacts(html) {
|
|
70
|
+
const facts = [];
|
|
71
|
+
let pageType = null;
|
|
72
|
+
let checkout = false;
|
|
73
|
+
const walk = (node) => {
|
|
74
|
+
const children = node.content ? node.content.childNodes : node.childNodes;
|
|
75
|
+
for (const child of children || []) {
|
|
76
|
+
if (child.nodeName && !child.nodeName.startsWith("#")) {
|
|
77
|
+
const tag = child.nodeName.toLowerCase();
|
|
78
|
+
const attrs = new Map((child.attrs || []).map((attr) => [attr.name.toLowerCase(), attr.value]));
|
|
79
|
+
for (const [name, value] of attrs) {
|
|
80
|
+
if (COMMERCE_ATTRIBUTE.test(name)) facts.push(`${tag}[${name}]=${value}`);
|
|
81
|
+
if (CHECKOUT_ATTRIBUTE.test(name)) checkout = true;
|
|
82
|
+
}
|
|
83
|
+
const metaName = String(attrs.get("name") || "").trim().toLowerCase();
|
|
84
|
+
if (tag === "meta" && metaName.startsWith("next-")) {
|
|
85
|
+
facts.push(`meta[${metaName}]=${attrs.get("content") ?? ""}`);
|
|
86
|
+
if (metaName === "next-page-type") pageType = String(attrs.get("content") || "").trim().toLowerCase() || null;
|
|
87
|
+
}
|
|
88
|
+
if (tag === "script") facts.push(attrs.has("src") ? `script[src]=${attrs.get("src")}` : `script=${sha256(textOf(child))}`);
|
|
89
|
+
if (tag === "a" && attrs.has("href")) facts.push(`a[href]=${attrs.get("href")}`);
|
|
90
|
+
if (tag === "form" && attrs.has("action")) facts.push(`form[action]=${attrs.get("action")}`);
|
|
91
|
+
}
|
|
92
|
+
walk(child);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk(parse(String(html)));
|
|
96
|
+
return { signature: sha256(facts.join("\n")), page_type: pageType, checkout };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The campaign's own Page Kit config entry, hashed; a missing or unreadable
|
|
100
|
+
// entry is its status, so a change from present to absent still differs.
|
|
101
|
+
export function campaignConfigFingerprint(targetRepo, publicRouteSlug) {
|
|
102
|
+
const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
|
|
103
|
+
if (load.status !== "ok") return `unavailable:${load.status}`;
|
|
104
|
+
const canonical = (value) => Array.isArray(value) ? value.map(canonical)
|
|
105
|
+
: isObject(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
|
|
106
|
+
return sha256(JSON.stringify(canonical(load.entry)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The signature `record build` keeps on stages.assembly.commerce_signature:
|
|
111
|
+
* `{ algorithm, campaign_config, pages: { <built path>: sha256 } }` for the
|
|
112
|
+
* pages of a resolved built-site scope (resolveBuiltSiteScope). Null when the
|
|
113
|
+
* scope did not resolve; a page that cannot be read is left out, so a later
|
|
114
|
+
* change to it reads as unclassifiable.
|
|
115
|
+
*/
|
|
116
|
+
export function buildCommerceSignature({ scope, outputRoot, targetRepo, publicRouteSlug }) {
|
|
117
|
+
if (!scope?.ok) return null;
|
|
118
|
+
const pages = {};
|
|
119
|
+
for (const page of scope.pages) {
|
|
120
|
+
const path = builtPath(outputRoot, page.built_path);
|
|
121
|
+
try {
|
|
122
|
+
pages[path] = pageCommerceFacts(readFileSync(page.built_path, "utf8")).signature;
|
|
123
|
+
} catch { /* left out: an unreadable page cannot be classified later */ }
|
|
124
|
+
}
|
|
125
|
+
return { algorithm: COMMERCE_SIGNATURE_ALGORITHM, campaign_config: campaignConfigFingerprint(targetRepo, publicRouteSlug), pages };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function builtPath(outputRoot, file) {
|
|
129
|
+
const relativePath = file.startsWith(outputRoot) ? file.slice(outputRoot.length).replace(/^[\\/]+/, "") : file;
|
|
130
|
+
return relativePath.split(/[\\/]/).join("/");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function recordedSignature(assembly) {
|
|
134
|
+
const signature = assembly?.commerce_signature;
|
|
135
|
+
return isObject(signature) && signature.algorithm === COMMERCE_SIGNATURE_ALGORITHM && isObject(signature.pages)
|
|
136
|
+
&& typeof signature.campaign_config === "string" ? signature : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const capped = (items) => items.slice(0, CHANGE_PATH_LIMIT);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Classify the drift between a recorded build and the output on disk.
|
|
143
|
+
* `changes` is assessEvidenceDebt's known change list (changed_pages,
|
|
144
|
+
* unmapped_files, extra, missing). `baseline` is the recorded
|
|
145
|
+
* stages.assembly. Returns `{ kind, reasons }`, `kind` one of CHANGE_KINDS
|
|
146
|
+
* and `reasons` the commerce reasons found, each `{ code, path? }`.
|
|
147
|
+
*/
|
|
148
|
+
export function classifyDrift({ changes, baseline, outputRoot, targetRepo, publicRouteSlug }) {
|
|
149
|
+
if (changes?.status !== "known") return { kind: "unknown", reasons: [] };
|
|
150
|
+
if (!changes.changed_pages.length && !changes.unmapped_files.length) return { kind: "none", reasons: [] };
|
|
151
|
+
const reasons = [];
|
|
152
|
+
for (const path of changes.unmapped_files) reasons.push({ code: "unmapped_file", path });
|
|
153
|
+
const signature = recordedSignature(baseline);
|
|
154
|
+
if (!signature) reasons.push({ code: "commerce_signature_unavailable" });
|
|
155
|
+
else if (signature.campaign_config.startsWith("unavailable:")
|
|
156
|
+
|| campaignConfigFingerprint(targetRepo, publicRouteSlug) !== signature.campaign_config) {
|
|
157
|
+
reasons.push({ code: signature.campaign_config.startsWith("unavailable:") ? "campaign_config_unavailable" : "campaign_config_changed" });
|
|
158
|
+
}
|
|
159
|
+
for (const page of changes.changed_pages) {
|
|
160
|
+
if (changes.extra.includes(page.built_path)) { reasons.push({ code: "page_added", path: page.built_path }); continue; }
|
|
161
|
+
if (changes.missing.includes(page.built_path)) { reasons.push({ code: "page_removed", path: page.built_path }); continue; }
|
|
162
|
+
let current;
|
|
163
|
+
try {
|
|
164
|
+
current = pageCommerceFacts(readFileSync(join(outputRoot, page.built_path), "utf8"));
|
|
165
|
+
} catch {
|
|
166
|
+
reasons.push({ code: "commerce_signature_unavailable", path: page.built_path });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const type = current.page_type || inferPageType(routeForFile(outputRoot, join(outputRoot, page.built_path)));
|
|
170
|
+
if (CHECKOUT_PAGE_TYPES.has(type) || current.checkout) reasons.push({ code: "checkout_page", path: page.built_path });
|
|
171
|
+
else if (!signature || !Object.hasOwn(signature.pages, page.built_path)) {
|
|
172
|
+
if (signature) reasons.push({ code: "commerce_signature_unavailable", path: page.built_path });
|
|
173
|
+
} else if (signature.pages[page.built_path] !== current.signature) {
|
|
174
|
+
reasons.push({ code: "commerce_markup_changed", path: page.built_path });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { kind: reasons.length ? "commerce_affecting" : "page_only", reasons: capped(reasons) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One build_changes entry for a rebuild from `from` to `to`, from the debt
|
|
182
|
+
* assessment of the new output against the previous record.
|
|
183
|
+
*/
|
|
184
|
+
export function buildChangeEntry({ from, to, at, changes, classification }) {
|
|
185
|
+
return {
|
|
186
|
+
from_build_fingerprint: from,
|
|
187
|
+
build_fingerprint: to,
|
|
188
|
+
recorded_at: at,
|
|
189
|
+
kind: classification.kind,
|
|
190
|
+
changed_pages: changes.status === "known" ? changes.changed_pages.map((page) => ({ page_id: page.page_id, built_path: page.built_path })) : [],
|
|
191
|
+
unmapped_files: changes.status === "known" ? capped(changes.unmapped_files) : [],
|
|
192
|
+
unmapped_file_count: changes.status === "known" ? changes.unmapped_files.length : 0,
|
|
193
|
+
reasons: classification.reasons,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validEntry(entry) {
|
|
198
|
+
return isObject(entry) && typeof entry.from_build_fingerprint === "string" && typeof entry.build_fingerprint === "string"
|
|
199
|
+
&& CHANGE_KINDS.includes(entry.kind) && Array.isArray(entry.changed_pages) && Array.isArray(entry.reasons);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The kept rebuild history after recording `entry`: the previous history
|
|
204
|
+
* with this rebuild appended, trimmed to the entries a stage anchored at one
|
|
205
|
+
* of `anchors` can still walk, and to BUILD_CHANGES_LIMIT.
|
|
206
|
+
*/
|
|
207
|
+
export function nextBuildChanges(previous, entry, anchors = []) {
|
|
208
|
+
const history = [...(Array.isArray(previous) ? previous.filter(validEntry) : []), entry];
|
|
209
|
+
const wanted = new Set(anchors.filter((anchor) => typeof anchor === "string" && anchor));
|
|
210
|
+
const kept = [];
|
|
211
|
+
let at = entry.build_fingerprint;
|
|
212
|
+
for (let index = history.length - 1; index >= 0 && wanted.size; index -= 1) {
|
|
213
|
+
if (history[index].build_fingerprint !== at) break;
|
|
214
|
+
kept.unshift(history[index]);
|
|
215
|
+
at = history[index].from_build_fingerprint;
|
|
216
|
+
wanted.delete(at);
|
|
217
|
+
}
|
|
218
|
+
return kept.slice(-BUILD_CHANGES_LIMIT);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const KIND_RANK = Object.freeze({ none: 0, page_only: 1, commerce_affecting: 2, unknown: 3 });
|
|
222
|
+
// The worse of two change kinds; an unrecognized kind reads as unknown.
|
|
223
|
+
const rankOf = (kind) => KIND_RANK[kind] ?? KIND_RANK.unknown;
|
|
224
|
+
export function worseChangeKind(a, b) {
|
|
225
|
+
const kind = rankOf(a) >= rankOf(b) ? a : b;
|
|
226
|
+
return Object.hasOwn(KIND_RANK, kind) ? kind : "unknown";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The change a stage that reviewed `anchor` owes for: the recorded rebuilds
|
|
231
|
+
* from `anchor` to the recorded build, then the drift from the recorded build
|
|
232
|
+
* to the output on disk. Returns `{ kind, since_build_fingerprint,
|
|
233
|
+
* changed_pages, unmapped_files, reasons }`; `kind` is unknown when the
|
|
234
|
+
* history does not reach `anchor` or the drift cannot be read.
|
|
235
|
+
*/
|
|
236
|
+
export function changeSince(anchor, { assembly, drift, driftClassification }) {
|
|
237
|
+
const recorded = typeof assembly?.build_fingerprint === "string" ? assembly.build_fingerprint : null;
|
|
238
|
+
const result = { kind: "none", since_build_fingerprint: anchor || null, changed_pages: [], unmapped_files: [], reasons: [] };
|
|
239
|
+
if (!anchor || !recorded) return { ...result, kind: "unknown" };
|
|
240
|
+
const segments = [];
|
|
241
|
+
if (anchor !== recorded) {
|
|
242
|
+
const history = Array.isArray(assembly.build_changes) ? assembly.build_changes.filter(validEntry) : [];
|
|
243
|
+
let at = recorded;
|
|
244
|
+
for (let index = history.length - 1; index >= 0 && at !== anchor; index -= 1) {
|
|
245
|
+
if (history[index].build_fingerprint !== at) break;
|
|
246
|
+
segments.unshift(history[index]);
|
|
247
|
+
at = history[index].from_build_fingerprint;
|
|
248
|
+
}
|
|
249
|
+
if (at !== anchor) return { ...result, kind: "unknown", reasons: [{ code: "change_history_unavailable" }] };
|
|
250
|
+
}
|
|
251
|
+
const pages = new Map();
|
|
252
|
+
const unmapped = new Set();
|
|
253
|
+
let kind = "none";
|
|
254
|
+
const add = (part, partKind, reasons) => {
|
|
255
|
+
kind = worseChangeKind(kind, partKind);
|
|
256
|
+
for (const page of part.changed_pages || []) pages.set(page.built_path, { page_id: page.page_id, built_path: page.built_path });
|
|
257
|
+
for (const path of part.unmapped_files || []) unmapped.add(path);
|
|
258
|
+
result.reasons.push(...(reasons || []));
|
|
259
|
+
};
|
|
260
|
+
for (const segment of segments) add(segment, segment.kind, segment.reasons);
|
|
261
|
+
if (drift?.status !== "known") kind = "unknown";
|
|
262
|
+
else add(drift, driftClassification?.kind || "unknown", driftClassification?.reasons);
|
|
263
|
+
return {
|
|
264
|
+
...result,
|
|
265
|
+
kind,
|
|
266
|
+
changed_pages: [...pages.values()].sort((a, b) => a.built_path.localeCompare(b.built_path)),
|
|
267
|
+
unmapped_files: capped([...unmapped].sort()),
|
|
268
|
+
reasons: capped(result.reasons),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The page id a QA topology page has in the change lists: the id of its
|
|
273
|
+
// route under the campaign base, as built-site scope derives it.
|
|
274
|
+
export function routePageIdForUrl(url, baseUrl) {
|
|
275
|
+
try {
|
|
276
|
+
const base = new URL(baseUrl).pathname.replace(/\/+$/, "");
|
|
277
|
+
const path = new URL(url).pathname;
|
|
278
|
+
const route = (path.startsWith(`${base}/`) || path === base ? path.slice(base.length) : path)
|
|
279
|
+
.replace(/^\/+/, "").replace(/(?:^|\/)index\.html$/, "").replace(/\.html$/i, "").replace(/\/+$/, "");
|
|
280
|
+
return pageIdForRoute(route);
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|