@nextcommerce/campaigns-os 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 +5 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +189 -10
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
|
@@ -7,6 +7,7 @@ import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
|
7
7
|
import { resolve, relative, dirname, posix, sep } from 'node:path';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { isUtf8 } from 'node:buffer';
|
|
10
|
+
import { singleLineFragment } from './text-safety.mjs';
|
|
10
11
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
11
12
|
const version = value => {
|
|
12
13
|
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value))
|
|
@@ -49,6 +50,9 @@ function normalizeScope(value) {
|
|
|
49
50
|
throw new Error('Scope and exclusions must be literal repository-relative files or directories.');
|
|
50
51
|
return value.replace(/^\.\//, '').replace(/\/$/, '') || '.';
|
|
51
52
|
}
|
|
53
|
+
const PREFIX_SCAN_REASONS = new Set(['dynamic-key', 'unsupported-storage-method', 'storage-object-escape']);
|
|
54
|
+
const LOOPS = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
|
|
55
|
+
const ITERATION_METHODS = new Set(['forEach', 'filter', 'map', 'flatMap', 'find', 'findIndex', 'some', 'every', 'reduce']);
|
|
52
56
|
const inside = (path, scope) => scope === '.' || path === scope || path.startsWith(scope + '/');
|
|
53
57
|
export function readStorageManifest(path) {
|
|
54
58
|
const bytes = readFileSync(path);
|
|
@@ -219,7 +223,72 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
|
|
|
219
223
|
else
|
|
220
224
|
reason = 'literal-unaffected-key';
|
|
221
225
|
}
|
|
222
|
-
|
|
226
|
+
const prefixScan = status === 'unknown' && !storage.shadowed && PREFIX_SCAN_REASONS.has(reason) ? sdkPrefixScan(storage.name, node) : null;
|
|
227
|
+
emit(node, { operation, area: storage.name, key, status, reason, replacement, ...(prefixScan && { prefixScan }) });
|
|
228
|
+
}
|
|
229
|
+
// A loop that matches storage keys by a scoped SDK key's prefix: its key()/getItem(k) lines stay unknown, but are named.
|
|
230
|
+
// A prefix test counts only when it tests a key the loop read from storage itself: a `k = storage.key(i)` binding,
|
|
231
|
+
// a for-of/for-in variable over `Object.keys(storage)` or `storage`, or the element parameter of an array callback
|
|
232
|
+
// chained on `Object.keys(storage)`. A line is named only when it is that key's source or reads that key.
|
|
233
|
+
// Names are matched within the loop, not resolved through scopes; it is a hint only.
|
|
234
|
+
const stack = [];
|
|
235
|
+
const prefixCache = new WeakMap();
|
|
236
|
+
const callName = call => call?.type === 'CallExpression' && call.callee.type === 'MemberExpression' ? (call.callee.computed ? literal(call.callee.property, scopes.get(call)) : call.callee.property.name) : null;
|
|
237
|
+
const keysOf = node => node?.type === 'CallExpression' && node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.object.name === 'Object' && callName(node) === 'keys' ? node.arguments[0] : null;
|
|
238
|
+
function keySource(node) {
|
|
239
|
+
// Object.keys(storage).filter(...).forEach(...): the chain bottoms out at Object.keys.
|
|
240
|
+
while (node?.type === 'CallExpression' && ITERATION_METHODS.has(callName(node)))
|
|
241
|
+
node = node.callee.object;
|
|
242
|
+
return keysOf(node);
|
|
243
|
+
}
|
|
244
|
+
function prefixTests(loop) {
|
|
245
|
+
if (prefixCache.has(loop)) return prefixCache.get(loop);
|
|
246
|
+
const keys = new Map(), sources = new Map(), tests = [];
|
|
247
|
+
// source: the storage reference the key came from (the key() call, or the storage in Object.keys(storage) / for-in).
|
|
248
|
+
const bind = (id, storageNode, source = storageNode) => {
|
|
249
|
+
const storage = storageNode && area(storageNode, scopes.get(storageNode));
|
|
250
|
+
if (id?.type !== 'Identifier' || !storage) return;
|
|
251
|
+
keys.set(id.name, storage.name);
|
|
252
|
+
sources.set(source, id.name);
|
|
253
|
+
};
|
|
254
|
+
const loopVariable = left => left?.type === 'VariableDeclaration' ? left.declarations[0]?.id : left;
|
|
255
|
+
(function walk(n) {
|
|
256
|
+
if (n.type === 'VariableDeclarator' && callName(n.init) === 'key')
|
|
257
|
+
bind(n.id, n.init.callee.object, n.init);
|
|
258
|
+
if (n.type === 'AssignmentExpression' && n.operator === '=' && callName(n.right) === 'key')
|
|
259
|
+
bind(n.left, n.right.callee.object, n.right);
|
|
260
|
+
if (n.type === 'ForOfStatement')
|
|
261
|
+
bind(loopVariable(n.left), keysOf(n.right));
|
|
262
|
+
if (n.type === 'ForInStatement')
|
|
263
|
+
bind(loopVariable(n.left), n.right);
|
|
264
|
+
if (n.type === 'CallExpression' && ITERATION_METHODS.has(callName(n)) && ['ArrowFunctionExpression', 'FunctionExpression'].includes(n.arguments[0]?.type))
|
|
265
|
+
bind(n.arguments[0].params[callName(n) === 'reduce' ? 1 : 0], keySource(n.callee.object));
|
|
266
|
+
if (callName(n) === 'startsWith')
|
|
267
|
+
tests.push({ receiver: n.callee.object, prefix: literal(n.arguments[0], scopes.get(n)) });
|
|
268
|
+
if (n.type === 'BinaryExpression' && ['===', '==', '!==', '!='].includes(n.operator))
|
|
269
|
+
for (const [call, zero] of [[n.left, n.right], [n.right, n.left]])
|
|
270
|
+
if (zero.type === 'Literal' && zero.value === 0 && callName(call) === 'indexOf' && call.arguments.length === 1)
|
|
271
|
+
tests.push({ receiver: call.callee.object, prefix: literal(call.arguments[0], scopes.get(call)) });
|
|
272
|
+
for (const child of children(n)) walk(child);
|
|
273
|
+
})(loop);
|
|
274
|
+
const prefixes = tests.filter(t => t.prefix && t.receiver.type === 'Identifier' && keys.has(t.receiver.name)).map(t => ({ name: t.receiver.name, prefix: t.prefix, area: keys.get(t.receiver.name) }));
|
|
275
|
+
const result = { prefixes, sources };
|
|
276
|
+
prefixCache.set(loop, result);
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
function sdkPrefixScan(storageName, node) {
|
|
280
|
+
// The key this line reads (getItem(k), storage[k]), if it is a plain name.
|
|
281
|
+
const argument = node.type === 'CallExpression' ? node.arguments[0] : node.type === 'MemberExpression' && node.computed ? node.property : null;
|
|
282
|
+
// Nearest matching loop wins; a loop that does not test this line's key passes to the one around it.
|
|
283
|
+
for (const loop of [...stack].reverse()) {
|
|
284
|
+
if (!LOOPS.has(loop.type) && !(loop.type === 'CallExpression' && ITERATION_METHODS.has(callName(loop)))) continue;
|
|
285
|
+
const { prefixes, sources } = prefixTests(loop);
|
|
286
|
+
const names = [sources.get(node), argument?.type === 'Identifier' ? argument.name : null];
|
|
287
|
+
const tested = prefixes.filter(t => t.area === storageName && names.includes(t.name)).map(t => t.prefix);
|
|
288
|
+
const entry = manifest.keys.find(e => e.scoped && e.areas.includes(storageName) && e.key.split('{')[0] && tested.includes(e.key.split('{')[0]));
|
|
289
|
+
if (entry) return { prefix: entry.key.split('{')[0], key: entry.key, replacement: entry.replacement ?? null };
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
223
292
|
}
|
|
224
293
|
const handled = new WeakSet();
|
|
225
294
|
const aliasInitialization = (node, parent) => parent?.type === 'VariableDeclarator' && parent.init === node && parent.id.type === 'Identifier' && binding(parent.id.name, scopes.get(parent))?.init === node;
|
|
@@ -229,6 +298,7 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
|
|
|
229
298
|
(['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(parent?.type) && parent.label === node)
|
|
230
299
|
);
|
|
231
300
|
function visit(node, parent) {
|
|
301
|
+
stack.push(node);
|
|
232
302
|
const scope = scopes.get(node);
|
|
233
303
|
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
|
|
234
304
|
const member = node.callee, storage = area(member.object, scope), method = member.computed ? literal(member.property, scope) : member.property.name;
|
|
@@ -258,6 +328,7 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
|
|
|
258
328
|
}
|
|
259
329
|
for (const child of children(node))
|
|
260
330
|
visit(child, node);
|
|
331
|
+
stack.pop();
|
|
261
332
|
}
|
|
262
333
|
visit(ast, null);
|
|
263
334
|
return findings;
|
|
@@ -291,6 +362,19 @@ function frontmatterScripts(text) {
|
|
|
291
362
|
// linePos is 1-based within the YAML block, which starts on the file's line 2.
|
|
292
363
|
return { scripts: scripts.items.map(item => ({ value: item.value, line: lineCounter.linePos(item.range[0]).line + 1 })) };
|
|
293
364
|
}
|
|
365
|
+
const INVENTORY_MAX_BYTES = 256 * 1024 * 1024;
|
|
366
|
+
const INVENTORY_DETAIL_MAX = 200;
|
|
367
|
+
|
|
368
|
+
// A missing git already fails at rev-parse, above the inventory.
|
|
369
|
+
export function inventoryFailure(error) {
|
|
370
|
+
if (error.code === 'ENOBUFS') return `the tracked-file list under --scope is larger than ${INVENTORY_MAX_BYTES / 1024 / 1024} MiB; narrow --scope.`;
|
|
371
|
+
const stderr = typeof error.stderr === 'string' ? error.stderr : error.stderr?.toString() ?? '';
|
|
372
|
+
const detail = singleLineFragment(stderr.trim().split('\n')[0], `git exited ${error.status ?? error.signal ?? error.code ?? 'abnormally'} with no message`);
|
|
373
|
+
const chars = Array.from(detail);
|
|
374
|
+
const bounded = chars.length > INVENTORY_DETAIL_MAX ? `${chars.slice(0, INVENTORY_DETAIL_MAX).join('')}…` : detail;
|
|
375
|
+
return `${bounded.replace(/\.$/, '')}.`;
|
|
376
|
+
}
|
|
377
|
+
|
|
294
378
|
export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
|
|
295
379
|
version(targetSdkVersion);
|
|
296
380
|
if (!Array.isArray(scope) || !scope.length)
|
|
@@ -305,16 +389,22 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
|
|
|
305
389
|
if (rootIdentity.dev !== targetIdentity.dev || rootIdentity.ino !== targetIdentity.ino)
|
|
306
390
|
throw new Error('--target must be the Git repository root.');
|
|
307
391
|
const { value: manifest, evidence } = readStorageManifest(manifestPath);
|
|
392
|
+
// Inventory only the scope: a whole-repository list can pass Node's 1 MiB
|
|
393
|
+
// default buffer on a large repository (#717). Scopes are literal paths.
|
|
308
394
|
let tracked;
|
|
309
|
-
try {
|
|
310
|
-
|
|
395
|
+
try {
|
|
396
|
+
tracked = execFileSync('git', ['--literal-pathspecs', '-C', root, 'ls-files', '-z', '--', ...scope], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: INVENTORY_MAX_BYTES }).split('\0').filter(Boolean);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw new Error(`Cannot inventory tracked Git source files: ${inventoryFailure(error)}`);
|
|
399
|
+
}
|
|
311
400
|
for (const item of scope)
|
|
312
401
|
if (!tracked.some(p => inside(p, item)))
|
|
313
402
|
throw new Error(`Scope has no tracked files: ${item}`);
|
|
314
403
|
const selected = tracked.filter(p => /\.(?:html?|[cm]?js)$/i.test(p) && scope.some(s => inside(p, s)) && !exclude.some(s => inside(p, s))).sort();
|
|
315
404
|
const files = [], findings = [];
|
|
316
405
|
const unknown = (path, reason, detail, line = 1) => findings.push({ path, line, column: 1, operation: 'unknown', area: null, key: null, status: 'unknown', reason, detail, replacement: null });
|
|
317
|
-
|
|
406
|
+
const targetCovered = compare(targetSdkVersion, manifest.supportedSdkVersions.min) >= 0 && compare(targetSdkVersion, manifest.supportedSdkVersions.max) <= 0;
|
|
407
|
+
if (!targetCovered)
|
|
318
408
|
unknown(evidence.path, 'target-outside-manifest-range', targetSdkVersion);
|
|
319
409
|
if (!selected.length)
|
|
320
410
|
unknown('.', 'empty-source-scope', 'No tracked HTML or JavaScript files selected.');
|
|
@@ -412,9 +502,28 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
|
|
|
412
502
|
html(document);
|
|
413
503
|
}
|
|
414
504
|
const status = findings.some(f => f.status === 'incompatible') ? 'incompatible' : findings.some(f => f.status === 'unknown') ? 'unknown' : 'source-compatible';
|
|
415
|
-
return { schemaVersion: 1, targetSdkVersion, manifest: evidence, scope, exclude, files, findings, status, evidenceBoundary: 'Static tracked source compatibility only; no browser, order, or analytics proof.' };
|
|
505
|
+
return { schemaVersion: 1, targetSdkVersion, targetCovered, manifest: evidence, scope, exclude, files, findings, status, evidenceBoundary: 'Static tracked source compatibility only; no browser, order, or analytics proof.' };
|
|
416
506
|
}
|
|
507
|
+
const sdkRange = ({ min, max }) => min === max ? min : `${min} to ${max}`;
|
|
417
508
|
export function formatStorageCompatibilityReport(result) {
|
|
418
509
|
const counts = status => result.findings.filter(f => f.status === status).length;
|
|
419
|
-
|
|
510
|
+
const target = result.targetSdkVersion, covered = sdkRange(result.manifest.supportedSdkVersions);
|
|
511
|
+
const outside = result.targetCovered === false;
|
|
512
|
+
const header = outside
|
|
513
|
+
? [`SDK storage compatibility: ${result.status} (checked against SDK ${covered}, not the requested target ${target})`, `The manifest (Campaign Cart ${result.manifest.sdkVersion}) declares support for SDK ${covered} only, so this report does not speak for ${target}.`]
|
|
514
|
+
: [`SDK storage compatibility: ${result.status} (target ${target})`];
|
|
515
|
+
const summary = `${result.files.length} tracked source files; ${counts('incompatible')} incompatible, ${counts('unknown')} unknown, ${counts('unaffected')} unaffected storage accesses${outside ? ` (against SDK ${covered}, not ${target})` : ''}.`;
|
|
516
|
+
const line = f => `${f.path}:${f.line}:${f.column} ${f.status}: ${f.reason}${f.key ? ` (${f.key})` : ''}${f.detail ? ` — ${f.detail}` : ''}${f.prefixScan ? ` [prefix loop: ${f.prefixScan.prefix}]` : ''}`;
|
|
517
|
+
return [...header, summary, `Manifest ${result.manifest.sha256} (${result.manifest.source.verification})`, ...result.findings.filter(f => f.status !== 'unaffected').map(line), ...prefixScanNote(result.findings), result.evidenceBoundary].join('\n');
|
|
518
|
+
}
|
|
519
|
+
// One note for every [prefix loop] line, so a reader can tell them from unknowns with no known shape.
|
|
520
|
+
function prefixScanNote(findings) {
|
|
521
|
+
const scans = findings.filter(f => f.prefixScan);
|
|
522
|
+
if (!scans.length) return [];
|
|
523
|
+
const prefixes = [...new Set(scans.map(f => f.prefixScan.prefix))];
|
|
524
|
+
const replacements = [...new Map(scans.filter(f => f.prefixScan.replacement).map(f => [f.prefixScan.prefix, f.prefixScan.replacement])).entries()];
|
|
525
|
+
return [
|
|
526
|
+
`${scans.length} unknown ${scans.length === 1 ? 'line is' : 'lines are'} [prefix loop]: a loop that matches storage keys starting with ${prefixes.join(', ')}. Such a loop finds the SDK key with or without its scope suffix, so the scoping rename alone does not break it. It stays unknown because on an origin shared with another campaign, or another top folder of this one, the same prefix also matches that copy; check which key the loop takes.`,
|
|
527
|
+
...replacements.map(([prefix, r]) => `For ${prefix}, the manifest names the public ${r.export} export instead (${r.guide}).`),
|
|
528
|
+
];
|
|
420
529
|
}
|
|
@@ -559,6 +559,20 @@ function addSpecHints(mapping, page) {
|
|
|
559
559
|
if (variantLabels) mapping.variant_labels = variantLabels;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
+
/**
|
|
563
|
+
* The page-kit projection of every page in `specPages` (a Map of page id to
|
|
564
|
+
* projection), as intake projects a mapped page: target path, public route,
|
|
565
|
+
* page_type and the frontmatter links to the other pages. `record spec`
|
|
566
|
+
* re-projects with it when routes move or, after handoff, when pages are
|
|
567
|
+
* added or removed (#531, #721); every page is re-projected because a page's
|
|
568
|
+
* next_url and decline_url read the routes of the pages it links to.
|
|
569
|
+
*/
|
|
570
|
+
export function projectPageKitTargets({ specPages = [], publicRouteSlug, outputDir }) {
|
|
571
|
+
const pages = Array.isArray(specPages) ? specPages : [];
|
|
572
|
+
const pageById = new Map(pages.map((page) => [page.id, page]));
|
|
573
|
+
return new Map(pages.map((page) => [page.id, pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages: pages })]));
|
|
574
|
+
}
|
|
575
|
+
|
|
562
576
|
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
|
|
563
577
|
const specRoute = publicRouteForPage(page);
|
|
564
578
|
const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
|
|
@@ -707,7 +721,7 @@ function normalizeRootedRoute(value) {
|
|
|
707
721
|
return normalized ? `/${normalized}` : "/";
|
|
708
722
|
}
|
|
709
723
|
|
|
710
|
-
function cpkPageTypeForSpecType(type) {
|
|
724
|
+
export function cpkPageTypeForSpecType(type) {
|
|
711
725
|
if (type === "presell" || type === "landing") return "product";
|
|
712
726
|
if (type === "thankyou" || type === "receipt") return "receipt";
|
|
713
727
|
if (type === "upsell" || type === "downsell") return "upsell";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { normalizeDesignFileReference } from "./design-file-reference.mjs";
|
|
2
3
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
|
|
@@ -165,6 +166,12 @@ function validateManifestPage(entry, index, add, addWarning = () => {}) {
|
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
warnOnUnusableScreenshotRecords(entry, location, addWarning);
|
|
169
|
+
if (Object.hasOwn(entry, "design_file")) {
|
|
170
|
+
const { declaration, warnings } = normalizeDesignFileReference(entry.design_file);
|
|
171
|
+
for (const detail of warnings) {
|
|
172
|
+
addWarning(`${location}.design_file`, `${location}.design_file (page_id ${JSON.stringify(entry.page_id)}) ${declaration ? "has an ignored frame" : "is ignored as a design-file declaration"}: ${detail}. The rest of the manifest is used as written.`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
// Screenshot records are optional proof, so a malformed one is not a reason to
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { constants, openSync, fstatSync, readSync, closeSync, realpathSync, lstatSync } from "node:fs";
|
|
3
|
+
import { realpath, stat, lstat, open } from "node:fs/promises";
|
|
4
|
+
import { resolve, relative, isAbsolute, extname } from "node:path";
|
|
5
|
+
import { VISUAL_MAX_REFERENCE_BYTES } from "./png-header.mjs";
|
|
6
|
+
|
|
7
|
+
// Declared inputs (packet, manifest and campaign files) are opened without
|
|
8
|
+
// blocking and read only when the opened descriptor is a regular file within
|
|
9
|
+
// `maxBytes`: a FIFO, socket, device or directory is refused before any read,
|
|
10
|
+
// so no declared path can hold the event loop ahead of a deadline.
|
|
11
|
+
// With a `root`, the path is opened first and only then resolved: the
|
|
12
|
+
// descriptor must be the very file (dev and ino) the contained realpath names,
|
|
13
|
+
// so a path replaced between the open and the check is refused, never read.
|
|
14
|
+
// `noFollow` also refuses a final component that is a link.
|
|
15
|
+
export const DECLARED_INPUT_MAX_BYTES = VISUAL_MAX_REFERENCE_BYTES;
|
|
16
|
+
const READ_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
17
|
+
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
18
|
+
const refusal = (code) => Object.assign(new Error(code), { code });
|
|
19
|
+
const inside = (root, path) => { const rel = relative(root, path); return !isAbsolute(rel) && rel !== ".." && !rel.startsWith("../"); };
|
|
20
|
+
// The resolved target is read with lstat: a final component swapped for a link
|
|
21
|
+
// after resolution is that link's own inode, which no opened file matches.
|
|
22
|
+
const sameFile = (info, target) => !target.isSymbolicLink() && info.dev === target.dev && info.ino === target.ino;
|
|
23
|
+
export function readRegularFileSync(path, { maxBytes = DECLARED_INPUT_MAX_BYTES, statOnly = false, root = null, noFollow = false } = {}) {
|
|
24
|
+
const fd = openSync(path, READ_FLAGS | (noFollow ? NO_FOLLOW : 0));
|
|
25
|
+
try {
|
|
26
|
+
const info = fstatSync(fd);
|
|
27
|
+
if (!info.isFile()) throw refusal("not_regular_file");
|
|
28
|
+
if (root !== null) {
|
|
29
|
+
const resolved = realpathSync(path);
|
|
30
|
+
if (!inside(realpathSync(root), resolved) || !sameFile(info, lstatSync(resolved))) throw refusal("outside_root");
|
|
31
|
+
}
|
|
32
|
+
if (info.size > maxBytes) throw refusal("too_large");
|
|
33
|
+
if (statOnly) return null;
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let length = 0;
|
|
36
|
+
while (length <= maxBytes) {
|
|
37
|
+
const chunk = Buffer.alloc(Math.min(65536, maxBytes + 1 - length));
|
|
38
|
+
const n = readSync(fd, chunk, 0, chunk.length, null);
|
|
39
|
+
if (!n) break;
|
|
40
|
+
chunks.push(chunk.subarray(0, n)); length += n;
|
|
41
|
+
}
|
|
42
|
+
if (length > maxBytes) throw refusal("too_large");
|
|
43
|
+
return Buffer.concat(chunks, length);
|
|
44
|
+
} finally { closeSync(fd); }
|
|
45
|
+
}
|
|
46
|
+
// Whether a read of `path` would reach its bytes; the size is not judged here.
|
|
47
|
+
export const isRegularFileSync = (path, { root = null } = {}) => {
|
|
48
|
+
try { readRegularFileSync(path, { statOnly: true, maxBytes: Infinity, root }); return true; } catch { return false; }
|
|
49
|
+
};
|
|
50
|
+
// The server's read: open, fstat, then resolve and match the descriptor against
|
|
51
|
+
// the contained target. Returns the resolved path (for the type) and the bytes.
|
|
52
|
+
async function readContainedFile(base, path, { maxBytes, statOnly }) {
|
|
53
|
+
const handle = await open(path, READ_FLAGS);
|
|
54
|
+
try {
|
|
55
|
+
const info = await handle.stat();
|
|
56
|
+
if (!info.isFile() || info.size > maxBytes) throw refusal("not_served");
|
|
57
|
+
const resolved = await realpath(path);
|
|
58
|
+
if (!inside(base, resolved) || !sameFile(info, await lstat(resolved))) throw refusal("not_served");
|
|
59
|
+
if (statOnly) return { resolved, bytes: null };
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let length = 0;
|
|
62
|
+
while (length <= maxBytes) {
|
|
63
|
+
const { bytesRead, buffer } = await handle.read(Buffer.alloc(Math.min(65536, maxBytes + 1 - length)));
|
|
64
|
+
if (!bytesRead) break;
|
|
65
|
+
chunks.push(buffer.subarray(0, bytesRead)); length += bytesRead;
|
|
66
|
+
}
|
|
67
|
+
if (length > maxBytes) throw refusal("not_served");
|
|
68
|
+
return { resolved, bytes: Buffer.concat(chunks, length) };
|
|
69
|
+
} finally { await handle.close(); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TYPES = Object.freeze({ ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript",
|
|
73
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
|
|
74
|
+
".webp": "image/webp", ".avif": "image/avif", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
|
|
75
|
+
".ico": "image/x-icon", ".json": "application/json" });
|
|
76
|
+
// This listener is also the sealed browser's refusing proxy. There is no
|
|
77
|
+
// outbound client: even absolute-form requests to our own origin are refused.
|
|
78
|
+
export async function startSourceHtmlServer({ root, signal, maxBytes = DECLARED_INPUT_MAX_BYTES } = {}) {
|
|
79
|
+
const base = await realpath(root);
|
|
80
|
+
if (!(await stat(base)).isDirectory()) throw new Error("source_root_unavailable");
|
|
81
|
+
if (signal?.aborted) throw signal.reason;
|
|
82
|
+
const refusals = new Map();
|
|
83
|
+
let totalRefusals = 0;
|
|
84
|
+
const record = (target, connect = false) => {
|
|
85
|
+
totalRefusals++;
|
|
86
|
+
try {
|
|
87
|
+
const origin = new URL(connect ? `https://${target}` : target).origin;
|
|
88
|
+
if (refusals.has(origin) || refusals.size < 32) refusals.set(origin, (refusals.get(origin) || 0) + 1);
|
|
89
|
+
} catch { /* never persist a malformed target */ }
|
|
90
|
+
};
|
|
91
|
+
const server = createServer(async (req, res) => {
|
|
92
|
+
res.setHeader("cache-control", "no-store");
|
|
93
|
+
const end = (code) => { res.statusCode = code; res.end(); };
|
|
94
|
+
if (!req.url.startsWith("/") || req.url.startsWith("//")) { record(req.url); return end(403); }
|
|
95
|
+
if (!["GET", "HEAD"].includes(req.method)) return end(405);
|
|
96
|
+
try {
|
|
97
|
+
const pathname = decodeURIComponent(req.url.split("?")[0]);
|
|
98
|
+
if (pathname.includes("\0") || pathname.includes("\\")) return end(404);
|
|
99
|
+
const { resolved, bytes } = await readContainedFile(base, resolve(base, `.${pathname}`), { maxBytes, statOnly: req.method === "HEAD" });
|
|
100
|
+
res.setHeader("content-type", TYPES[extname(resolved).toLowerCase()] || "application/octet-stream");
|
|
101
|
+
res.statusCode = 200;
|
|
102
|
+
res.end(bytes);
|
|
103
|
+
} catch { end(404); }
|
|
104
|
+
});
|
|
105
|
+
server.on("connect", (req, socket) => {
|
|
106
|
+
record(req.url, true);
|
|
107
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
108
|
+
});
|
|
109
|
+
server.on("upgrade", (req, socket) => {
|
|
110
|
+
record(req.url);
|
|
111
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
112
|
+
});
|
|
113
|
+
server.requestTimeout = 5_000;
|
|
114
|
+
server.headersTimeout = 5_000;
|
|
115
|
+
const sockets = new Set();
|
|
116
|
+
server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
|
117
|
+
await new Promise((done, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", done); });
|
|
118
|
+
let closing;
|
|
119
|
+
const close = () => closing ||= new Promise((done, reject) => {
|
|
120
|
+
server.close((error) => error ? reject(error) : done());
|
|
121
|
+
for (const socket of sockets) socket.destroy();
|
|
122
|
+
});
|
|
123
|
+
signal?.addEventListener("abort", () => { void close().catch(() => {}); }, { once: true });
|
|
124
|
+
if (signal?.aborted) { await close(); throw signal.reason; }
|
|
125
|
+
const port = server.address().port;
|
|
126
|
+
return { origin: `http://127.0.0.1:${port}`, port,
|
|
127
|
+
refusedTotal: () => totalRefusals,
|
|
128
|
+
refused: () => [...refusals].sort(([a], [b]) => a.localeCompare(b)).map(([origin, count]) => ({ origin, count })), close };
|
|
129
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// `campaigns-os spec derive`, live campaign half (#721): refresh the offer and
|
|
2
|
+
// package fields the CampaignSpec mirrors from the Campaigns App.
|
|
3
|
+
//
|
|
4
|
+
// Field classes (#432): a page package's ref, name, price (recurring price
|
|
5
|
+
// included), product, variant and availability are mirrored from the Campaigns App's packages; the offer
|
|
6
|
+
// records (`offers[]`, and the copy a page carries in `pages[].offers[]`) are
|
|
7
|
+
// mirrored from its offers. Which packages and offers a page carries, and a
|
|
8
|
+
// package's qty and role flags (is_upsell, is_order_bump, default_selected,
|
|
9
|
+
// configurable), are authored and never written here. A price is owned by the
|
|
10
|
+
// Campaigns App: it reaches the spec only from the live campaign, never from
|
|
11
|
+
// the repo.
|
|
12
|
+
//
|
|
13
|
+
// The live campaign is the body of the one read-only GET of
|
|
14
|
+
// `{proxy-base}/api/campaign` that doctor and `qa run` already make under the
|
|
15
|
+
// public campaign key (live-campaign-refs.mjs). This module is pure: it
|
|
16
|
+
// plans rows in spec-derive.mjs's shape from the spec and that body, and
|
|
17
|
+
// spec-derive.mjs applies them under its write guard.
|
|
18
|
+
|
|
19
|
+
// The page package fields the live campaign is the authority for, as Map
|
|
20
|
+
// Builder exports them: the recurring price fields a subscription package
|
|
21
|
+
// carries, and `variant_attributes`, which the campaign states as
|
|
22
|
+
// `product_variant_attribute_values`.
|
|
23
|
+
export const MIRRORED_PACKAGE_FIELDS = Object.freeze([
|
|
24
|
+
"name",
|
|
25
|
+
"price",
|
|
26
|
+
"price_retail",
|
|
27
|
+
"product_name",
|
|
28
|
+
"product_sku",
|
|
29
|
+
"product_variant_name",
|
|
30
|
+
"product_purchase_availability",
|
|
31
|
+
"product_inventory_availability",
|
|
32
|
+
"image",
|
|
33
|
+
"is_recurring",
|
|
34
|
+
"price_recurring",
|
|
35
|
+
"interval",
|
|
36
|
+
"interval_count",
|
|
37
|
+
"variant_attributes",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// A spec field whose live value sits under another key.
|
|
41
|
+
const LIVE_FIELD = Object.freeze({ variant_attributes: "product_variant_attribute_values" });
|
|
42
|
+
|
|
43
|
+
// The offer record fields (root `offers[]`), and the subset a page's copy
|
|
44
|
+
// (`pages[].offers[]`) carries.
|
|
45
|
+
export const MIRRORED_OFFER_FIELDS = Object.freeze(["name", "type", "code", "condition", "benefit", "packages"]);
|
|
46
|
+
export const MIRRORED_PAGE_OFFER_FIELDS = Object.freeze(["name", "type", "code", "condition", "benefit"]);
|
|
47
|
+
|
|
48
|
+
function isPlainObject(value) {
|
|
49
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A ref as the campaign states it, compared as its string form (5 and "5"
|
|
53
|
+
// are one ref), or null when the entry carries none.
|
|
54
|
+
function refOf(value) {
|
|
55
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
56
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function canonicalJson(value) {
|
|
61
|
+
if (value === undefined) return "undefined";
|
|
62
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
63
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
64
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pathLabel(path) {
|
|
68
|
+
return path.map((segment, index) => (typeof segment === "number" ? `[${segment}]` : `${index ? "." : ""}${segment}`)).join("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clone(value) {
|
|
72
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Every active page with the JSON path it lives at, as spec-derive.mjs reads
|
|
76
|
+
// them (funnels[] first; the legacy funnel_pages[] only without it).
|
|
77
|
+
function activePagesWithPaths(spec) {
|
|
78
|
+
const pages = [];
|
|
79
|
+
const visit = (page, path) => {
|
|
80
|
+
if (isPlainObject(page) && page.enabled !== false && typeof page.id === "string" && page.id.trim()) pages.push({ page, path });
|
|
81
|
+
};
|
|
82
|
+
if (Array.isArray(spec?.funnels)) {
|
|
83
|
+
spec.funnels.forEach((funnel, funnelIndex) => {
|
|
84
|
+
(Array.isArray(funnel?.pages) ? funnel.pages : []).forEach((page, pageIndex) => visit(page, ["funnels", funnelIndex, "pages", pageIndex]));
|
|
85
|
+
});
|
|
86
|
+
} else if (Array.isArray(spec?.funnel_pages)) {
|
|
87
|
+
spec.funnel_pages.forEach((page, index) => visit(page, ["funnel_pages", index]));
|
|
88
|
+
}
|
|
89
|
+
return pages;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function indexByRef(list) {
|
|
93
|
+
const map = new Map();
|
|
94
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
95
|
+
const ref = isPlainObject(entry) ? refOf(entry.ref_id) : null;
|
|
96
|
+
if (ref && !map.has(ref)) map.set(ref, entry);
|
|
97
|
+
}
|
|
98
|
+
return map;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The rows that bring one mirrored record in line with the live one: a field
|
|
102
|
+
// the live record states is written when the spec's value differs, and only
|
|
103
|
+
// where the spec already carries the key or the live value says something
|
|
104
|
+
// (not null, false or an empty list, which is how Map Builder leaves a
|
|
105
|
+
// one-time package's recurring fields out), so a refresh does not pad the
|
|
106
|
+
// spec with empty keys.
|
|
107
|
+
function mirrorRows({ record, live, fields, path, source, pageId = null }) {
|
|
108
|
+
const changes = [];
|
|
109
|
+
const unchanged = [];
|
|
110
|
+
for (const field of fields) {
|
|
111
|
+
const key = LIVE_FIELD[field] || field;
|
|
112
|
+
if (!Object.hasOwn(live, key) || live[key] === undefined) continue;
|
|
113
|
+
const value = live[key];
|
|
114
|
+
const before = Object.hasOwn(record, field) ? record[field] : undefined;
|
|
115
|
+
if (before === undefined && (value === null || value === false || (Array.isArray(value) && !value.length))) continue;
|
|
116
|
+
const row = { field: pathLabel([...path, field]), path: [...path, field], before: clone(before), after: clone(value), source, ...(pageId ? { page_id: pageId } : {}) };
|
|
117
|
+
if (canonicalJson(before) === canonicalJson(value)) unchanged.push(row);
|
|
118
|
+
else changes.push(row);
|
|
119
|
+
}
|
|
120
|
+
return { changes, unchanged };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The plan for the mirrored offer and package fields: `changes` and
|
|
125
|
+
* `unchanged` rows in spec-derive.mjs's shape, `not_derived` for a record the
|
|
126
|
+
* spec lists that the live campaign does not serve (reported, never removed:
|
|
127
|
+
* which records a page carries is authored), and `summary` counts. `campaign`
|
|
128
|
+
* is the live campaign body; `campaignRef` names it in each row's source.
|
|
129
|
+
*/
|
|
130
|
+
export function planCampaignMirrorDerive({ spec, campaign, campaignRef = null } = {}) {
|
|
131
|
+
const changes = [];
|
|
132
|
+
const unchanged = [];
|
|
133
|
+
const notDerived = [];
|
|
134
|
+
const ref = campaignRef || refOf(campaign?.ref_id ?? campaign?.id);
|
|
135
|
+
const source = `the Campaigns App campaign${ref ? ` ${ref}` : ""} (GET /api/campaign)`;
|
|
136
|
+
const livePackages = indexByRef(campaign?.packages);
|
|
137
|
+
const liveOffers = Array.isArray(campaign?.offers) ? indexByRef(campaign.offers) : null;
|
|
138
|
+
const summary = { packages_checked: 0, offers_checked: 0, offers_added: 0 };
|
|
139
|
+
|
|
140
|
+
for (const { page, path } of activePagesWithPaths(spec)) {
|
|
141
|
+
(Array.isArray(page.packages) ? page.packages : []).forEach((pkg, index) => {
|
|
142
|
+
if (!isPlainObject(pkg)) return;
|
|
143
|
+
const pkgRef = refOf(pkg.ref_id);
|
|
144
|
+
const pkgPath = [...path, "packages", index];
|
|
145
|
+
if (!pkgRef) return;
|
|
146
|
+
const live = livePackages.get(pkgRef);
|
|
147
|
+
if (!live) {
|
|
148
|
+
notDerived.push({ field: pathLabel(pkgPath), page_id: page.id, reason: "package_not_live", detail: `page "${page.id}" carries package ${pkgRef}, which the live campaign does not serve; nothing was changed. Point the page at a package the campaign serves, or restore it in the Campaigns App.` });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
summary.packages_checked += 1;
|
|
152
|
+
const rows = mirrorRows({ record: pkg, live, fields: MIRRORED_PACKAGE_FIELDS, path: pkgPath, source, pageId: page.id });
|
|
153
|
+
changes.push(...rows.changes);
|
|
154
|
+
unchanged.push(...rows.unchanged);
|
|
155
|
+
});
|
|
156
|
+
(Array.isArray(page.offers) ? page.offers : []).forEach((offer, index) => {
|
|
157
|
+
if (!isPlainObject(offer) || !liveOffers) return;
|
|
158
|
+
const offerRef = refOf(offer.ref_id);
|
|
159
|
+
const offerPath = [...path, "offers", index];
|
|
160
|
+
if (!offerRef) return;
|
|
161
|
+
const live = liveOffers.get(offerRef);
|
|
162
|
+
if (!live) {
|
|
163
|
+
notDerived.push({ field: pathLabel(offerPath), page_id: page.id, reason: "offer_not_live", detail: `page "${page.id}" carries offer ${offerRef}, which the live campaign does not serve; nothing was changed. Remove it from the page, or restore it in the Campaigns App.` });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const rows = mirrorRows({ record: offer, live, fields: MIRRORED_PAGE_OFFER_FIELDS, path: offerPath, source, pageId: page.id });
|
|
167
|
+
changes.push(...rows.changes);
|
|
168
|
+
unchanged.push(...rows.unchanged);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!liveOffers) {
|
|
173
|
+
if (Array.isArray(spec?.offers) && spec.offers.length) {
|
|
174
|
+
notDerived.push({ field: "offers", reason: "offers_not_in_live_campaign", detail: "the live campaign read carried no offers list, so the spec's offer records were not refreshed." });
|
|
175
|
+
}
|
|
176
|
+
} else if (!Array.isArray(spec?.offers)) {
|
|
177
|
+
const records = [...liveOffers.values()].map((live) => recordFromLive(live));
|
|
178
|
+
if (records.length) {
|
|
179
|
+
changes.push({ field: "offers", path: ["offers"], before: spec?.offers === undefined ? undefined : clone(spec.offers), after: records, source });
|
|
180
|
+
summary.offers_added = records.length;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
spec.offers.forEach((offer, index) => {
|
|
185
|
+
if (!isPlainObject(offer)) return;
|
|
186
|
+
const offerRef = refOf(offer.ref_id);
|
|
187
|
+
if (!offerRef) return;
|
|
188
|
+
seen.add(offerRef);
|
|
189
|
+
const live = liveOffers.get(offerRef);
|
|
190
|
+
if (!live) {
|
|
191
|
+
notDerived.push({ field: `offers[${index}]`, reason: "offer_not_live", detail: `offer ${offerRef} is in the spec's offers but not in the live campaign; it was kept, since pages may still name it. Remove it, or restore it in the Campaigns App.` });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
summary.offers_checked += 1;
|
|
195
|
+
const rows = mirrorRows({ record: offer, live, fields: MIRRORED_OFFER_FIELDS, path: ["offers", index], source });
|
|
196
|
+
changes.push(...rows.changes);
|
|
197
|
+
unchanged.push(...rows.unchanged);
|
|
198
|
+
});
|
|
199
|
+
let next = spec.offers.length;
|
|
200
|
+
for (const [offerRef, live] of liveOffers) {
|
|
201
|
+
if (seen.has(offerRef)) continue;
|
|
202
|
+
changes.push({ field: `offers[${next}]`, path: ["offers", next], op: "insert", before: undefined, after: recordFromLive(live), source });
|
|
203
|
+
next += 1;
|
|
204
|
+
summary.offers_added += 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { changes, unchanged, not_derived: notDerived, summary };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// An offer record as the spec stores one: its ref and the mirrored fields the
|
|
211
|
+
// live offer states.
|
|
212
|
+
function recordFromLive(live) {
|
|
213
|
+
const record = { ref_id: live.ref_id };
|
|
214
|
+
for (const field of MIRRORED_OFFER_FIELDS) if (Object.hasOwn(live, field) && live[field] !== undefined) record[field] = clone(live[field]);
|
|
215
|
+
return record;
|
|
216
|
+
}
|