@nextcommerce/campaigns-os 1.50.0 → 1.52.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 +426 -0
- package/agents/claude/CLAUDE.md +2 -2
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +81 -2
- package/contracts/release-ledger.json +906 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/build-packet.md +93 -9
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +45 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- 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 +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -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 +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +12 -0
- package/src/build-brief.mjs +68 -21
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +310 -34
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/invocation.mjs +2 -1
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-browser.mjs +308 -96
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +104 -12
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-record.mjs +56 -17
|
@@ -210,3 +210,74 @@ export function isKnownCheckoutFieldName(value) {
|
|
|
210
210
|
return SDK_CHECKOUT_FIELD_NAMES.includes(name)
|
|
211
211
|
|| SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length);
|
|
212
212
|
}
|
|
213
|
+
|
|
214
|
+
// The Campaign Cart template placeholders, for the raw cart placeholder check
|
|
215
|
+
// (built_output.cart_placeholders). Generated from the SDK renderers at the
|
|
216
|
+
// same tag as the attribute index above, v0.4.38:
|
|
217
|
+
//
|
|
218
|
+
// src/features/cart/cart-summary/cart-summary.renderer.ts:46-83 (bare vars)
|
|
219
|
+
// src/features/cart/cart-summary/cart-summary.line-renderer.ts:150-173,
|
|
220
|
+
// 228-240,256-314 ({item.*} and its {line.*} alias, {property.*},
|
|
221
|
+
// {discount.*})
|
|
222
|
+
// src/features/cart/package-selector/package-selector.renderer.ts:26
|
|
223
|
+
// ({package.<any key>})
|
|
224
|
+
// src/features/cart/bundle-selector/bundle-selector.renderer.ts:37-42
|
|
225
|
+
// ({bundle.<any key>})
|
|
226
|
+
// src/features/cart/package-toggle/package-toggle.enhancer.ts:59-65
|
|
227
|
+
// ({toggle.<any key>})
|
|
228
|
+
// src/features/cart/quantity-control/quantity-control.renderer.ts:69-70
|
|
229
|
+
// ({quantity}, {step})
|
|
230
|
+
// src/features/cart/remove-item/remove-item.renderer.ts:26 ({quantity})
|
|
231
|
+
// src/features/display/quantity-text/quantity-text.enhancer.ts:124,149
|
|
232
|
+
// ({qty...} and {singular|plural})
|
|
233
|
+
//
|
|
234
|
+
// Every namespaced renderer replaces any `{<namespace>.<key>}` in its
|
|
235
|
+
// template (item.property.<key> and the package, bundle and toggle keys are
|
|
236
|
+
// open-ended; an unmapped key renders empty), so the namespace alone makes a
|
|
237
|
+
// token known. `{tax}` is not a cart-summary var at this tag. Regenerate the
|
|
238
|
+
// lists whole from the renderers when the index pin advances; never edit them
|
|
239
|
+
// by hand.
|
|
240
|
+
export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
|
|
241
|
+
cart_summary_vars: Object.freeze([
|
|
242
|
+
"subtotal",
|
|
243
|
+
"total",
|
|
244
|
+
"shipping",
|
|
245
|
+
"shippingName",
|
|
246
|
+
"shippingCode",
|
|
247
|
+
"shippingOriginal",
|
|
248
|
+
"shippingDiscountAmount",
|
|
249
|
+
"shippingDiscountPercentage",
|
|
250
|
+
"totalDiscount",
|
|
251
|
+
"totalDiscountPercentage",
|
|
252
|
+
"discounts",
|
|
253
|
+
"currency",
|
|
254
|
+
"isCalculating",
|
|
255
|
+
"isEmpty",
|
|
256
|
+
"itemCount",
|
|
257
|
+
"totalQuantity",
|
|
258
|
+
"isFreeShipping",
|
|
259
|
+
"hasShippingDiscount",
|
|
260
|
+
"hasDiscounts",
|
|
261
|
+
]),
|
|
262
|
+
namespaces: Object.freeze(["item", "line", "discount", "property", "package", "bundle", "toggle"]),
|
|
263
|
+
// Tokens the SDK substitutes in live markup, and the elements that own them.
|
|
264
|
+
live_tokens: Object.freeze(["quantity", "step", "qty"]),
|
|
265
|
+
// [data-next-quantity="increase|decrease|set"]: {quantity} and {step}.
|
|
266
|
+
quantity_control: Object.freeze({ attribute: "data-next-quantity", values: Object.freeze(["increase", "decrease", "set"]), tokens: Object.freeze(["quantity", "step"]) }),
|
|
267
|
+
// [data-next-remove-item]: {quantity} only.
|
|
268
|
+
remove_item: Object.freeze({ attribute: "data-next-remove-item", tokens: Object.freeze(["quantity"]) }),
|
|
269
|
+
// [data-next-quantity-text]: {qty...} and the singular/plural form.
|
|
270
|
+
// `qty_forms` is the renderer's own {qty} pattern (quantity-text.enhancer.ts:
|
|
271
|
+
// 124, /\{qty([*+\-]?\d*)\}/): {qty}, {qty*2}, {qty+1}, {qty-1}.
|
|
272
|
+
quantity_text: Object.freeze({ attribute: "data-next-quantity-text", tokens: Object.freeze(["qty"]), qty_forms: "qty[*+\\-]?\\d*" }),
|
|
273
|
+
// Item lists whose innerHTML the SDK uses as the row template or replaces
|
|
274
|
+
// (cart-item-list.enhancer.ts:21-35, order-item-list.enhancer.ts:26-36).
|
|
275
|
+
item_list_containers: Object.freeze(["data-next-cart-items", "data-next-order-items"]),
|
|
276
|
+
item_template_selector: "data-item-template-selector",
|
|
277
|
+
item_template: "data-item-template",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// 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"]);
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -86,7 +86,7 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
86
86
|
|
|
87
87
|
// Attributes whose value names a <template> by id. The SDK reads the template
|
|
88
88
|
// they point at, so that template is SDK-owned wherever it sits.
|
|
89
|
-
const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
|
|
89
|
+
export const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
|
|
90
90
|
|
|
91
91
|
// Containers whose DIRECT <template> child the SDK clones (each does a
|
|
92
92
|
// `:scope > template` lookup at v0.4.38: cart-summary and its
|
|
@@ -98,7 +98,7 @@ const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
|
|
|
98
98
|
// package-toggle).
|
|
99
99
|
// Only the direct child: a vendor template nested deeper inside SDK chrome is
|
|
100
100
|
// never read, so it may use any syntax.
|
|
101
|
-
const TEMPLATE_CONTAINER_ATTRIBUTES = [
|
|
101
|
+
export const TEMPLATE_CONTAINER_ATTRIBUTES = [
|
|
102
102
|
"data-next-cart-summary",
|
|
103
103
|
"data-summary-lines",
|
|
104
104
|
"data-next-discounts",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Read-only source evidence. SDK migration names come exclusively from the supplied manifest.
|
|
2
2
|
import { parse as parseJs } from 'acorn';
|
|
3
3
|
import { parse as parseHtml } from 'parse5';
|
|
4
|
+
import { parseDocument, isSeq, isScalar, LineCounter } from 'yaml';
|
|
4
5
|
import { execFileSync } from 'node:child_process';
|
|
5
6
|
import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
6
7
|
import { resolve, relative, dirname, posix, sep } from 'node:path';
|
|
@@ -261,6 +262,35 @@ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset
|
|
|
261
262
|
visit(ast, null);
|
|
262
263
|
return findings;
|
|
263
264
|
}
|
|
265
|
+
// Page Kit's campaign_asset filter serves src/<slug>/assets/<path>, <slug> being
|
|
266
|
+
// the campaign folder the template sits in. A layout's {% for script in scripts %}
|
|
267
|
+
// loop loads each page's frontmatter `scripts:` entries through the same filter.
|
|
268
|
+
const CAMPAIGN_ASSET_SRC = /^\{\{-?\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z_]\w*))\s*\|\s*campaign_asset\s*-?\}\}$/;
|
|
269
|
+
function campaignAssetRoot(path) {
|
|
270
|
+
const match = /^((?:.*\/)?src\/[^/]+)\//.exec(path);
|
|
271
|
+
return match ? `${match[1]}/assets` : null;
|
|
272
|
+
}
|
|
273
|
+
function loopsOverPageScripts(text, name) {
|
|
274
|
+
return new RegExp(`\\{%-?\\s*for\\s+${name}\\s+in\\s+(?:page\\.)?scripts\\b`).test(text);
|
|
275
|
+
}
|
|
276
|
+
// Each local-or-remote entry of a page's frontmatter `scripts:` list with the
|
|
277
|
+
// file line it is declared on, or an error naming why the list cannot be read.
|
|
278
|
+
function frontmatterScripts(text) {
|
|
279
|
+
const block = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
280
|
+
if (!block) return { scripts: [] };
|
|
281
|
+
const lineCounter = new LineCounter();
|
|
282
|
+
const document = parseDocument(block[1], { lineCounter });
|
|
283
|
+
if (document.errors.length) {
|
|
284
|
+
const { line } = lineCounter.linePos(document.errors[0].pos[0]);
|
|
285
|
+
return { error: `Frontmatter is not valid YAML (${document.errors[0].code}); review the scripts this page loads.`, line: line + 1 };
|
|
286
|
+
}
|
|
287
|
+
const scripts = document.get('scripts', true);
|
|
288
|
+
if (scripts === undefined || scripts === null || (isScalar(scripts) && scripts.value === null)) return { scripts: [] };
|
|
289
|
+
if (!isSeq(scripts) || !scripts.items.every(item => isScalar(item) && typeof item.value === 'string'))
|
|
290
|
+
return { error: 'Frontmatter scripts is not a list of paths; review the scripts this page loads.', line: lineCounter.linePos(scripts.range?.[0] ?? 0).line + 1 };
|
|
291
|
+
// linePos is 1-based within the YAML block, which starts on the file's line 2.
|
|
292
|
+
return { scripts: scripts.items.map(item => ({ value: item.value, line: lineCounter.linePos(item.range[0]).line + 1 })) };
|
|
293
|
+
}
|
|
264
294
|
export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
|
|
265
295
|
version(targetSdkVersion);
|
|
266
296
|
if (!Array.isArray(scope) || !scope.length)
|
|
@@ -320,20 +350,50 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
|
|
|
320
350
|
for (const child of node.childNodes ?? []) findBase(child);
|
|
321
351
|
}
|
|
322
352
|
findBase(document);
|
|
353
|
+
const assetRoot = campaignAssetRoot(path);
|
|
354
|
+
const requireSelected = (sourcePath, line) => {
|
|
355
|
+
if (!selected.includes(sourcePath))
|
|
356
|
+
unknown(path, 'shared-script-outside-scope', sourcePath, line);
|
|
357
|
+
};
|
|
358
|
+
// Page Kit serves campaign_asset paths from the assets folder only; an entry
|
|
359
|
+
// that normalizes outside it is not a file the page loads from there.
|
|
360
|
+
const requireAsset = (asset, line) => {
|
|
361
|
+
const sourcePath = posix.normalize(`${assetRoot}/${asset.split(/[?#]/)[0]}`);
|
|
362
|
+
if (sourcePath.startsWith(`${assetRoot}/`)) requireSelected(sourcePath, line);
|
|
363
|
+
else unknown(path, 'shared-script-outside-scope', `${asset} resolves outside ${assetRoot}`, line);
|
|
364
|
+
};
|
|
365
|
+
if (assetRoot) {
|
|
366
|
+
const frontmatter = frontmatterScripts(text);
|
|
367
|
+
if (frontmatter.error)
|
|
368
|
+
unknown(path, 'shared-script-outside-scope', frontmatter.error, frontmatter.line);
|
|
369
|
+
for (const { value, line } of frontmatter.scripts ?? []) {
|
|
370
|
+
if (/^(?:[a-z]+:)?\/\//i.test(value) || value.startsWith('data:')) continue;
|
|
371
|
+
requireAsset(value, line);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
323
374
|
function html(node) {
|
|
324
375
|
if (node.tagName === 'script') {
|
|
325
376
|
const attrs = Object.fromEntries((node.attrs ?? []).map(a => [a.name, a.value]));
|
|
326
377
|
const type = (attrs.type ?? '').trim().toLowerCase();
|
|
327
378
|
if (!type || ['module', 'text/javascript', 'application/javascript', 'text/ecmascript', 'application/ecmascript'].includes(type)) {
|
|
328
|
-
|
|
379
|
+
const campaignAsset = attrs.src ? CAMPAIGN_ASSET_SRC.exec(attrs.src.trim()) : null;
|
|
380
|
+
if (campaignAsset) {
|
|
381
|
+
const line = node.sourceCodeLocation?.startLine ?? 1;
|
|
382
|
+
const literal = campaignAsset[1] ?? campaignAsset[2];
|
|
383
|
+
if (assetRoot && literal !== undefined)
|
|
384
|
+
requireAsset(literal, line);
|
|
385
|
+
else if (!assetRoot || !loopsOverPageScripts(text, campaignAsset[3]))
|
|
386
|
+
unknown(path, 'shared-script-outside-scope', attrs.src, line);
|
|
387
|
+
// A frontmatter scripts loop: each page's own entries are checked above.
|
|
388
|
+
}
|
|
389
|
+
else if (attrs.src) {
|
|
329
390
|
if (baseHref !== null && !attrs.src.startsWith('/') && !/^[a-z]+:/i.test(attrs.src)) {
|
|
330
391
|
unknown(path, 'html-base-script-resolution', `Relative script ${attrs.src} resolves against base ${baseHref}; include/review the actual dependency.`, node.sourceCodeLocation?.startLine ?? 1);
|
|
331
392
|
}
|
|
332
393
|
if (!/^(?:[a-z]+:)?\/\//i.test(attrs.src) && !attrs.src.startsWith('data:')) {
|
|
333
394
|
const localSource = attrs.src.split(/[?#]/)[0];
|
|
334
395
|
const sourcePath = posix.normalize(localSource.startsWith('/') ? localSource.slice(1) : posix.join(posix.dirname(path), localSource));
|
|
335
|
-
|
|
336
|
-
unknown(path, 'shared-script-outside-scope', sourcePath, node.sourceCodeLocation?.startLine ?? 1);
|
|
396
|
+
requireSelected(sourcePath, node.sourceCodeLocation?.startLine ?? 1);
|
|
337
397
|
}
|
|
338
398
|
}
|
|
339
399
|
else if (node.sourceCodeLocation?.startTag) {
|
package/src/source-prep.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// source asset crawl (source_asset.* codes) so the two checks never disagree
|
|
9
9
|
// about the same reference.
|
|
10
10
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
11
|
-
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
12
12
|
import { collectDocumentWrapperNames } from "./adapter-decision-contract.mjs";
|
|
13
13
|
|
|
14
14
|
export const SOURCE_PREP_DOCUMENT_WRAPPER = "source_html.prep.document_wrapper";
|
|
@@ -201,7 +201,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
201
201
|
const policyNote = wrapperPolicy === "preserve_document_wrappers"
|
|
202
202
|
? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
|
|
203
203
|
: "";
|
|
204
|
-
return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
|
|
204
|
+
return `Mapped source HTML (the converted page-kit page once it exists at page_kit.output_path, else the design) is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
|
|
205
205
|
}
|
|
206
206
|
if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
|
|
207
207
|
const listed = sample.map((page) => {
|
|
@@ -220,6 +220,26 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
220
220
|
return `Mapped source HTML still links to source files instead of CampaignSpec routes: ${listed}${more}. Replace internal links and CTA destinations with CampaignSpec-derived routes, usually via campaign_link; source filenames like checkout.html are not built campaign URLs. See ${docs}.`;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
// The converted page-kit page for a mapped design (page_kit.output_path under
|
|
224
|
+
// the target repo), when it has been written.
|
|
225
|
+
function convertedPageContent(targetRoot, page) {
|
|
226
|
+
const outputPath = page?.page_kit?.output_path;
|
|
227
|
+
if (!isNonEmptyString(targetRoot) || !isNonEmptyString(outputPath)) return null;
|
|
228
|
+
// output_path is repo-relative. One that is absolute or climbs out of the
|
|
229
|
+
// target repo is not a converted page of this campaign, so it reads like a
|
|
230
|
+
// missing one and the design is checked instead.
|
|
231
|
+
const root = resolve(targetRoot);
|
|
232
|
+
const fullPath = resolve(root, outputPath);
|
|
233
|
+
const rel = relative(root, fullPath);
|
|
234
|
+
if (isAbsolute(outputPath) || rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
235
|
+
if (!safeIsFile(fullPath)) return null;
|
|
236
|
+
try {
|
|
237
|
+
return { path: toPosixPath(outputPath), content: readFileSync(fullPath, "utf8") };
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
223
243
|
/**
|
|
224
244
|
* Evaluates the page-kit source-preparation expectations for every mapped
|
|
225
245
|
* source page. Deterministic: same files in, same findings out. Unreadable or
|
|
@@ -227,7 +247,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
227
247
|
*
|
|
228
248
|
* @returns {{ checked_page_count: number, findings: Array<{code, severity, message, docs, pages}> }}
|
|
229
249
|
*/
|
|
230
|
-
export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolicy = null }) {
|
|
250
|
+
export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolicy = null, targetRoot = null }) {
|
|
231
251
|
const mappedPaths = new Set(
|
|
232
252
|
pages
|
|
233
253
|
.map((page) => (isNonEmptyString(page?.path) ? toPosixPath(page.path) : null))
|
|
@@ -247,14 +267,24 @@ export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolic
|
|
|
247
267
|
continue;
|
|
248
268
|
}
|
|
249
269
|
checked += 1;
|
|
250
|
-
|
|
270
|
+
let pageFindings = inspectPageContent({ content, sourceRoot, pagePath: page.path, mappedPaths });
|
|
271
|
+
// Wrappers are stripped when the design is converted into the page-kit
|
|
272
|
+
// page at page_kit.output_path, the file page-kit builds. Once that page
|
|
273
|
+
// exists, it is the one checked; the design keeps its wrappers.
|
|
274
|
+
const converted = convertedPageContent(targetRoot, page);
|
|
275
|
+
if (converted) {
|
|
276
|
+
pageFindings = pageFindings.filter((finding) => finding.code !== SOURCE_PREP_DOCUMENT_WRAPPER);
|
|
277
|
+
const wrappers = collectDocumentWrapperNames(converted.content);
|
|
278
|
+
if (wrappers.length) pageFindings.push({ code: SOURCE_PREP_DOCUMENT_WRAPPER, wrappers, path: converted.path });
|
|
279
|
+
}
|
|
251
280
|
for (const finding of pageFindings) {
|
|
252
281
|
if (!byCode.has(finding.code)) byCode.set(finding.code, new Map());
|
|
253
282
|
const pagesForCode = byCode.get(finding.code);
|
|
254
|
-
|
|
255
|
-
|
|
283
|
+
const findingPath = finding.path || page.path;
|
|
284
|
+
if (!pagesForCode.has(findingPath)) {
|
|
285
|
+
pagesForCode.set(findingPath, { page_id: page.page_id || null, path: findingPath, wrappers: [], hrefs: [], variants: [] });
|
|
256
286
|
}
|
|
257
|
-
const entry = pagesForCode.get(
|
|
287
|
+
const entry = pagesForCode.get(findingPath);
|
|
258
288
|
if (finding.wrappers) entry.wrappers.push(...finding.wrappers);
|
|
259
289
|
if (finding.hrefs) entry.hrefs.push(...finding.hrefs);
|
|
260
290
|
if (finding.variant) entry.variants.push({ variant: finding.variant, lines: finding.lines || [] });
|
package/src/stage-record.mjs
CHANGED
|
@@ -29,7 +29,8 @@ import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
|
29
29
|
import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
30
30
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
31
31
|
import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
32
|
-
import { isLocalServePacket } from "./local-proof.mjs";
|
|
32
|
+
import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
|
|
33
|
+
import { CARRIED_FORWARD } from "./local-preview-policy.mjs";
|
|
33
34
|
import { isLoopbackHostname } from "./remit.mjs";
|
|
34
35
|
import { campaignRouteRoot } from "./route-identity.mjs";
|
|
35
36
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
@@ -51,11 +52,17 @@ import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
|
51
52
|
|
|
52
53
|
export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme", "deploy"]);
|
|
53
54
|
|
|
54
|
-
// Every flag `record` reads, plus the
|
|
55
|
-
// lifecycle journal
|
|
56
|
-
|
|
55
|
+
// Every flag `record` reads, plus the three any command accepts (run id,
|
|
56
|
+
// lifecycle journal, and the deviation reason the deviation notice asks
|
|
57
|
+
// agents to declare). Anything else is refused before a file is read.
|
|
58
|
+
const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal", "deviation-reason"]);
|
|
57
59
|
const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
|
|
58
60
|
const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
|
|
61
|
+
const BUILD_RECORD_FLAGS = Object.freeze(["build-environment"]);
|
|
62
|
+
// The page-kit environment the built output was rendered in, recorded on
|
|
63
|
+
// stages.assembly.evidence.build_environment (local proof mode builds in
|
|
64
|
+
// development; doctor and page-kit parity read it).
|
|
65
|
+
export const BUILD_ENVIRONMENTS = Object.freeze([LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT]);
|
|
59
66
|
|
|
60
67
|
// The keys a --evidence file may carry. `evidence` is stages.polish.evidence;
|
|
61
68
|
// `repair_loop_defect` is report.theme.repair_loop_defect; `blockers` (status
|
|
@@ -74,6 +81,13 @@ function schemaValidator(file) {
|
|
|
74
81
|
return validators.get(file);
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
// The value at an Ajv instancePath (JSON Pointer) in the validated document.
|
|
85
|
+
function valueAt(document, pointer) {
|
|
86
|
+
return pointer.split("/").slice(1)
|
|
87
|
+
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
88
|
+
.reduce((node, key) => (node == null ? undefined : node[key]), document);
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
// Ajv's instancePath (`/theme/repair_loop_defect`) as the dotted field name
|
|
78
92
|
// the rest of the toolkit prints (`theme.repair_loop_defect`).
|
|
79
93
|
function schemaProblems(file, value, label) {
|
|
@@ -83,7 +97,11 @@ function schemaProblems(file, value, label) {
|
|
|
83
97
|
const problems = [];
|
|
84
98
|
for (const error of validate.errors || []) {
|
|
85
99
|
const field = error.instancePath.split("/").filter(Boolean).join(".") || "(root)";
|
|
86
|
-
|
|
100
|
+
// Ajv's enum message names no values; the allowed list is the remedy.
|
|
101
|
+
const allowed = error.keyword === "enum" && Array.isArray(error.params?.allowedValues)
|
|
102
|
+
? `: ${error.params.allowedValues.map((allowedValue) => JSON.stringify(allowedValue)).join(", ")} (got ${JSON.stringify(valueAt(value, error.instancePath))})`
|
|
103
|
+
: "";
|
|
104
|
+
const line = `${label} ${field} ${error.message}${allowed}`;
|
|
87
105
|
if (seen.has(line)) continue;
|
|
88
106
|
seen.add(line);
|
|
89
107
|
problems.push(line);
|
|
@@ -96,6 +114,11 @@ function typeName(value) {
|
|
|
96
114
|
return Array.isArray(value) ? "array" : typeof value;
|
|
97
115
|
}
|
|
98
116
|
|
|
117
|
+
// visual_review keys only `polish capture` writes. `record polish --evidence`
|
|
118
|
+
// refuses them by name and carries the captured values forward.
|
|
119
|
+
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight"]);
|
|
120
|
+
export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
|
|
121
|
+
|
|
99
122
|
function refuseRecord(stage, problems) {
|
|
100
123
|
return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
|
|
101
124
|
}
|
|
@@ -103,26 +126,31 @@ function refuseRecord(stage, problems) {
|
|
|
103
126
|
export function parseRecordArgs(args) {
|
|
104
127
|
const stage = args._[1];
|
|
105
128
|
if (!RECORD_STAGES.includes(stage) || args._.length !== 2) {
|
|
106
|
-
throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>,
|
|
129
|
+
throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.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>, and record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>].`);
|
|
107
130
|
}
|
|
108
|
-
const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : [])]);
|
|
131
|
+
const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : [])]);
|
|
109
132
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
|
|
110
133
|
if (unknown.length) {
|
|
111
134
|
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
|
|
112
135
|
}
|
|
113
|
-
for (const flag of ["context", "report", "run-id", "lifecycle-journal"]) {
|
|
136
|
+
for (const flag of ["context", "report", "run-id", "lifecycle-journal", "deviation-reason"]) {
|
|
114
137
|
if (Object.hasOwn(args, flag)) requireArg(args, flag);
|
|
115
138
|
}
|
|
116
139
|
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
117
140
|
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
118
141
|
}
|
|
119
142
|
if (Object.hasOwn(args, "json") && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
|
|
143
|
+
const buildEnvironment = Object.hasOwn(args, "build-environment") ? args["build-environment"] : null;
|
|
144
|
+
if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
|
|
145
|
+
throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
|
|
146
|
+
}
|
|
120
147
|
return {
|
|
121
148
|
stage,
|
|
122
149
|
packetPath: resolve(requireArg(args, "packet")),
|
|
123
150
|
evidencePath: stage === "polish" ? resolve(requireArg(args, "evidence")) : null,
|
|
124
151
|
baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
|
|
125
152
|
dryRun: args["dry-run"] === true,
|
|
153
|
+
buildEnvironment,
|
|
126
154
|
};
|
|
127
155
|
}
|
|
128
156
|
|
|
@@ -181,8 +209,13 @@ export function readPolishEvidenceFile(path) {
|
|
|
181
209
|
}
|
|
182
210
|
if (evidence.visual_review !== undefined && !isObject(evidence.visual_review)) {
|
|
183
211
|
problems.push(`evidence.visual_review must be an object with a screenshots array (got ${typeName(evidence.visual_review)}).`);
|
|
184
|
-
} else if (isObject(evidence.visual_review)
|
|
185
|
-
|
|
212
|
+
} else if (isObject(evidence.visual_review)) {
|
|
213
|
+
// Package-owned keys are refused by name and listed first, so the
|
|
214
|
+
// refusal leads with its code.
|
|
215
|
+
const owned = PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(evidence.visual_review, key));
|
|
216
|
+
if (owned.length) {
|
|
217
|
+
problems.unshift(`${PACKAGE_OWNED_KEY_REFUSAL}: ${owned.map((key) => `evidence.visual_review.${key}`).join(" and ")} ${owned.length === 1 ? "is" : "are"} written only by ${cmd("polish")} capture; remove ${owned.length === 1 ? "it" : "them"} from the file (the captured value on the report is kept).`);
|
|
218
|
+
}
|
|
186
219
|
}
|
|
187
220
|
}
|
|
188
221
|
if (Object.hasOwn(input, "repair_loop_defect") && input.repair_loop_defect !== null && !isObject(input.repair_loop_defect)) {
|
|
@@ -235,10 +268,12 @@ function composeSetup(report, context, { now, recordedBy }) {
|
|
|
235
268
|
return { report: nextReport, context: nextContext };
|
|
236
269
|
}
|
|
237
270
|
|
|
238
|
-
function composeBuild(report, { now, recordedBy, fingerprint }) {
|
|
271
|
+
function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null }) {
|
|
239
272
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
273
|
+
const previousAssembly = stageObject(report, "assembly");
|
|
240
274
|
const assembly = {
|
|
241
|
-
...withoutKeys(
|
|
275
|
+
...withoutKeys(previousAssembly, ["source_package_material_fingerprint"]),
|
|
276
|
+
...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
|
|
242
277
|
stage: "assembly",
|
|
243
278
|
status: "completed",
|
|
244
279
|
build_fingerprint: fingerprint,
|
|
@@ -275,7 +310,7 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
|
|
|
275
310
|
...input.evidence,
|
|
276
311
|
visual_review: {
|
|
277
312
|
...input.evidence.visual_review,
|
|
278
|
-
...(Object.hasOwn(previousVisual,
|
|
313
|
+
...Object.fromEntries(PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(previousVisual, key)).map((key) => [key, previousVisual[key]])),
|
|
279
314
|
},
|
|
280
315
|
}
|
|
281
316
|
: previous.evidence;
|
|
@@ -580,6 +615,9 @@ function ladderProblems(stage, doctor, report) {
|
|
|
580
615
|
if (gate) return [`next answers prepare-build: ${gate.reason}`];
|
|
581
616
|
const problems = [];
|
|
582
617
|
for (const earlier of NEXT_STAGE_ORDER.slice(0, NEXT_STAGE_ORDER.indexOf(stage))) {
|
|
618
|
+
// The rule next's stage picker reads: on the local preview a missing polish
|
|
619
|
+
// is carried forward (local-preview-policy), and next moves on to deploy.
|
|
620
|
+
if (earlier === "polish" && doctor.derived?.polish_gate?.status === CARRIED_FORWARD) continue;
|
|
583
621
|
const key = reportKeyForCliStage(earlier);
|
|
584
622
|
const status = String(report.stages[key]?.status || "");
|
|
585
623
|
if (!stageIsTerminal(status)) {
|
|
@@ -697,7 +735,7 @@ function readPacketFile(stage, packetPath) {
|
|
|
697
735
|
* composed or written.
|
|
698
736
|
*/
|
|
699
737
|
export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
|
|
700
|
-
const { stage, packetPath, evidencePath, dryRun } = parseRecordArgs(args);
|
|
738
|
+
const { stage, packetPath, evidencePath, dryRun, buildEnvironment } = parseRecordArgs(args);
|
|
701
739
|
if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
|
|
702
740
|
if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
|
|
703
741
|
// Operator input, not target state: no campaigns-os writer produces it.
|
|
@@ -718,7 +756,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
718
756
|
// A dry run writes nothing, so it takes no lock and creates no lock files
|
|
719
757
|
// (the commitAssemblyReport preview convention); it reads in the same order.
|
|
720
758
|
const run = () => recordUnderLock({
|
|
721
|
-
stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead,
|
|
759
|
+
stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment,
|
|
722
760
|
});
|
|
723
761
|
const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
|
|
724
762
|
const { composed, facts, layer, reportPath, contextPath, after } = recorded;
|
|
@@ -735,6 +773,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
735
773
|
] : [
|
|
736
774
|
`stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
|
|
737
775
|
...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
|
|
776
|
+
...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
|
|
738
777
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
739
778
|
...(composed.context ? ["Build Context scaffold.required = false"] : []),
|
|
740
779
|
];
|
|
@@ -767,7 +806,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
767
806
|
// re-check, and the post-write doctor read for `next_stage`. No campaigns-os
|
|
768
807
|
// writer can rebind, rewrite or republish any of them between the read and
|
|
769
808
|
// the write.
|
|
770
|
-
function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead }) {
|
|
809
|
+
function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null }) {
|
|
771
810
|
// The same workspace `next` resolves, so the record lands in the report
|
|
772
811
|
// `next` reads now, not the one it read before the lock was free.
|
|
773
812
|
const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
|
|
@@ -802,7 +841,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
802
841
|
const next = stage === "setup"
|
|
803
842
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
804
843
|
: stage === "build"
|
|
805
|
-
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint })
|
|
844
|
+
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment })
|
|
806
845
|
: stage === "theme"
|
|
807
846
|
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
808
847
|
: stage === "deploy"
|