@nextcommerce/campaigns-os 1.63.0 → 1.66.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 +62 -0
- package/campaign-spec/dist/types.d.ts +1 -1
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +129 -1
- package/contracts/release-ledger.json +549 -0
- package/contracts/supported-surface.json +3 -3
- package/docs/build-packet.md +31 -5
- package/docs/campaigns-os-build-flow.md +38 -0
- package/docs/campaignspec-authoring-examples.md +3 -2
- package/docs/effects.md +8 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +44 -10
- package/docs/readback.md +8 -1
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
- package/skills.json +11 -11
- package/src/change-scope.mjs +25 -6
- package/src/cli.mjs +120 -7
- package/src/commercial-journey.mjs +3 -0
- package/src/commercial-parity.mjs +157 -7
- package/src/doctor/checks.mjs +28 -3
- package/src/doctor/ci.mjs +36 -1
- package/src/evidence-debt.mjs +9 -1
- package/src/input-currency.mjs +4 -2
- package/src/input-refresh.mjs +1 -1
- package/src/invocation.mjs +3 -2
- package/src/orchestration-stage-contract.mjs +3 -1
- package/src/polish-gate.mjs +10 -0
- package/src/qa-browser.mjs +46 -0
- package/src/qa-commercial-parity.mjs +173 -18
- package/src/qa-node.mjs +10 -5
- package/src/qa-verdict.mjs +3 -1
- package/src/readback.mjs +1 -0
- package/src/runtime-state-ignore.mjs +44 -19
- package/src/stage-ledger.mjs +35 -9
- package/src/stage-record.mjs +93 -14
package/skills.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "campaigns-os-skills",
|
|
3
3
|
"description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
|
|
4
|
-
"bundle_revision": "1.
|
|
4
|
+
"bundle_revision": "1.66.0+skills.1",
|
|
5
5
|
"homepage": "https://github.com/NextCommerceCo/campaigns-os",
|
|
6
6
|
"retired_skills": [
|
|
7
7
|
{
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"id": "next-campaigns-os",
|
|
18
18
|
"name": "Campaigns OS Lifecycle",
|
|
19
|
-
"version": "1.0.
|
|
19
|
+
"version": "1.0.72",
|
|
20
20
|
"path": "skills/next-campaigns-os/SKILL.md",
|
|
21
21
|
"domain": "campaigns",
|
|
22
22
|
"description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
{
|
|
25
25
|
"id": "next-campaigns-os-setup",
|
|
26
26
|
"name": "Campaigns OS Setup",
|
|
27
|
-
"version": "2.0.
|
|
27
|
+
"version": "2.0.55",
|
|
28
28
|
"path": "skills/next-campaigns-os-setup/SKILL.md",
|
|
29
29
|
"domain": "campaigns",
|
|
30
30
|
"description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
{
|
|
33
33
|
"id": "next-campaigns-build",
|
|
34
34
|
"name": "Campaign Build",
|
|
35
|
-
"version": "1.0.
|
|
35
|
+
"version": "1.0.57",
|
|
36
36
|
"path": "skills/next-campaigns-build/SKILL.md",
|
|
37
37
|
"domain": "campaigns",
|
|
38
38
|
"description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
{
|
|
41
41
|
"id": "next-campaigns-polish",
|
|
42
42
|
"name": "Campaign Polish",
|
|
43
|
-
"version": "1.1.
|
|
43
|
+
"version": "1.1.57",
|
|
44
44
|
"path": "skills/next-campaigns-polish/SKILL.md",
|
|
45
45
|
"domain": "campaigns",
|
|
46
46
|
"description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
{
|
|
49
49
|
"id": "next-campaigns-qa",
|
|
50
50
|
"name": "Campaign QA",
|
|
51
|
-
"version": "1.3.
|
|
51
|
+
"version": "1.3.56",
|
|
52
52
|
"path": "skills/next-campaigns-qa/SKILL.md",
|
|
53
53
|
"domain": "campaigns",
|
|
54
54
|
"description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
{
|
|
57
57
|
"id": "campaign-lifecycle-orientation",
|
|
58
58
|
"name": "Campaign Lifecycle Orientation",
|
|
59
|
-
"version": "1.0.
|
|
59
|
+
"version": "1.0.52",
|
|
60
60
|
"path": "skills/campaign-lifecycle-orientation/SKILL.md",
|
|
61
61
|
"domain": "campaigns",
|
|
62
62
|
"description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
{
|
|
65
65
|
"id": "campaign-run-evidence",
|
|
66
66
|
"name": "Campaign Run Evidence",
|
|
67
|
-
"version": "1.0.
|
|
67
|
+
"version": "1.0.52",
|
|
68
68
|
"path": "skills/campaign-run-evidence/SKILL.md",
|
|
69
69
|
"domain": "campaigns",
|
|
70
70
|
"description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
{
|
|
73
73
|
"id": "campaign-readback-classification",
|
|
74
74
|
"name": "Campaign Readback Classification",
|
|
75
|
-
"version": "1.0.
|
|
75
|
+
"version": "1.0.52",
|
|
76
76
|
"path": "skills/campaign-readback-classification/SKILL.md",
|
|
77
77
|
"domain": "campaigns",
|
|
78
78
|
"description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
{
|
|
81
81
|
"id": "contribution-intake",
|
|
82
82
|
"name": "Contribution Intake",
|
|
83
|
-
"version": "1.0.
|
|
83
|
+
"version": "1.0.52",
|
|
84
84
|
"path": "skills/contribution-intake/SKILL.md",
|
|
85
85
|
"domain": "campaigns",
|
|
86
86
|
"description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
{
|
|
89
89
|
"id": "next-campaigns-sdk-bump",
|
|
90
90
|
"name": "Campaign SDK Bump",
|
|
91
|
-
"version": "1.0.
|
|
91
|
+
"version": "1.0.12",
|
|
92
92
|
"path": "skills/next-campaigns-sdk-bump/SKILL.md",
|
|
93
93
|
"domain": "campaigns",
|
|
94
94
|
"description": "Bump a live campaign from one Campaign Cart 0.4.x release to a later 0.4.x on a page-kit or static repo: find every pin, check the release diff and storage against the campaign code, prove on a preview with approved test orders, and report what is not proven."
|
package/src/change-scope.mjs
CHANGED
|
@@ -149,11 +149,12 @@ export function classifyDrift({ changes, baseline, outputRoot, targetRepo, publi
|
|
|
149
149
|
if (changes?.status !== "known") return { kind: "unknown", reasons: [] };
|
|
150
150
|
if (!changes.changed_pages.length && !changes.unmapped_files.length) return { kind: "none", reasons: [] };
|
|
151
151
|
const reasons = [];
|
|
152
|
-
for (const path of changes.unmapped_files) reasons.push({ code: "unmapped_file", path });
|
|
153
152
|
const signature = recordedSignature(baseline);
|
|
153
|
+
// First, so the list's cap never drops it: `record baseline` reads it.
|
|
154
154
|
if (!signature) reasons.push({ code: "commerce_signature_unavailable" });
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
for (const path of changes.unmapped_files) reasons.push({ code: "unmapped_file", path });
|
|
156
|
+
if (signature && (signature.campaign_config.startsWith("unavailable:")
|
|
157
|
+
|| campaignConfigFingerprint(targetRepo, publicRouteSlug) !== signature.campaign_config)) {
|
|
157
158
|
reasons.push({ code: signature.campaign_config.startsWith("unavailable:") ? "campaign_config_unavailable" : "campaign_config_changed" });
|
|
158
159
|
}
|
|
159
160
|
for (const page of changes.changed_pages) {
|
|
@@ -177,6 +178,13 @@ export function classifyDrift({ changes, baseline, outputRoot, targetRepo, publi
|
|
|
177
178
|
return { kind: reasons.length ? "commerce_affecting" : "page_only", reasons: capped(reasons) };
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
// True when a kept rebuild was read against a build recorded before markup
|
|
182
|
+
// hashes existed (pre-1.62), the one kind of record `record baseline` is for.
|
|
183
|
+
export function rebuiltFromBuildWithoutSignature(assembly) {
|
|
184
|
+
return Array.isArray(assembly?.build_changes) && assembly.build_changes.some((entry) => Array.isArray(entry?.reasons)
|
|
185
|
+
&& entry.reasons.some((reason) => reason?.code === "commerce_signature_unavailable" && !reason.path));
|
|
186
|
+
}
|
|
187
|
+
|
|
180
188
|
/**
|
|
181
189
|
* One build_changes entry for a rebuild from `from` to `to`, from the debt
|
|
182
190
|
* assessment of the new output against the previous record.
|
|
@@ -230,8 +238,12 @@ export function worseChangeKind(a, b) {
|
|
|
230
238
|
* The change a stage that reviewed `anchor` owes for: the recorded rebuilds
|
|
231
239
|
* from `anchor` to the recorded build, then the drift from the recorded build
|
|
232
240
|
* to the output on disk. Returns `{ kind, since_build_fingerprint,
|
|
233
|
-
* changed_pages, unmapped_files, reasons }`; `kind` is
|
|
234
|
-
* history does not reach `anchor` or the drift cannot be read.
|
|
241
|
+
* changed_pages, unmapped_files, reasons, commerce_rebuild }`; `kind` is
|
|
242
|
+
* unknown when the history does not reach `anchor` or the drift cannot be read.
|
|
243
|
+
* `commerce_rebuild`, when there is one, is the latest recorded rebuild since
|
|
244
|
+
* `anchor` that was not page-only, with the kind of the latest change
|
|
245
|
+
* (`latest_change_kind`), so a page-only edit on top of it can say why it still
|
|
246
|
+
* owes the full stages.
|
|
235
247
|
*/
|
|
236
248
|
export function changeSince(anchor, { assembly, drift, driftClassification }) {
|
|
237
249
|
const recorded = typeof assembly?.build_fingerprint === "string" ? assembly.build_fingerprint : null;
|
|
@@ -257,9 +269,16 @@ export function changeSince(anchor, { assembly, drift, driftClassification }) {
|
|
|
257
269
|
for (const path of part.unmapped_files || []) unmapped.add(path);
|
|
258
270
|
result.reasons.push(...(reasons || []));
|
|
259
271
|
};
|
|
260
|
-
for (const segment of segments)
|
|
272
|
+
for (const segment of segments) {
|
|
273
|
+
add(segment, segment.kind, segment.reasons);
|
|
274
|
+
if (!["none", "page_only"].includes(segment.kind)) result.commerce_rebuild = { recorded_at: segment.recorded_at ?? null, build_fingerprint: segment.build_fingerprint, kind: segment.kind };
|
|
275
|
+
}
|
|
261
276
|
if (drift?.status !== "known") kind = "unknown";
|
|
262
277
|
else add(drift, driftClassification?.kind || "unknown", driftClassification?.reasons);
|
|
278
|
+
if (result.commerce_rebuild) {
|
|
279
|
+
const driftKind = driftClassification?.kind || "unknown";
|
|
280
|
+
result.commerce_rebuild.latest_change_kind = driftKind === "none" ? segments.at(-1).kind : driftKind;
|
|
281
|
+
}
|
|
263
282
|
return {
|
|
264
283
|
...result,
|
|
265
284
|
kind,
|
package/src/cli.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { routeMaskedSpecMaterialHash, specMaterialHash, specHashesMatch } from "
|
|
|
32
32
|
// The same predicate stage-ledger.mjs judges a mutator's result with, imported
|
|
33
33
|
// rather than re-stated so the waive preview and the commit agree by identity.
|
|
34
34
|
import { isPlainObject } from "./repo-scan.mjs";
|
|
35
|
-
import { applyDerivedAssemblyReportSummary, campaignHandedOff, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, recordProducerStageOutcome } from "./stage-ledger.mjs";
|
|
35
|
+
import { applyDerivedAssemblyReportSummary, campaignHandedOff, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, recordProducerStageOutcome, qaRestsOnBaseline } from "./stage-ledger.mjs";
|
|
36
36
|
import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
|
|
37
37
|
import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
|
|
38
38
|
import {
|
|
@@ -190,7 +190,7 @@ import {
|
|
|
190
190
|
createCampaignBuildBriefArtifact,
|
|
191
191
|
inferBuildBriefPath,
|
|
192
192
|
} from "./build-brief.mjs";
|
|
193
|
-
import { briefIntakeBindings, currentPacketInputs, inputRefreshCommands, inputStamps, stageWriteInputs } from "./input-currency.mjs";
|
|
193
|
+
import { briefIntakeBindings, currentPacketInputs, currentSpecMaterial, inputRefreshCommands, inputStamps, specMaterialStamp, stageWriteInputs } from "./input-currency.mjs";
|
|
194
194
|
import { prepareBuildStageStatus, priorBriefInputsForIntake } from "./input-refresh.mjs";
|
|
195
195
|
import {
|
|
196
196
|
DESIGN_SOURCE_PACKAGE_REL_PATH,
|
|
@@ -347,6 +347,7 @@ Usage:
|
|
|
347
347
|
campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope, source_html.producer_provenance (per page: --page <page_id> is required, for a Figma-typed page whose approved source is hand-written HTML). --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
|
|
348
348
|
campaigns-os checkpoint accept --packet <campaign-runtime.build.json> --result <result_id>@<fingerprint12> [--result ...] --reason "<the operator's reason>" --accepted-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # record the operator's accept of a measured QC warning, as the QC handoff of next prints it; changes no readiness and lapses when the measured state or the build changes. --dry-run runs every check and prints the accepts it would record, without touching the report
|
|
349
349
|
campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
|
|
350
|
+
campaigns-os spec fetch --packet <campaign-runtime.build.json> [--proxy-base <url>] [--json] # restore a saved-Map packet's CampaignSpec to spec.local_path on a checkout that does not have it, then commit it. Sends one GET of {proxy-base}/api/spec/<map-id>. A copy already on disk is never replaced and nothing is sent, because it can hold spec derive edits the Map does not. Takes fetched-specs/ out of an older managed .gitignore block so the file can be committed. Reports whether the content matches the records (bound: matches, differs or unknown); differs means doctor will show build, Polish and QA owed. Exit 2 when the packet is not a saved-Map packet, the Map returns another campaign's spec, or the fetch fails.
|
|
350
351
|
campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--no-live-refs] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId, and each starter-template vendor's id key, such as ga4_id or triplewhale_name -> analytics.vendors.<vendor>.id); prints a field-by-field before -> after diff and writes nothing else. On a handed-off campaign (deploy or QA completed, now or in the stage's history) the page tree also adds pages (a file with a page-kit page_type no spec page binds; id from the file name, appended to the funnel, routing from its next_url/decline_url/next_step frontmatter) and removes pages (one whose built file the packet named is gone), matched by page id; page order is reported as not derived. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves, it also makes the one read-only GET of {proxy-base}/api/campaign doctor makes and refreshes the fields the spec mirrors from the Campaigns App (each page package's name, price, product, variant and availability; the offer records and each page's offer copies; live offers missing from offers[] are added) — a price is never written from the repo; --no-live-refs sends nothing and leaves those fields as they are; --proxy-base names the proxy. Otherwise repo-derived fields only; --from-store <subdomain> (the <store> of <store>.29next.store) also reads through campaigns-os login gateway credentials (--store-token-source env:<VAR> explicitly selects the warned break-glass Admin path; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
|
|
351
352
|
campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json] # local proof mode (deploy.target local-serve): render the current source in development and production through the target's page-kit into temp dirs, assert the served _site/ is the current development render and that production differs from it only in environment-gated output (same page set, same route slugs, same Campaign Cart pin and next-api-key); records stages.assembly.evidence.local_proof.production_parity, which doctor reads as local_proof.production_parity. Exit 2 on a non-gated difference.
|
|
352
353
|
campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
|
|
@@ -354,6 +355,7 @@ Usage:
|
|
|
354
355
|
campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
|
|
355
356
|
campaigns-os record setup --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # record setup complete once the campaign output directory exists: Build Context scaffold.required=false and stages.setup completed, validated against their schemas before either is written
|
|
356
357
|
campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: records scalar adapter decisions on the Assembly Report (all pairs in one flag; a repeated flag keeps only the last), stamps stages.assembly.build_fingerprint from doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when present), and makes Polish required unless its evidence is bound to this output; --build-environment records stages.assembly.evidence.build_environment (local proof mode records development)
|
|
358
|
+
campaigns-os record baseline --packet <campaign-runtime.build.json> --accepted-by "<named person>" --reason "<why>" [--context <json>] [--report <json>] [--dry-run] [--json] # one time per funnel, for a handed-off funnel whose records predate markup hashes (before 1.62.0) and whose last QA completed: accept the recorded build as the starting point without testing it, so a later page-only change owes QA on that page alone. Polish and QA read baseline_accepted, never completed, and what they held moves to history. A commerce-affecting change still owes the full stages. Run record build first. Writes the Assembly Report.
|
|
357
359
|
campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
|
|
358
360
|
campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
|
|
359
361
|
campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a served deploy after polish is recorded: GETs every built page under the URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again). A local-serve packet takes a loopback URL and each page must answer 2xx; any other deploy target takes an https URL (plain http only on a loopback host), each page must answer 200, and every script and stylesheet the built pages load from that origin must be served byte-identical (sha256) to the built output (HTML is not compared). Refused, writing nothing, when the URL breaks those rules or is not the route root, a page does not answer, a served asset differs or is missing, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
|
|
@@ -720,6 +722,22 @@ function hasDoneRecommendation(session) {
|
|
|
720
722
|
return session?.last_recommendation?.stage === "done";
|
|
721
723
|
}
|
|
722
724
|
|
|
725
|
+
// A page-scoped pass on a baseline paid for those pages and proved nothing
|
|
726
|
+
// else, so QA still reads baseline_accepted. Only a completed full QA ends the
|
|
727
|
+
// baseline: a blocked run proved nothing, so it leaves the marker alone.
|
|
728
|
+
function settleQaBaseline(onBaseline, report) {
|
|
729
|
+
const qa = report.stages?.qa;
|
|
730
|
+
if (!isObject(qa) || !String(qa.status).startsWith("completed")) return report;
|
|
731
|
+
if (!onBaseline) {
|
|
732
|
+
delete qa.baseline_accepted_at;
|
|
733
|
+
return report;
|
|
734
|
+
}
|
|
735
|
+
qa.status = "baseline_accepted";
|
|
736
|
+
delete qa.completed_at;
|
|
737
|
+
delete qa.purchase_proof;
|
|
738
|
+
return report;
|
|
739
|
+
}
|
|
740
|
+
|
|
723
741
|
export function recordQaStageOutcome(args, result) {
|
|
724
742
|
try {
|
|
725
743
|
const packetArg = optionalString(args.packet);
|
|
@@ -757,7 +775,8 @@ export function recordQaStageOutcome(args, result) {
|
|
|
757
775
|
throw new Error("The build was recorded again during this page-scoped QA run; report evidence was not changed. Re-run qa run --page against the recorded build.");
|
|
758
776
|
}
|
|
759
777
|
const evidence = qaStageGateEvidence(verdict, report, result);
|
|
760
|
-
|
|
778
|
+
const onBaseline = Boolean(pageScope && report.stages?.qa?.baseline_accepted_at);
|
|
779
|
+
return settleQaBaseline(onBaseline, recordProducerStageOutcome(report, {
|
|
761
780
|
stage: "qa",
|
|
762
781
|
disposition: verdict.disposition,
|
|
763
782
|
timestamp: verdict.completed_at,
|
|
@@ -783,12 +802,14 @@ export function recordQaStageOutcome(args, result) {
|
|
|
783
802
|
// path from a `--test-order off` diagnostic. A page-scoped run places
|
|
784
803
|
// no orders and the change it pays touches no commerce, so the
|
|
785
804
|
// purchase proof of the full run it extends is kept.
|
|
786
|
-
|
|
805
|
+
// On a baseline there is none to keep, and a zero from this run would
|
|
806
|
+
// read as a purchase that failed, not one nobody tested.
|
|
807
|
+
proof: pageScope && (isObject(report.stages?.qa?.purchase_proof) || onBaseline)
|
|
787
808
|
? report.stages.qa.purchase_proof
|
|
788
809
|
: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
|
|
789
810
|
stamps: qaStamps,
|
|
790
811
|
inputs: qaInputs,
|
|
791
|
-
});
|
|
812
|
+
}));
|
|
792
813
|
}, {
|
|
793
814
|
stage: "qa",
|
|
794
815
|
// The sidecar names this refresh as its producer (generated_by, #312).
|
|
@@ -1251,7 +1272,12 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1251
1272
|
// Inequality on purpose: src/invocation.test.mjs harvests top-level
|
|
1252
1273
|
// command literals by an equality pattern a subcommand equality would
|
|
1253
1274
|
// also match.
|
|
1254
|
-
if (subcommand !== "derive") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json].");
|
|
1275
|
+
if (subcommand !== "derive" && subcommand !== "fetch") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json], or campaigns-os spec fetch --packet <campaign-runtime.build.json> [--proxy-base <url>] [--json].");
|
|
1276
|
+
if (subcommand !== "derive") {
|
|
1277
|
+
const fetched = await specFetchCommand(args);
|
|
1278
|
+
writeResult(fetched, args, fetched.ok ? 0 : 2);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1255
1281
|
const result = await specDeriveWithMapWriteback(args);
|
|
1256
1282
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
1257
1283
|
else for (const line of specDeriveWriteMapTextLines(result)) console.log(line);
|
|
@@ -3745,6 +3771,93 @@ function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored =
|
|
|
3745
3771
|
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
3746
3772
|
}
|
|
3747
3773
|
|
|
3774
|
+
const SPEC_FETCH_FLAGS = ["_", "packet", "proxy-base", "json"];
|
|
3775
|
+
|
|
3776
|
+
// `spec fetch`: put a saved-Map packet's CampaignSpec back on a checkout that
|
|
3777
|
+
// lacks it. Only ever fills a gap: the copy on disk can hold `spec derive`
|
|
3778
|
+
// edits the Map does not, so it is never replaced. The packet and the records
|
|
3779
|
+
// are not touched either; a Map that has moved since they were made is
|
|
3780
|
+
// reported as `bound: "differs"` for doctor and `record spec` to deal with.
|
|
3781
|
+
export async function specFetchCommand(args, { fetchImpl = undefined } = {}) {
|
|
3782
|
+
const unknown = Object.keys(args).filter((key) => !SPEC_FETCH_FLAGS.includes(key));
|
|
3783
|
+
if (unknown.length) {
|
|
3784
|
+
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for spec fetch: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: --packet, --proxy-base, --json.`);
|
|
3785
|
+
}
|
|
3786
|
+
if (Object.hasOwn(args, "proxy-base") && !optionalString(args["proxy-base"])) throw refused("spec fetch: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
|
|
3787
|
+
const packetPath = resolve(requireArg(args, "packet"));
|
|
3788
|
+
const result = { ok: false, status: "blocked", packet_path: packetPath, spec_path: null, map_id: null, bound: "unknown", gitignore: null, errors: [], warnings: [], ready: [] };
|
|
3789
|
+
const workspace = (() => {
|
|
3790
|
+
try {
|
|
3791
|
+
return resolveCampaignWorkspace(packetPath, { followContextPointer: true });
|
|
3792
|
+
} catch (error) {
|
|
3793
|
+
addIssue(result.errors, "spec.fetch.packet_unreadable", `Could not read the packet: ${singleLineDetail(error.message)}`);
|
|
3794
|
+
return null;
|
|
3795
|
+
}
|
|
3796
|
+
})();
|
|
3797
|
+
if (!workspace) return result;
|
|
3798
|
+
const mapId = optionalString(workspace.packet?.spec?.map_id);
|
|
3799
|
+
const localPath = optionalString(workspace.packet?.spec?.local_path);
|
|
3800
|
+
if (!mapId || !localPath || !workspace.targetRepo) {
|
|
3801
|
+
addIssue(result.errors, "spec.fetch.not_saved_map", "This packet does not name a saved Map and a spec.local_path, so there is nothing to fetch. A local-spec packet's CampaignSpec is restored from wherever it is kept.");
|
|
3802
|
+
return result;
|
|
3803
|
+
}
|
|
3804
|
+
result.map_id = mapId;
|
|
3805
|
+
const cachePath = join(workspace.targetRepo, ".campaign-runtime", "fetched-specs", `${sanitizeMapIdForFilename(mapId)}.json`);
|
|
3806
|
+
const specPath = resolveFromFile(packetPath, localPath);
|
|
3807
|
+
result.spec_path = specPath;
|
|
3808
|
+
if (specPath !== cachePath) {
|
|
3809
|
+
addIssue(result.errors, "spec.fetch.not_saved_map", `spec.local_path is ${localPath}, not this Map's copy (.campaign-runtime/fetched-specs/${basename(cachePath)}), so spec fetch cannot restore it. Restore that file from where it is kept.`);
|
|
3810
|
+
return result;
|
|
3811
|
+
}
|
|
3812
|
+
let boundMaterial;
|
|
3813
|
+
let specMaterial;
|
|
3814
|
+
try {
|
|
3815
|
+
const report = existsSync(workspace.reportPath) ? readJson(workspace.reportPath) : null;
|
|
3816
|
+
boundMaterial = specMaterialStamp(report?.identity?.spec_material_hash);
|
|
3817
|
+
if (existsSync(specPath)) {
|
|
3818
|
+
specMaterial = currentSpecMaterial(readJson(specPath));
|
|
3819
|
+
result.status = "present";
|
|
3820
|
+
result.ready.push(`${localPath} is already here; it was left as it is and nothing was fetched.`);
|
|
3821
|
+
} else {
|
|
3822
|
+
assertFetchedSpecCacheWritable(workspace.targetRepo, cachePath);
|
|
3823
|
+
const fetched = await fetchSpecByMapId(mapId, { proxyBase: optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE, fetchImpl });
|
|
3824
|
+
// This file is what the records and spec derive read, so it must be this campaign's.
|
|
3825
|
+
const fetchedMapId = optionalString(fetched?.spec_identity?.map_id) || optionalString(fetched?.map_id);
|
|
3826
|
+
const fetchedSlug = specPublicRouteSlug(fetched);
|
|
3827
|
+
const packetSlug = normalizePublicRouteSlug(workspace.packet?.campaign?.public_route_slug);
|
|
3828
|
+
if ((fetchedMapId && fetchedMapId !== mapId) || (fetchedSlug && packetSlug && fetchedSlug !== packetSlug)) {
|
|
3829
|
+
addIssue(result.errors, "spec.fetch.identity_mismatch", `The Map returned a spec for ${singleLineField(fetchedMapId || mapId)} on route "${singleLineField(fetchedSlug || "")}", but this packet is ${singleLineField(mapId)} on route "${singleLineField(packetSlug || "")}"; nothing was written.`);
|
|
3830
|
+
return result;
|
|
3831
|
+
}
|
|
3832
|
+
// The same reduction intake made, so the copy reads as the records' did.
|
|
3833
|
+
const { spec } = stripHostPrefixedRoutes(fetched);
|
|
3834
|
+
// Again at the write: the links can change during the request.
|
|
3835
|
+
assertFetchedSpecCacheWritable(workspace.targetRepo, cachePath);
|
|
3836
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
3837
|
+
replaceFetchedSpec(cachePath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
3838
|
+
specMaterial = currentSpecMaterial(spec);
|
|
3839
|
+
result.status = "restored";
|
|
3840
|
+
result.ready.push(`Restored ${localPath} from the saved Map. Commit it so the next checkout has it.`);
|
|
3841
|
+
}
|
|
3842
|
+
} catch (error) {
|
|
3843
|
+
addIssue(result.errors, "spec.fetch.failed", singleLineDetail(error.message));
|
|
3844
|
+
return result;
|
|
3845
|
+
}
|
|
3846
|
+
result.ok = true;
|
|
3847
|
+
const current = specMaterialStamp(specMaterial);
|
|
3848
|
+
result.bound = boundMaterial && current ? (boundMaterial === current ? "matches" : "differs") : "unknown";
|
|
3849
|
+
if (result.bound === "differs") {
|
|
3850
|
+
addIssue(result.warnings, "spec.fetch.differs_from_records", "This spec differs from the one the campaign's records were made against: the Map changed since then, or the earlier copy held `spec derive` edits the Map does not. Doctor will show build, Polish and QA owed until `record spec` binds it.");
|
|
3851
|
+
}
|
|
3852
|
+
if (result.bound === "matches") result.ready.push("It matches the spec the campaign's records were made against.");
|
|
3853
|
+
if (result.bound === "unknown") result.ready.push("The report records no spec to compare it with.");
|
|
3854
|
+
// The file has to be committable, and older repositories' managed block says otherwise.
|
|
3855
|
+
result.gitignore = ensureRuntimeStateIgnored(workspace.targetRepo);
|
|
3856
|
+
if (result.gitignore.removed_entries?.length) result.ready.push("Took fetched-specs/ out of the managed .gitignore block.");
|
|
3857
|
+
if (result.gitignore.action === "skipped") addIssue(result.warnings, "spec.fetch.gitignore_not_updated", `Could not update .gitignore (${singleLineDetail(result.gitignore.reason)}). If it still lists .campaign-runtime/fetched-specs/, remove that line so this file can be committed.`);
|
|
3858
|
+
return result;
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3748
3861
|
export function specDeriveCommand(args, { store: storeRead = null, liveCampaign = undefined } = {}) {
|
|
3749
3862
|
// A command that rewrites the spec rejects flags it does not know: a
|
|
3750
3863
|
// mistyped --dryrun must not fall through to a real write. --report names
|
|
@@ -4643,7 +4756,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4643
4756
|
errors,
|
|
4644
4757
|
warnings,
|
|
4645
4758
|
ready,
|
|
4646
|
-
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
|
|
4759
|
+
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}${qaRestsOnBaseline(report) ? ` QA rests on a baseline ${report.commerce_baseline.accepted_by} accepted, not a test; say so when you report this campaign.` : ""}${warnings.some((issue) => issue.code === "built_output.absent") ? " The built output is not on this checkout, so these records were not compared with it; build before treating the campaign as current." : ""}`,
|
|
4647
4760
|
});
|
|
4648
4761
|
}
|
|
4649
4762
|
stage = picked.stage;
|
|
@@ -632,6 +632,9 @@ function normalizeLine(echo, state, packages) {
|
|
|
632
632
|
})),
|
|
633
633
|
final: moneyFact(line.total, state),
|
|
634
634
|
savings: moneyFact(line.total_discount, state),
|
|
635
|
+
// What one unit costs after offers: the figure a printed per-unit price is
|
|
636
|
+
// held against. A response without it leaves that claim unresolved.
|
|
637
|
+
...(present(line.unit_price) ? { unit_price: moneyFact(line.unit_price, state) } : {}),
|
|
635
638
|
};
|
|
636
639
|
}
|
|
637
640
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dependency-free extraction and Exact-only commercial parity comparison.
|
|
3
3
|
*
|
|
4
|
-
* Source HTML is intentional: Campaign Cart replaces authored
|
|
5
|
-
* after readiness, which can hide an authored mismatch in the
|
|
4
|
+
* Source HTML is intentional for bundle prices: Campaign Cart replaces authored
|
|
5
|
+
* placeholders after readiness, which can hide an authored mismatch in the
|
|
6
|
+
* rendered DOM. package.price is the other way round: its authored text is a
|
|
7
|
+
* template placeholder no shopper is meant to read, so only its rendered value
|
|
8
|
+
* is compared and the source merely says the claim exists.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import {
|
|
@@ -416,6 +419,48 @@ function governedPriceBinding(value) {
|
|
|
416
419
|
return typeof value === "string" && /^bundle\.[^.]+\.price$/.test(value.trim());
|
|
417
420
|
}
|
|
418
421
|
|
|
422
|
+
// The SDK's own package.price paths: an id in the path, or none and the nearest
|
|
423
|
+
// package id around the node. No other package.* field is a price claim here.
|
|
424
|
+
const PACKAGE_PRICE_BINDING = /^package\.(?:([1-9]\d*)\.)?price$/;
|
|
425
|
+
const PACKAGE_CONTEXT_ATTRIBUTES = Object.freeze(["data-next-package-id", "data-next-package", "data-package-id"]);
|
|
426
|
+
|
|
427
|
+
export const PACKAGE_PRICE_NOT_RENDERED = "package_price_not_rendered";
|
|
428
|
+
export const PACKAGE_PRICE_NOT_FILLED = "package_price_not_filled";
|
|
429
|
+
export const PACKAGE_PRICE_UNRESOLVED = "package_price_unresolved";
|
|
430
|
+
|
|
431
|
+
function packageContextId(node) {
|
|
432
|
+
for (let current = node; current; current = current.parent) {
|
|
433
|
+
for (const attribute of PACKAGE_CONTEXT_ATTRIBUTES) {
|
|
434
|
+
const id = positiveInteger(current.attrs?.[attribute]);
|
|
435
|
+
if (id !== null) return id;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Lists the claims, deliberately without their text: what the source holds is
|
|
442
|
+
// the template's placeholder, and comparing it would report every starter page.
|
|
443
|
+
function extractPackagePrices(nodeIndex, consumeClaim) {
|
|
444
|
+
const seen = new Set();
|
|
445
|
+
const claims = [];
|
|
446
|
+
nodeIndex.nodes.forEach((node) => {
|
|
447
|
+
const binding = String(node.attrs?.["data-next-display"] || "").trim();
|
|
448
|
+
const match = PACKAGE_PRICE_BINDING.exec(binding);
|
|
449
|
+
const format = String(node.attrs?.["data-next-format"] || "currency").trim().toLowerCase();
|
|
450
|
+
if (!match
|
|
451
|
+
|| format !== "currency"
|
|
452
|
+
|| nodeIndex.hidden.get(node) === true
|
|
453
|
+
|| nodeIndex.comparison.get(node) === true) return;
|
|
454
|
+
const packageId = positiveInteger(match[1]) ?? packageContextId(node);
|
|
455
|
+
const key = `${binding}\u0000${packageId}`;
|
|
456
|
+
if (seen.has(key)) return;
|
|
457
|
+
seen.add(key);
|
|
458
|
+
consumeClaim();
|
|
459
|
+
claims.push({ binding, package_id: packageId });
|
|
460
|
+
});
|
|
461
|
+
return claims;
|
|
462
|
+
}
|
|
463
|
+
|
|
419
464
|
function extractPrices(nodeIndex, consumeClaim) {
|
|
420
465
|
const seen = new Set();
|
|
421
466
|
const prices = [];
|
|
@@ -529,6 +574,7 @@ export function extractCommercialClaims(html, options = {}) {
|
|
|
529
574
|
}
|
|
530
575
|
};
|
|
531
576
|
const priceClaims = extractPrices(nodeIndex, consumeClaim);
|
|
577
|
+
const packagePriceClaims = extractPackagePrices(nodeIndex, consumeClaim);
|
|
532
578
|
const recurrenceClaims = extractRecurrences(nodeIndex, extractionErrors, consumeClaim);
|
|
533
579
|
const vouchers = extractVouchers(nodeIndex, extractionErrors, consumeClaim);
|
|
534
580
|
return {
|
|
@@ -537,6 +583,7 @@ export function extractCommercialClaims(html, options = {}) {
|
|
|
537
583
|
price_claims: priceClaims,
|
|
538
584
|
recurrence_claims: recurrenceClaims,
|
|
539
585
|
vouchers,
|
|
586
|
+
...(packagePriceClaims.length ? { package_price_claims: packagePriceClaims } : {}),
|
|
540
587
|
...(extractionErrors.length ? { extraction_errors: extractionErrors } : {}),
|
|
541
588
|
};
|
|
542
589
|
}
|
|
@@ -563,8 +610,23 @@ function validVoucherClaim(claim) {
|
|
|
563
610
|
return record(claim) && typeof claim.code === "string" && present(claim.code);
|
|
564
611
|
}
|
|
565
612
|
|
|
613
|
+
function validPackagePriceClaim(claim) {
|
|
614
|
+
return record(claim)
|
|
615
|
+
&& typeof claim.binding === "string"
|
|
616
|
+
&& PACKAGE_PRICE_BINDING.test(claim.binding)
|
|
617
|
+
&& (claim.package_id === null || positiveInteger(claim.package_id) !== null);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function validRenderedPackagePrice(claim) {
|
|
621
|
+
return validPackagePriceClaim(claim)
|
|
622
|
+
&& typeof claim.filled === "boolean"
|
|
623
|
+
&& (claim.text === null || typeof claim.text === "string");
|
|
624
|
+
}
|
|
625
|
+
|
|
566
626
|
function validCaptureEnvelope(capture) {
|
|
567
627
|
if (!record(capture)) return false;
|
|
628
|
+
if (capture.package_price_claims !== undefined && !Array.isArray(capture.package_price_claims)) return false;
|
|
629
|
+
if (capture.rendered_package_prices !== undefined && !Array.isArray(capture.rendered_package_prices)) return false;
|
|
568
630
|
if (capture.page_id !== undefined
|
|
569
631
|
&& capture.page_id !== null
|
|
570
632
|
&& (typeof capture.page_id !== "string" || !present(capture.page_id))) return false;
|
|
@@ -580,7 +642,9 @@ function validCapture(capture) {
|
|
|
580
642
|
return validCaptureEnvelope(capture)
|
|
581
643
|
&& capture.price_claims.every(validPriceClaim)
|
|
582
644
|
&& capture.recurrence_claims.every(validRecurrenceClaim)
|
|
583
|
-
&& capture.vouchers.every(validVoucherClaim)
|
|
645
|
+
&& capture.vouchers.every(validVoucherClaim)
|
|
646
|
+
&& array(capture.package_price_claims).every(validPackagePriceClaim)
|
|
647
|
+
&& array(capture.rendered_package_prices).every(validRenderedPackagePrice);
|
|
584
648
|
}
|
|
585
649
|
|
|
586
650
|
function captureCandidates(capturesValue) {
|
|
@@ -626,6 +690,64 @@ function priceComparisonClaims(capture, page) {
|
|
|
626
690
|
return { compared, unresolved: claims.length - compared.length };
|
|
627
691
|
}
|
|
628
692
|
|
|
693
|
+
// Printed money as the SDK formats it: a symbol or code around one amount. A
|
|
694
|
+
// decimal comma or anything else stays unread, so it is unresolved, not wrong.
|
|
695
|
+
function renderedMoney(text) {
|
|
696
|
+
const match = decodeEntities(String(text ?? "")).replace(/[\u00a0\s]+/g, " ").trim()
|
|
697
|
+
.match(/^[^\d-]*(\d[\d,]*(?:\.\d{1,2})?)\s*[A-Za-z]*$/);
|
|
698
|
+
return match ? normalizeMoney(match[1]) : null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// One distinct Exact per-unit price for the package on this page, or nothing:
|
|
702
|
+
// two rows of one package at different unit prices (quantity tiers, say) do not
|
|
703
|
+
// say which one a generic package.price means.
|
|
704
|
+
function exactPackageUnitPrice(page, packageId) {
|
|
705
|
+
const prices = new Set(array(page?.rows)
|
|
706
|
+
.filter((row) => String(row?.package_id) === String(packageId))
|
|
707
|
+
.map((row) => (row?.state === PricingState.Exact ? exactMoneyFact(row?.unit_price)?.value : null)));
|
|
708
|
+
return prices.size === 1 ? [...prices][0] ?? null : null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// The rendered read is the authority once it found any. When it found none, the
|
|
712
|
+
// source still says the claims exist, so they are not allowed to disappear.
|
|
713
|
+
export function packagePriceClaimCount(capture) {
|
|
714
|
+
const rendered = array(capture?.rendered_package_prices);
|
|
715
|
+
return rendered.length || array(capture?.package_price_claims).length;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Sort a page's package.price claims into compared and unresolved. Only a
|
|
720
|
+
* rendered value the SDK filled is ever compared. Each unresolved claim names
|
|
721
|
+
* its reason, so the run can say what to do about it. Distinct claims only: one
|
|
722
|
+
* price repeated around an upsell page is one claim.
|
|
723
|
+
*/
|
|
724
|
+
function packagePriceComparisonClaims(capture, page) {
|
|
725
|
+
const rendered = array(capture?.rendered_package_prices);
|
|
726
|
+
if (!rendered.length) {
|
|
727
|
+
return {
|
|
728
|
+
compared: [],
|
|
729
|
+
unresolved: array(capture?.package_price_claims).map(() => PACKAGE_PRICE_NOT_RENDERED),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
const compared = [];
|
|
733
|
+
const unresolved = [];
|
|
734
|
+
const voucherUnpriced = voucherComparisonClaims(capture, page).unresolved > 0;
|
|
735
|
+
rendered.forEach((claim) => {
|
|
736
|
+
if (!claim?.filled) {
|
|
737
|
+
unresolved.push(PACKAGE_PRICE_NOT_FILLED);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const value = renderedMoney(claim.text);
|
|
741
|
+
const truth = claim.package_id === null || voucherUnpriced ? null : exactPackageUnitPrice(page, claim.package_id);
|
|
742
|
+
if (!value || !truth) {
|
|
743
|
+
unresolved.push(PACKAGE_PRICE_UNRESOLVED);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
compared.push({ package_id: claim.package_id, value, truth });
|
|
747
|
+
});
|
|
748
|
+
return { compared, unresolved };
|
|
749
|
+
}
|
|
750
|
+
|
|
629
751
|
function pageForCapture(capture, journey) {
|
|
630
752
|
const pages = array(journey?.pages);
|
|
631
753
|
if (present(capture?.page_id)) {
|
|
@@ -745,6 +867,19 @@ export function diffCommercialParity(capturesValue, journey, options = {}) {
|
|
|
745
867
|
});
|
|
746
868
|
}
|
|
747
869
|
|
|
870
|
+
(compareMoney ? packagePriceComparisonClaims(capture, page).compared : []).forEach(({ package_id: packageId, value, truth }) => {
|
|
871
|
+
if (moneyToCents(value) === moneyToCents(truth)) return;
|
|
872
|
+
findings.push({
|
|
873
|
+
type: "price-claim-mismatch",
|
|
874
|
+
page_id: pageId,
|
|
875
|
+
...source,
|
|
876
|
+
binding: `package.${packageId}.price`,
|
|
877
|
+
package_id: packageId,
|
|
878
|
+
claimed: value,
|
|
879
|
+
calculated: truth,
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
|
|
748
883
|
(compareMoney ? recurrenceComparisonClaims(capture, page).compared : []).forEach(({ claim, truth }) => {
|
|
749
884
|
const packageId = positiveInteger(claim?.package_id);
|
|
750
885
|
const claimed = {
|
|
@@ -817,7 +952,11 @@ function assertionForFinding(finding, id) {
|
|
|
817
952
|
...common,
|
|
818
953
|
expected: finding.calculated,
|
|
819
954
|
actual: finding.claimed,
|
|
820
|
-
evidence: {
|
|
955
|
+
evidence: {
|
|
956
|
+
type: finding.type,
|
|
957
|
+
binding: finding.binding,
|
|
958
|
+
...(finding.package_id !== undefined ? { package_id: finding.package_id } : {}),
|
|
959
|
+
},
|
|
821
960
|
};
|
|
822
961
|
}
|
|
823
962
|
if (finding.type === "cadence-disclosure-mismatch") {
|
|
@@ -952,7 +1091,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
|
|
|
952
1091
|
.filter((page) => !pageKeys.has(String(page?.page_id ?? "page")))
|
|
953
1092
|
.map((page) => ({ page_id: present(page?.page_id) ? String(page.page_id) : null }));
|
|
954
1093
|
const extractedPriceClaims = matched.reduce(
|
|
955
|
-
(sum, { capture }) => sum + capture.price_claims.length,
|
|
1094
|
+
(sum, { capture }) => sum + capture.price_claims.length + packagePriceClaimCount(capture),
|
|
956
1095
|
0,
|
|
957
1096
|
);
|
|
958
1097
|
const extractedRecurrenceClaims = matched.reduce(
|
|
@@ -963,12 +1102,22 @@ export function createCommercialParityReport(capturesValue, journey, options = {
|
|
|
963
1102
|
(sum, { capture }) => sum + capture.vouchers.length,
|
|
964
1103
|
0,
|
|
965
1104
|
);
|
|
1105
|
+
const packagePriceCounts = {
|
|
1106
|
+
extracted: matched.reduce((sum, { capture }) => sum + packagePriceClaimCount(capture), 0),
|
|
1107
|
+
compared: 0,
|
|
1108
|
+
};
|
|
1109
|
+
const packagePriceUnresolved = {};
|
|
966
1110
|
const priceComparisonCounts = countsOnly || moneyReason
|
|
967
1111
|
? { compared: 0, unresolved: extractedPriceClaims }
|
|
968
1112
|
: matched.reduce((counts, { capture, page }) => {
|
|
969
1113
|
const comparison = priceComparisonClaims(capture, page);
|
|
970
|
-
|
|
971
|
-
|
|
1114
|
+
const packagePrices = packagePriceComparisonClaims(capture, page);
|
|
1115
|
+
packagePriceCounts.compared += packagePrices.compared.length;
|
|
1116
|
+
packagePrices.unresolved.forEach((code) => {
|
|
1117
|
+
packagePriceUnresolved[code] = (packagePriceUnresolved[code] || 0) + 1;
|
|
1118
|
+
});
|
|
1119
|
+
counts.compared += comparison.compared.length + packagePrices.compared.length;
|
|
1120
|
+
counts.unresolved += comparison.unresolved + packagePrices.unresolved.length;
|
|
972
1121
|
return counts;
|
|
973
1122
|
}, { compared: 0, unresolved: 0 });
|
|
974
1123
|
const recurrenceComparisonCounts = countsOnly || moneyReason
|
|
@@ -1007,6 +1156,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
|
|
|
1007
1156
|
extracted_price_claims: extractedPriceClaims,
|
|
1008
1157
|
compared_price_claims: priceComparisonCounts.compared,
|
|
1009
1158
|
unresolved_price_claims: priceComparisonCounts.unresolved,
|
|
1159
|
+
package_price_claims: { ...packagePriceCounts, unresolved: packagePriceUnresolved },
|
|
1010
1160
|
extracted_recurrence_claims: extractedRecurrenceClaims,
|
|
1011
1161
|
compared_recurrence_claims: recurrenceComparisonCounts.compared,
|
|
1012
1162
|
unresolved_recurrence_claims: recurrenceComparisonCounts.unresolved,
|