@nextcommerce/campaigns-os 1.52.0 → 1.54.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 (76) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -0
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +2 -0
  5. package/agents/copilot/copilot-instructions.md +2 -0
  6. package/agents/cursor/campaigns-os.mdc +2 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -27
  11. package/contracts/effects.v1.json +295 -47
  12. package/contracts/release-ledger.json +660 -0
  13. package/contracts/supported-surface.json +3 -3
  14. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  15. package/docs/brand-theme-bridge.md +2 -0
  16. package/docs/build-packet.md +67 -19
  17. package/docs/campaign-build-brief.md +48 -17
  18. package/docs/campaign-standardization-report.md +32 -0
  19. package/docs/design-source-package.md +7 -3
  20. package/docs/effects.md +10 -1
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +2 -0
  24. package/docs/progress-snapshots.md +5 -5
  25. package/docs/qa-and-test-orders.md +30 -3
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +15 -0
  29. package/docs/skills-revision.md +10 -10
  30. package/docs/supported-surface.md +1 -0
  31. package/package.json +1 -1
  32. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  33. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  34. package/skills/campaign-readback-classification/SKILL.md +3 -3
  35. package/skills/campaign-run-evidence/SKILL.md +3 -3
  36. package/skills/contribution-intake/SKILL.md +3 -3
  37. package/skills/next-campaigns-build/SKILL.md +5 -3
  38. package/skills/next-campaigns-os/SKILL.md +10 -19
  39. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  40. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  41. package/skills/next-campaigns-polish/SKILL.md +7 -5
  42. package/skills/next-campaigns-qa/SKILL.md +5 -3
  43. package/skills.json +10 -10
  44. package/src/brand-theme.mjs +21 -20
  45. package/src/brief-summary.mjs +275 -0
  46. package/src/build-brief.mjs +167 -15
  47. package/src/campaign-ecosystem.mjs +153 -31
  48. package/src/cli.mjs +182 -115
  49. package/src/contrast.mjs +649 -0
  50. package/src/design-source-publication.mjs +110 -3
  51. package/src/directory-lock.mjs +79 -7
  52. package/src/doctor/checks.mjs +141 -16
  53. package/src/doctor/inspect.mjs +13 -0
  54. package/src/doctor/next-step.mjs +15 -6
  55. package/src/input-currency.mjs +394 -0
  56. package/src/input-refresh.mjs +708 -0
  57. package/src/invocation.mjs +6 -2
  58. package/src/polish-browser.mjs +503 -201
  59. package/src/polish-deadline.mjs +7 -0
  60. package/src/polish-node.mjs +326 -46
  61. package/src/polish-readability.mjs +828 -0
  62. package/src/progress-node.mjs +18 -5
  63. package/src/qa-browser.mjs +211 -93
  64. package/src/qa-node.mjs +47 -7
  65. package/src/qc-accept.mjs +44 -8
  66. package/src/qc-check-registry.mjs +15 -7
  67. package/src/qc-results.mjs +326 -34
  68. package/src/readback.mjs +58 -10
  69. package/src/sdk-attribute-index.mjs +111 -23
  70. package/src/sdk-markup.mjs +49 -10
  71. package/src/sdk-repin.mjs +282 -0
  72. package/src/stage-ledger.mjs +235 -44
  73. package/src/stage-record.mjs +154 -30
  74. package/src/standardization-report.mjs +26 -1
  75. package/src/target-lock.mjs +9 -1
  76. package/src/tooling-setup.mjs +1 -1
@@ -1,25 +1,43 @@
1
1
  // The Campaign Cart SDK attribute index, vendored for the static SDK markup
2
2
  // checks (#303).
3
3
  //
