@nextcommerce/campaigns-os 1.53.0 → 1.55.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -11
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  6. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  7. package/contracts/commerce-surface-catalog.json +27 -27
  8. package/contracts/effects.v1.json +96 -37
  9. package/contracts/release-ledger.json +774 -0
  10. package/contracts/supported-surface.json +4 -4
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/brand-theme-bridge.md +10 -0
  13. package/docs/build-packet.md +45 -22
  14. package/docs/campaign-standardization-report.md +32 -0
  15. package/docs/campaigns-os-build-flow.md +1 -1
  16. package/docs/design-source-package.md +7 -3
  17. package/docs/effects.md +4 -6
  18. package/docs/local-setup.md +1 -1
  19. package/docs/orientation-contract-reference.md +1 -1
  20. package/docs/progress-snapshots.md +3 -1
  21. package/docs/qa-and-test-orders.md +83 -14
  22. package/docs/runtime-readiness.md +1 -1
  23. package/docs/sdk-storage-compatibility.md +15 -0
  24. package/docs/skills-revision.md +10 -10
  25. package/docs/supported-surface.md +2 -1
  26. package/docs/workflow-findings-sidecar.md +57 -2
  27. package/package.json +3 -1
  28. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  29. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  30. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  31. package/skills/campaign-readback-classification/SKILL.md +3 -3
  32. package/skills/campaign-run-evidence/SKILL.md +3 -3
  33. package/skills/contribution-intake/SKILL.md +3 -3
  34. package/skills/next-campaigns-build/SKILL.md +3 -3
  35. package/skills/next-campaigns-os/SKILL.md +3 -3
  36. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  37. package/skills/next-campaigns-polish/SKILL.md +3 -3
  38. package/skills/next-campaigns-qa/SKILL.md +4 -4
  39. package/skills.json +10 -10
  40. package/src/adapter-decision-contract.mjs +26 -12
  41. package/src/brand-theme.mjs +44 -8
  42. package/src/campaign-ecosystem.mjs +153 -31
  43. package/src/campaign-identity.mjs +33 -4
  44. package/src/cli.mjs +58 -62
  45. package/src/consent.mjs +3 -2
  46. package/src/design-source-publication.mjs +1 -1
  47. package/src/deviation.mjs +2 -1
  48. package/src/directory-lock.mjs +79 -7
  49. package/src/doctor/checks.mjs +28 -8
  50. package/src/doctor/inspect.mjs +13 -0
  51. package/src/doctor/next-step.mjs +16 -6
  52. package/src/invocation.mjs +7 -4
  53. package/src/lifecycle.mjs +88 -6
  54. package/src/local-preview-policy.mjs +6 -0
  55. package/src/login.mjs +2 -1
  56. package/src/polish-browser.mjs +8 -2
  57. package/src/polish-node.mjs +6 -1
  58. package/src/polish-readability.mjs +2 -2
  59. package/src/progress-node.mjs +3 -2
  60. package/src/qa-browser.mjs +595 -95
  61. package/src/qa-cart-entry.mjs +29 -6
  62. package/src/qa-node.mjs +177 -43
  63. package/src/qa-verdict-publish.mjs +2 -1
  64. package/src/run-record.mjs +46 -34
  65. package/src/sdk-attribute-index.mjs +111 -23
  66. package/src/sdk-markup.mjs +89 -10
  67. package/src/sdk-repin.mjs +282 -0
  68. package/src/stage-ledger.mjs +15 -3
  69. package/src/stage-record.mjs +85 -17
  70. package/src/standardization-report.mjs +26 -1
  71. package/src/target-lock.mjs +9 -1
