@nextcommerce/campaigns-os 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +189 -10
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
|
@@ -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")) };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { collectFigmaProvenanceFindings } from "./doctor/source-provenance.mjs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { canonicalJson } from "./polish-capture.mjs";
|
|
4
|
+
|
|
5
|
+
const VIEWPORTS = ["desktop", "tablet", "mobile"];
|
|
6
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
|
|
7
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
const absoluteHttps = (value) => {
|
|
9
|
+
if (!nonEmpty(value)) return null;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
return url.protocol === "https:" ? url : null;
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Declared links are data. No design-file reader or network access lives here.
|
|
17
|
+
export function normalizeDesignFileReference(value) {
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const url = object(value) && absoluteHttps(value.file_url);
|
|
20
|
+
if (!url) return { declaration: null, warnings: ["file_url must be a non-empty absolute https: URL"] };
|
|
21
|
+
const type = nonEmpty(value.type) ? value.type.toLowerCase() : (url.hostname === "figma.com" || url.hostname.endsWith(".figma.com")) ? "figma" : "other";
|
|
22
|
+
let fileKey = null;
|
|
23
|
+
if (type === "figma") {
|
|
24
|
+
const parts = url.pathname.split("/");
|
|
25
|
+
const index = parts.findIndex((part) => ["file", "design", "proto", "board"].includes(part));
|
|
26
|
+
let candidate = index < 0 ? null : parts[index + 1];
|
|
27
|
+
const branch = parts.indexOf("branch", index + 2);
|
|
28
|
+
if (index >= 0 && branch >= 0 && /^[A-Za-z0-9]+$/.test(parts[branch + 1] ?? "")) candidate = parts[branch + 1];
|
|
29
|
+
if (typeof candidate === "string" && /^[A-Za-z0-9]+$/.test(candidate)) fileKey = candidate;
|
|
30
|
+
}
|
|
31
|
+
const identity = fileKey == null ? { type, url: url.origin + url.pathname } : { type, file_key: fileKey };
|
|
32
|
+
const file_identity_sha256 = createHash("sha256").update(canonicalJson(identity)).digest("hex");
|
|
33
|
+
const frames = {};
|
|
34
|
+
for (const viewport of VIEWPORTS) {
|
|
35
|
+
if (!object(value.breakpoints) || !Object.hasOwn(value.breakpoints, viewport)) continue;
|
|
36
|
+
const link = value.breakpoints[viewport];
|
|
37
|
+
let frame;
|
|
38
|
+
try { frame = nonEmpty(link) ? new URL(link) : null; } catch { frame = null; }
|
|
39
|
+
if (!frame) { warnings.push(`breakpoints.${viewport} must be an absolute frame URL`); continue; }
|
|
40
|
+
frames[viewport] = { url: link, node_id: frame.searchParams.get("node-id")?.replaceAll("-", ":") ?? null };
|
|
41
|
+
}
|
|
42
|
+
return { declaration: { type, file_url: value.file_url, file_key: fileKey, identity, file_identity_sha256, frames }, warnings };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveDesignFileReferences({ activePages = [], mappings = [], manifest = null, specChannel = { mode: "intake" } } = {}) {
|
|
46
|
+
const pages = [];
|
|
47
|
+
const files = new Map();
|
|
48
|
+
// Intake records only Figma spec pointers backed by #534's provenance test.
|
|
49
|
+
// Doctor first uses today's intake declaration. Only a page with recorded
|
|
50
|
+
// design-file lineage can fall back to its Figma spec without provenance.
|
|
51
|
+
// Manifest declarations stand alone; a shared file's producer is not per-page.
|
|
52
|
+
const exportProvenance = manifest != null && !collectFigmaProvenanceFindings(manifest).some((finding) => finding.severity === "provenance");
|
|
53
|
+
const producer = manifest?.producer_provenance;
|
|
54
|
+
const producerKeys = new Set([producer?.figma_file_key, ...(Array.isArray(producer?.figma_file_keys) ? producer.figma_file_keys : [])].filter(nonEmpty));
|
|
55
|
+
for (const page of activePages) {
|
|
56
|
+
const mapping = mappings.find((entry) => entry?.page_id === page.id);
|
|
57
|
+
if (!nonEmpty(mapping?.path)) continue;
|
|
58
|
+
const matches = (Array.isArray(manifest?.pages) ? manifest.pages : []).filter((entry) => entry?.page_id === page.id && entry.path === mapping.path);
|
|
59
|
+
const manifestValue = matches.length === 1 ? matches[0].design_file : null;
|
|
60
|
+
const specDeclaration = normalizeDesignFileReference(page.design_source).declaration;
|
|
61
|
+
const fromManifest = normalizeDesignFileReference(manifestValue);
|
|
62
|
+
const intakeSpec = specDeclaration?.type === "figma" && exportProvenance ? specDeclaration : null;
|
|
63
|
+
const fallbackSpec = !intakeSpec && !fromManifest.declaration
|
|
64
|
+
&& specChannel.mode === "doctor" && specChannel.recordedPageIds?.has(page.id)
|
|
65
|
+
&& specDeclaration?.type === "figma" ? specDeclaration : null;
|
|
66
|
+
const fromSpec = intakeSpec || fallbackSpec;
|
|
67
|
+
const declaration = fromSpec || fromManifest.declaration;
|
|
68
|
+
if (!declaration) continue;
|
|
69
|
+
const declared_by = fromSpec ? "campaign_spec" : "source_html_manifest";
|
|
70
|
+
let conflict = null;
|
|
71
|
+
// Provenance decides which declaration records lineage, never whether a
|
|
72
|
+
// Figma spec that disagrees with the manifest is reported.
|
|
73
|
+
const specNamed = fromSpec || (specDeclaration?.type === "figma" ? specDeclaration : null);
|
|
74
|
+
if (specNamed && fromManifest.declaration) {
|
|
75
|
+
const other = fromManifest.declaration;
|
|
76
|
+
const frames = VIEWPORTS.filter((viewport) => specNamed.frames[viewport] && other.frames[viewport] && specNamed.frames[viewport].node_id !== other.frames[viewport].node_id).sort();
|
|
77
|
+
if (specNamed.file_identity_sha256 !== other.file_identity_sha256 || frames.length) {
|
|
78
|
+
conflict = { kind: "spec_vs_manifest", identities: [specNamed.file_identity_sha256, other.file_identity_sha256].sort(), ...(frames.length ? { frames } : {}) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!conflict && declaration.type === "figma" && declaration.file_key && producerKeys.size && !producerKeys.has(declaration.file_key)) {
|
|
82
|
+
conflict = { kind: "producer_file_key", identities: [declaration.file_identity_sha256] };
|
|
83
|
+
}
|
|
84
|
+
pages.push({ page_id: page.id, declaration, declared_by, conflict, warnings: manifestValue == null ? [] : fromManifest.warnings });
|
|
85
|
+
const sha = declaration.file_identity_sha256;
|
|
86
|
+
if (!files.has(sha)) files.set(sha, { contribution_id: `design-file-${sha.slice(0, 12)}`, identity: declaration.identity, file_url: declaration.file_url, producer: declared_by, pages: [] });
|
|
87
|
+
const file = files.get(sha);
|
|
88
|
+
// A shared file keeps the manifest's provenance if any page declared it there.
|
|
89
|
+
if (declared_by === "source_html_manifest") file.producer = declared_by;
|
|
90
|
+
file.pages.push(page.id);
|
|
91
|
+
}
|
|
92
|
+
return { pages, files: [...files.values()] };
|
|
93
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { resolveDesignFileReferences } from "./design-file-reference.mjs";
|
|
2
3
|
|
|
3
4
|
export const DESIGN_SOURCE_PACKAGE_SCHEMA = "campaign-design-source-package/v0";
|
|
4
5
|
export const DESIGN_SOURCE_PACKAGE_REL_PATH = ".campaign-runtime/input/design-source-package.json";
|
|
@@ -166,7 +167,7 @@ const PRESENTATION_INTENT_FIELDS = new Set([
|
|
|
166
167
|
"brand",
|
|
167
168
|
"responsive_behavior",
|
|
168
169
|
]);
|
|
169
|
-
const SOURCE_REF_FIELDS = new Set(["id", "kind", "path", "url", "sha256", "role", "media_type", "notes"]);
|
|
170
|
+
const SOURCE_REF_FIELDS = new Set(["id", "kind", "path", "url", "sha256", "role", "media_type", "notes", "design_file_type"]);
|
|
170
171
|
const VISUAL_REF_FIELDS = new Set([
|
|
171
172
|
"id",
|
|
172
173
|
"kind",
|
|
@@ -195,6 +196,7 @@ const TEMPLATE_REFERENCE_FIELDS = new Set([
|
|
|
195
196
|
"standard_viewport_refs",
|
|
196
197
|
]);
|
|
197
198
|
const COVERAGE_MAPPING_FIELDS = new Set([
|
|
199
|
+
"derived_from",
|
|
198
200
|
"id",
|
|
199
201
|
"surface_id",
|
|
200
202
|
"coverage_role",
|
|
@@ -337,6 +339,7 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
|
|
|
337
339
|
});
|
|
338
340
|
|
|
339
341
|
const manifestInput = normalizeManifestInput(manifest, manifestPath);
|
|
342
|
+
const designFiles = resolveDesignFileReferences({ activePages, mappings: resolvedMappings, manifest: manifestInput.value });
|
|
340
343
|
const crawl = isObject(sourceAssetCrawl) ? sourceAssetCrawl : isObject(assetCrawl) ? assetCrawl : null;
|
|
341
344
|
const crawlInputName = isObject(sourceAssetCrawl) ? "sourceAssetCrawl" : "assetCrawl";
|
|
342
345
|
assertOptionalInputArray(manifestInput.value?.files, "manifest.files");
|
|
@@ -480,6 +483,9 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
|
|
|
480
483
|
htmlCoverage.push({
|
|
481
484
|
id: `html-${surfaceId}-coverage`,
|
|
482
485
|
surface_id: surfaceId,
|
|
486
|
+
...(designFiles.pages.some((entry) => entry.page_id === page.pageId) ? {
|
|
487
|
+
derived_from: designFiles.files.find((file) => file.pages.includes(page.pageId)).contribution_id,
|
|
488
|
+
} : {}),
|
|
483
489
|
coverage_role: explicitRole || "primary_design",
|
|
484
490
|
confidence: normalizeConfidence(mapping?.confidence)
|
|
485
491
|
|| confidenceForHtmlMapping(mapping, manifestPage),
|
|
@@ -525,6 +531,37 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
|
|
|
525
531
|
};
|
|
526
532
|
|
|
527
533
|
const contributions = [htmlContribution];
|
|
534
|
+
for (const file of [...designFiles.files].sort((a, b) => a.contribution_id.localeCompare(b.contribution_id))) {
|
|
535
|
+
const id = file.contribution_id;
|
|
536
|
+
// kind collapses non-Figma tools to "other". Use a new field so existing
|
|
537
|
+
// source-reference notes remain nonmaterial, including design_file refs.
|
|
538
|
+
const sourceRefs = [{ id: `${id}-file`, kind: "url", url: file.file_url, role: "design_file",
|
|
539
|
+
...(!["figma", "other"].includes(file.identity.type) ? { design_file_type: file.identity.type } : {}),
|
|
540
|
+
}];
|
|
541
|
+
const mappings = file.pages.map((pageId) => {
|
|
542
|
+
const surfaceId = surfaceByPageId.get(pageId);
|
|
543
|
+
const declaration = designFiles.pages.find((page) => page.page_id === pageId).declaration;
|
|
544
|
+
const frameRefs = Object.entries(declaration.frames).map(([viewport, frame]) => ({
|
|
545
|
+
id: `${id}-${surfaceId}-frame-${viewport}`, kind: "url", url: frame.url, role: `design_frame:${viewport}`,
|
|
546
|
+
}));
|
|
547
|
+
sourceRefs.push(...frameRefs);
|
|
548
|
+
return { id: `${id}-${surfaceId}-coverage`, surface_id: surfaceId, coverage_role: "reference_only", confidence: "high", source_refs: [`${id}-file`, ...frameRefs.map((ref) => ref.id)], screenshot_refs: [], reference_refs: [] };
|
|
549
|
+
});
|
|
550
|
+
const fromManifest = file.producer === "source_html_manifest";
|
|
551
|
+
contributions.push({
|
|
552
|
+
id, kind: file.identity.type === "figma" ? "figma_frames" : "other", trust: file.identity.type === "figma" ? "native" : "opaque", renderable: false,
|
|
553
|
+
provenance: {
|
|
554
|
+
source_type: "design_file", adapter: "design-file-reference", producer: file.producer,
|
|
555
|
+
generator: null, generator_version: null, source_root: null,
|
|
556
|
+
manifest_schema_version: fromManifest ? optionalString(manifestInput.value?.schema_version) : null,
|
|
557
|
+
manifest_path: fromManifest ? optionalString(manifestInput.path) : null,
|
|
558
|
+
manifest_sha256: fromManifest ? validSha(manifestInput.value?.sha256) : null,
|
|
559
|
+
producer_material_fingerprint: null, asset_crawl_schema_version: null,
|
|
560
|
+
},
|
|
561
|
+
presentation_intent: { summary: "Design file the page's HTML export was produced from. Recorded as the design authority; this version does not read it." },
|
|
562
|
+
source_refs: sourceRefs, screenshot_refs: [], reference_refs: [], mappings,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
528
565
|
const template = normalizeTemplateFamily(templateFamily);
|
|
529
566
|
const qualifyingHtmlSurfaces = new Set(
|
|
530
567
|
htmlCoverage
|
|
@@ -893,6 +930,42 @@ export function designSourcePackageMaterialProjection(value) {
|
|
|
893
930
|
};
|
|
894
931
|
}
|
|
895
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
|
+
|
|
896
969
|
export function computeDesignSourcePackageMaterialFingerprint(value) {
|
|
897
970
|
const canonical = canonicalJson(designSourcePackageMaterialProjection(value));
|
|
898
971
|
return `${MATERIAL_FINGERPRINT_PREFIX}${createHash("sha256").update(canonical).digest("hex")}`;
|
|
@@ -998,6 +1071,15 @@ export function validateDesignSourcePackage(value, {
|
|
|
998
1071
|
if (contributionIds.has(contribution.id)) add("design_source_package.contribution_id_duplicate", `${path}.id`, `Duplicate contribution id "${contribution.id}".`);
|
|
999
1072
|
contributionIds.add(contribution.id);
|
|
1000
1073
|
});
|
|
1074
|
+
contributions.forEach((contribution, index) => {
|
|
1075
|
+
arrayOrEmpty(contribution?.mappings).forEach((mapping, mappingIndex) => {
|
|
1076
|
+
if (!Object.hasOwn(mapping || {}, "derived_from")) return;
|
|
1077
|
+
const target = contributions.find((entry) => entry?.id === mapping.derived_from && entry !== contribution);
|
|
1078
|
+
if (!target || !["figma_frames", "figma_sections", "other"].includes(target.kind) || !arrayOrEmpty(target.mappings).some((entry) => entry?.surface_id === mapping.surface_id)) {
|
|
1079
|
+
add("design_source_package.derived_from_ref", `$.contributions[${index}].mappings[${mappingIndex}].derived_from`, "derived_from must name another design contribution mapped to the same surface_id.");
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1001
1083
|
|
|
1002
1084
|
validateRecordArray(value.source_gaps, "$.source_gaps", GAP_FIELDS,
|
|
1003
1085
|
["id", "kind", "scope", "applies_to", "reason", "status", "attributed_by"], add,
|
|
@@ -1394,6 +1476,8 @@ function projectContribution(contribution) {
|
|
|
1394
1476
|
function projectSourceRef(ref) {
|
|
1395
1477
|
return compactDefined({
|
|
1396
1478
|
...pickFields(ref, ["id", "kind", "path", "url", "role", "media_type"]),
|
|
1479
|
+
// Absent from every BASE-valid package; never project existing prose notes.
|
|
1480
|
+
design_file_type: ref?.design_file_type,
|
|
1397
1481
|
sha256: canonicalHashSpelling(ref?.sha256),
|
|
1398
1482
|
});
|
|
1399
1483
|
}
|
|
@@ -1451,6 +1535,7 @@ function projectProvenance(provenance) {
|
|
|
1451
1535
|
|
|
1452
1536
|
function projectCoverageMapping(mapping) {
|
|
1453
1537
|
return compactDefined({
|
|
1538
|
+
derived_from: mapping?.derived_from,
|
|
1454
1539
|
id: mapping?.id,
|
|
1455
1540
|
surface_id: mapping?.surface_id,
|
|
1456
1541
|
coverage_role: mapping?.coverage_role,
|
|
@@ -2252,6 +2337,10 @@ function validateSourceReference(value, path, add) {
|
|
|
2252
2337
|
if (!checkStrictObject(value, path, SOURCE_REF_FIELDS, ["id", "kind"], add)) return;
|
|
2253
2338
|
checkNonEmptyString(value.id, `${path}.id`, add);
|
|
2254
2339
|
checkEnum(value.kind, SOURCE_REF_KINDS, `${path}.kind`, add);
|
|
2340
|
+
if (Object.hasOwn(value, "design_file_type")) {
|
|
2341
|
+
checkNonEmptyString(value.design_file_type, `${path}.design_file_type`, add);
|
|
2342
|
+
if (value.role !== "design_file") add("design_source_package.design_file_type", `${path}.design_file_type`, "design_file_type requires role design_file.");
|
|
2343
|
+
}
|
|
2255
2344
|
if (!isNonEmptyString(value.path) && !isNonEmptyString(value.url)) add("design_source_package.source_ref_location", path, "Source reference requires path or url.");
|
|
2256
2345
|
for (const field of ["path", "url", "role", "media_type"]) if (value[field] != null) checkNonEmptyString(value[field], `${path}.${field}`, add);
|
|
2257
2346
|
if (value.sha256 != null && !SHA256_PATTERN.test(value.sha256)) add("design_source_package.sha256", `${path}.sha256`, "sha256 must be 64 lowercase hex, with optional sha256: prefix.");
|
|
@@ -2293,6 +2382,7 @@ function validateCoverageMapping(value, path, add) {
|
|
|
2293
2382
|
"id", "surface_id", "coverage_role", "confidence", "source_refs", "screenshot_refs", "reference_refs",
|
|
2294
2383
|
], add)) return false;
|
|
2295
2384
|
checkNonEmptyString(value.id, `${path}.id`, add);
|
|
2385
|
+
if (Object.hasOwn(value, "derived_from")) checkNonEmptyString(value.derived_from, `${path}.derived_from`, add);
|
|
2296
2386
|
checkNonEmptyString(value.surface_id, `${path}.surface_id`, add);
|
|
2297
2387
|
checkEnum(value.coverage_role, new Set(COVERAGE_ROLES), `${path}.coverage_role`, add);
|
|
2298
2388
|
checkEnum(value.confidence, new Set(MAPPING_CONFIDENCE_LEVELS), `${path}.confidence`, add);
|
|
@@ -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);
|