@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
package/src/spec-identity.mjs
CHANGED
|
@@ -19,6 +19,20 @@ export function specMaterialHash(spec) {
|
|
|
19
19
|
return `sha256:${createHash("sha256").update(canonicalJson(materialSpec(spec))).digest("hex")}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// The spec material with every page's page_url left out (funnels[].pages[]
|
|
23
|
+
// and the legacy funnel_pages[] mirror). Intake and record spec record it in
|
|
24
|
+
// the Build Context, so record spec can tell a change of routes alone from
|
|
25
|
+
// one that also touched anything else the build reads (#531).
|
|
26
|
+
export function routeMaskedSpecMaterialHash(spec) {
|
|
27
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return specMaterialHash(spec);
|
|
28
|
+
const masked = JSON.parse(JSON.stringify(spec));
|
|
29
|
+
const lists = [...(Array.isArray(masked.funnels) ? masked.funnels.map((funnel) => funnel?.pages) : []), masked.funnel_pages];
|
|
30
|
+
for (const pages of lists) {
|
|
31
|
+
for (const page of Array.isArray(pages) ? pages : []) if (page && typeof page === "object") delete page.page_url;
|
|
32
|
+
}
|
|
33
|
+
return specMaterialHash(masked);
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
// The three pure comparators live in `./spec-hash.mjs`, a dependency-free
|
|
23
37
|
// leaf, so that browser-targeted consumers of the public
|
|
24
38
|
// `./commercial-journey` export can reach them without pulling this module's
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
NEXT_STAGE_CONTRACTS,
|
|
12
12
|
NEXT_STAGE_OWNERS,
|
|
13
13
|
stageIsBlocked,
|
|
14
|
+
stageIsTerminal,
|
|
14
15
|
} from "./orchestration-stage-contract.mjs";
|
|
15
16
|
|
|
16
17
|
const PRODUCER_STAGES = new Set(["doctor", "qa"]);
|
|
@@ -226,6 +227,20 @@ export function restorePolishKeptWithOutput(polish, { fingerprint, sourcePackage
|
|
|
226
227
|
return { ...restored, stage: "polish", ...stamps };
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/**
|
|
231
|
+
* True when the campaign has been handed off (#721): its deploy or its QA was
|
|
232
|
+
* completed, as the live record or as a record the stage's history keeps
|
|
233
|
+
* (a deploy or QA made owed again by a later change still happened). From
|
|
234
|
+
* then on the repo, not the design source, is the authority for which pages
|
|
235
|
+
* the funnel has, so `spec derive` adds and removes pages from the page tree
|
|
236
|
+
* and `record spec` binds that change without intake.
|
|
237
|
+
*/
|
|
238
|
+
export function campaignHandedOff(report) {
|
|
239
|
+
const completedIn = (stage) => isPlainObject(stage) && (isCompletedStatus(stage.status)
|
|
240
|
+
|| (Array.isArray(stage.history) && stage.history.some((entry) => isPlainObject(entry) && isCompletedStatus(entry.status))));
|
|
241
|
+
return completedIn(report?.stages?.deploy) || completedIn(report?.stages?.qa);
|
|
242
|
+
}
|
|
243
|
+
|
|
229
244
|
/**
|
|
230
245
|
* True when any stage of `report` carries a non-empty history: recorded
|
|
231
246
|
* operator-visible state an intake must not silently drop.
|
|
@@ -573,6 +588,30 @@ export function qaRecordedBuildFingerprint(report) {
|
|
|
573
588
|
|| optionalString(report?.stages?.qa?.evidence?.source_build_fingerprint);
|
|
574
589
|
}
|
|
575
590
|
|
|
591
|
+
// #720: the build whose completed QA a page-scoped run extends. A completed
|
|
592
|
+
// QA record extends its own build. A page-scoped run that did not complete
|
|
593
|
+
// extends the build of the full QA it was based on, which was completed when
|
|
594
|
+
// that run was accepted. Any other record (blocked or pending full QA, a
|
|
595
|
+
// skip, a required stage) extends nothing, so it owes a full QA run.
|
|
596
|
+
const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
|
|
597
|
+
export function qaExtendedBuildFingerprint(report) {
|
|
598
|
+
const qa = report?.stages?.qa;
|
|
599
|
+
if (QA_EXTENDABLE_STATUSES.includes(qa?.status)) return qaRecordedBuildFingerprint(report);
|
|
600
|
+
if (qa?.status === "blocked") return optionalString(qa?.evidence?.page_scope?.base_build_fingerprint);
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The build Polish last reviewed: the output a terminal Polish is bound to,
|
|
605
|
+
// or, once a rebuild has made it required, the build that rebuild kept for
|
|
606
|
+
// it. A blocked or pending Polish reviewed nothing.
|
|
607
|
+
export function polishReviewedBuild(polish) {
|
|
608
|
+
if (stageIsTerminal(String(polish?.status || ""))) return optionalString(polish.source_build_fingerprint);
|
|
609
|
+
// A brief or spec change since then (input_change, which only a restored
|
|
610
|
+
// or new Polish clears) owes the full review, whatever the build change.
|
|
611
|
+
if (Object.hasOwn(polish || {}, "input_change")) return null;
|
|
612
|
+
return polish?.status === "required" && polish.required_by === "build" ? optionalString(polish.owed_since_build_fingerprint) : null;
|
|
613
|
+
}
|
|
614
|
+
|
|
576
615
|
export function qaRecordedForCurrentBuild(report, buildFingerprint) {
|
|
577
616
|
const recorded = qaRecordedBuildFingerprint(report);
|
|
578
617
|
const current = optionalString(buildFingerprint);
|
|
@@ -716,7 +755,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
716
755
|
const doctor = refreshDoctor(outcome);
|
|
717
756
|
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
|
|
718
757
|
} else if (outcome.written) {
|
|
719
|
-
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
|
|
758
|
+
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim(), ...(doctorOutPath ? { path: doctorOutPath } : {}) });
|
|
720
759
|
}
|
|
721
760
|
return outcome;
|
|
722
761
|
};
|
package/src/stage-record.mjs
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
// stylesheets must also match the built output byte for byte).
|
|
24
24
|
import { createHash } from "node:crypto";
|
|
25
25
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
|
|
29
29
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
30
30
|
|
|
31
|
-
import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
31
|
+
import { BRAND_FONTS_FILENAME, BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
32
32
|
import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
|
|
33
33
|
import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
34
34
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
@@ -36,7 +36,9 @@ import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requ
|
|
|
36
36
|
import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
|
|
37
37
|
import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
|
|
38
38
|
import { isLoopbackHostname } from "./remit.mjs";
|
|
39
|
-
import { campaignRouteRoot } from "./route-identity.mjs";
|
|
39
|
+
import { campaignRouteRoot, normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
40
|
+
import { buildChangeEntry, buildCommerceSignature, nextBuildChanges } from "./change-scope.mjs";
|
|
41
|
+
import { assessEvidenceDebt } from "./evidence-debt.mjs";
|
|
40
42
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
41
43
|
import { doctorPacket } from "./doctor/inspect.mjs";
|
|
42
44
|
import { validateAssemblyReport } from "./doctor/checks.mjs";
|
|
@@ -52,7 +54,7 @@ import {
|
|
|
52
54
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
53
55
|
import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
|
|
54
56
|
import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
|
|
55
|
-
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
57
|
+
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
56
58
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
57
59
|
import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
58
60
|
|
|
@@ -127,7 +129,7 @@ function typeName(value) {
|
|
|
127
129
|
|
|
128
130
|
// visual_review keys only `polish capture` writes. `record polish --evidence`
|
|
129
131
|
// refuses them by name and carries the captured values forward.
|
|
130
|
-
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
|
|
132
|
+
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability", "captures", "element_parity"]);
|
|
131
133
|
export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
|
|
132
134
|
|
|
133
135
|
export function refuseRecord(stage, problems) {
|
|
@@ -330,17 +332,39 @@ function currentStamps(inputs) {
|
|
|
330
332
|
return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
|
|
331
333
|
}
|
|
332
334
|
|
|
333
|
-
|
|
335
|
+
// #707/#720: what this rebuild changed against the build recorded before it,
|
|
336
|
+
// classified page-only or commerce-affecting, appended to the kept history so
|
|
337
|
+
// a stage that reviewed an older build can still read its changed pages; and
|
|
338
|
+
// the commerce signature of this output, for the next rebuild to compare
|
|
339
|
+
// against. Read from the same packet and output doctor fingerprinted.
|
|
340
|
+
function buildChangeFacts(report, { packet, packetPath, fingerprint, now }) {
|
|
341
|
+
if (!packet || !packetPath) return {};
|
|
342
|
+
const previousAssembly = stageObject(report, "assembly");
|
|
343
|
+
const targetRepo = targetRepoFor(packetPath, packet);
|
|
344
|
+
const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
345
|
+
const outputRoot = join(targetRepo, "_site", slug);
|
|
346
|
+
const signature = buildCommerceSignature({ scope: resolveBuiltSiteScope(outputRoot), outputRoot, targetRepo, publicRouteSlug: slug });
|
|
347
|
+
const from = optionalString(previousAssembly.build_fingerprint);
|
|
348
|
+
if (!from || from === fingerprint) return { signature, buildChanges: previousAssembly.build_changes };
|
|
349
|
+
const debt = assessEvidenceDebt({ packet, packetPath, report });
|
|
350
|
+
const entry = buildChangeEntry({ from, to: fingerprint, at: now, changes: debt.changes,
|
|
351
|
+
classification: { kind: debt.changes.kind, reasons: debt.changes.reasons } });
|
|
352
|
+
const anchors = [qaRecordedBuildFingerprint(report), qaExtendedBuildFingerprint(report), polishReviewedBuild(stageObject(report, "polish"))];
|
|
353
|
+
return { signature, buildChanges: nextBuildChanges(previousAssembly.build_changes, entry, anchors) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null, packet = null, packetPath = null }) {
|
|
334
357
|
const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
|
|
335
358
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
336
359
|
const previousAssembly = stageObject(report, "assembly");
|
|
360
|
+
const { signature, buildChanges } = buildChangeFacts(report, { packet, packetPath, fingerprint, now });
|
|
337
361
|
const stamps = currentStamps(inputs);
|
|
338
362
|
// A replaced completed record whose stamps differ from the current inputs
|
|
339
363
|
// records the change now, if nothing recorded it before.
|
|
340
364
|
const writeInputs = stageWriteInputs("assembly", inputs);
|
|
341
365
|
const detected = REPLACED_COMPLETED_STATUSES.includes(previousAssembly.status) ? writeInputs.detectChange(previousAssembly) : null;
|
|
342
366
|
let assembly = {
|
|
343
|
-
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", ...STAMP_FIELDS]),
|
|
367
|
+
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", "commerce_signature", "build_changes", ...STAMP_FIELDS]),
|
|
344
368
|
...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
|
|
345
369
|
stage: "assembly",
|
|
346
370
|
status: "completed",
|
|
@@ -349,6 +373,14 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
|
|
|
349
373
|
// (doctor's built_output.fingerprint_stale, polish capture) can name the
|
|
350
374
|
// files that differ. Left out when it cannot be read back as this value.
|
|
351
375
|
...(manifest ? { build_manifest: manifest } : {}),
|
|
376
|
+
// One hash per built page over its commerce-bearing markup, and one of
|
|
377
|
+
// the campaign's Page Kit config entry, so the next change can be read
|
|
378
|
+
// as page-only or commerce-affecting (change-scope.mjs).
|
|
379
|
+
...(signature ? { commerce_signature: signature } : {}),
|
|
380
|
+
// The rebuilds since the builds Polish and QA last reviewed, each with
|
|
381
|
+
// its changed pages and classification, so the changed-page list
|
|
382
|
+
// survives this record (readback evidence_debt.<stage>.change).
|
|
383
|
+
...(Array.isArray(buildChanges) && buildChanges.length ? { build_changes: buildChanges } : {}),
|
|
352
384
|
...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
|
|
353
385
|
...stamps,
|
|
354
386
|
completed_at: now,
|
|
@@ -393,14 +425,18 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
|
|
|
393
425
|
if (keptPolish) return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish: keptPolish, ...deploy } }, context: null };
|
|
394
426
|
const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
|
|
395
427
|
&& optionalString(previousPolish.source_build_fingerprint) === fingerprint;
|
|
428
|
+
// The build Polish reviewed stays named once its binding is cleared, so a
|
|
429
|
+
// page-only change since then reads as advisory (evidence-debt.mjs).
|
|
430
|
+
const reviewed = polishReviewedBuild(previousPolish);
|
|
396
431
|
const polish = polishStillCurrent
|
|
397
432
|
? previousPolish
|
|
398
433
|
: {
|
|
399
|
-
...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", ...STAMP_FIELDS]),
|
|
434
|
+
...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", "owed_since_build_fingerprint", ...STAMP_FIELDS]),
|
|
400
435
|
stage: "polish",
|
|
401
436
|
status: "required",
|
|
402
437
|
required_by: "build",
|
|
403
438
|
required_for: ["qa"],
|
|
439
|
+
...(reviewed ? { owed_since_build_fingerprint: reviewed } : {}),
|
|
404
440
|
};
|
|
405
441
|
return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
|
|
406
442
|
}
|
|
@@ -456,7 +492,7 @@ function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {
|
|
|
456
492
|
: previous.evidence;
|
|
457
493
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
458
494
|
let polish = {
|
|
459
|
-
...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", ...STAMP_FIELDS]),
|
|
495
|
+
...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", "owed_since_build_fingerprint", ...STAMP_FIELDS]),
|
|
460
496
|
stage: "polish",
|
|
461
497
|
status: input.status,
|
|
462
498
|
performed_by: POLISH_PRODUCER,
|
|
@@ -512,10 +548,24 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
|
|
|
512
548
|
// family components, so it must load a brand layer (brand-theme.css or
|
|
513
549
|
// checkout-brand.css) after it, and that file must be in the built output. A
|
|
514
550
|
// page that loads neither renders the design's own markup and needs no brand
|
|
515
|
-
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs").
|
|
551
|
+
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). When
|
|
552
|
+
// theme generate wrote brand-fonts.css (the design's own faces), each styled
|
|
553
|
+
// page must load the campaign's copy too, or the families brand-theme.css
|
|
554
|
+
// names fall back to the system font. Where it was written is the Build
|
|
555
|
+
// Context's theme.generated.fonts_css_path (theme generate --out-dir moves
|
|
556
|
+
// it); the default path is read only when the context names none. Any page
|
|
516
557
|
// that breaks the rule is a refusal naming the page.
|
|
517
|
-
function brandLayerFacts(doctor) {
|
|
558
|
+
function brandLayerFacts(doctor, context = null, runtimeDir = resolve(doctor.derived?.target_repo || ".", ".campaign-runtime")) {
|
|
518
559
|
const derived = doctor.derived || {};
|
|
560
|
+
const recordedFonts = context?.theme?.generated?.fonts_css_path;
|
|
561
|
+
const generatedFonts = typeof recordedFonts === "string" && recordedFonts
|
|
562
|
+
? resolve(derived.target_repo, recordedFonts)
|
|
563
|
+
: (context?.theme?.generated ? null : resolve(runtimeDir, "theme", BRAND_FONTS_FILENAME));
|
|
564
|
+
const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
|
|
565
|
+
const insideSite = (file) => {
|
|
566
|
+
const rel = relative(site.site_root, file);
|
|
567
|
+
return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
|
|
568
|
+
};
|
|
519
569
|
const commerce = commerceScopeFromScope(derived.scope);
|
|
520
570
|
if (!commerce.all.length) {
|
|
521
571
|
throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
|
|
@@ -566,6 +616,30 @@ function brandLayerFacts(doctor) {
|
|
|
566
616
|
problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
|
|
567
617
|
continue;
|
|
568
618
|
}
|
|
619
|
+
// brand-fonts.css holds only @font-face rules, so unlike the brand layer
|
|
620
|
+
// its position relative to next-core.css does not matter.
|
|
621
|
+
if (fontsRequired) {
|
|
622
|
+
const fontsHref = hrefs.find((candidate) => hrefName(candidate) === BRAND_FONTS_FILENAME);
|
|
623
|
+
const bare = fontsHref ? fontsHref.split(/[?#]/)[0] : null;
|
|
624
|
+
const fontsFile = bare && !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(bare)
|
|
625
|
+
? (bare.startsWith("/") ? join(site.site_root, bare) : resolve(dirname(built.built_path), bare))
|
|
626
|
+
: null;
|
|
627
|
+
const generatedRel = relative(derived.target_repo, generatedFonts);
|
|
628
|
+
const problem = !fontsHref
|
|
629
|
+
? `${label}: ${builtRel} links no ${BRAND_FONTS_FILENAME}. Theme generate wrote the design's font faces to ${generatedRel}; copy it into the campaign assets/css folder beside the brand layer, list it in the page's frontmatter styles and rebuild.`
|
|
630
|
+
: !fontsFile || !insideSite(fontsFile)
|
|
631
|
+
? `${label}: ${builtRel} links ${fontsHref}, which is not a file in this campaign's built output; link the campaign's own copy of ${generatedRel}.`
|
|
632
|
+
: !existsSync(fontsFile)
|
|
633
|
+
? `${label}: ${builtRel} links ${fontsHref}, which is not in the built output.`
|
|
634
|
+
: readFileSync(fontsFile, "utf8") !== readFileSync(generatedFonts, "utf8")
|
|
635
|
+
? `${label}: ${builtRel} links ${fontsHref}, which differs from the generated ${generatedRel}; copy the generated file and rebuild.`
|
|
636
|
+
: null;
|
|
637
|
+
if (problem) {
|
|
638
|
+
problems.push(problem);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, fontsFile)}, identical to the generated ${generatedRel}.`);
|
|
642
|
+
}
|
|
569
643
|
layers.push(file);
|
|
570
644
|
styled.push(label);
|
|
571
645
|
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
|
|
@@ -578,10 +652,10 @@ function brandLayerFacts(doctor) {
|
|
|
578
652
|
: `No built commerce page loads ${CORE_STYLESHEET} (${unstyled.join(", ")}${outOfScope.length ? `; declared but not built: ${outOfScope.join(", ")}` : ""}), so no page renders family components for a brand layer to style. If shipping without one is intended, record that with ${cmd("theme")} waive.`]);
|
|
579
653
|
}
|
|
580
654
|
const cssPath = relative(derived.target_repo, layers[0]);
|
|
581
|
-
const generated = resolve(
|
|
655
|
+
const generated = resolve(runtimeDir, "theme/brand-theme.css");
|
|
582
656
|
if (existsSync(generated)) {
|
|
583
657
|
const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
|
|
584
|
-
evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated .
|
|
658
|
+
evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated ${relative(derived.target_repo, generated).split(sep).join("/")}.`);
|
|
585
659
|
}
|
|
586
660
|
return {
|
|
587
661
|
cssPath,
|
|
@@ -865,7 +939,7 @@ function validateRecord(stage, { report, context, packet, fingerprint }) {
|
|
|
865
939
|
// over the report this record would write. A blocked or skipped Polish is
|
|
866
940
|
// not a pass the gate could grant; doctor keeps QA blocked on it.
|
|
867
941
|
const hiddenEagerMediaGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
|
|
868
|
-
const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint });
|
|
942
|
+
const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint, newEvidence: true });
|
|
869
943
|
if (gate.status === "blocked") {
|
|
870
944
|
problems.push(`${gate.code}: ${gate.reason}`);
|
|
871
945
|
for (const problem of gate.problems || []) problems.push(problem);
|
|
@@ -1110,6 +1184,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1110
1184
|
...(stage === "build" && Array.isArray(composed.report.stages.assembly.build_manifest) ? [`stages.assembly.build_manifest lists ${composed.report.stages.assembly.build_manifest.length} file(s), so a later fingerprint mismatch names the paths that differ`] : []),
|
|
1111
1185
|
...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
|
|
1112
1186
|
...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
|
|
1187
|
+
...(stage === "build" ? buildChangeReadyLines(composed.report.stages.assembly) : []),
|
|
1113
1188
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
1114
1189
|
...(stage === "build" && composed.report.stages.deploy?.required_by === "build" ? [`stages.deploy.status = ${composed.report.stages.deploy.status} (the recorded deploy probed other output; run ${cmd("record")} deploy again)`] : []),
|
|
1115
1190
|
...(composed.context ? ["Build Context scaffold.required = false"] : []),
|
|
@@ -1125,7 +1200,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1125
1200
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
1126
1201
|
build_fingerprint: facts.fingerprint || null,
|
|
1127
1202
|
// The manifest is kept in the report, not echoed: it lists every built file.
|
|
1128
|
-
record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest"]) : composed.report.stages[stageKey],
|
|
1203
|
+
record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest", "commerce_signature"]) : composed.report.stages[stageKey],
|
|
1129
1204
|
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
1130
1205
|
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
1131
1206
|
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
@@ -1137,6 +1212,14 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1137
1212
|
};
|
|
1138
1213
|
}
|
|
1139
1214
|
|
|
1215
|
+
// One line for the rebuild this record kept, when it replaced another build.
|
|
1216
|
+
function buildChangeReadyLines(assembly) {
|
|
1217
|
+
const latest = Array.isArray(assembly.build_changes) ? assembly.build_changes.at(-1) : null;
|
|
1218
|
+
if (!latest || latest.build_fingerprint !== assembly.build_fingerprint) return [];
|
|
1219
|
+
const pages = latest.changed_pages.map((page) => page.page_id).join(", ") || "none";
|
|
1220
|
+
return [`stages.assembly.build_changes: this rebuild is ${latest.kind.replace("_", "-")} (changed pages: ${pages}${latest.unmapped_file_count ? `; ${latest.unmapped_file_count} file(s) outside any page` : ""})`];
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1140
1223
|
// Everything a record reads from the target, in order, all under the target
|
|
1141
1224
|
// lock (except a dry run, which writes nothing): the packet and the Build
|
|
1142
1225
|
// Context's report pointer (the workspace), the report, the Build Context
|
|
@@ -1180,12 +1263,12 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1180
1263
|
}
|
|
1181
1264
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
1182
1265
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
1183
|
-
if (stage === "theme") layer = brandLayerFacts(doctor);
|
|
1266
|
+
if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath), workspace.runtimeDir);
|
|
1184
1267
|
if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
|
|
1185
1268
|
const next = stage === "setup"
|
|
1186
1269
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
1187
1270
|
: stage === "build"
|
|
1188
|
-
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, manifest: outputManifest(facts), buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
|
|
1271
|
+
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, manifest: outputManifest(facts), buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason, packet, packetPath })
|
|
1189
1272
|
: stage === "theme"
|
|
1190
1273
|
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
1191
1274
|
: stage === "deploy"
|
package/src/target-lock.mjs
CHANGED
|
@@ -22,9 +22,19 @@ export function targetLockPath(targetRepo) {
|
|
|
22
22
|
// holds it, only a pid, so the holder is described generically. A lock with
|
|
23
23
|
// no owner record is called out on its own: it is never taken over, and the
|
|
24
24
|
// operator needs to know it will not clear by waiting. So is anything at the
|
|
25
|
-
// lock path that is not a lock at all, named by what it is
|
|
25
|
+
// lock path that is not a lock at all, named by what it is, and a lock held
|
|
26
|
+
// by a live process of another campaigns-os release, which is refused at once
|
|
27
|
+
// (#514).
|
|
26
28
|
function unavailable(targetRepo, lockPath, command) {
|
|
27
29
|
return (error) => {
|
|
30
|
+
if (error?.code === "ELOCKFORMAT") {
|
|
31
|
+
return Object.assign(new Error(
|
|
32
|
+
`${command}: the target lock at ${lockPath} is held by process ${error.holderPid}, which runs a different campaigns-os release. `
|
|
33
|
+
+ "Two releases taking this lock at the same moment can both get it, so this run stops instead of waiting. "
|
|
34
|
+
+ `Let that run finish, then retry with one campaigns-os release working on ${targetRepo} at a time.`,
|
|
35
|
+
{ cause: error },
|
|
36
|
+
), { code: error.code, holderPid: error.holderPid, lockFormat: error.lockFormat });
|
|
37
|
+
}
|
|
28
38
|
if (error?.code === "ENOTLOCK") {
|
|
29
39
|
return Object.assign(new Error(
|
|
30
40
|
`${command}: the target lock path ${lockPath} is occupied by ${error.obstruction}. `
|
package/src/theme-gate.mjs
CHANGED
|
@@ -166,13 +166,13 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
|
|
|
166
166
|
id: "theme_generate",
|
|
167
167
|
kind: "command",
|
|
168
168
|
command: `campaigns-os theme generate --packet ${packetArg}`,
|
|
169
|
-
description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens.",
|
|
169
|
+
description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens, and brand-fonts.css beside it when the design source ships the faces its headings, body copy or buttons use.",
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
172
|
id: "apply_brand_layer",
|
|
173
173
|
kind: "manual",
|
|
174
174
|
command: null,
|
|
175
|
-
description: `Copy brand-theme.css into the campaign assets/css folder, list
|
|
175
|
+
description: `Copy brand-theme.css (and brand-fonts.css, when theme generate wrote one) into the campaign assets/css folder, list brand-theme.css after next-core.css, and brand-fonts.css anywhere, in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
id: "waive_theme",
|