@nextcommerce/campaigns-os 1.61.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 +118 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- 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/commerce-surface-catalog.json +59 -53
- 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 +999 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +29 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- 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 +62 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- 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/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- 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 +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- 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-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- 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 +58 -13
- package/src/target-lock.mjs +11 -1
|
@@ -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
|
+
}
|