@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.
Files changed (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. 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
- const currency = mapDoc?.campaign?.currency ?? mapDoc?.currency;
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 = { attached_offers: attached, known_offer_codes: knownOfferCodes, page_rows: rows };
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,
@@ -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
- export function loadBriefPayload(targetRepo) {
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
- export async function withDesignSourcePublication({ targetRepo, outputs, force = false, publishSpec = null }, fn) {
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(targetRepo, DESIGN_SOURCE_PACKAGE_REL_PATH);
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(targetRepo, ".campaign-runtime/theme/theme-report.json")],
868
- ["Brand Theme CSS", resolve(targetRepo, ".campaign-runtime/theme/brand-theme.css")],
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,
@@ -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. (See publishStagedDirectory for the one
21
- // mixed-version race this cannot close, tracked in #514.) Anything else at the
22
- // lock path, a file or a directory holding other entries, is not a lock at
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
- // Staging and released siblings a dead process left beside the lock are swept
26
- // by the next holder, under the lock; a sibling whose owner is alive or
27
- // unreadable is left alone (#514).
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. Each carries an owner.json.
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 an older release does, for the instant between its
123
- // mkdir and its owner write. A new writer's check-then-rename can land in
124
- // that instant and replace it, so running an older release and this one
125
- // on the same target at the same moment is not safe (#501; tracked in #514).
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, or a directory with no owner record that holds entries a lock
229
- // never has, will not clear by waiting and is never removed here.
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
- if (exists(ownerPath)) return null;
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 { foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort(); } catch { return null; }
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);