@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
|
@@ -109,7 +109,11 @@ function selectedShipping(page) {
|
|
|
109
109
|
// `is_upsell: true` on a non-upsell page. Every consumer that must
|
|
110
110
|
// tell a bump from a main selector row (parity scenarios, bump deltas, the QA
|
|
111
111
|
// selector-tier planner, `next`'s order-bump QA command) reads this, so a
|
|
112
|
-
// marker change lands in one place.
|
|
112
|
+
// marker change lands in one place. The page-type half of the contract
|
|
113
|
+
// (campaigns-os#710, stated on the schema's pagePackage.is_upsell) sits with
|
|
114
|
+
// the callers: on an upsell or downsell page every row is the page's offer,
|
|
115
|
+
// so planScenarios puts all of them in mainRows, and the tier and bump readers
|
|
116
|
+
// are only given the select, checkout_step and checkout pages of a path.
|
|
113
117
|
export function isBumpRow(row) {
|
|
114
118
|
return Boolean(row?.is_order_bump || row?.is_upsell);
|
|
115
119
|
}
|
|
@@ -262,7 +266,9 @@ function descriptor(page, mapDoc, role, lines, options = {}) {
|
|
|
262
266
|
const body = { lines: lines.map((line) => ({ ...line })) };
|
|
263
267
|
if (array(options.vouchers).length) body.vouchers = [...options.vouchers];
|
|
264
268
|
|
|
265
|
-
|
|
269
|
+
// A run currency (`qa run --currency`) prices every scenario in that
|
|
270
|
+
// currency; otherwise the Map's campaign currency is asked for.
|
|
271
|
+
const currency = present(options.currency) ? options.currency : mapDoc?.campaign?.currency ?? mapDoc?.currency;
|
|
266
272
|
if (present(currency)) body.currency = String(currency);
|
|
267
273
|
if (present(options.shipping_method)) body.shipping_method = options.shipping_method;
|
|
268
274
|
return { ...result, body };
|
|
@@ -270,6 +276,8 @@ function descriptor(page, mapDoc, role, lines, options = {}) {
|
|
|
270
276
|
|
|
271
277
|
/**
|
|
272
278
|
* Convert one authored page into deterministic calculate request descriptors.
|
|
279
|
+
* `options.currency`, when given, is the currency every request asks for in
|
|
280
|
+
* place of the Map's campaign currency.
|
|
273
281
|
*/
|
|
274
282
|
export function planScenarios(page, mapDoc = {}, options = {}) {
|
|
275
283
|
const rows = array(page?.packages);
|
|
@@ -310,7 +318,12 @@ export function planScenarios(page, mapDoc = {}, options = {}) {
|
|
|
310
318
|
!pageIsUpsell || !carriedVouchers.includes(String(value.offer_code)),
|
|
311
319
|
);
|
|
312
320
|
|
|
313
|
-
const shared = {
|
|
321
|
+
const shared = {
|
|
322
|
+
attached_offers: attached,
|
|
323
|
+
known_offer_codes: knownOfferCodes,
|
|
324
|
+
page_rows: rows,
|
|
325
|
+
...(present(options.currency) ? { currency: String(options.currency) } : {}),
|
|
326
|
+
};
|
|
314
327
|
const plan = [];
|
|
315
328
|
const add = (value) => {
|
|
316
329
|
if (plan.length >= maxScenarios) throw new CommercialJourneyLimitError("scenarios", maxScenarios);
|
|
@@ -714,10 +714,14 @@ function findingPage(capture, page) {
|
|
|
714
714
|
|
|
715
715
|
/**
|
|
716
716
|
* Pure, Exact-only diff. Unresolved, Stale, unbound, and malformed facts are
|
|
717
|
-
* deliberately silent.
|
|
717
|
+
* deliberately silent. `options.unresolvedMoneyClaims`, a reason code, skips
|
|
718
|
+
* the price and recurrence comparisons: the captured amounts are in another
|
|
719
|
+
* currency than the calculated totals, so neither a match nor a mismatch
|
|
720
|
+
* means anything.
|
|
718
721
|
*/
|
|
719
|
-
export function diffCommercialParity(capturesValue, journey) {
|
|
722
|
+
export function diffCommercialParity(capturesValue, journey, options = {}) {
|
|
720
723
|
const captures = partitionCaptures(capturesValue).valid;
|
|
724
|
+
const compareMoney = !present(options.unresolvedMoneyClaims);
|
|
721
725
|
const findings = [];
|
|
722
726
|
|
|
723
727
|
captures.forEach((capture) => {
|
|
@@ -725,7 +729,7 @@ export function diffCommercialParity(capturesValue, journey) {
|
|
|
725
729
|
if (!page) return;
|
|
726
730
|
const pageId = findingPage(capture, page);
|
|
727
731
|
const source = present(capture.url) ? { url: String(capture.url) } : {};
|
|
728
|
-
const representative = exactMoneyFact(page.representative_total);
|
|
732
|
+
const representative = compareMoney ? exactMoneyFact(page.representative_total) : null;
|
|
729
733
|
if (representative) {
|
|
730
734
|
priceComparisonClaims(capture, page).compared.forEach((claim) => {
|
|
731
735
|
const claimed = normalizeMoney(claim?.value);
|
|
@@ -741,7 +745,7 @@ export function diffCommercialParity(capturesValue, journey) {
|
|
|
741
745
|
});
|
|
742
746
|
}
|
|
743
747
|
|
|
744
|
-
recurrenceComparisonClaims(capture, page).compared.forEach(({ claim, truth }) => {
|
|
748
|
+
(compareMoney ? recurrenceComparisonClaims(capture, page).compared : []).forEach(({ claim, truth }) => {
|
|
745
749
|
const packageId = positiveInteger(claim?.package_id);
|
|
746
750
|
const claimed = {
|
|
747
751
|
amount: normalizeMoney(claim?.amount),
|
|
@@ -929,8 +933,9 @@ export function recurringClaimAbsentAssertions(absences, { maxAssertions = Numbe
|
|
|
929
933
|
*/
|
|
930
934
|
export function createCommercialParityReport(capturesValue, journey, options = {}) {
|
|
931
935
|
const countsOnly = options.countsOnly === true;
|
|
936
|
+
const moneyReason = present(options.unresolvedMoneyClaims) ? String(options.unresolvedMoneyClaims) : null;
|
|
932
937
|
const { valid: captures, invalid: invalidCaptures } = partitionCaptures(capturesValue, { countsOnly });
|
|
933
|
-
const findings = countsOnly ? [] : diffCommercialParity(captures, journey);
|
|
938
|
+
const findings = countsOnly ? [] : diffCommercialParity(captures, journey, { unresolvedMoneyClaims: moneyReason });
|
|
934
939
|
const { assertions, omittedFindingCount } = countsOnly
|
|
935
940
|
? { assertions: [], omittedFindingCount: 0 }
|
|
936
941
|
: serializeCommercialFindings(findings, options);
|
|
@@ -958,7 +963,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
|
|
|
958
963
|
(sum, { capture }) => sum + capture.vouchers.length,
|
|
959
964
|
0,
|
|
960
965
|
);
|
|
961
|
-
const priceComparisonCounts = countsOnly
|
|
966
|
+
const priceComparisonCounts = countsOnly || moneyReason
|
|
962
967
|
? { compared: 0, unresolved: extractedPriceClaims }
|
|
963
968
|
: matched.reduce((counts, { capture, page }) => {
|
|
964
969
|
const comparison = priceComparisonClaims(capture, page);
|
|
@@ -966,7 +971,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
|
|
|
966
971
|
counts.unresolved += comparison.unresolved;
|
|
967
972
|
return counts;
|
|
968
973
|
}, { compared: 0, unresolved: 0 });
|
|
969
|
-
const recurrenceComparisonCounts = countsOnly
|
|
974
|
+
const recurrenceComparisonCounts = countsOnly || moneyReason
|
|
970
975
|
? { compared: 0, unresolved: extractedRecurrenceClaims }
|
|
971
976
|
: matched.reduce((counts, { capture, page }) => {
|
|
972
977
|
const comparison = recurrenceComparisonClaims(capture, page);
|
|
@@ -1008,6 +1013,9 @@ export function createCommercialParityReport(capturesValue, journey, options = {
|
|
|
1008
1013
|
extracted_voucher_claims: extractedVoucherClaims,
|
|
1009
1014
|
compared_voucher_claims: voucherComparisonCounts.compared,
|
|
1010
1015
|
unresolved_voucher_claims: voucherComparisonCounts.unresolved,
|
|
1016
|
+
...(moneyReason && extractedPriceClaims + extractedRecurrenceClaims > 0
|
|
1017
|
+
? { unresolved_money_claims_reason: moneyReason }
|
|
1018
|
+
: {}),
|
|
1011
1019
|
serialized_assertion_count: assertions.length,
|
|
1012
1020
|
omitted_assertion_count: omittedFindingCount,
|
|
1013
1021
|
findings,
|
package/src/content-residue.mjs
CHANGED
|
@@ -189,9 +189,11 @@ export function scanBuiltOutputContentResidue(outputDir, { urgencyVerified = fal
|
|
|
189
189
|
// seeds non-usable candidates to test exactly that.
|
|
190
190
|
export const BRIEF_PAYLOAD_REL_PATH = ".campaign-runtime/input/brief-payload.json";
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
// `runtimeRelPath` is the packet's runtime directory (packetRuntimeRelPath in
|
|
193
|
+
// campaign-workspace.mjs), so a project's second funnel reads its own payload.
|
|
194
|
+
export function loadBriefPayload(targetRepo, runtimeRelPath = ".campaign-runtime") {
|
|
193
195
|
if (!targetRepo) return null;
|
|
194
|
-
const path = join(targetRepo, BRIEF_PAYLOAD_REL_PATH);
|
|
196
|
+
const path = join(targetRepo, runtimeRelPath, relative(".campaign-runtime", BRIEF_PAYLOAD_REL_PATH));
|
|
195
197
|
if (!existsSync(path)) return null;
|
|
196
198
|
try {
|
|
197
199
|
return { path, payload: JSON.parse(readFileSync(path, "utf8")) };
|
|
@@ -930,6 +930,42 @@ export function designSourcePackageMaterialProjection(value) {
|
|
|
930
930
|
};
|
|
931
931
|
}
|
|
932
932
|
|
|
933
|
+
/**
|
|
934
|
+
* The package with each kept page's Surface Identity re-stated from the
|
|
935
|
+
* current CampaignSpec page and its re-projected page-kit target (#531):
|
|
936
|
+
* label, aliases and mappings come out as intake would write them for that
|
|
937
|
+
* page now, the surface keeps its id, and the source fields intake recorded
|
|
938
|
+
* (source_path, producer_page_type) are carried over. Surfaces of pages no
|
|
939
|
+
* longer active, and contributions, gaps and the rest, are untouched; the
|
|
940
|
+
* material fingerprint is recomputed. `projections` maps a page id to its
|
|
941
|
+
* page-kit projection; a page with none keeps the page_kit it had.
|
|
942
|
+
*/
|
|
943
|
+
export function rebindDesignSourcePackagePages(value, { activePages = [], projections = new Map() } = {}) {
|
|
944
|
+
const next = JSON.parse(JSON.stringify(value));
|
|
945
|
+
const pageById = new Map(arrayOrEmpty(activePages).filter((page) => isObject(page) && isNonEmptyString(page.id)).map((page) => [page.id, page]));
|
|
946
|
+
for (const surface of arrayOrEmpty(next.surface_identity)) {
|
|
947
|
+
if (!isObject(surface) || surface.kind !== "page" || !isObject(surface.mappings)) continue;
|
|
948
|
+
const pageId = optionalString(surface.mappings.campaign_spec_page_id);
|
|
949
|
+
if (!pageId || !pageById.has(pageId)) continue;
|
|
950
|
+
const projection = projections instanceof Map ? projections.get(pageId) : null;
|
|
951
|
+
const mapping = optionalString(surface.mappings.source_page_id)
|
|
952
|
+
? {
|
|
953
|
+
page_id: pageId,
|
|
954
|
+
path: surface.mappings.source_path ?? null,
|
|
955
|
+
page_type: surface.mappings.producer_page_type ?? null,
|
|
956
|
+
page_kit: isObject(surface.mappings.page_kit) && isObject(projection) ? projection : surface.mappings.page_kit ?? null,
|
|
957
|
+
}
|
|
958
|
+
: null;
|
|
959
|
+
const expected = createSurfaceCatalog(collectPageInputs([pageById.get(pageId)], mapping ? [mapping] : []), {}).surfaces.find((entry) => entry.kind === "page");
|
|
960
|
+
if (!expected) continue;
|
|
961
|
+
surface.label = expected.label;
|
|
962
|
+
surface.aliases = expected.aliases;
|
|
963
|
+
surface.mappings = expected.mappings;
|
|
964
|
+
}
|
|
965
|
+
next.material_fingerprint = computeDesignSourcePackageMaterialFingerprint(next);
|
|
966
|
+
return next;
|
|
967
|
+
}
|
|
968
|
+
|
|
933
969
|
export function computeDesignSourcePackageMaterialFingerprint(value) {
|
|
934
970
|
const canonical = canonicalJson(designSourcePackageMaterialProjection(value));
|
|
935
971
|
return `${MATERIAL_FINGERPRINT_PREFIX}${createHash("sha256").update(canonical).digest("hex")}`;
|
|
@@ -413,7 +413,7 @@ function assertValidPreparedDesignSourcePackage(value, path, currentPageScope, c
|
|
|
413
413
|
// hash if the run adopts another writer's package instead of publishing, and
|
|
414
414
|
// is removed once a report has recorded the package. A retry that finds it
|
|
415
415
|
// still accepts only bytes that hash to one of its entries.
|
|
416
|
-
function readPriorDesignSourceProvenance(reportPath, packagePath) {
|
|
416
|
+
export function readPriorDesignSourceProvenance(reportPath, packagePath) {
|
|
417
417
|
const synthesized = [];
|
|
418
418
|
const pending = readJsonIfExistsQuietly(pendingDesignSourceProvenancePath(packagePath));
|
|
419
419
|
// All or nothing: a record with any entry that is not a digest (a torn or
|
|
@@ -452,7 +452,7 @@ function pendingDesignSourceProvenancePath(designSourcePackagePath) {
|
|
|
452
452
|
// Written (staged, then renamed into place) before a synthesized package is
|
|
453
453
|
// published, so the claim survives a crash between that publication and the
|
|
454
454
|
// report's. An empty list removes the record.
|
|
455
|
-
function recordPendingDesignSourceProvenance(designSourcePackagePath, sha256) {
|
|
455
|
+
export function recordPendingDesignSourceProvenance(designSourcePackagePath, sha256) {
|
|
456
456
|
const pendingPath = pendingDesignSourceProvenancePath(designSourcePackagePath);
|
|
457
457
|
if (!sha256.length) {
|
|
458
458
|
rmSync(pendingPath, { force: true });
|
|
@@ -852,9 +852,12 @@ const PUBLICATION_STEPS = ["package", "writeTheme", "publish", "publishDoctorOut
|
|
|
852
852
|
|
|
853
853
|
// Runs `fn(publication)` inside the target lock; the handle's steps run in
|
|
854
854
|
// PUBLICATION_STEPS order, and `package` and `publish` run once.
|
|
855
|
-
|
|
855
|
+
// `runtimeDir` is the funnel's runtime directory (src/campaign-workspace.mjs):
|
|
856
|
+
// the Design Source Package and the theme output go under it. The target lock
|
|
857
|
+
// stays project-wide, so funnels of one project still take turns.
|
|
858
|
+
export async function withDesignSourcePublication({ targetRepo, runtimeDir = resolve(targetRepo, ".campaign-runtime"), outputs, force = false, publishSpec = null }, fn) {
|
|
856
859
|
const { packetPath, contextPath, reportPath, doctorOutPath, briefPath } = outputs;
|
|
857
|
-
const designSourcePackagePath = resolve(
|
|
860
|
+
const designSourcePackagePath = resolve(runtimeDir, relative(".campaign-runtime", DESIGN_SOURCE_PACKAGE_REL_PATH));
|
|
858
861
|
const paths = Object.freeze({ packetPath, contextPath, reportPath, doctorOutPath, briefPath, designSourcePackagePath });
|
|
859
862
|
const prepareBuildOutputPaths = [
|
|
860
863
|
["Build Packet", packetPath],
|
|
@@ -864,8 +867,8 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
|
|
|
864
867
|
["Campaign Build Brief", briefPath],
|
|
865
868
|
];
|
|
866
869
|
const prepareBuildThemeOutputPaths = [
|
|
867
|
-
["Theme Report", resolve(
|
|
868
|
-
["Brand Theme CSS", resolve(
|
|
870
|
+
["Theme Report", resolve(runtimeDir, "theme/theme-report.json")],
|
|
871
|
+
["Brand Theme CSS", resolve(runtimeDir, "theme/brand-theme.css")],
|
|
869
872
|
];
|
|
870
873
|
const prepareBuildCollisionPaths = [
|
|
871
874
|
...prepareBuildOutputPaths,
|
package/src/directory-lock.mjs
CHANGED
|
@@ -17,14 +17,19 @@
|
|
|
17
17
|
// record is never taken over: this module cannot produce one, so it belongs
|
|
18
18
|
// to an older writer that may still be alive between its mkdir and its owner
|
|
19
19
|
// write (#501). A waiter refuses it after a short grace, leaving it for the
|
|
20
|
-
// documented offline procedure.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// all: it is refused by name and never removed (#514).
|
|
20
|
+
// documented offline procedure. Anything else at the lock path, a file, a
|
|
21
|
+
// directory this process cannot read or a directory holding other entries, is
|
|
22
|
+
// not a lock at all: it is refused by name and never removed (#514).
|
|
24
23
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// Every owner record this module writes carries LOCK_FORMAT. A lock held by a
|
|
25
|
+
// live process whose record has no lock format, or a different one, belongs
|
|
26
|
+
// to another release, and a waiter refuses it at once instead of waiting to
|
|
27
|
+
// race that release for the next acquisition. That narrows the mixed-version
|
|
28
|
+
// race publishStagedDirectory describes; it cannot close it (#514).
|
|
29
|
+
//
|
|
30
|
+
// Staging, recovery, released and abandoned siblings a dead process left
|
|
31
|
+
// beside the lock are swept by the next holder, under the lock; a sibling
|
|
32
|
+
// whose owner is alive or unreadable is left alone (#514).
|
|
28
33
|
//
|
|
29
34
|
// The lock is reentrant for its holder: code running inside `fn` (in the
|
|
30
35
|
// same async context) that asks for the same lock enters directly instead of
|
|
@@ -37,6 +42,11 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
37
42
|
|
|
38
43
|
const heldLocks = new AsyncLocalStorage();
|
|
39
44
|
|
|
45
|
+
// The lock format this release records in every owner record it writes.
|
|
46
|
+
// Records without one come from earlier releases, including those before
|
|
47
|
+
// 1.43.2 that acquired with mkdir and wrote their owner afterwards (#501).
|
|
48
|
+
export const LOCK_FORMAT = 2;
|
|
49
|
+
|
|
40
50
|
const readOwner = (path) => {
|
|
41
51
|
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
42
52
|
};
|
|
@@ -56,9 +66,11 @@ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
56
66
|
|
|
57
67
|
// The siblings this module creates beside a lock and normally removes itself:
|
|
58
68
|
// a crash leaves staging and recovery-staging ones, a release whose tomb could
|
|
59
|
-
// not be put back leaves a released one
|
|
69
|
+
// not be put back leaves a released one, and a recovery whose removal of the
|
|
70
|
+
// abandoned lock was cut short leaves an abandoned one. Each carries an
|
|
71
|
+
// owner.json; an abandoned one carries the dead holder's.
|
|
60
72
|
function siblingPattern(path) {
|
|
61
|
-
return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released)-[0-9a-f]{32}$`);
|
|
73
|
+
return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released|abandoned)-[0-9a-f]{32}$`);
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
// Called only by the holder of the lock, so no two sweeps race, and a
|
|
@@ -85,6 +97,9 @@ function sweepDeadSiblings(path) {
|
|
|
85
97
|
// What a lock directory may hold besides its owner record: the recovery
|
|
86
98
|
// claim. A directory without owner.json that holds anything else is not a lock.
|
|
87
99
|
const LOCK_ENTRIES = new Set(["owner.json", ".recovery"]);
|
|
100
|
+
// A directory at the lock path that answers these cannot be told apart from
|
|
101
|
+
// a lock whose owner record is merely hidden from this process.
|
|
102
|
+
const UNREADABLE_CODES = new Set(["EACCES", "EPERM"]);
|
|
88
103
|
|
|
89
104
|
// The reentrancy key names the lock directory through its real parent, so a
|
|
90
105
|
// holder that reached the target through a symlink still recognizes itself.
|
|
@@ -119,10 +134,13 @@ function publishStagedDirectory(stagingPath, dest) {
|
|
|
119
134
|
try {
|
|
120
135
|
// rename(2) replaces an EMPTY destination directory, so never rename over
|
|
121
136
|
// an existing entry. This module never leaves an empty directory at a
|
|
122
|
-
// lock path; only
|
|
123
|
-
// mkdir and its owner write. A
|
|
124
|
-
//
|
|
125
|
-
//
|
|
137
|
+
// lock path; only a release before #501 does, for the instant between its
|
|
138
|
+
// mkdir and its owner write. A writer's check-then-rename can land in that
|
|
139
|
+
// instant and replace it, and Node has no rename that refuses to replace.
|
|
140
|
+
// The waiter's lock-format check (otherRelease) refuses such a release
|
|
141
|
+
// whenever it is seen holding the lock, but two first attempts that meet
|
|
142
|
+
// in that instant can still both enter, so running a release before #501
|
|
143
|
+
// and this one on the same target at the same moment stays unsafe (#514).
|
|
126
144
|
if (exists(dest)) return false;
|
|
127
145
|
try {
|
|
128
146
|
renameSync(stagingPath, dest);
|
|
@@ -145,7 +163,7 @@ const OWNERLESS_GRACE_MS = 1000;
|
|
|
145
163
|
function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGraceMs = OWNERLESS_GRACE_MS }) {
|
|
146
164
|
const token = randomBytes(16).toString("hex");
|
|
147
165
|
const start = now();
|
|
148
|
-
const owner = { pid: process.pid, token };
|
|
166
|
+
const owner = { pid: process.pid, token, lock_format: LOCK_FORMAT };
|
|
149
167
|
const ownerPath = join(path, "owner.json");
|
|
150
168
|
const stagingPath = `${path}.staging-${token}`;
|
|
151
169
|
|
|
@@ -224,17 +242,49 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
224
242
|
return current - start >= budgetMs;
|
|
225
243
|
};
|
|
226
244
|
const fail = (error) => unavailable(error);
|
|
245
|
+
// The refusal for a lock held by a live process of another release, else
|
|
246
|
+
// null. A record without this release's lock format was written by another
|
|
247
|
+
// release: waiting for it to release would put this waiter's rename in a
|
|
248
|
+
// race with that release's next mkdir. A dead holder is recovered as usual,
|
|
249
|
+
// and a record naming this process was written by this release.
|
|
250
|
+
const otherRelease = () => {
|
|
251
|
+
try {
|
|
252
|
+
const stat = lstatSync(path);
|
|
253
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const holder = readOwner(ownerPath);
|
|
258
|
+
if (!(Number.isInteger(holder?.pid) && holder.pid > 0 && typeof holder.token === "string")) return null;
|
|
259
|
+
if (holder.lock_format === LOCK_FORMAT || holder.pid === process.pid || ownerIsDead(holder)) return null;
|
|
260
|
+
const format = holder.lock_format === undefined ? "no lock format" : `lock format ${JSON.stringify(holder.lock_format)}`;
|
|
261
|
+
return Object.assign(
|
|
262
|
+
new Error(`Lock is held by process ${holder.pid} of another campaigns-os release (${format}; this release writes lock format ${LOCK_FORMAT}): ${path}`),
|
|
263
|
+
{ code: "ELOCKFORMAT", holderPid: holder.pid, lockFormat: holder.lock_format ?? null },
|
|
264
|
+
);
|
|
265
|
+
};
|
|
227
266
|
// Names what occupies the lock path when it is not a lock, else null. A
|
|
228
|
-
// file there,
|
|
229
|
-
// never has, will not clear by
|
|
267
|
+
// file there, a directory this process cannot read, or a directory with no
|
|
268
|
+
// owner record that holds entries a lock never has, will not clear by
|
|
269
|
+
// waiting and is never removed here.
|
|
230
270
|
const obstruction = () => {
|
|
231
271
|
let stat;
|
|
232
272
|
try { stat = lstatSync(path); } catch { return null; }
|
|
233
273
|
if (stat.isSymbolicLink()) return "a symbolic link, not a lock";
|
|
234
274
|
if (!stat.isDirectory()) return stat.isFile() ? "a regular file, not a lock" : "a special file, not a lock";
|
|
235
|
-
|
|
275
|
+
const unreadable = (error) => `a directory this process cannot read (${error.code}), so it cannot be checked for an owner record`;
|
|
276
|
+
try {
|
|
277
|
+
lstatSync(ownerPath);
|
|
278
|
+
return null;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (UNREADABLE_CODES.has(error?.code)) return unreadable(error);
|
|
281
|
+
}
|
|
236
282
|
let foreign;
|
|
237
|
-
try {
|
|
283
|
+
try {
|
|
284
|
+
foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort();
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return UNREADABLE_CODES.has(error?.code) ? unreadable(error) : null;
|
|
287
|
+
}
|
|
238
288
|
if (foreign.length === 0) return null;
|
|
239
289
|
const shown = foreign.slice(0, 3).join(", ") + (foreign.length > 3 ? `, and ${foreign.length - 3} more` : "");
|
|
240
290
|
return `a directory that is not a lock (no owner.json; it holds ${shown})`;
|
|
@@ -263,7 +313,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
263
313
|
}
|
|
264
314
|
};
|
|
265
315
|
|
|
266
|
-
return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, sweep, release };
|
|
316
|
+
return { token, stage, publish, recover, heldBySelfProcess, otherRelease, expired, fail, contended, sweep, release };
|
|
267
317
|
}
|
|
268
318
|
|
|
269
319
|
function reentrantKey(path) {
|
|
@@ -297,6 +347,8 @@ export async function withDirectoryLock(path, fn, options) {
|
|
|
297
347
|
throw lock.fail(error);
|
|
298
348
|
}
|
|
299
349
|
if (acquired) break;
|
|
350
|
+
const mixed = lock.otherRelease();
|
|
351
|
+
if (mixed) throw lock.fail(mixed);
|
|
300
352
|
if (lock.expired()) throw lock.fail(lock.contended());
|
|
301
353
|
lock.recover();
|
|
302
354
|
await sleep(20);
|
|
@@ -329,6 +381,8 @@ export function withDirectoryLockSync(path, fn, options) {
|
|
|
329
381
|
throw lock.fail(error);
|
|
330
382
|
}
|
|
331
383
|
if (acquired) break;
|
|
384
|
+
const mixed = lock.otherRelease();
|
|
385
|
+
if (mixed) throw lock.fail(mixed);
|
|
332
386
|
if (lock.expired() || lock.heldBySelfProcess()) throw lock.fail(lock.contended());
|
|
333
387
|
lock.recover();
|
|
334
388
|
(options.sleep ?? sleepSync)(20);
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../adapter-decision-contract.mjs";
|
|
15
15
|
import { createDoctorCheckRegistry, runDoctorCheckRegistry } from "../doctor-check-registry.mjs";
|
|
16
16
|
import { evaluateSourcePreparation } from "../source-prep.mjs";
|
|
17
|
+
import { staleRoutingHints } from "../spec-derive.mjs";
|
|
17
18
|
import { isLoopbackHostname } from "../remit.mjs";
|
|
18
19
|
import {
|
|
19
20
|
HOST_STRIPPED_CODE,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
SOURCE_HTML_MANIFEST_SCHEMA,
|
|
27
28
|
} from "../source-html-manifest.mjs";
|
|
28
29
|
import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
|
|
30
|
+
import { packetRuntimeRelPath } from "../campaign-workspace.mjs";
|
|
29
31
|
import { singleLineDetail, singleLineField } from "../text-safety.mjs";
|
|
30
32
|
import {
|
|
31
33
|
campaignRouteRoot,
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
normalizePublicRouteSlug,
|
|
35
37
|
packetRouteRoot,
|
|
36
38
|
runtimeRelativeRouteForSpecValue,
|
|
39
|
+
specPublicRouteSlug,
|
|
37
40
|
stripPublicRoutePrefix,
|
|
38
41
|
} from "../route-identity.mjs";
|
|
39
42
|
import { evaluatePageKitBuildSummary } from "../page-kit-build-summary.mjs";
|
|
@@ -102,6 +105,7 @@ import {
|
|
|
102
105
|
import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
|
|
103
106
|
import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
|
|
104
107
|
import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
|
|
108
|
+
import { evaluatePageKitAnalyticsVendors, PAGE_KIT_ANALYTICS_VENDORS_SCOPE } from "../page-kit-analytics-vendors.mjs";
|
|
105
109
|
// ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
|
|
106
110
|
// campaign authoring UI (e.g. a Map Builder bundle) import the same rules, so a
|
|
107
111
|
// spec check is authored once and reaches internal teams and agencies alike.
|
|
@@ -382,6 +386,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
382
386
|
phase: "target",
|
|
383
387
|
run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState),
|
|
384
388
|
},
|
|
389
|
+
{
|
|
390
|
+
id: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
|
|
391
|
+
phase: "target",
|
|
392
|
+
run: ({ spec, warnings, ready, buildState }) => validateTargetAnalyticsVendors(spec, warnings, ready, buildState),
|
|
393
|
+
},
|
|
385
394
|
{
|
|
386
395
|
id: "spec.shipping_countries",
|
|
387
396
|
phase: "spec",
|
|
@@ -392,6 +401,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
392
401
|
phase: "spec",
|
|
393
402
|
run: ({ spec, packet, warnings, ready, derived, buildState }) => validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived, buildState),
|
|
394
403
|
},
|
|
404
|
+
{
|
|
405
|
+
id: "spec.routing_hint_stale",
|
|
406
|
+
phase: "spec",
|
|
407
|
+
run: ({ spec, packet, errors, ready }) => validateSpecRoutingHintsCurrent(spec, packet, errors, ready),
|
|
408
|
+
},
|
|
395
409
|
{
|
|
396
410
|
id: "spec.routing_host_prefix",
|
|
397
411
|
phase: "spec",
|
|
@@ -1416,6 +1430,18 @@ function validateTargetStoreProfile(spec, errors, warnings, ready, derived, buil
|
|
|
1416
1430
|
+ storeProfileIntentionallyEmptyNote(intentionallyEmpty));
|
|
1417
1431
|
}
|
|
1418
1432
|
|
|
1433
|
+
// The starter-template vendor keys against analytics.vendors: advisory only,
|
|
1434
|
+
// one warning per vendor the two sides disagree on (#689).
|
|
1435
|
+
function validateTargetAnalyticsVendors(spec, warnings, ready, buildState) {
|
|
1436
|
+
const result = evaluatePageKitAnalyticsVendors({ spec, targetLoad: buildState?.pageKitCampaignConfig });
|
|
1437
|
+
for (const finding of result.findings) {
|
|
1438
|
+
addIssue(warnings, finding.code, finding.message, { vendor: finding.vendor, id_key: finding.id_key, repair: finding.repair });
|
|
1439
|
+
}
|
|
1440
|
+
if (result.status === "ok" && result.vendors.length) {
|
|
1441
|
+
ready.push(`Target campaigns.json vendor keys match the CampaignSpec analytics.vendors (${result.vendors.join(", ")}).`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1419
1445
|
function validateSpecShippingCountries(spec, warnings, ready) {
|
|
1420
1446
|
const countries = spec?.campaign?.available_shipping_countries;
|
|
1421
1447
|
if (countries === "all" || (Array.isArray(countries) && countries.length === 0)) {
|
|
@@ -1557,11 +1583,7 @@ export function validateRouteSlugIdentity(spec, packet, errors, ready) {
|
|
|
1557
1583
|
const mapId = optionalString(spec?.spec_identity?.map_id)
|
|
1558
1584
|
|| optionalString(spec?.map_id)
|
|
1559
1585
|
|| optionalString(packet?.spec?.map_id);
|
|
1560
|
-
const specSlug =
|
|
1561
|
-
optionalString(spec?.spec_identity?.public_route_slug)
|
|
1562
|
-
|| optionalString(spec?.campaign?.slug)
|
|
1563
|
-
|| optionalString(spec?.campaign?.id)
|
|
1564
|
-
);
|
|
1586
|
+
const specSlug = specPublicRouteSlug(spec);
|
|
1565
1587
|
const specDeclaresMapIdSlug = Boolean(specSlug) && Boolean(mapId) && specSlug === mapId;
|
|
1566
1588
|
|
|
1567
1589
|
if (mapId && packetSlug === mapId && !specDeclaresMapIdSlug) {
|
|
@@ -1692,6 +1714,31 @@ export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, deriv
|
|
|
1692
1714
|
);
|
|
1693
1715
|
}
|
|
1694
1716
|
|
|
1717
|
+
// #531: a routing hint (`sdk_hints.meta_tags` next-success-url and the upsell
|
|
1718
|
+
// accept/decline urls) that does not match the route of the page its routing
|
|
1719
|
+
// field names. Nothing else catches it once the build exists: the built meta
|
|
1720
|
+
// check compares the built page against the hint, and QA derives its
|
|
1721
|
+
// expectation from the hint, so a moved upsell with checkout still pointing
|
|
1722
|
+
// at the old path passes both and sends shoppers to a 404 after payment. It
|
|
1723
|
+
// blocks on every read, before and after the build, and record spec refuses a
|
|
1724
|
+
// routes-only bind on the same function (spec-derive.mjs staleRoutingHints).
|
|
1725
|
+
export function validateSpecRoutingHintsCurrent(spec, packet, errors, ready) {
|
|
1726
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1727
|
+
const stale = staleRoutingHints(spec, { publicRouteSlug });
|
|
1728
|
+
if (!stale.length) {
|
|
1729
|
+
if (activeSpecPages(spec).some((page) => isObject(page.sdk_hints?.meta_tags))) ready.push("CampaignSpec routing hints match the routes of the pages they name");
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const sample = stale.slice(0, 5).map((hint) => `${hint.page_id}:${hint.tag} ${JSON.stringify(hint.value)} but page "${hint.target_page_id}" routes to ${JSON.stringify(hint.derived_route)}`).join("; ");
|
|
1733
|
+
const more = stale.length > 5 ? `; plus ${stale.length - 5} more` : "";
|
|
1734
|
+
addIssue(
|
|
1735
|
+
errors,
|
|
1736
|
+
"spec.routing_hint_stale",
|
|
1737
|
+
`CampaignSpec routing hint(s) name a route the page they point at no longer has: ${sample}${more}. The built meta tag follows the hint, so the SDK would send shoppers to the old path. Edit each hint to the route shown in the spec (or, while the Map is still the spec's source, re-save the Map, which regenerates them), then run record spec.`,
|
|
1738
|
+
{ hints: stale },
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1695
1742
|
// #531: a route with a host in front of its path ("shop.example.com/route/x/")
|
|
1696
1743
|
// is nested under the campaign root by every stage that composes a URL
|
|
1697
1744
|
// (polish capture requested "/route/shop.example.com/route/x/"), so it blocks.
|
|
@@ -3264,6 +3311,12 @@ function scanMarketSensitiveCopy(spec, warnings, ready, derived) {
|
|
|
3264
3311
|
}
|
|
3265
3312
|
}
|
|
3266
3313
|
|
|
3314
|
+
// CampaignSpec owns `campaign.available_currencies`: every currency the
|
|
3315
|
+
// campaign sells in, the default included (the CampaignCurrencies rule checks
|
|
3316
|
+
// its codes). `additional_currencies` is the Campaigns App API's name for the
|
|
3317
|
+
// same list without the default and is not a spec field; both market scopes
|
|
3318
|
+
// still read it, and its camelCase spelling, so a spec copied from an API body
|
|
3319
|
+
// gets the copy review it needs.
|
|
3267
3320
|
function deriveMarketScope(spec) {
|
|
3268
3321
|
const campaign = spec?.campaign || {};
|
|
3269
3322
|
const defaultCurrency = normalizeCurrency(campaign.currency);
|
|
@@ -3712,6 +3765,25 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3712
3765
|
if (!activeIds.has(page.page_id)) {
|
|
3713
3766
|
addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
|
|
3714
3767
|
}
|
|
3768
|
+
// A page added from the repo's page tree after handoff (#721): its source
|
|
3769
|
+
// is the target page file itself, so it is mapped and, once that file
|
|
3770
|
+
// exists, a build candidate like any other.
|
|
3771
|
+
if (page.origin === "repo") {
|
|
3772
|
+
const repoFile = optionalString(page.page_kit?.output_path) && derived.target_repo ? resolve(derived.target_repo, page.page_kit.output_path) : null;
|
|
3773
|
+
if (!repoFile || !existsSync(repoFile) || !statSync(repoFile).isFile()) {
|
|
3774
|
+
addIssue(errors, "source_html.pages.repo_page_missing", `Page "${page.page_id}" was added from the repo's page tree, but its page file ${page.page_kit?.output_path || "(no page_kit.output_path)"} is not in the target repo. Restore the file, or run spec derive and record spec to remove the page.`, { page_id: page.page_id, output_path: page.page_kit?.output_path || null });
|
|
3775
|
+
} else if (specPage) {
|
|
3776
|
+
builtPages.push({
|
|
3777
|
+
page_id: specPage.id,
|
|
3778
|
+
type: specPage.type || "page",
|
|
3779
|
+
role: pageRole(specPage.type),
|
|
3780
|
+
route: builtRoute,
|
|
3781
|
+
source_path: null,
|
|
3782
|
+
repo_source: page.page_kit.output_path,
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
continue;
|
|
3786
|
+
}
|
|
3715
3787
|
if (page.path) {
|
|
3716
3788
|
const fullPath = resolve(sourceRoot, page.path);
|
|
3717
3789
|
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
|
|
@@ -4625,7 +4697,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
|
|
|
4625
4697
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
4626
4698
|
const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
|
|
4627
4699
|
if (!siteRoot || !existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
|
|
4628
|
-
const brief = loadBriefPayload(derived.target_repo);
|
|
4700
|
+
const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
|
|
4629
4701
|
const urgencyVerified = briefUrgencyVerified(brief?.payload);
|
|
4630
4702
|
const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
|
|
4631
4703
|
if (!findings.length) {
|
|
@@ -4694,7 +4766,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
|
|
|
4694
4766
|
// non-shipped, non-usable assets stay warnings (the attestation queue), so a
|
|
4695
4767
|
// producer that correctly excluded them is not blocked.
|
|
4696
4768
|
export function validateProofAttestation(packet, errors, warnings, ready, derived, buildState = {}) {
|
|
4697
|
-
const brief = loadBriefPayload(derived.target_repo);
|
|
4769
|
+
const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
|
|
4698
4770
|
if (!brief) return; // not an AI-assembled run — no brief payload, no gate
|
|
4699
4771
|
if (brief.error) {
|
|
4700
4772
|
// Fail closed: the brief payload is the source of attestation state. If
|