@@ -0,0 +1,282 @@
1
+ // `sdk repin`: rewrite the hardcoded Campaign Cart SDK pins of a static
2
+ // campaign repo (one with no page-kit `_data/campaigns.json`).
3
+ //
4
+ // The references are the ones the campaign-ecosystem scan already finds: every
5
+ // http(s) Campaign Cart URL that points at a loader or dist artifact
6
+ // (loader.js, campaign-cart.css), in HTML and script files, HTML comments
7
+ // masked. Only a semver pin (`@vX.Y.Z` or `@X.Y.Z`) below the target version is
8
+ // rewritten, and only its version segment changes; the leading `v` is kept as
9
+ // written. `@latest`, `@main`, commit refs and prerelease refs are reported and
10
+ // left alone, as is a pin already at or above the target (never a downgrade).
11
+ //
12
+ // Preview is the default and writes nothing. `--apply` writes the files and a
13
+ // change record at .campaign-runtime/sdk-repin.json (schema
14
+ // campaigns-os-sdk-repin/v0) that a Run Record can cite by path and sha256.
15
+ // A repo with `_data/campaigns.json` is refused: page-kit owns that pin.
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
23
+ import {
24
+ campaignCartArtifactReferences,
25
+ isCampaignCartHtmlFile,
26
+ listCampaignCartScanFiles,
27
+ listCampaignCartSourceFiles,
28
+ loadSdkSupportPolicy,
29
+ } from "./campaign-ecosystem.mjs";
30
+ import { refused } from "./lifecycle.mjs";
31
+ import { compareVersions, relPath, unique } from "./repo-scan.mjs";
32
+
33
+ export const SDK_REPIN_SCHEMA = "campaigns-os-sdk-repin/v0";
34
+ export const SDK_REPIN_RECORD_REL_PATH = ".campaign-runtime/sdk-repin.json";
35
+ const PAGE_KIT_CAMPAIGNS_SUFFIX = "_data/campaigns.json";
36
+
37
+ const PACKAGE_VERSION = (() => {
38
+ try {
39
+ return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ })();
44
+
45
+ /** A --target-sdk value repin accepts: a released x.y.z, with or without a leading v. */
46
+ export const isRepinTargetVersion = (value) => isReleasedSdkVersion(String(value ?? "").trim().replace(/^v/, ""));
47
+
48
+ /**
49
+ * Plan the rewrite without touching the target. `targetSdk` is an x.y.z (a
50
+ * leading v is accepted); when absent the policy's preferred_minimum is the
51
+ * target, the same policy the ecosystem scan evaluates pins against. A target
52
+ * that is not a directory, or a target version that is not a released SDK
53
+ * version, is refused (a tagged refusal: one line, nothing journaled).
54
+ */
55
+ export function planSdkRepin(options = {}) {
56
+ return planWithSpans(options).plan;
57
+ }
58
+
59
+ // The plan plus the internal rewrite spans (file offsets) runSdkRepin needs;
60
+ // the spans never leave this module.
61
+ function planWithSpans({ targetRepo, targetSdk = null, sdkSupportPolicy = null } = {}) {
62
+ const target = resolve(targetRepo);
63
+ if (!existsSync(target)) throw refused(`sdk repin: --target ${targetRepo} does not exist.`);
64
+ if (!statSync(target).isDirectory()) throw refused(`sdk repin: --target ${targetRepo} is not a directory.`);
65
+ const policy = loadSdkSupportPolicy(sdkSupportPolicy);
66
+ const explicit = targetSdk !== null && targetSdk !== undefined;
67
+ const toVersion = explicit ? String(targetSdk).trim().replace(/^v/, "") : policy.preferred_minimum;
68
+ if (!isReleasedSdkVersion(toVersion)) {
69
+ throw refused(explicit
70
+ ? `sdk repin: --target-sdk ${targetSdk} is not a released SDK version (x.y.z).`
71
+ : `sdk repin: the SDK support policy (${policy.source}) names no usable preferred_minimum; pass --target-sdk <x.y.z>.`);
72
+ }
73
+ const base = {
74
+ schema_version: SDK_REPIN_SCHEMA,
75
+ target_repo: target,
76
+ to_version: toVersion,
77
+ to_version_source: explicit ? "argument" : "policy.preferred_minimum",
78
+ policy,
79
+ };
80
+
81
+ const pageKitData = findPageKitCampaignData(target);
82
+ if (pageKitData.length) {
83
+ return { spans: [], plan: {
84
+ ...base,
85
+ ok: false,
86
+ status: "refused_page_kit",
87
+ message: `${pageKitData.join(", ")} exists: this is a page-kit repo, whose Campaign Cart pin lives in _data/campaigns.json and is owned by page-kit. Use \`campaigns-os page-kit sync --packet <campaign-runtime.build.json>\` to set it from the CampaignSpec; sdk repin rewrites hardcoded pins in static repos only. Nothing was changed.`,
88
+ page_kit_data: pageKitData,
89
+ references: [],
90
+ rewrites: [],
91
+ left_alone: { not_semver: [], at_target: [], newer_than_target: [] },
92
+ files: [],
93
+ } };
94
+ }
95
+
96
+ const references = [];
97
+ for (const file of listCampaignCartSourceFiles(target)) {
98
+ const raw = readText(file);
99
+ if (raw === null) continue;
100
+ for (const entry of campaignCartArtifactReferences(raw, { isHtml: isCampaignCartHtmlFile(file) })) {
101
+ // Masking keeps offsets, so the span is the ref in the raw text; a
102
+ // mismatch would mean the two disagree, and nothing is rewritten then.
103
+ if (raw.slice(entry.ref_start, entry.ref_end) !== entry.ref) continue;
104
+ const artifact = /\.css(?:[?#]|$)/i.test(entry.url) ? "css" : "loader";
105
+ const action = !entry.version
106
+ ? "not_semver"
107
+ : compareVersions(entry.version, toVersion) < 0
108
+ ? "rewrite"
109
+ : compareVersions(entry.version, toVersion) === 0 ? "at_target" : "newer_than_target";
110
+ const newRef = action === "rewrite" ? `${entry.ref.startsWith("v") ? "v" : ""}${toVersion}` : null;
111
+ const urlRefStart = entry.ref_start - entry.url_start;
112
+ references.push({
113
+ path: relPath(target, file),
114
+ line: entry.line,
115
+ artifact,
116
+ url: entry.url,
117
+ ref: entry.ref,
118
+ version: entry.version,
119
+ action,
120
+ new_ref: newRef,
121
+ new_url: newRef ? `${entry.url.slice(0, urlRefStart)}${newRef}${entry.url.slice(urlRefStart + entry.ref.length)}` : null,
122
+ ref_start: entry.ref_start,
123
+ ref_end: entry.ref_end,
124
+ });
125
+ }
126
+ }
127
+
128
+ const rewrites = references.filter((entry) => entry.action === "rewrite");
129
+ const status = !references.length
130
+ ? "no_references"
131
+ : rewrites.length ? "changes" : "nothing_to_change";
132
+ const spans = rewrites.map(({ path, ref_start, ref_end, ref, new_ref }) => ({ path, ref_start, ref_end, ref, new_ref }));
133
+ return { spans, plan: {
134
+ ...base,
135
+ ok: true,
136
+ status,
137
+ message: statusMessage(status, toVersion),
138
+ references: references.map(publicReference),
139
+ rewrites: rewrites.map(publicReference),
140
+ left_alone: {
141
+ not_semver: references.filter((entry) => entry.action === "not_semver").map(publicReference),
142
+ at_target: references.filter((entry) => entry.action === "at_target").map(publicReference),
143
+ newer_than_target: references.filter((entry) => entry.action === "newer_than_target").map(publicReference),
144
+ },
145
+ files: unique(rewrites.map((entry) => entry.path)),
146
+ } };
147
+ }
148
+
149
+ /**
150
+ * Plan, and with `apply` write. Returns the plan plus `mode`, `applied`,
151
+ * `change_record` and (after a write) `verified`, the re-scan confirming no
152
+ * reference below the target is left.
153
+ */
154
+ export function runSdkRepin({ targetRepo, targetSdk = null, apply = false, sdkSupportPolicy = null, now = () => new Date() } = {}) {
155
+ const { plan, spans } = planWithSpans({ targetRepo, targetSdk, sdkSupportPolicy });
156
+ const result = { ...plan };
157
+ result.mode = apply ? "apply" : "preview";
158
+ result.applied = false;
159
+ result.change_record = { path: SDK_REPIN_RECORD_REL_PATH, written: false, sha256: null, record: null };
160
+ if (plan.status !== "changes") return result;
161
+
162
+ const record = buildChangeRecord(plan, now());
163
+ result.change_record.record = record;
164
+ if (!apply) return result;
165
+
166
+ const byFile = new Map();
167
+ for (const span of spans) {
168
+ if (!byFile.has(span.path)) byFile.set(span.path, []);
169
+ byFile.get(span.path).push(span);
170
+ }
171
+ // Every file is re-read and every span checked BEFORE any file is written,
172
+ // so a file that changed since the plan aborts the run with nothing written.
173
+ const updates = [];
174
+ for (const [path, fileSpans] of byFile) {
175
+ const file = join(plan.target_repo, path);
176
+ let text = readFileSync(file, "utf8");
177
+ // Right to left, so earlier offsets stay valid.
178
+ for (const span of [...fileSpans].sort((a, b) => b.ref_start - a.ref_start)) {
179
+ if (text.slice(span.ref_start, span.ref_end) !== span.ref) {
180
+ throw refused(`sdk repin: ${path} changed between the scan and the write (a pinned reference is no longer where the scan found it); no file was written and no change record was made. Re-run sdk repin to plan against the current files.`);
181
+ }
182
+ text = `${text.slice(0, span.ref_start)}${span.new_ref}${text.slice(span.ref_end)}`;
183
+ }
184
+ updates.push({ file, text });
185
+ }
186
+ for (const { file, text } of updates) writeFileSync(file, text);
187
+ const recordPath = join(plan.target_repo, SDK_REPIN_RECORD_REL_PATH);
188
+ mkdirSync(dirname(recordPath), { recursive: true });
189
+ const body = `${JSON.stringify(record, null, 2)}\n`;
190
+ writeFileSync(recordPath, body);
191
+ result.applied = true;
192
+ result.change_record.written = true;
193
+ result.change_record.sha256 = createHash("sha256").update(body).digest("hex");
194
+
195
+ const after = planSdkRepin({ targetRepo: plan.target_repo, targetSdk: plan.to_version, sdkSupportPolicy });
196
+ result.verified = after.ok && after.rewrites.length === 0;
197
+ result.message = `Rewrote ${plan.rewrites.length} reference(s) in ${plan.files.length} file(s) to ${plan.to_version}.`;
198
+ return result;
199
+ }
200
+
201
+ // The change record: what moved, with repo-relative paths only, so it can be
202
+ // committed and cited (path + sha256) by a Run Record.
203
+ function buildChangeRecord(plan, generatedAt) {
204
+ return {
205
+ schema_version: SDK_REPIN_SCHEMA,
206
+ generated_at: generatedAt.toISOString(),
207
+ campaigns_os_version: PACKAGE_VERSION,
208
+ command: "sdk repin",
209
+ to_version: plan.to_version,
210
+ to_version_source: plan.to_version_source,
211
+ from_versions: unique(plan.rewrites.map((entry) => entry.version)).sort(compareVersions),
212
+ files_touched: plan.files,
213
+ reference_count: plan.rewrites.length,
214
+ rewrites: plan.rewrites.map(({ path, line, artifact, ref, new_ref }) => ({ path, line, artifact, from: ref, to: new_ref })),
215
+ left_alone: {
216
+ not_semver: plan.left_alone.not_semver.map(({ path, line, ref }) => ({ path, line, ref })),
217
+ at_target: plan.left_alone.at_target.length,
218
+ newer_than_target: plan.left_alone.newer_than_target.map(({ path, line, ref }) => ({ path, line, ref })),
219
+ },
220
+ };
221
+ }
222
+
223
+ export function formatSdkRepinReport(result) {
224
+ const lines = [];
225
+ const header = result.mode === "apply" ? "sdk repin: apply" : "sdk repin: preview (nothing written; re-run with --apply to write)";
226
+ lines.push(header);
227
+ lines.push(`Target: ${result.target_repo}`);
228
+ lines.push(`Target SDK: ${result.to_version} (${result.to_version_source === "argument" ? "--target-sdk" : `policy preferred_minimum, ${result.policy.source}`})`);
229
+ if (result.status === "refused_page_kit") {
230
+ lines.push(`Refused: ${result.message}`);
231
+ return lines.join("\n");
232
+ }
233
+ if (result.rewrites.length) {
234
+ lines.push(`${result.applied ? "Rewrote" : "Would rewrite"} ${result.rewrites.length} reference(s) in ${result.files.length} file(s):`);
235
+ for (const entry of result.rewrites) {
236
+ lines.push(` ${entry.path}:${entry.line} ${entry.url}`);
237
+ lines.push(` -> ${entry.new_url}`);
238
+ }
239
+ }
240
+ if (result.left_alone.not_semver.length) {
241
+ lines.push(`Left alone, not semver-pinned (${result.left_alone.not_semver.length}):`);
242
+ for (const entry of result.left_alone.not_semver) lines.push(` ${entry.path}:${entry.line} @${entry.ref} ${entry.url}`);
243
+ }
244
+ if (result.left_alone.newer_than_target.length) {
245
+ lines.push(`Left alone, newer than ${result.to_version} (${result.left_alone.newer_than_target.length}):`);
246
+ for (const entry of result.left_alone.newer_than_target) lines.push(` ${entry.path}:${entry.line} @${entry.ref}`);
247
+ }
248
+ if (result.left_alone.at_target.length) lines.push(`Already at ${result.to_version}: ${result.left_alone.at_target.length} reference(s).`);
249
+ if (result.status !== "changes") lines.push(result.message);
250
+ if (result.change_record.written) {
251
+ lines.push(`Change record: ${result.change_record.path} (sha256 ${result.change_record.sha256})`);
252
+ lines.push(`Re-scan: ${result.verified ? "no pinned reference below the target remains" : "references below the target remain; inspect the files"}`);
253
+ }
254
+ return lines.join("\n");
255
+ }
256
+
257
+ function statusMessage(status, toVersion) {
258
+ if (status === "no_references") return "No Campaign Cart loader or stylesheet reference was found; nothing to change.";
259
+ if (status === "nothing_to_change") return `No semver-pinned reference is below ${toVersion}; nothing to change.`;
260
+ return `Semver-pinned references below ${toVersion} can be rewritten.`;
261
+ }
262
+
263
+ function publicReference({ path, line, artifact, url, ref, version, action, new_ref, new_url }) {
264
+ return { path, line, artifact, url, ref, version, action, new_ref, new_url };
265
+ }
266
+
267
+ // `_data/campaigns.json` at the target or under any directory the scan walks
268
+ // (a page-kit project nested in a larger repo is still page-kit's pin).
269
+ function findPageKitCampaignData(target) {
270
+ return listCampaignCartScanFiles(target)
271
+ .map((file) => relPath(target, file))
272
+ .filter((path) => path === PAGE_KIT_CAMPAIGNS_SUFFIX || path.endsWith(`/${PAGE_KIT_CAMPAIGNS_SUFFIX}`))
273
+ .sort();
274
+ }
275
+
276
+ function readText(path) {
277
+ try {
278
+ return readFileSync(path, "utf8");
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
@@ -115,7 +115,7 @@ const HISTORY_IDENTITY_FIELDS = Object.freeze([
115
115
  "source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence",
116
116
  ]);
117
117
  export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "qa run", "prepare-build --force"]);
118
- export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset"]);
118
+ export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed"]);
119
119
  const ARCHIVED_STAGE_KEYS = Object.freeze(["assembly", "polish", "qa"]);
120
120
 
121
121
  const isCompletedStatus = (status) => typeof status === "string" && status.startsWith("completed");
@@ -541,14 +541,26 @@ export function qaGateEvidence(report, gate) {
541
541
  };
542
542
  }
543
543
 
544
+ export function qaRecordedBuildFingerprint(report) {
545
+ return optionalString(report?.stages?.qa?.evidence?.qc_build_fingerprint)
546
+ || optionalString(report?.stages?.qa?.evidence?.source_build_fingerprint);
547
+ }
548
+
549
+ export function qaRecordedForCurrentBuild(report, buildFingerprint) {
550
+ const recorded = qaRecordedBuildFingerprint(report);
551
+ const current = optionalString(buildFingerprint);
552
+ return Boolean(recorded && current && recorded === current);
553
+ }
554
+
544
555
  // `qaCurrency` is QA's read-time input currency
545
556
  // (derived.input_currency.stages.qa): while QA is owed again or its inputs
546
557
  // cannot be confirmed, no QA gate pass counts.
547
558
  export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint, qaCurrency = null }) {
548
559
  if (qaCurrency === "owed" || qaCurrency === "unknown") return false;
549
560
  const outcome = qaGateEvidence(report, gate);
550
- const current = optionalString(buildFingerprint);
551
- return Boolean(outcome && outcome.status === QA_STATUS.PASS && current && outcome.source_build_fingerprint === current);
561
+ return Boolean(outcome && outcome.status === QA_STATUS.PASS
562
+ && outcome.source_build_fingerprint === optionalString(buildFingerprint)
563
+ && qaRecordedForCurrentBuild(report, buildFingerprint));
552
564
  }
