@nextcommerce/campaigns-os 1.61.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 +118 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- 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/commerce-surface-catalog.json +59 -53
- 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 +999 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +29 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- 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 +62 -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 +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- 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/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- 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 +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- 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-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- 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 +58 -13
- package/src/target-lock.mjs +11 -1
package/src/input-refresh.mjs
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
// brief from the brief file last saved by intake or record brief (never by
|
|
13
13
|
// discovery), and makes build, Polish and QA owed again unless a stage was
|
|
14
14
|
// already recorded against the new content; waivers, accepts, the theme,
|
|
15
|
-
// the packet and the setup and deploy records are never touched
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
// the packet and the setup and deploy records are never touched, except when
|
|
16
|
+
// the active pages changed in a way record spec binds (#531, #721): a move of
|
|
17
|
+
// routes alone, or on a handed-off campaign a page added or removed. Then the
|
|
18
|
+
// packet's and the Build Context's page-kit projection, the Design Source
|
|
19
|
+
// Package's page surfaces and the three references to that package are
|
|
20
|
+
// rewritten too, and deploy is owed again when the set of built pages
|
|
21
|
+
// changes. Refusals are checked in a fixed order before anything is written;
|
|
22
|
+
// --dry-run runs every check and writes nothing.
|
|
23
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
24
|
+
import { existsSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
25
|
import { dirname, join, relative, resolve } from "node:path";
|
|
21
26
|
|
|
22
27
|
import {
|
|
@@ -27,6 +32,8 @@ import {
|
|
|
27
32
|
} from "./build-brief.mjs";
|
|
28
33
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
29
34
|
import { isObject, optionalString, portableArtifactPaths, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
|
|
35
|
+
import { createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, rebindDesignSourcePackagePages, serializeDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
|
|
36
|
+
import { readPriorDesignSourceProvenance, recordPendingDesignSourceProvenance } from "./design-source-publication.mjs";
|
|
30
37
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
31
38
|
import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
|
|
32
39
|
import {
|
|
@@ -42,9 +49,11 @@ import {
|
|
|
42
49
|
wellFormedBriefMaterial,
|
|
43
50
|
} from "./input-currency.mjs";
|
|
44
51
|
import { cmd } from "./install-invocation.mjs";
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
52
|
+
import { campaignRouteRoot, intakeRouteRoot, normalizePublicRouteSlug, specPublicRouteSlug } from "./route-identity.mjs";
|
|
53
|
+
import { projectPageKitTargets, publicRouteForPage } from "./source-html-intake.mjs";
|
|
54
|
+
import { staleRoutingHints } from "./spec-derive.mjs";
|
|
55
|
+
import { routeMaskedSpecMaterialHash, specHashesMatch } from "./spec-identity.mjs";
|
|
56
|
+
import { archiveSupersededRecord, assemblyReportMatchesPacket, campaignHandedOff, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
|
|
48
57
|
import { refuseRecord } from "./stage-record.mjs";
|
|
49
58
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
50
59
|
|
|
@@ -219,14 +228,33 @@ function briefTooLarge(path) {
|
|
|
219
228
|
return entries > BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES ? `${path} has ${entries} design_authority entries; the limit is ${BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES}.` : null;
|
|
220
229
|
}
|
|
221
230
|
|
|
222
|
-
|
|
231
|
+
// An active page as the Build Context records it.
|
|
232
|
+
const recordedActivePage = (page) => ({ id: page.id, type: page.type || null, label: page.label || null, page_url: publicRouteForPage(page) });
|
|
223
233
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
234
|
+
/**
|
|
235
|
+
* How the CampaignSpec's active pages differ from the ones the Build Context
|
|
236
|
+
* records, split the way record spec binds them (#531, #721): page identity
|
|
237
|
+
* (`added` and `removed` ids, and whether the pages both lists keep are in
|
|
238
|
+
* another order) and routes (`moved`: kept pages whose route changed).
|
|
239
|
+
* `changed` is true when anything differs, or when the context records no
|
|
240
|
+
* pages, or a page id twice (then `unbindable`, as a reorder is).
|
|
241
|
+
*/
|
|
242
|
+
export function pageScopeChange(spec, context) {
|
|
227
243
|
const recorded = Array.isArray(context?.spec?.active_pages) ? context.spec.active_pages : null;
|
|
228
|
-
const
|
|
229
|
-
|
|
244
|
+
const current = activeSpecPages(spec).map(recordedActivePage);
|
|
245
|
+
if (!recorded) return { recorded: false, current, added: [], removed: [], moved: [], reordered: false, unbindable: true, changed: true };
|
|
246
|
+
const recordedIds = recorded.map((page) => page?.id);
|
|
247
|
+
const currentIds = current.map((page) => page.id);
|
|
248
|
+
const duplicate = new Set(recordedIds).size !== recordedIds.length || new Set(currentIds).size !== currentIds.length;
|
|
249
|
+
const added = currentIds.filter((id) => !recordedIds.includes(id));
|
|
250
|
+
const removed = recordedIds.filter((id) => !currentIds.includes(id));
|
|
251
|
+
const keptRecorded = recordedIds.filter((id) => currentIds.includes(id));
|
|
252
|
+
const keptCurrent = currentIds.filter((id) => recordedIds.includes(id));
|
|
253
|
+
const reordered = keptRecorded.length !== keptCurrent.length || keptRecorded.some((id, index) => id !== keptCurrent[index]);
|
|
254
|
+
const recordedById = new Map(recorded.map((page) => [page?.id, page]));
|
|
255
|
+
const moved = current.filter((page) => recordedById.has(page.id) && (page.page_url ?? null) !== (recordedById.get(page.id)?.page_url ?? null)).map((page) => page.id);
|
|
256
|
+
const unbindable = duplicate || reordered;
|
|
257
|
+
return { recorded: true, current, added, removed, moved, reordered, unbindable, changed: unbindable || added.length > 0 || removed.length > 0 || moved.length > 0 };
|
|
230
258
|
}
|
|
231
259
|
|
|
232
260
|
// The files Campaigns OS writes itself, which a brief path must never name.
|
|
@@ -292,8 +320,8 @@ function briefRefusal({ candidates, packageArtifacts, context, spec, report }) {
|
|
|
292
320
|
if (!current || !bound || current !== `sha256:${bound.replace(/^sha256:/, "")}`) {
|
|
293
321
|
return ["spec_changed_run_record_spec", `the CampaignSpec material (${current || "unreadable"}) differs from the one the Assembly Report binds (${bound || "none"}); save the spec change with ${cmd("record")} spec first.`];
|
|
294
322
|
}
|
|
295
|
-
if (
|
|
296
|
-
return ["page_scope_changed",
|
|
323
|
+
if (pageScopeChange(spec, context).changed) {
|
|
324
|
+
return ["page_scope_changed", `the CampaignSpec's active pages (ids, order or routes) differ from the Build Context's spec.active_pages; bind the page change with ${cmd("record")} spec first (a routes-only change, or a page added or removed after handoff), or re-run intake for any other.`];
|
|
297
325
|
}
|
|
298
326
|
return null;
|
|
299
327
|
}
|
|
@@ -531,6 +559,17 @@ function readSpecFile(specPath) {
|
|
|
531
559
|
}
|
|
532
560
|
}
|
|
533
561
|
|
|
562
|
+
// A text file written whole or not at all: staged beside it, then renamed.
|
|
563
|
+
function writeTextAtomic(path, text) {
|
|
564
|
+
const staged = join(dirname(path), `.${relative(dirname(path), path)}.${randomUUID()}.tmp`);
|
|
565
|
+
try {
|
|
566
|
+
writeFileSync(staged, text, { flag: "wx" });
|
|
567
|
+
renameSync(staged, path);
|
|
568
|
+
} finally {
|
|
569
|
+
rmSync(staged, { force: true });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
534
573
|
// The brief file last saved, by intake or record brief: its --brief path,
|
|
535
574
|
// otherwise the file that save found; never discovery. Null when intake
|
|
536
575
|
// generated the guided draft and no brief file was saved since.
|
|
@@ -544,19 +583,206 @@ function recordedBriefFile(context, { targetRepo, previousNormalizedBrief }) {
|
|
|
544
583
|
|
|
545
584
|
// Every record spec refusal, in the fixed order: the spec checks first (the
|
|
546
585
|
// page-scope comparison needs a readable spec of the bound identity), then
|
|
547
|
-
// the recorded brief file. Returns
|
|
548
|
-
|
|
549
|
-
|
|
586
|
+
// the page rebind's own checks, then the recorded brief file. Returns
|
|
587
|
+
// { refusal: [code, detail] | null, rebind } where `rebind` is the page
|
|
588
|
+
// rebind plan (null when the active pages are the ones recorded).
|
|
589
|
+
function specRefusal({ read, specPath, packet, packetPath, report, reportPath, context, targetRepo, briefFile, packageArtifacts }) {
|
|
590
|
+
const refuse = (code, detail) => ({ refusal: [code, detail], rebind: null });
|
|
591
|
+
if (!read) return refuse("spec_unreadable", `the CampaignSpec at ${specPath || "(the packet names no spec.local_path)"} could not be read as a JSON object; restore it, then run ${cmd("record")} spec again.`);
|
|
550
592
|
const packetKind = specIdentityMismatch(read.spec, packet.spec);
|
|
551
593
|
const reportKind = isObject(report.identity) ? specIdentityMismatch(read.spec, report.identity) : null;
|
|
552
594
|
if (packetKind || reportKind) {
|
|
553
|
-
return
|
|
595
|
+
return refuse("spec_identity_changed", `the CampaignSpec's ${(packetKind || reportKind) === "local_spec" ? "local_spec_id" : "map_id"} names another campaign than the ${packetKind ? "Build Packet" : "Assembly Report"} binds; point spec.local_path at this campaign's spec. A new campaign identity needs intake.`);
|
|
596
|
+
}
|
|
597
|
+
const scope = pageScopeChange(read.spec, context);
|
|
598
|
+
const intake = `${cmd("prepare-build")}; with --force it clears recorded stage evidence and operator decisions, archiving the build, Polish and QA records`;
|
|
599
|
+
if (scope.unbindable) {
|
|
600
|
+
return refuse("page_scope_changed", `the CampaignSpec's active pages are ${scope.recorded ? "in another order than" : "not comparable with"} the Build Context's spec.active_pages. record spec binds a move of routes, and after handoff a page added or removed; a reorder needs intake (${intake}).`);
|
|
554
601
|
}
|
|
555
|
-
if (
|
|
556
|
-
return
|
|
602
|
+
if ((scope.added.length || scope.removed.length) && !campaignHandedOff(report)) {
|
|
603
|
+
return refuse("page_scope_changed", `the CampaignSpec ${[scope.added.length ? `adds ${scope.added.map((id) => `"${id}"`).join(", ")}` : null, scope.removed.length ? `removes ${scope.removed.map((id) => `"${id}"`).join(", ")}` : null].filter(Boolean).join(" and ")} against the Build Context's spec.active_pages. Before handoff (no deploy or QA completed) the design source decides the pages, and the Design Source Package and the source mapping are built per page, so a page added or removed needs intake (${intake}).`);
|
|
557
604
|
}
|
|
558
|
-
|
|
559
|
-
|
|
605
|
+
let rebind = null;
|
|
606
|
+
if (scope.changed) {
|
|
607
|
+
const planned = planPageRebind({ spec: read.spec, packet, packetPath, report, reportPath, context, targetRepo, scope });
|
|
608
|
+
if (planned.refusal) return { refusal: planned.refusal, rebind: null };
|
|
609
|
+
rebind = planned;
|
|
610
|
+
}
|
|
611
|
+
if (!briefFile) return { refusal: null, rebind };
|
|
612
|
+
const fileRefusal = briefFileRefusal([briefFile], packageArtifacts, (file) => `the brief file last saved, ${briefFile.path}, ${file?.why || "does not exist"}; restore it, or save a brief file with ${cmd("record")} brief --brief <file>.`);
|
|
613
|
+
return { refusal: fileRefusal, rebind: fileRefusal ? null : rebind };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const canonicalJson = (value) => {
|
|
617
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
|
|
618
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
619
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* What binding the changed active pages writes (#531, #721), or the refusal
|
|
624
|
+
* that stops it. Every page is re-projected to its page-kit target (a page's
|
|
625
|
+
* next_url reads the routes of the pages it links to). Refused, in order:
|
|
626
|
+
* a move of the route root or slug, a routing hint left stale, two pages on
|
|
627
|
+
* one target, and a Design Source Package record spec cannot rewrite (one
|
|
628
|
+
* prepare-build did not synthesize, or one edited since it was recorded).
|
|
629
|
+
* Returns the next packet pages and page map, the rewritten package (or
|
|
630
|
+
* null when its bytes do not change), whether the build's targets or the set
|
|
631
|
+
* of built pages changed, and whether the change touched routes alone.
|
|
632
|
+
*/
|
|
633
|
+
function planPageRebind({ spec, packet, packetPath, report, reportPath, context, targetRepo, scope }) {
|
|
634
|
+
const refuse = (code, detail) => ({ refusal: [code, detail] });
|
|
635
|
+
const slug = normalizePublicRouteSlug(packet.campaign?.public_route_slug);
|
|
636
|
+
const specSlug = specPublicRouteSlug(spec);
|
|
637
|
+
const packetRoot = campaignRouteRoot(packet);
|
|
638
|
+
const declaredRoot = optionalString(spec.spec_identity?.route_root) || optionalString(spec.campaign?.route_root);
|
|
639
|
+
const specRoot = declaredRoot ? intakeRouteRoot(declaredRoot, slug) : null;
|
|
640
|
+
if ((specSlug && specSlug !== slug) || (declaredRoot && specRoot !== packetRoot)) {
|
|
641
|
+
return refuse("route_root_changed", `the CampaignSpec ${specSlug && specSlug !== slug ? `names route slug "${specSlug}"` : `declares route_root ${JSON.stringify(declaredRoot)}`}, but the packet serves the campaign at ${packetRoot || "(no route root)"}; the recorded preview and deploy are rooted there, so moving the route root or slug needs intake (${cmd("prepare-build")}).`);
|
|
642
|
+
}
|
|
643
|
+
const stale = staleRoutingHints(spec, { publicRouteSlug: slug });
|
|
644
|
+
if (stale.length) {
|
|
645
|
+
return refuse("routing_hint_stale", `the CampaignSpec's routing hints name routes their pages no longer have: ${stale.map((hint) => `${hint.page_id} sdk_hints.meta_tags.${hint.tag} ${JSON.stringify(hint.value)} (page "${hint.target_page_id}" routes to ${JSON.stringify(hint.derived_route)})`).join("; ")}. The built meta tag follows the hint, so shoppers would be sent to the old path. Edit each hint to the route shown in the spec (${cmd("spec")} derive rewrites the ones on an edge it re-points after handoff; while the Map is still the spec's source, re-saving it regenerates them), then run ${cmd("record")} spec again.`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const activePages = activeSpecPages(spec);
|
|
649
|
+
const pageById = new Map(activePages.map((page) => [page.id, page]));
|
|
650
|
+
const outputDir = optionalString(packet.assembly?.output_dir) || `src/${slug}`;
|
|
651
|
+
const projections = projectPageKitTargets({ specPages: activePages, publicRouteSlug: slug, outputDir });
|
|
652
|
+
const removed = new Set(scope.removed);
|
|
653
|
+
const previousPages = Array.isArray(packet.source_html?.pages) ? packet.source_html.pages : [];
|
|
654
|
+
const nextPages = [];
|
|
655
|
+
for (const entry of previousPages) {
|
|
656
|
+
if (!isObject(entry) || removed.has(entry.page_id)) continue;
|
|
657
|
+
nextPages.push(isObject(entry.page_kit) && projections.has(entry.page_id) ? { ...entry, page_kit: projections.get(entry.page_id) } : entry);
|
|
658
|
+
}
|
|
659
|
+
for (const id of scope.added) {
|
|
660
|
+
nextPages.push({ page_id: id, ...(optionalString(pageById.get(id)?.type) ? { page_type: pageById.get(id).type } : {}), origin: "repo", page_kit: projections.get(id) });
|
|
661
|
+
}
|
|
662
|
+
const byTarget = new Map();
|
|
663
|
+
for (const entry of nextPages) {
|
|
664
|
+
const target = entry.page_kit?.output_path;
|
|
665
|
+
if (!target) continue;
|
|
666
|
+
if (byTarget.has(target)) {
|
|
667
|
+
return refuse("page_kit_target_conflict", `pages "${byTarget.get(target)}" and "${entry.page_id}" both project to page-kit target ${target}; give one page a distinct route, then run ${cmd("record")} spec again.`);
|
|
668
|
+
}
|
|
669
|
+
byTarget.set(target, entry.page_id);
|
|
670
|
+
}
|
|
671
|
+
const kitOf = (entries) => new Map(entries.filter((entry) => isObject(entry?.page_kit)).map((entry) => [entry.page_id, canonicalJson(entry.page_kit)]));
|
|
672
|
+
const differs = (before, after) => before.size !== after.size || [...after].some(([id, kit]) => before.get(id) !== kit);
|
|
673
|
+
const afterKits = kitOf(nextPages);
|
|
674
|
+
// Compared with the Build Context's page map as well as the packet's: a
|
|
675
|
+
// run that stopped after writing the packet leaves the packet already
|
|
676
|
+
// re-projected, and the Context still says what was built.
|
|
677
|
+
const projectionChanged = differs(kitOf(previousPages), afterKits) || differs(kitOf(Array.isArray(context.page_map) ? context.page_map : []), afterKits);
|
|
678
|
+
const routesOf = (entries) => [...new Set(entries.filter((entry) => isObject(entry?.page_kit)).map((entry) => entry.page_kit.public_route))].sort();
|
|
679
|
+
const builtPagesChanged = canonicalJson(routesOf(previousPages)) !== canonicalJson(routesOf(nextPages));
|
|
680
|
+
// A moved page with no page-kit target (out of scope, not built) still
|
|
681
|
+
// moves a URL QA reads, so it counts as a change to what is served.
|
|
682
|
+
const unprojectedMoved = scope.moved.some((id) => !afterKits.has(id));
|
|
683
|
+
|
|
684
|
+
const previousMap = Array.isArray(context.page_map) ? context.page_map : [];
|
|
685
|
+
const nextMap = [];
|
|
686
|
+
for (const entry of previousMap) {
|
|
687
|
+
if (!isObject(entry) || removed.has(entry.page_id)) continue;
|
|
688
|
+
if (isObject(entry.page_kit) && projections.has(entry.page_id)) {
|
|
689
|
+
const kit = projections.get(entry.page_id);
|
|
690
|
+
nextMap.push({ ...entry, output_path: relFromDir(targetRepo, resolve(targetRepo, kit.output_path)), page_kit: kit });
|
|
691
|
+
} else nextMap.push(entry);
|
|
692
|
+
}
|
|
693
|
+
for (const id of scope.added) {
|
|
694
|
+
const kit = projections.get(id);
|
|
695
|
+
nextMap.push({ page_id: id, source_path: null, skip_reason: null, output_path: relFromDir(targetRepo, resolve(targetRepo, kit.output_path)), page_kit: kit, origin: "repo" });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
let designSource = null;
|
|
699
|
+
const reference = packet.design_source_package;
|
|
700
|
+
if (isObject(reference)) {
|
|
701
|
+
const packagePath = resolveFromFile(packetPath, reference.path);
|
|
702
|
+
let bytes;
|
|
703
|
+
let value;
|
|
704
|
+
try {
|
|
705
|
+
bytes = readFileSync(packagePath);
|
|
706
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
707
|
+
} catch (error) {
|
|
708
|
+
return refuse("design_source_package_unreadable", `the Design Source Package at ${packagePath} could not be read (${error.message}); restore it, or re-run intake (${cmd("prepare-build")}).`);
|
|
709
|
+
}
|
|
710
|
+
const onDisk = hashSerializedDesignSourcePackage(bytes);
|
|
711
|
+
let rebound;
|
|
712
|
+
let serialized;
|
|
713
|
+
try {
|
|
714
|
+
rebound = rebindDesignSourcePackagePages(value, { activePages, projections });
|
|
715
|
+
serialized = serializeDesignSourcePackage(rebound);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
return refuse("design_source_package_unbindable", `the Design Source Package at ${packagePath} is not a package record spec can re-bind (${error.message}); re-run intake (${cmd("prepare-build")}).`);
|
|
718
|
+
}
|
|
719
|
+
// Only a rebind that changes the package writes it, so only then do the
|
|
720
|
+
// checks on who may rewrite it apply.
|
|
721
|
+
if (serialized !== serializeDesignSourcePackage(value)) {
|
|
722
|
+
const origin = report.design_source_package?.origin ?? null;
|
|
723
|
+
if (origin !== "synthesized") {
|
|
724
|
+
return refuse("design_source_package_operator_supplied", `the Design Source Package at ${packagePath} ${origin === "adopted" ? "was adopted by prepare-build from a package it did not write" : origin ? `has origin ${JSON.stringify(origin)}` : "has no recorded origin (an Assembly Report older than the origin field)"}, so record spec does not rewrite its page routes, which the new pages would change; reconcile it with the new pages and re-run intake (${cmd("prepare-build")}).`);
|
|
725
|
+
}
|
|
726
|
+
const provenance = readPriorDesignSourceProvenance(reportPath, packagePath);
|
|
727
|
+
if (onDisk !== reference.sha256 && !(provenance?.synthesized || []).includes(onDisk)) {
|
|
728
|
+
return refuse("design_source_package_edited", `the Design Source Package at ${packagePath} no longer matches the sha256 the packet records (it was edited since prepare-build wrote it), so record spec does not rewrite it; restore it, or re-run intake (${cmd("prepare-build")} --force regenerates a package it synthesized).`);
|
|
729
|
+
}
|
|
730
|
+
const validation = validateDesignSourcePackage(rebound);
|
|
731
|
+
if (!validation.ok) {
|
|
732
|
+
return refuse("design_source_package_unbindable", `the Design Source Package with the new page routes does not validate (${validation.errors.slice(0, 3).map((error) => `[${error.code}] ${error.message}`).join("; ")}); re-run intake (${cmd("prepare-build")}).`);
|
|
733
|
+
}
|
|
734
|
+
const identity = createDesignSourcePackageArtifactReference(rebound, { serialized });
|
|
735
|
+
designSource = { path: packagePath, serialized, sha256: identity.sha256, material_fingerprint: identity.material_fingerprint };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Routes alone: no page added or removed, and nothing but page_url differs
|
|
740
|
+
// from the material the Build Context recorded at the last bind.
|
|
741
|
+
const recordedMasked = optionalString(context.spec?.route_masked_material_hash);
|
|
742
|
+
const routesOnly = !scope.added.length && !scope.removed.length && Boolean(recordedMasked) && recordedMasked === routeMaskedSpecMaterialHash(spec);
|
|
743
|
+
return {
|
|
744
|
+
refusal: null,
|
|
745
|
+
scope,
|
|
746
|
+
nextPages,
|
|
747
|
+
nextMap,
|
|
748
|
+
designSource,
|
|
749
|
+
projectionChanged,
|
|
750
|
+
builtPagesChanged,
|
|
751
|
+
// Nothing the build wrote changes: the same targets, links and routing
|
|
752
|
+
// meta, the same Design Source Package, and only routes moved.
|
|
753
|
+
builtUnchanged: routesOnly && !projectionChanged && !unprojectedMoved && !designSource,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The Design Source Package reference a record spec run that stopped part
|
|
759
|
+
* way left behind (#531). The rewritten package is published first and the
|
|
760
|
+
* report last, so a run that dies between them leaves the packet, the Build
|
|
761
|
+
* Context or the Assembly Report naming the old sha256 while the package on
|
|
762
|
+
* disk is the new one. When prepare-build's provenance (the report's
|
|
763
|
+
* synthesized reference or the pending record beside the package) vouches
|
|
764
|
+
* for the bytes on disk, and any of the three references names other bytes,
|
|
765
|
+
* this returns the identity to move them to; otherwise null, and doctor
|
|
766
|
+
* reports a mismatch it cannot vouch for.
|
|
767
|
+
*/
|
|
768
|
+
function designSourceReferenceRepair({ packet, packetPath, context, report, reportPath }) {
|
|
769
|
+
const reference = packet.design_source_package;
|
|
770
|
+
if (!isObject(reference) || report.design_source_package?.origin !== "synthesized") return null;
|
|
771
|
+
const packagePath = resolveFromFile(packetPath, reference.path);
|
|
772
|
+
let bytes;
|
|
773
|
+
let value;
|
|
774
|
+
try {
|
|
775
|
+
bytes = readFileSync(packagePath);
|
|
776
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
777
|
+
} catch {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
const onDisk = hashSerializedDesignSourcePackage(bytes);
|
|
781
|
+
const references = [reference, context.design_source_package, report.design_source_package].filter(isObject);
|
|
782
|
+
if (references.every((ref) => ref.sha256 === onDisk)) return null;
|
|
783
|
+
if (!(readPriorDesignSourceProvenance(reportPath, packagePath)?.synthesized || []).includes(onDisk)) return null;
|
|
784
|
+
const identity = createDesignSourcePackageArtifactReference(value, { serialized: bytes });
|
|
785
|
+
return { path: packagePath, sha256: identity.sha256, material_fingerprint: identity.material_fingerprint };
|
|
560
786
|
}
|
|
561
787
|
|
|
562
788
|
// The currency reasons that mean a stage's stamp differs from the current
|
|
@@ -580,7 +806,7 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
580
806
|
const normalizedPath = optionalString(packet.build_brief?.normalized_path) ? resolveFromFile(packetPath, packet.build_brief.normalized_path) : null;
|
|
581
807
|
const previousNormalizedBrief = readJsonOrNull(normalizedPath);
|
|
582
808
|
const briefFile = normalizedPath ? recordedBriefFile(context, { targetRepo, previousNormalizedBrief }) : null;
|
|
583
|
-
const refusal = specRefusal({ read, specPath, packet, report, context, briefFile, packageArtifacts: packageArtifactPaths(workspace, normalizedPath) });
|
|
809
|
+
const { refusal, rebind } = specRefusal({ read, specPath, packet, packetPath, report, reportPath, context, targetRepo, briefFile, packageArtifacts: packageArtifactPaths(workspace, normalizedPath) });
|
|
584
810
|
if (refusal) throw refuseRecord("spec", [`${refusal[0]}: ${refusal[1]}`]);
|
|
585
811
|
|
|
586
812
|
const { spec, rawHash } = read;
|
|
@@ -593,7 +819,14 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
593
819
|
const owedOnStamp = STAMPED_STAGES.filter((key) => STAMP_OWED_REASONS.includes(currency.reasons[key]));
|
|
594
820
|
const notices = bindingUnknownNotices(currency);
|
|
595
821
|
const specChanged = !specHashesMatch(boundMaterial, material);
|
|
596
|
-
|
|
822
|
+
// A package reference an earlier, interrupted run left behind is moved
|
|
823
|
+
// with whatever else this run binds (a rewrite this run makes moves them
|
|
824
|
+
// itself).
|
|
825
|
+
const repair = rebind?.designSource ? null : designSourceReferenceRepair({ packet, packetPath, context, report, reportPath });
|
|
826
|
+
// A recovery run never keeps the build: what the interrupted run changed
|
|
827
|
+
// cannot be told from what this one sees, so the stages are owed again.
|
|
828
|
+
const builtUnchanged = Boolean(rebind?.builtUnchanged) && !repair;
|
|
829
|
+
const unchanged = !specChanged && !owedOnStamp.length && !rebind && !repair;
|
|
597
830
|
|
|
598
831
|
const writes = [];
|
|
599
832
|
let demoted = [];
|
|
@@ -605,12 +838,15 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
605
838
|
// normalized brief and its bindings, and a material change also makes
|
|
606
839
|
// the stages its partitions name owed.
|
|
607
840
|
let brief = null;
|
|
841
|
+
// The context the brief is re-derived from carries the rebound pages, so
|
|
842
|
+
// a page added after handoff is evaluated as the repo page it is.
|
|
843
|
+
const pageContext = rebind ? { ...context, page_map: rebind.nextMap } : context;
|
|
608
844
|
if (normalizedPath) {
|
|
609
845
|
// Checked only when the brief is re-derived: an unchanged result reads
|
|
610
846
|
// nothing the missing intake products would give.
|
|
611
847
|
const missing = briefInputsMissing(context);
|
|
612
848
|
if (missing.length) throw refuseRecord("spec", [`brief_inputs_unavailable: the Build Context lacks ${missing.join(", ")}, which re-deriving the brief needs; re-run intake to restore it.`]);
|
|
613
|
-
brief = rederiveBrief({ briefFile, spec, context, packet, report, normalizedPath });
|
|
849
|
+
brief = rederiveBrief({ briefFile, spec, context: pageContext, packet, report, normalizedPath });
|
|
614
850
|
briefOutcome = briefSaveOutcome(report, brief.buildBrief, brief.bindings);
|
|
615
851
|
}
|
|
616
852
|
const briefSaved = briefOutcome && briefOutcome.outcome !== "unchanged";
|
|
@@ -620,8 +856,8 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
620
856
|
const briefWritten = Boolean(brief) && (briefSaved || !sameBriefMaterial(brief.bindings.material, currentBrief));
|
|
621
857
|
const newBrief = brief ? brief.bindings.material : currentBrief;
|
|
622
858
|
const saved = briefSaved
|
|
623
|
-
? briefSaveArtifacts({ context, report, buildBrief: brief.buildBrief, bindings: brief.bindings, targetRepo })
|
|
624
|
-
: { nextContext:
|
|
859
|
+
? briefSaveArtifacts({ context: pageContext, report, buildBrief: brief.buildBrief, bindings: brief.bindings, targetRepo })
|
|
860
|
+
: { nextContext: pageContext, nextReport: report };
|
|
625
861
|
const labels = new Map(activeSpecPages(spec).map((page) => [page.id, page.label || null]));
|
|
626
862
|
const nextContext = {
|
|
627
863
|
...saved.nextContext,
|
|
@@ -629,10 +865,25 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
629
865
|
...saved.nextContext.spec,
|
|
630
866
|
hash: rawHash,
|
|
631
867
|
material_hash: material,
|
|
632
|
-
|
|
868
|
+
route_masked_material_hash: routeMaskedSpecMaterialHash(spec),
|
|
869
|
+
active_pages: rebind
|
|
870
|
+
? rebind.scope.current
|
|
871
|
+
: saved.nextContext.spec.active_pages.map((page) => ({ ...page, label: labels.has(page.id) ? labels.get(page.id) : page.label ?? null })),
|
|
633
872
|
},
|
|
634
873
|
};
|
|
635
874
|
let nextReport = { ...saved.nextReport, identity: { ...saved.nextReport.identity, spec_hash: rawHash, spec_material_hash: material } };
|
|
875
|
+
// The rebound pages' Design Source Package: the same path, new bytes, and
|
|
876
|
+
// each of the three references to it moved together (#531).
|
|
877
|
+
let nextPacket = null;
|
|
878
|
+
if (rebind) nextPacket = { ...packet, source_html: { ...packet.source_html, pages: rebind.nextPages } };
|
|
879
|
+
const movedTo = rebind?.designSource || repair;
|
|
880
|
+
if (movedTo) {
|
|
881
|
+
const moved = { sha256: movedTo.sha256, material_fingerprint: movedTo.material_fingerprint };
|
|
882
|
+
const at = (ref) => ref.sha256 !== moved.sha256 || ref.material_fingerprint !== moved.material_fingerprint;
|
|
883
|
+
if (at(packet.design_source_package)) nextPacket = { ...(nextPacket || packet), design_source_package: { ...packet.design_source_package, ...moved } };
|
|
884
|
+
if (isObject(nextContext.design_source_package) && at(nextContext.design_source_package)) nextContext.design_source_package = { ...nextContext.design_source_package, ...moved };
|
|
885
|
+
if (isObject(nextReport.design_source_package) && at(nextReport.design_source_package)) nextReport.design_source_package = { ...nextReport.design_source_package, ...moved };
|
|
886
|
+
}
|
|
636
887
|
// A stage is owed on the spec unless its spec stamp already equals the
|
|
637
888
|
// new material (a build recorded after the edit has recorded the change
|
|
638
889
|
// itself); a completed stage with no spec stamp is owed on it too. It is
|
|
@@ -640,6 +891,22 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
640
891
|
// that partition, or when its stamp of that partition differs from the
|
|
641
892
|
// re-derived brief. The spec reason wins where both apply.
|
|
642
893
|
const briefCauses = briefOutcome?.outcome === "saved_with_invalidation" ? briefOutcome.causes : [];
|
|
894
|
+
// Routes that moved without moving anything the build wrote (the same
|
|
895
|
+
// targets, links and routing meta, #531): a stage made against the bound
|
|
896
|
+
// spec is restamped with the new material instead of made owed again.
|
|
897
|
+
const keptStages = [];
|
|
898
|
+
if (builtUnchanged && specChanged) {
|
|
899
|
+
for (const key of STAMPED_STAGES) {
|
|
900
|
+
const record = nextReport.stages[key];
|
|
901
|
+
if (!COMPLETED_STATUSES.includes(record?.status) || !specHashesMatch(record.source_spec_material_hash, boundMaterial)) continue;
|
|
902
|
+
const restamped = { ...record, source_spec_material_hash: material };
|
|
903
|
+
if (isObject(record.unchanged_output_reason?.for_inputs) && specHashesMatch(record.unchanged_output_reason.for_inputs.spec_material_hash, boundMaterial)) {
|
|
904
|
+
restamped.unchanged_output_reason = { ...record.unchanged_output_reason, for_inputs: { ...record.unchanged_output_reason.for_inputs, spec_material_hash: material } };
|
|
905
|
+
}
|
|
906
|
+
nextReport = { ...nextReport, stages: { ...nextReport.stages, [key]: restamped } };
|
|
907
|
+
keptStages.push(key);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
643
910
|
const causesFor = (key) => {
|
|
644
911
|
const record = nextReport.stages[key];
|
|
645
912
|
const stamp = record?.source_brief_material;
|
|
@@ -662,24 +929,52 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
662
929
|
demoted = [...demoted, ...applied.demoted];
|
|
663
930
|
allCauses.push(...causes);
|
|
664
931
|
}
|
|
932
|
+
// Deploy evidence is the list of URLs that answered; when the set of
|
|
933
|
+
// built pages changes, the probe is owed again (#531). A deploy bound to
|
|
934
|
+
// the build fingerprint it probed (#619) is demoted by record build when
|
|
935
|
+
// the rebuilt site differs, so only a deploy recorded before that stamp
|
|
936
|
+
// existed is demoted here.
|
|
937
|
+
if (rebind?.builtPagesChanged) {
|
|
938
|
+
const previousDeploy = nextReport.stages.deploy;
|
|
939
|
+
if (isObject(previousDeploy) && COMPLETED_STATUSES.includes(previousDeploy.status) && !optionalString(previousDeploy.source_build_fingerprint)) {
|
|
940
|
+
const demotedDeploy = { ...previousDeploy, stage: "deploy", status: "required", required_by: "spec", required_for: ["qa"] };
|
|
941
|
+
for (const field of ["source_build_fingerprint", "evidence", "outputs", "completed_at", "recorded_by", "performed_by"]) delete demotedDeploy[field];
|
|
942
|
+
nextReport = { ...nextReport, stages: { ...nextReport.stages, deploy: archiveSupersededRecord(demotedDeploy, previousDeploy, { by: "record spec", reason: "spec_material_changed", at: timestamp }) } };
|
|
943
|
+
demoted = [...demoted, "deploy"];
|
|
944
|
+
}
|
|
945
|
+
}
|
|
665
946
|
reason = changeReason(allCauses);
|
|
666
947
|
const briefNote = briefSaved
|
|
667
948
|
? `; brief re-derived (${briefOutcome.outcome})`
|
|
668
949
|
: briefWritten ? "; normalized brief re-derived from its source" : "";
|
|
950
|
+
const pageNote = rebind
|
|
951
|
+
? `; pages ${[rebind.scope.added.length ? `added ${rebind.scope.added.join(", ")}` : null, rebind.scope.removed.length ? `removed ${rebind.scope.removed.join(", ")}` : null, rebind.scope.moved.length ? `moved ${rebind.scope.moved.join(", ")}` : null].filter(Boolean).join("; ")}, page-kit projection ${rebind.projectionChanged ? "rewritten" : "unchanged"}${rebind.designSource ? ", Design Source Package page surfaces rewritten" : ""}${keptStages.length ? `, ${keptStages.join(", ")} kept (nothing built changed)` : ""}`
|
|
952
|
+
: "";
|
|
953
|
+
const repairNote = repair ? `; Design Source Package references moved to ${repair.sha256}, the package an interrupted record spec published` : "";
|
|
669
954
|
nextReport.evidence = [
|
|
670
955
|
...(Array.isArray(report.evidence) ? report.evidence : []),
|
|
671
|
-
`${timestamp} record spec: CampaignSpec material ${specChanged ? `${boundMaterial || "(none bound)"} -> ${material}` : `${material} already bound`}${briefNote}; ${demoted.length ? `${demoted.join(", ")} owed again` : "no recorded stage was demoted"}${reason ? ` (${reason})` : ""}.`,
|
|
956
|
+
`${timestamp} record spec: CampaignSpec material ${specChanged ? `${boundMaterial || "(none bound)"} -> ${material}` : `${material} already bound`}${briefNote}${pageNote}${repairNote}; ${demoted.length ? `${demoted.join(", ")} owed again` : "no recorded stage was demoted"}${reason ? ` (${reason})` : ""}.`,
|
|
672
957
|
];
|
|
673
|
-
writes.push(...(briefWritten ? [normalizedPath] : []), contextPath, reportPath);
|
|
958
|
+
writes.push(...(rebind?.designSource ? [rebind.designSource.path] : []), ...(nextPacket ? [packetPath] : []), ...(briefWritten ? [normalizedPath] : []), contextPath, reportPath);
|
|
674
959
|
if (!dryRun) {
|
|
675
960
|
// Intake's publication order, the report last: a failure between files
|
|
676
961
|
// leaves the report bound to the old spec material, which reads as owed.
|
|
962
|
+
// The rewritten Design Source Package goes first, vouched for by the
|
|
963
|
+
// pending provenance record until the report records its new sha256,
|
|
964
|
+
// so a run that stops between the two leaves a package the next
|
|
965
|
+
// record spec or prepare-build still knows as its own.
|
|
966
|
+
if (rebind?.designSource) {
|
|
967
|
+
recordPendingDesignSourceProvenance(rebind.designSource.path, [rebind.designSource.sha256, packet.design_source_package.sha256]);
|
|
968
|
+
writeTextAtomic(rebind.designSource.path, rebind.designSource.serialized);
|
|
969
|
+
}
|
|
970
|
+
if (nextPacket) writeJsonAtomic(packetPath, portableArtifactPaths(nextPacket, targetRepo, { artifactPath: packetPath }));
|
|
677
971
|
if (briefWritten) writeJsonAtomic(normalizedPath, portableArtifactPaths(brief.buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
|
|
678
972
|
writeJsonAtomic(contextPath, portableArtifactPaths(nextContext, targetRepo, { artifactPath: contextPath }));
|
|
679
973
|
commitAssemblyReport(workspace, () => nextReport, {
|
|
680
974
|
command: "record spec",
|
|
681
975
|
staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",
|
|
682
976
|
});
|
|
977
|
+
if (movedTo) recordPendingDesignSourceProvenance(movedTo.path, []);
|
|
683
978
|
}
|
|
684
979
|
}
|
|
685
980
|
|
|
@@ -697,6 +992,18 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
|
|
|
697
992
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
698
993
|
demoted,
|
|
699
994
|
...(briefOutcome ? { brief: { outcome: briefOutcome.outcome, brief_path: briefFile?.path || null } } : {}),
|
|
995
|
+
...(rebind ? {
|
|
996
|
+
pages: {
|
|
997
|
+
added: rebind.scope.added,
|
|
998
|
+
removed: rebind.scope.removed,
|
|
999
|
+
moved: rebind.scope.moved,
|
|
1000
|
+
projection_changed: rebind.projectionChanged,
|
|
1001
|
+
design_source_package_rewritten: Boolean(rebind.designSource),
|
|
1002
|
+
built_pages_changed: rebind.builtPagesChanged,
|
|
1003
|
+
built_unchanged: builtUnchanged,
|
|
1004
|
+
},
|
|
1005
|
+
} : {}),
|
|
1006
|
+
...(repair ? { design_source_references_repaired: { sha256: repair.sha256 } } : {}),
|
|
700
1007
|
...(reason ? { input_change: { reason, at: timestamp } } : {}),
|
|
701
1008
|
notices,
|
|
702
1009
|
note: outcome === "unchanged"
|
package/src/invocation.mjs
CHANGED
|
@@ -111,6 +111,12 @@ export const commandNames = () => Object.keys(COMMANDS);
|
|
|
111
111
|
|
|
112
112
|
export const subcommandNames = (command) => (Object.hasOwn(COMMANDS, command) && COMMANDS[command].subcommands) || frozen([]);
|
|
113
113
|
|
|
114
|
+
// The flag names parseArgs met spelled `--flag=value` (in argv order), kept off
|
|
115
|
+
// the enumerable keys. The invocation is refused at dispatch, and it does
|
|
116
|
+
// nothing first: its values were dropped, so the root the stale sweep would
|
|
117
|
+
// act on, and the --no-remit / --proxy-base it would inherit, are unknown.
|
|
118
|
+
export const JOINED_FLAGS = Symbol("campaigns-os.joined-flags");
|
|
119
|
+
|
|
114
120
|
// A run opted out of sessions altogether: no stale sweep, and no intake
|
|
115
121
|
// auto-start (which reads this predicate from here).
|
|
116
122
|
export const optsOutOfRunSession = (args) => args["no-run-session"] === true;
|
|
@@ -133,7 +139,7 @@ export function resolveInvocationPolicy(command, args) {
|
|
|
133
139
|
const noWrite = args["no-write"] === true;
|
|
134
140
|
const implementsDryRun = rule.dryRun === true;
|
|
135
141
|
const help = Boolean(args.help);
|
|
136
|
-
const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
|
|
142
|
+
const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun) || Boolean(args[JOINED_FLAGS]?.length);
|
|
137
143
|
const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
|
|
138
144
|
return Object.freeze({
|
|
139
145
|
class: rule.class,
|
|
@@ -209,6 +209,7 @@ export async function readLiveCampaign({
|
|
|
209
209
|
timeoutMs = LIVE_CAMPAIGN_TIMEOUT_MS,
|
|
210
210
|
maxBytes = LIVE_CAMPAIGN_MAX_BYTES,
|
|
211
211
|
warn = undefined,
|
|
212
|
+
includeCampaign = false,
|
|
212
213
|
} = {}) {
|
|
213
214
|
if (typeof apiKey !== "string" || !apiKey.trim()) {
|
|
214
215
|
return notRun("no_key", "No public Campaigns API key resolved, so the live campaign was not read.");
|
|
@@ -307,7 +308,10 @@ export async function readLiveCampaign({
|
|
|
307
308
|
if (picked.notRun) return { ...picked.notRun, ...requested };
|
|
308
309
|
const refs = liveCampaignRefs(picked.campaign);
|
|
309
310
|
if (!refs) return notRun("unexpected_body", "The live campaign did not carry package and shipping-method lists with a ref on every entry.", requested);
|
|
310
|
-
|
|
311
|
+
// `spec derive` refreshes the spec's mirrored offer and package fields from
|
|
312
|
+
// the same read (#721), so it asks for the campaign body as well; doctor and
|
|
313
|
+
// QA compare refs only and never carry the body in their results.
|
|
314
|
+
return { status: "read", ...requested, ...refs, ...(includeCampaign ? { campaign: picked.campaign } : {}) };
|
|
311
315
|
}
|
|
312
316
|
|
|
313
317
|
// The `--no-live-refs` opt-out on `doctor` and `qa run`: no request, and the
|
|
@@ -357,7 +361,7 @@ function readPacketLocalSpec(packet, packetPath) {
|
|
|
357
361
|
// and env-name gates apply, plus the stricter env-name gate above), then one
|
|
358
362
|
// read, for the campaign the CampaignSpec's `campaign.ref_id` names when it
|
|
359
363
|
// names one.
|
|
360
|
-
export async function readLiveCampaignForPacket({ packet, packetPath = null, spec = undefined, env = process.env, fetchImpl = null, proxyBase = null, timeoutMs, warn } = {}) {
|
|
364
|
+
export async function readLiveCampaignForPacket({ packet, packetPath = null, spec = undefined, env = process.env, fetchImpl = null, proxyBase = null, timeoutMs, warn, includeCampaign = false } = {}) {
|
|
361
365
|
const localSpec = spec !== undefined ? spec : readPacketLocalSpec(packet, packetPath);
|
|
362
366
|
const resolved = resolveCampaignsApiKeySource(packet, packetPath, env, localSpec === undefined ? {} : { spec: localSpec });
|
|
363
367
|
const refusedEnv = refusedEnvKeySource(packet, resolved);
|
|
@@ -377,6 +381,7 @@ export async function readLiveCampaignForPacket({ packet, packetPath = null, spe
|
|
|
377
381
|
fetchImpl,
|
|
378
382
|
proxyBase,
|
|
379
383
|
campaignRefId: campaignRefIdFromSpec(localSpec),
|
|
384
|
+
includeCampaign,
|
|
380
385
|
...(timeoutMs ? { timeoutMs } : {}),
|
|
381
386
|
...(warn ? { warn } : {}),
|
|
382
387
|
});
|