@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.
- package/CHANGELOG.md +145 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -11
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +774 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +45 -22
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +4 -6
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +83 -14
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- 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.json +10 -10
- package/src/adapter-decision-contract.mjs +26 -12
- package/src/brand-theme.mjs +44 -8
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +58 -62
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +28 -8
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +7 -4
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +6 -0
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +6 -1
- package/src/polish-readability.mjs +2 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +595 -95
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +177 -43
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +89 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +15 -3
- package/src/stage-record.mjs +85 -17
- package/src/standardization-report.mjs +26 -1
- 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
|
+
}
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -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
|
-
|
|
551
|
-
|
|
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
|
/**
|
package/src/stage-record.mjs
CHANGED
|
@@ -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 {
|
|
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({ ...
|
|
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: { ...
|
|
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)
|
|
608
|
-
|
|
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
|
|
685
|
-
|
|
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)) {
|
package/src/target-lock.mjs
CHANGED
|
@@ -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
|
}
|