553
565
 
554
566
  /**
@@ -26,11 +26,12 @@ import { fileURLToPath } from "node:url";
26
26
  import Ajv2020 from "ajv/dist/2020.js";
27
27
 
28
28
  import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
29
+ import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
29
30
  import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
30
31
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
31
32
  import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
32
33
  import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
33
- import { CARRIED_FORWARD } from "./local-preview-policy.mjs";
34
+ import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
34
35
  import { isLoopbackHostname } from "./remit.mjs";
35
36
  import { campaignRouteRoot } from "./route-identity.mjs";
36
37
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
@@ -48,7 +49,7 @@ import {
48
49
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
49
50
  import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
50
51
  import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
51
- import { applyDerivedAssemblyReportSummary, archiveStageRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
52
+ import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
52
53
  import { withTargetLockSync } from "./target-lock.mjs";
53
54
  import { commerceScopeFromScope } from "./theme-gate.mjs";
54
55
 
@@ -60,7 +61,7 @@ export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme",
60
61
  const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal", "deviation-reason"]);
61
62
  const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
62
63
  const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
63
- const BUILD_RECORD_FLAGS = Object.freeze(["build-environment"]);
64
+ const BUILD_RECORD_FLAGS = Object.freeze(["build-environment", "adapter-decision"]);
64
65
  // `record brief` saves a brief file: --brief names it (otherwise intake's
65
66
  // discovery finds it).
66
67
  const BRIEF_RECORD_FLAGS = Object.freeze(["brief"]);
@@ -130,10 +131,43 @@ export function refuseRecord(stage, problems) {
130
131
  return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
131
132
  }
132
133
 
134
+ function parseAdapterDecisions(value) {
135
+ const allowedKeys = Object.keys(ADAPTER_DECISION_SCALAR_VALUES).join(", ");
136
+ const reject = (reason, key = null) => {
137
+ const allowedValues = key && Object.hasOwn(ADAPTER_DECISION_SCALAR_VALUES, key)
138
+ ? ` Allowed values for ${key}: ${ADAPTER_DECISION_SCALAR_VALUES[key].join(", ")}.`
139
+ : "";
140
+ throw refused(`${reason} Allowed keys: ${allowedKeys}.${allowedValues}`);
141
+ };
142
+ if (typeof value !== "string" || !value) reject("--adapter-decision requires a comma-separated list of key=value pairs.");
143
+ const decisions = {};
144
+ for (const pair of value.split(",")) {
145
+ if (!pair) reject("--adapter-decision has an empty pair; use key=value pairs separated by commas.");
146
+ const equal = pair.indexOf("=");
147
+ if (equal < 0) reject(`--adapter-decision pair ${JSON.stringify(pair)} must be key=value.`);
148
+ const key = pair.slice(0, equal);
149
+ const choice = pair.slice(equal + 1);
150
+ if (!key || /\s/.test(key)) reject(`--adapter-decision key ${JSON.stringify(key)} must be a nonempty key without whitespace.`);
151
+ if (key === "wrapper_policy") reject("wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; record build cannot change it.");
152
+ if (!Object.hasOwn(ADAPTER_DECISION_SCALAR_VALUES, key)) reject(`Unknown --adapter-decision key ${JSON.stringify(key)}.`);
153
+ if (Object.hasOwn(decisions, key)) reject(`Duplicate --adapter-decision key ${JSON.stringify(key)} in one list.`);
154
+ const allowed = ADAPTER_DECISION_SCALAR_VALUES[key];
155
+ if (!choice || /\s/.test(choice) || choice.includes("=") || !allowed.includes(choice)) {
156
+ reject(`--adapter-decision ${key} must be one of: ${allowed.join(", ")} (got ${JSON.stringify(choice)}).`, key);
157
+ }
158
+ decisions[key] = choice;
159
+ }
160
+ return decisions;
161
+ }
162
+
133
163
  export function parseRecordArgs(args) {
134
164
  const stage = args._[1];
165
+ // The shared argv parser leaves an explicit empty value in positional args.
166
+ if (stage === "build" && args["adapter-decision"] === true && args._.length === 3 && args._[2] === "") {
167
+ parseAdapterDecisions("");
168
+ }
135
169
  if (!RECORD_SUBCOMMANDS.includes(stage) || args._.length !== 2) {
136
- throw refused(`Use: ${cmd("record")} <${RECORD_SUBCOMMANDS.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>], and record brief [--brief <yaml|json>].`);
170
+ throw refused(`Use: ${cmd("record")} <${RECORD_SUBCOMMANDS.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>] [--adapter-decision <key=value[,key=value...]>], and record brief [--brief <yaml|json>].`);
137
171
  }
138
172
  const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : []), ...(stage === "brief" ? BRIEF_RECORD_FLAGS : [])]);
139
173
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
@@ -151,6 +185,7 @@ export function parseRecordArgs(args) {
151
185
  if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
152
186
  throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
153
187
  }
188
+ const adapterDecisions = Object.hasOwn(args, "adapter-decision") ? parseAdapterDecisions(args["adapter-decision"]) : null;
154
189
  return {
155
190
  stage,
156
191
  packetPath: resolve(requireArg(args, "packet")),
@@ -158,6 +193,7 @@ export function parseRecordArgs(args) {
158
193
  baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
159
194
  dryRun: args["dry-run"] === true,
160
195
  buildEnvironment,
196
+ adapterDecisions,
161
197
  briefPath: stage === "brief" && Object.hasOwn(args, "brief") ? resolve(args.brief) : null,
162
198
  deviationReason: optionalString(args["deviation-reason"]),
163
199
  };
@@ -291,7 +327,8 @@ function currentStamps(inputs) {
291
327
  return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
292
328
  }
293
329
 
294
- function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, inputs = {}, deviationReason = null }) {
330
+ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
331
+ const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
295
332
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
296
333
  const previousAssembly = stageObject(report, "assembly");
297
334
  const stamps = currentStamps(inputs);
@@ -331,9 +368,13 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
331
368
  // Otherwise Polish evidence bound to this exact output stays and anything
332
369
  // else is owed again; the evidence object is kept so `polish capture` has
333
370
  // somewhere to attach page_load, and its stale identity fields are removed.
371
+ // A deploy that stamped the build it probed is owed again once the output
372
+ // differs; one with no stamp (recorded before deploy stamped it) is kept.
373
+ const deployOwed = deployAfterBuild(stageObject(report, "deploy"), fingerprint, now);
374
+ const deploy = deployOwed ? { deploy: deployOwed } : {};
334
375
  if (detected) {
335
376
  const cause = detected === "spec_material_changed" ? "spec" : "presentation";
336
- const demoted = demoteStages({ ...report, stages: { ...report.stages, assembly } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
377
+ const demoted = demoteStages({ ...nextReport, stages: { ...nextReport.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
337
378
  return { report: demoted.report, context: null };
338
379
  }
339
380
  const previousPolish = stageObject(report, "polish");
@@ -348,7 +389,29 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
348
389
  required_by: "build",
349
390
  required_for: ["qa"],
350
391
  };
351
- return { report: { ...report, stages: { ...report.stages, assembly, polish } }, context: null };
392
+ return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
393
+ }
394
+
395
+ // stages.deploy after a build of `fingerprint`, or null when it is kept: a
396
+ // completed deploy whose source_build_fingerprint names other output goes
397
+ // whole into history (archived_by "record build", reason_code
398
+ // "build_output_changed") and reads required (required_by "build",
399
+ // required_for ["qa"]) until record deploy probes this build. As a demotion
400
+ // does, the live record loses what was written by the completion (completed_at,
401
+ // recorded_by, performed_by) and what describes the old output's probe
402
+ // (source_build_fingerprint, evidence, outputs); blockers, warnings, inputs
403
+ // and commands are kept.
404
+ function deployAfterBuild(previousDeploy, fingerprint, now) {
405
+ const stamped = optionalString(previousDeploy.source_build_fingerprint);
406
+ if (!stamped || stamped === fingerprint || !REPLACED_COMPLETED_STATUSES.includes(previousDeploy.status)) return null;
407
+ const demoted = {
408
+ ...withoutKeys(previousDeploy, ["source_build_fingerprint", "evidence", "outputs", "completed_at", "recorded_by", "performed_by"]),
409
+ stage: "deploy",
410
+ status: "required",
411
+ required_by: "build",
412
+ required_for: ["qa"],
413
+ };
414
+ return archiveSupersededRecord(demoted, previousDeploy, { by: "record build", reason: "build_output_changed", at: now });
352
415
  }
353
416
 
354
417
  function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {} }) {
@@ -604,12 +667,16 @@ export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globa
604
667
  }
605
668
 
606
669
  // A recorded local preview: the packet's deploy.preview_url, and stages.deploy
607
- // completed with the URL in outputs (what next reads) and the probe as evidence.
608
- function composeDeploy(report, packet, { now, recordedBy, probe }) {
670
+ // completed with the URL in outputs (what next reads), the probe as evidence,
671
+ // and the build it probed as source_build_fingerprint (doctorFacts has checked
672
+ // it equals stages.assembly.build_fingerprint), so a later record build of
673
+ // different output makes deploy required again, as it does Polish.
674
+ function composeDeploy(report, packet, { now, recordedBy, probe, fingerprint }) {
609
675
  const deploy = {
610
676
  ...stageObject(report, "deploy"),
611
677
  stage: "deploy",
612
678
  status: "completed",
679
+ source_build_fingerprint: fingerprint,
613
680
  outputs: [probe.url],
614
681
  evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
615
682
  completed_at: now,
@@ -681,9 +748,8 @@ function ladderProblems(stage, doctor, report) {
681
748
  if (gate) return [`next answers prepare-build: ${gate.reason}`];
682
749
  const problems = [];
683
750
  for (const earlier of NEXT_STAGE_ORDER.slice(0, NEXT_STAGE_ORDER.indexOf(stage))) {
684
- // The rule next's stage picker reads: on the local preview a missing polish
685
- // is carried forward (local-preview-policy), and next moves on to deploy.
686
- if (earlier === "polish" && doctor.derived?.polish_gate?.status === CARRIED_FORWARD) continue;
751
+ // The same carried-forward rule next's stage picker reads.
752
+ if (earlier === "polish" && polishCarriedForwardForLadder(report, doctor.derived?.polish_gate)) continue;
687
753
  // The effective status: a stage owed again by an input change reads
688
754
  // required, and one whose inputs cannot be confirmed reads unknown.
689
755
  const key = reportKeyForCliStage(earlier);
@@ -822,7 +888,7 @@ function readPacketFile(stage, packetPath) {
822
888
  * composed or written.
823
889
  */