4
- // Source: docs/attribute-index.md in NextCommerceCo/campaign-cart at tag
5
- // v0.4.38 (the SDK pin every certified starter family ships today), which is
6
- // generated from the feature manifests. Only the data-next-* names are kept:
7
- // the checks that read this list ask "does the SDK read this data-next-*
8
- // attribute at all", and the SDK's other attribute families (data-item-*,
9
- // data-package-*, plain data-*) are not what an agent kit misspells.
4
+ // Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
5
+ // v0.4.41 (the SDK pin every certified starter family ships today): every
6
+ // data-next-* name the SDK's non-test TypeScript names as a string, plus every
7
+ // `dataset.next*` property it reads or writes, turned back into its
8
+ // attribute. Only the data-next-* names are kept: the checks that read this
9
+ // list ask "does the SDK read this data-next-* attribute at all", and the
10
+ // SDK's other attribute families (data-item-*, data-package-*, plain data-*)
11
+ // are not what an agent kit misspells.
10
12
  //
11
13
  // This is a hardcoded copy and says so, rather than restating the SDK in
12
14
  // prose that then drifts (the partner kit's mistake): the list is regenerated
13
- // whole from the index at a named tag, never edited by hand.
15
+ // whole from the source at a named tag, never edited by hand.
14
16
  //
15
- // git -C ../campaign-cart show v0.4.38:docs/attribute-index.md \
16
- // | grep -oE '`data-[a-z0-9-]+(\s*/\s*data-[a-z0-9-]+)*`' \
17
- // | tr -d '`' | tr '/' '\n' | tr -d ' ' | grep '^data-next-' | sort -u
17
+ // paths=('src/**/*.ts' ':!src/**/*.test.ts' ':!src/**/tests/**' ':!src/docs/**')
18
+ // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.41 -- "${paths[@]}"
19
+ // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.41 -- "${paths[@]}" \
20
+ // | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
21
+ // } | sort -u
22
+ //
23
+ // Up to v0.4.40 the list came from docs/attribute-index.md, which the SDK
24
+ // generated from its feature manifests. v0.4.41 removed the manifests and the
25
+ // index (campaign-cart 5d81b4eb), and its hand-written attribute docs
26
+ // (docs/sdk-attributes.md, docs/guides/reference/data-attributes.md, each
27
+ // feature's guide/) leave out names the source still reads, such as
28
+ // data-next-package-selector and data-next-remove-item. Run at v0.4.40, the
29
+ // source recipe returns every name the v0.4.40 index listed, plus names the
30
+ // manifests never did (the address block's, data-next-i18n,
31
+ // data-next-bump-section, data-next-button-text, the variant option state).
32
+ // At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
33
+ // those and data-next-phone-e164 / data-next-phone-country.
18
34
  //
19
35
  // A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
20
36
  // suffix.
21
37
 
22
- export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.38";
38
+ import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
39
+
40
+ export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.41";
23
41
 
24
42
  export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
25
43
  "data-next-accordion",
@@ -28,8 +46,15 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
28
46
  "data-next-accordion-trigger",
29
47
  "data-next-action",
30
48
  "data-next-active",
49
+ "data-next-address",
50
+ "data-next-address-api",
51
+ "data-next-address-field",
52
+ "data-next-address-lang",
53
+ "data-next-address-row",
54
+ "data-next-address-state",
31
55
  "data-next-await",
32
56
  "data-next-bump",
57
+ "data-next-bump-section",
33
58
  "data-next-bundle-card",
34
59
  "data-next-bundle-display",
35
60
  "data-next-bundle-id",
@@ -47,6 +72,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
47
72
  "data-next-bundle-template-id",
48
73
  "data-next-bundle-vouchers",
49
74
  "data-next-bundles",
75
+ "data-next-button-text",
50
76
  "data-next-cart-item-id",
51
77
  "data-next-cart-items",
52
78
  "data-next-cart-selector",
@@ -74,6 +100,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
74
100
  "data-next-fallback",
75
101
  "data-next-format",
76
102
  "data-next-hide",
103
+ "data-next-i18n",
77
104
  "data-next-id",
78
105
  "data-next-in-cart",
79
106
  "data-next-include-shipping",
@@ -98,6 +125,8 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
98
125
  "data-next-payment-form",
99
126
  "data-next-payment-method",
100
127
  "data-next-payment-state",