824
890
  export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
825
- const { stage, packetPath, evidencePath, dryRun, buildEnvironment, deviationReason } = parseRecordArgs(args);
891
+ const { stage, packetPath, evidencePath, dryRun, buildEnvironment, adapterDecisions, deviationReason } = parseRecordArgs(args);
826
892
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
827
893
  if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
828
894
  // Operator input, not target state: no campaigns-os writer produces it.
@@ -843,7 +909,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
843
909
  // A dry run writes nothing, so it takes no lock and creates no lock files
844
910
  // (the commitAssemblyReport preview convention); it reads in the same order.
845
911
  const run = () => recordUnderLock({
846
- stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment, deviationReason,
912
+ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment, adapterDecisions, deviationReason,
847
913
  });
848
914
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
849
915
  const { composed, facts, layer, reportPath, contextPath, after } = recorded;
@@ -861,7 +927,9 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
861
927
  `stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
862
928
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
863
929
  ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
930
+ ...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
864
931
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
932
+ ...(stage === "build" && composed.report.stages.deploy?.required_by === "build" ? [`stages.deploy.status = ${composed.report.stages.deploy.status} (the recorded deploy probed other output; run ${cmd("record")} deploy again)`] : []),
865
933
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
866
934
  ];
867
935
  return {
@@ -899,7 +967,7 @@ function recordInputs(doctor) {
899
967
  return { briefMaterial: currency?.brief?.current ?? null, specMaterial: currency?.spec?.current ?? null };
900
968
  }
901
969
 
902
- function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null, deviationReason = null }) {
970
+ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null, adapterDecisions = null, deviationReason = null }) {
903
971
  // The same workspace `next` resolves, so the record lands in the report
904
972
  // `next` reads now, not the one it read before the lock was free.
905
973
  const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
@@ -934,11 +1002,11 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
934
1002
  const next = stage === "setup"
935
1003
  ? composeSetup(report, context, { now: timestamp, recordedBy })
936
1004
  : stage === "build"
937
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, inputs: recordInputs(doctor), deviationReason })
1005
+ ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
938
1006
  : stage === "theme"
939
1007
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
940
1008
  : stage === "deploy"
941
- ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
1009
+ ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input, fingerprint: facts.fingerprint })
942
1010
  : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
943
1011
  applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
944
1012
  // The packet's one new value, deploy.preview_url, is checked by
@@ -24,6 +24,7 @@ import {
24
24
  loadSdkSupportPolicy,
25
25
  scanCampaignCartAppRoot,
26
26
  } from "./campaign-ecosystem.mjs";
27
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
27
28
  import { resolveCommerceCatalog } from "./private-template-source.mjs";
28
29
  import {
29
30
  assessTemplateFreshness,
@@ -375,7 +376,9 @@ function scanPageKitRoot({
375
376
  const bindingFiles = sourceScan.binding_files;
376
377
  let checkoutFields = null;
377
378
  if (bindingFiles.length) {
378
- checkoutFields = inspectCheckoutFields(rootPath, bindingFiles, contract, contractFindings);
379
+ checkoutFields = inspectCheckoutFields(rootPath, bindingFiles, contract, contractFindings, {
380
+ sdkVersionForFile: campaignsSdkVersionResolver(campaigns.slugs),
381
+ });
379
382
  capabilities.push("checkout_field_contract");
380
383
  }
381
384
  const root = {
@@ -504,6 +507,28 @@ function isPageKitRoot(dir) {
504
507
  return Boolean(findPageKitDependency(pkg.value));
505
508
  }
506
509
 
510
+ // The SDK version a Page Kit source file's checkout bindings run against: its
511
+ // campaign's campaigns.json sdk_version (src/<slug>/...), else, for a file no
512
+ // one campaign owns, the lowest pin across campaigns. Unknown (null) when that
513
+ // pin is missing or not an exact release (isReleasedSdkVersion, as doctor
514
+ // reads the same pin), or when any campaign lacks one; the field contract
515
+ // judges unknown as an earlier SDK.
516
+ function campaignsSdkVersionResolver(slugs) {
517
+ const exact = new Map(slugs.map((entry) => [entry.slug, isReleasedSdkVersion(entry.sdk_version) ? entry.sdk_version : null]));
518
+ const pins = [...exact.values()];
519
+ const lowest = pins.length && pins.every(Boolean)
520
+ ? { version: [...pins].sort(compareVersions)[0], source: "campaigns_json" }
521
+ : null;
522
+ return (path) => {
523
+ const [top, slug] = String(path).split("/");
524
+ if (top === "src" && exact.has(slug)) {
525
+ const version = exact.get(slug);
526
+ return version ? { version, source: "campaigns_json" } : null;
527
+ }
528
+ return lowest;
529
+ };
530
+ }
531
+
507
532
  function readCampaigns(rootPath) {
508
533
  const campaignsPath = join(rootPath, "_data", "campaigns.json");
509
534
  if (!existsSync(campaignsPath)) {
@@ -21,9 +21,17 @@ export function targetLockPath(targetRepo) {
21
21
  // `command` names the waiting command. The lock does not record which command
22
22
  // holds it, only a pid, so the holder is described generically. A lock with
23
23
  // no owner record is called out on its own: it is never taken over, and the
24
- // operator needs to know it will not clear by waiting.
24
+ // operator needs to know it will not clear by waiting. So is anything at the
25
+ // lock path that is not a lock at all, named by what it is (#514).
25
26
  function unavailable(targetRepo, lockPath, command) {
26
27
  return (error) => {
28
+ if (error?.code === "ENOTLOCK") {
29
+ return Object.assign(new Error(
30
+ `${command}: the target lock path ${lockPath} is occupied by ${error.obstruction}. `
31
+ + "It is never removed automatically. Move it out of the way, then retry.",
32
+ { cause: error },
33
+ ), { code: error.code, obstruction: error.obstruction });
34
+ }
27
35
  if (error?.code !== "EEXIST") {
28
36
  return new Error(`${command} could not take the target lock at ${lockPath}${error?.code ? ` (${error.code})` : ""}: ${error?.message}`, { cause: error });
29
37
  }