128
+ "data-next-phone-country",
129
+ "data-next-phone-e164",
101
130
  "data-next-product-sync",
102
131
  "data-next-property",
103
132
  "data-next-property-container",
@@ -138,6 +167,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
138
167
  "data-next-tooltip-offset",
139
168
  "data-next-tooltip-placement",
140
169
  "data-next-tracking-tag",
170
+ "data-next-unavailable",
141
171
  "data-next-upsell",
142
172
  "data-next-upsell-action",
143
173
  "data-next-upsell-action-for",
@@ -152,17 +182,21 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
152
182
  "data-next-url",
153
183
  "data-next-validate",
154
184
  "data-next-variant-code",
185
+ "data-next-variant-name",
155
186
  "data-next-variant-option",
156
187
  "data-next-variant-option-template-id",
157
188
  "data-next-variant-options",
158
189
  "data-next-variant-selector-template-id",
159
190
  "data-next-variant-selectors",
191
+ "data-next-variant-value",
160
192
  ]);
161
193
 
162
- // The fixed data-next-checkout-field names, from the same tag. The SDK maps a
163
- // field to the order by this value, so an unlisted name is a silent no-op: the
164
- // input renders, the shopper fills it, nothing reaches the order. Sources at
165
- // v0.4.38: src/features/checkout/README.md ("Standard field names"), the
194
+ // The fixed data-next-checkout-field names. The SDK maps a field to the order
195
+ // by this value, so an unlisted name is a silent no-op: the input renders, the
196
+ // shopper fills it, nothing reaches the order. Taken at v0.4.38 and unchanged
197
+ // through v0.4.41 (the README's "Standard field names" line is the same; the
198
+ // names v0.4.39 and v0.4.41 added are the version-gated aliases below).
199
+ // Sources: src/features/checkout/README.md ("Standard field names"), the
166
200
  // checkout-form enhancer and its validation/persistence tables, the hosted
167
201
  // payment field slots (cc-number, cvv, cc-month/cc-year and their exp-*
168
202
  // spellings), payment-method, and accepts_marketing. Billing fields are the
@@ -188,7 +222,7 @@ export const SDK_CHECKOUT_FIELD_NAMES = Object.freeze([
188
222
  "exp-month",
189
223
  "exp-year",
190
224
  "cvv",
191
- // Legacy spellings the README at v0.4.38 still lists.
225
+ // Legacy spellings the README still lists at v0.4.41.
192
226
  "card-number",
193
227
  "card-expiry",
194
228
  "card-cvv",
@@ -197,6 +231,53 @@ export const SDK_CHECKOUT_FIELD_NAMES = Object.freeze([
197
231
 
198
232
  export const SDK_CHECKOUT_FIELD_PREFIXES = Object.freeze(["billing-"]);
199
233
 
234
+ // Field names a later SDK added, each with the first version that maps it.
235
+ // They are the orders API's own names, which the SDK reads as another
236
+ // spelling of its older one (src/utils/checkout-field-names.ts, SDK_NAME):
237
+ // first_name and last_name from v0.4.39 (v0.4.40 is the same SDK), and
238
+ // phone_number from v0.4.41, whose data-attributes reference ("Field names")
239
+ // makes these the names to write and says fname/lname/phone still work. The
240
+ // older names stay in SDK_CHECKOUT_FIELD_NAMES and are valid on every
241
+ // version. A page whose SDK version is unknown is judged as an earlier SDK:
242
+ // the older name works on every version, so asking for it is always safe,
243
+ // and accepting the newer name there could pass a field that never reaches
244
+ // the order. Kept in step with version_gated_aliases in
245
+ // contracts/campaign-cart-checkout-field-contract.v0.json (a test holds the
246
+ // two equal).
247
+ export const SDK_CHECKOUT_FIELD_NAMES_SINCE = Object.freeze({
248
+ first_name: Object.freeze({ since: "0.4.39", sdk_name: "fname" }),
249
+ last_name: Object.freeze({ since: "0.4.39", sdk_name: "lname" }),
250
+ phone_number: Object.freeze({ since: "0.4.41", sdk_name: "phone" }),
251
+ });
252
+
253
+ // Whether `version` is an exact released SDK version at or after `since`.
254
+ // "Exact released" is campaign-spec's RELEASED_SDK_VERSION_PATTERN, the one
255
+ // doctor's campaigns.json pin check uses, so doctor and standardize agree.
256
+ // Anything else (null, a range, a prerelease, @latest, a v prefix) is not: a
257
+ // prerelease sorts before its release, so 0.4.39-beta.1 does not carry
258
+ // 0.4.39's names.
259
+ export function sdkVersionAtLeast(version, since) {
260
+ const have = typeof version === "string" ? RELEASED_SDK_VERSION_PATTERN.exec(version) : null;
261
+ const need = typeof since === "string" ? RELEASED_SDK_VERSION_PATTERN.exec(since) : null;
262
+ if (!have || !need) return false;
263
+ for (let index = 1; index <= 3; index += 1) {
264
+ const diff = Number(have[index]) - Number(need[index]);
265
+ if (diff !== 0) return diff > 0;
266
+ }
267
+ return true;
268
+ }
269
+
270
+ // The version-gated entry for a field name, billing- prefix included
271
+ // ("billing-first_name" → first_name's entry), or null.
272
+ export function checkoutFieldNameSince(value) {
273
+ let name = String(value ?? "");
274
+ const prefix = SDK_CHECKOUT_FIELD_PREFIXES.find((candidate) => name.startsWith(candidate));
275
+ if (prefix) name = name.slice(prefix.length);
276
+ if (!Object.hasOwn(SDK_CHECKOUT_FIELD_NAMES_SINCE, name)) return null;
277
+ const entry = SDK_CHECKOUT_FIELD_NAMES_SINCE[name];
278
+ return { name, since: entry.since, sdk_name: `${prefix || ""}${entry.sdk_name}` };
279
+ }
280
+
200
281
  const exact = new Set(SDK_DATA_NEXT_ATTRIBUTES.filter((name) => !name.endsWith("-")));
201
282
  const prefixes = SDK_DATA_NEXT_ATTRIBUTES.filter((name) => name.endsWith("-"));
202
283
 
@@ -205,15 +286,20 @@ export function isIndexedSdkAttribute(name) {
205
286
  return exact.has(value) || prefixes.some((prefix) => value.startsWith(prefix) && value.length > prefix.length);
206
287
  }
207
288
 
208
- export function isKnownCheckoutFieldName(value) {
289
+ // Whether the SDK maps `value`. `sdkVersion` is the page's exact SDK
290
+ // version; without one, a version-gated name is not known (see
291
+ // SDK_CHECKOUT_FIELD_NAMES_SINCE).
292
+ export function isKnownCheckoutFieldName(value, sdkVersion = null) {
209
293
  const name = String(value ?? "");
210
- return SDK_CHECKOUT_FIELD_NAMES.includes(name)
211
- || SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length);
294
+ if (SDK_CHECKOUT_FIELD_NAMES.includes(name)
295
+ || SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length)) return true;
296
+ const gated = checkoutFieldNameSince(name);
297
+ return Boolean(gated) && sdkVersionAtLeast(sdkVersion, gated.since);
212
298
  }
213
299
 
214
300
  // The Campaign Cart template placeholders, for the raw cart placeholder check
215
301
  // (built_output.cart_placeholders). Generated from the SDK renderers at the
216
- // same tag as the attribute index above, v0.4.38:
302
+ // v0.4.38 (line numbers are at that tag):
217
303
  //
218
304
  // src/features/cart/cart-summary/cart-summary.renderer.ts:46-83 (bare vars)
219
305
  // src/features/cart/cart-summary/cart-summary.line-renderer.ts:150-173,
@@ -278,6 +364,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
278
364
  });
279
365
 
280
366
  // The SDK versions whose renderer files above were verified unchanged from
281
- // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39 and v0.4.40). A page whose
282
- // loader pins another version cannot pass the placeholder check.
283
- export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40"]);
367
+ // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40 and v0.4.41, for
368
+ // the eight renderer files cited above plus cart-item-list.enhancer.ts and
369
+ // order-item-list.enhancer.ts). A page whose loader pins another version
370
+ // cannot pass the placeholder check.
371
+ export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41"]);
@@ -22,7 +22,12 @@
22
22
  // WRONG_FIELD_NAME data-next-checkout-field with a value the SDK
23
23
  // does not map (firstName, lastName, zip …). The
24
24
  // input renders, the value never reaches the
25
- // order.
25
+ // order. first_name, last_name and phone_number
26
+ // are judged against the page's SDK version
27
+ // (SDK_CHECKOUT_FIELD_NAMES_SINCE): the page's
28
+ // own exact loader pin, else the campaign's
29
+ // campaigns.json sdk_version, else unknown, which
30
+ // is judged as an earlier SDK.
26
31
  // MISSING_SELECTOR_ID_MATCH an add-to-cart button whose data-next-selector-id
27
32
  // names no selector on the page. The button waits
28
33
  // for a selection that can never arrive.
@@ -64,8 +69,13 @@
64
69
 
65
70
  import { parse } from "parse5";
66
71
 
72
+ // cart-placeholders.mjs imports this module's template constants as well;
73
+ // both sides read the other's bindings only inside functions, so the cycle
74
+ // is safe at load.
75
+ import { readLoaderPin } from "./cart-placeholders.mjs";
67
76
  import {
68
77
  SDK_ATTRIBUTE_INDEX_VERSION,
78
+ checkoutFieldNameSince,
69
79
  isIndexedSdkAttribute,
70
80
  isKnownCheckoutFieldName,
71
81
  } from "./sdk-attribute-index.mjs";
@@ -158,10 +168,13 @@ function describe(entry) {
158
168
  /**
159
169
  * Scan one built page. Returns findings with { code_name, code, severity,
160
170
  * page_id, file, message, detail } and the set of unknown data-next-* names.
171
+ * `sdk_version` is the campaign's SDK pin, used when the page carries no
172
+ * exact loader pin of its own.
161
173
  */
162
- export function scanPageMarkup({ page_id, file = null, content = "" }) {
174
+ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version = null }) {
163
175
  const document = parse(String(content || ""));
164
176
  const where = file || page_id;
177
+ const sdk = resolvePageSdkVersion(document, sdk_version);
165
178
  const findings = [];
166
179
  const unknown = new Set();
167
180
 
@@ -187,10 +200,8 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
187
200
 
188
201
  if (a.has("data-next-checkout-field")) {
189
202
  const value = a.get("data-next-checkout-field") ?? "";
190
- if (!isKnownCheckoutFieldName(value)) {
191
- findings.push(finding("WRONG_FIELD_NAME", page_id, where,
192
- `data-next-checkout-field="${value}" on ${where} is not a field name the SDK ${SDK_ATTRIBUTE_INDEX_VERSION} maps (${suggestFieldName(value)}). The input renders and the value never reaches the order.`,
193
- { value, suggestion: suggestFieldName(value, true) }));
203
+ if (!isKnownCheckoutFieldName(value, sdk.version)) {
204
+ findings.push(wrongFieldName(page_id, where, value, sdk));
194
205
  }
195
206
  }
196
207
 
@@ -291,6 +302,32 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
291
302
  return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
292
303
  }
293
304
 
305
+ // The SDK version a page runs: its own exact loader pin, else the campaign
306
+ // pin handed in, else null (unknown).
307
+ function resolvePageSdkVersion(document, campaignVersion) {
308
+ const page = readLoaderPin(document);
309
+ if (page) return { version: page, source: "loader" };
310
+ const campaign = typeof campaignVersion === "string" && campaignVersion.trim() ? campaignVersion.trim() : null;
311
+ if (campaign) return { version: campaign, source: "campaigns_json" };
312
+ return { version: null, source: null };
313
+ }
314
+
315
+ function wrongFieldName(page_id, where, value, sdk) {
316
+ const gated = checkoutFieldNameSince(value);
317
+ const versionDetail = { sdk_version: sdk.version, sdk_version_source: sdk.source };
318
+ if (!gated) {
319
+ return finding("WRONG_FIELD_NAME", page_id, where,
320
+ `data-next-checkout-field="${value}" on ${where} is not a field name the SDK ${SDK_ATTRIBUTE_INDEX_VERSION} maps (${suggestFieldName(value)}). The input renders and the value never reaches the order.`,
321
+ { value, suggestion: suggestFieldName(value, true), ...versionDetail });
322
+ }
323
+ const running = sdk.version
324
+ ? `this page loads SDK ${sdk.version} (${sdk.source === "loader" ? "its loader pin" : "the campaigns.json sdk_version"})`
325
+ : "this page's SDK version could not be read (no exact loader pin on the page and no sdk_version in campaigns.json), so it is judged as an earlier SDK";
326
+ return finding("WRONG_FIELD_NAME", page_id, where,
327
+ `data-next-checkout-field="${value}" on ${where} is a field name the SDK maps only from ${gated.since}, and ${running}. The input renders and the value never reaches the order. Use "${gated.sdk_name}", which every SDK version maps, or pin the SDK to ${gated.since} or later.`,
328
+ { value, suggestion: gated.sdk_name, since: gated.since, ...versionDetail });
329
+ }
330
+
294
331
  function finding(codeName, page_id, file, message, detail = {}) {
295
332
  const { code, severity } = SDK_MARKUP_CODES[codeName];
296
333
  return { code_name: codeName, code, severity, page_id, file, message: `${codeName}: ${message}`, detail };
@@ -318,11 +355,13 @@ function suggestFieldName(value, bare = false) {
318
355
  /**
319
356
  * Evaluate the SDK markup family over built pages.
320
357
  *
321
- * @param {{ subject: object, pages: Array<{ page_id: string, file?: string|null, content: string }> }} input
358
+ * @param {{ subject: object, pages: Array<{ page_id: string, file?: string|null, content: string }>, sdkVersion?: string|null }} input
359
+ * `sdkVersion` is the campaign's SDK pin (campaigns.json sdk_version), the
360
+ * fallback for a page with no exact loader pin of its own.
322
361
  */
323
- export function evaluateSdkMarkup({ subject, pages = [] } = {}) {
362
+ export function evaluateSdkMarkup({ subject, pages = [], sdkVersion = null } = {}) {
324
363
  const resolvedSubject = subject && typeof subject === "object" ? subject : {};
325
- const base = { id: SDK_MARKUP, scope: SDK_MARKUP, waivable: false, subject: resolvedSubject, waiver: null, sdk_attribute_index_version: SDK_ATTRIBUTE_INDEX_VERSION };
364
+ const base = { id: SDK_MARKUP, scope: SDK_MARKUP, waivable: false, subject: resolvedSubject, waiver: null, sdk_attribute_index_version: SDK_ATTRIBUTE_INDEX_VERSION, campaign_sdk_version: typeof sdkVersion === "string" && sdkVersion.trim() ? sdkVersion.trim() : null };
326
365
  const list = Array.isArray(pages) ? pages : [];
327
366
 
328
367
  if (list.length === 0) {
@@ -333,7 +372,7 @@ export function evaluateSdkMarkup({ subject, pages = [] } = {}) {
333
372
  const warned = [];
334
373
  const unknown = new Map();
335
374
  for (const page of list) {
336
- const scan = scanPageMarkup(page);
375
+ const scan = scanPageMarkup({ ...page, sdk_version: page?.sdk_version ?? sdkVersion });
337
376
  for (const item of scan.findings) (item.severity === "error" ? findings : warned).push(item);
338
377
  for (const name of scan.unknown_attributes) {
339
378
  if (!unknown.has(name)) unknown.set(name, []);
@@ -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
+ }