@hominis/fireforge 0.50.0 → 0.52.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 (212) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/status-output.js +5 -1
  63. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  64. package/dist/src/commands/test-build-furnace.js +8 -2
  65. package/dist/src/commands/test-build-phase.d.ts +2 -0
  66. package/dist/src/commands/test-build-phase.js +34 -17
  67. package/dist/src/commands/test-checkjs.js +3 -2
  68. package/dist/src/commands/test-dispatch.d.ts +39 -0
  69. package/dist/src/commands/test-dispatch.js +220 -0
  70. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  71. package/dist/src/commands/test-furnace-reach.js +132 -0
  72. package/dist/src/commands/test-orchestrate.d.ts +14 -0
  73. package/dist/src/commands/test-orchestrate.js +25 -4
  74. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  75. package/dist/src/commands/test-perf-scope.js +55 -0
  76. package/dist/src/commands/test-prelock.js +100 -4
  77. package/dist/src/commands/test-register.js +18 -3
  78. package/dist/src/commands/test-results.d.ts +62 -0
  79. package/dist/src/commands/test-results.js +62 -0
  80. package/dist/src/commands/test-run.d.ts +13 -55
  81. package/dist/src/commands/test-run.js +73 -101
  82. package/dist/src/commands/test-shards.d.ts +110 -0
  83. package/dist/src/commands/test-shards.js +255 -0
  84. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  85. package/dist/src/commands/test-stale-gate.js +11 -2
  86. package/dist/src/commands/test-verdict.d.ts +2 -1
  87. package/dist/src/commands/test.d.ts +1 -1
  88. package/dist/src/commands/test.js +68 -75
  89. package/dist/src/commands/token-lint.js +48 -8
  90. package/dist/src/commands/verify.js +9 -2
  91. package/dist/src/core/build-baseline-types.d.ts +13 -0
  92. package/dist/src/core/build-baseline-validate.js +9 -0
  93. package/dist/src/core/build-baseline.js +7 -0
  94. package/dist/src/core/build-prepare.d.ts +6 -0
  95. package/dist/src/core/build-prepare.js +29 -3
  96. package/dist/src/core/config-paths.d.ts +1 -1
  97. package/dist/src/core/config-paths.js +2 -0
  98. package/dist/src/core/config-validate-check.js +83 -7
  99. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  100. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  101. package/dist/src/core/config-validate-token-scales.js +47 -5
  102. package/dist/src/core/config-validate.js +6 -0
  103. package/dist/src/core/coverage-extend.js +2 -2
  104. package/dist/src/core/coverage-records.d.ts +11 -0
  105. package/dist/src/core/coverage-records.js +34 -0
  106. package/dist/src/core/engine-session-lock.d.ts +6 -0
  107. package/dist/src/core/engine-session-lock.js +10 -2
  108. package/dist/src/core/file-lock-shared.d.ts +57 -0
  109. package/dist/src/core/file-lock-shared.js +117 -0
  110. package/dist/src/core/file-lock.d.ts +9 -0
  111. package/dist/src/core/file-lock.js +58 -2
  112. package/dist/src/core/furnace-apply.js +10 -10
  113. package/dist/src/core/furnace-config-validate-exceptions.d.ts +11 -3
  114. package/dist/src/core/furnace-config-validate-exceptions.js +59 -8
  115. package/dist/src/core/furnace-config-validate.js +7 -1
  116. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  117. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  118. package/dist/src/core/furnace-validate-accessibility.d.ts +5 -3
  119. package/dist/src/core/furnace-validate-accessibility.js +75 -30
  120. package/dist/src/core/furnace-validate-exceptions.d.ts +13 -4
  121. package/dist/src/core/furnace-validate-exceptions.js +59 -4
  122. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  123. package/dist/src/core/history-log-file.js +8 -1
  124. package/dist/src/core/line-prefixer.d.ts +18 -0
  125. package/dist/src/core/line-prefixer.js +34 -0
  126. package/dist/src/core/mach.d.ts +7 -0
  127. package/dist/src/core/mach.js +24 -9
  128. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  129. package/dist/src/core/module-resolution-preflight.js +24 -6
  130. package/dist/src/core/negative-control-compiled.d.ts +36 -0
  131. package/dist/src/core/negative-control-compiled.js +87 -0
  132. package/dist/src/core/negative-control-diff.d.ts +14 -2
  133. package/dist/src/core/negative-control-diff.js +49 -6
  134. package/dist/src/core/negative-control.d.ts +12 -0
  135. package/dist/src/core/negative-control.js +48 -5
  136. package/dist/src/core/park-store.d.ts +63 -0
  137. package/dist/src/core/park-store.js +216 -0
  138. package/dist/src/core/patch-export-update.d.ts +4 -0
  139. package/dist/src/core/patch-export-update.js +12 -3
  140. package/dist/src/core/patch-export.d.ts +3 -2
  141. package/dist/src/core/patch-export.js +21 -14
  142. package/dist/src/core/patch-files.js +26 -8
  143. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  144. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  145. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  146. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  147. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  148. package/dist/src/core/patch-lint-checkjs.js +13 -3
  149. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  150. package/dist/src/core/patch-lint-cross.js +5 -0
  151. package/dist/src/core/patch-lint-css.js +14 -10
  152. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  153. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  154. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  155. package/dist/src/core/patch-lint-ignore.js +173 -43
  156. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  157. package/dist/src/core/patch-lint-ownership.js +26 -0
  158. package/dist/src/core/patch-lint-size.js +9 -1
  159. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  160. package/dist/src/core/patch-lint-token-scale.js +206 -29
  161. package/dist/src/core/patch-lint.js +2 -2
  162. package/dist/src/core/patch-manifest-io.js +8 -1
  163. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  164. package/dist/src/core/patch-manifest-key-order.js +61 -0
  165. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  166. package/dist/src/core/patch-manifest-stage.js +96 -0
  167. package/dist/src/core/patch-manifest-validate.js +27 -1
  168. package/dist/src/core/patch-order-legal.d.ts +2 -1
  169. package/dist/src/core/patch-order-legal.js +7 -2
  170. package/dist/src/core/patch-policy.d.ts +6 -2
  171. package/dist/src/core/patch-policy.js +14 -7
  172. package/dist/src/core/patch-retired.d.ts +26 -4
  173. package/dist/src/core/patch-retired.js +40 -2
  174. package/dist/src/core/run-log.d.ts +6 -0
  175. package/dist/src/core/run-log.js +17 -1
  176. package/dist/src/core/test-harness-crash.d.ts +2 -1
  177. package/dist/src/core/test-harness-crash.js +5 -8
  178. package/dist/src/core/test-harness-signature.d.ts +16 -0
  179. package/dist/src/core/test-harness-signature.js +19 -1
  180. package/dist/src/core/test-raw-log.d.ts +66 -0
  181. package/dist/src/core/test-raw-log.js +122 -0
  182. package/dist/src/core/test-run-completeness.d.ts +47 -7
  183. package/dist/src/core/test-run-completeness.js +70 -23
  184. package/dist/src/core/test-stale-check.d.ts +10 -1
  185. package/dist/src/core/test-stale-check.js +15 -1
  186. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  187. package/dist/src/core/test-xpcshell-retry.js +2 -1
  188. package/dist/src/core/token-lint.d.ts +5 -2
  189. package/dist/src/core/token-lint.js +8 -3
  190. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  191. package/dist/src/core/token-sibling-sheets.js +119 -0
  192. package/dist/src/core/tree-guard.js +2 -0
  193. package/dist/src/core/typecheck-shim.d.ts +6 -1
  194. package/dist/src/core/typecheck-shim.js +139 -2
  195. package/dist/src/core/typecheck.js +3 -1
  196. package/dist/src/index.d.ts +9 -3
  197. package/dist/src/index.js +4 -1
  198. package/dist/src/types/commands/index.d.ts +2 -2
  199. package/dist/src/types/commands/options.d.ts +68 -7
  200. package/dist/src/types/commands/patches.d.ts +43 -0
  201. package/dist/src/types/config.d.ts +74 -2
  202. package/dist/src/types/furnace.d.ts +29 -0
  203. package/dist/src/types/index.d.ts +1 -1
  204. package/dist/src/utils/options.d.ts +3 -2
  205. package/dist/src/utils/options.js +9 -4
  206. package/docs/configuration.md +110 -9
  207. package/docs/furnace.md +61 -1
  208. package/docs/lifecycle-invariants.md +13 -11
  209. package/docs/machine-output.md +68 -18
  210. package/docs/patch-workflow.md +174 -13
  211. package/docs/testing.md +89 -5
  212. package/package.json +3 -3
@@ -21,7 +21,7 @@ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
21
21
  import { isGitRepository } from '../core/git.js';
22
22
  import { withPrivateGitIndex } from '../core/git-readonly-index.js';
23
23
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
24
- import { findUnresolvedSystemModuleImports, formatUnresolvedSystemModuleImports, } from '../core/module-resolution-preflight.js';
24
+ import { collectWorktreeModuleFiles, findUnresolvedSystemModuleImports, formatUnresolvedSystemModuleImports, } from '../core/module-resolution-preflight.js';
25
25
  import { buildPatchQueueContext, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
26
26
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
27
27
  import { evaluatePatchPolicy } from '../core/patch-policy.js';
@@ -206,8 +206,15 @@ export async function collectPatchQueueHealth(projectRoot, scope) {
206
206
  // CREATES. This asks the same question of the engine, so an import ADDED
207
207
  // to an existing module is named as text instead of surfacing later as a
208
208
  // bare `xpcshell return code: -11` with zero output.
209
+ // Unscoped, the engine worktree joins in: an unexported chrome script
210
+ // importing an unexported, unregistered module crashes every objdir
211
+ // start before either file is in the queue. A scoped run never judges
212
+ // unmanaged files.
209
213
  if (await pathExists(paths.engine)) {
210
- const unresolved = await findUnresolvedSystemModuleImports(paths.engine, scopePatches(manifest.patches, scope).flatMap((patch) => patch.filesAffected));
214
+ const worktree = scope === undefined && (await isGitRepository(paths.engine))
215
+ ? await collectWorktreeModuleFiles(paths.engine)
216
+ : undefined;
217
+ const unresolved = await findUnresolvedSystemModuleImports(paths.engine, scopePatches(manifest.patches, scope).flatMap((patch) => patch.filesAffected), worktree);
211
218
  if (unresolved.length > 0) {
212
219
  groups.push({
213
220
  title: `Unresolvable system-module imports (${unresolved.length})`,
@@ -144,6 +144,19 @@ export interface BuildBaseline {
144
144
  * as "unknown" when absent.
145
145
  */
146
146
  recordedBy?: string;
147
+ /**
148
+ * The process that wrote this baseline: its pid, its invocation id (the
149
+ * one its history records carry) and the caller's `FIREFORGE_SESSION_ID`
150
+ * when set. A coverage refusal names it, so a run whose claim another
151
+ * session's scoped build replaced can tell whose build that was.
152
+ */
153
+ recorder?: BaselineRecorder;
154
+ }
155
+ /** See {@link BuildBaseline.recorder}. */
156
+ export interface BaselineRecorder {
157
+ pid: number;
158
+ invocationId: string;
159
+ sessionId?: string;
147
160
  }
148
161
  /**
149
162
  * State of the engine at the last full build, as far as compiled-in XPCOM
@@ -32,6 +32,13 @@ function isCoverageRecordMap(value) {
32
32
  Array.isArray(record['inputs']) &&
33
33
  record['inputs'].every(isBaselinePath)));
34
34
  }
35
+ /** A `recorder` stamp: an integer pid, an invocation id and an optional session id. */
36
+ function isBaselineRecorder(value) {
37
+ return (isObject(value) &&
38
+ Number.isInteger(value['pid']) &&
39
+ typeof value['invocationId'] === 'string' &&
40
+ (value['sessionId'] === undefined || typeof value['sessionId'] === 'string'));
41
+ }
35
42
  /** Validates persisted fields without converting malformed coverage into a full claim. */
36
43
  export function isBuildBaseline(value) {
37
44
  if (!isObject(value))
@@ -59,6 +66,8 @@ export function isBuildBaseline(value) {
59
66
  return false;
60
67
  if (value['recordedBy'] !== undefined && typeof value['recordedBy'] !== 'string')
61
68
  return false;
69
+ if (value['recorder'] !== undefined && !isBaselineRecorder(value['recorder']))
70
+ return false;
62
71
  if (value['testCoverageRecords'] !== undefined &&
63
72
  !isCoverageRecordMap(value['testCoverageRecords']))
64
73
  return false;
@@ -32,6 +32,7 @@ import { hashEngineFile } from './coverage-extend.js';
32
32
  import { getHead, hasChanges, isMissingHeadError } from './git.js';
33
33
  import { git } from './git-base.js';
34
34
  import { getUntrackedFiles } from './git-status.js';
35
+ import { historyRecordIdentity } from './history-log-file.js';
35
36
  /*
36
37
  * Keep the IO fan-out bounded: a patched Firefox checkout can carry hundreds
37
38
  * of dirty packageable paths and serial reads add directly to every build.
@@ -72,6 +73,11 @@ export async function readBuildBaseline(projectRoot, invalid = 'ignore') {
72
73
  return undefined;
73
74
  }
74
75
  }
76
+ /** This process's {@link BuildBaseline.recorder} stamp. */
77
+ function baselineRecorder() {
78
+ const { pid, invocationId, sessionId } = historyRecordIdentity();
79
+ return { pid, invocationId, ...(sessionId !== undefined ? { sessionId } : {}) };
80
+ }
75
81
  /**
76
82
  * Records a successful build by writing a fresh baseline marker. Captures
77
83
  * engine HEAD SHA (or an empty string when the engine has no HEAD yet) and
@@ -121,6 +127,7 @@ export async function writeBuildBaseline(options) {
121
127
  ...(mozconfigHash !== undefined ? { mozconfigHash } : {}),
122
128
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
123
129
  ...(recordedBy !== undefined ? { recordedBy } : {}),
130
+ recorder: baselineRecorder(),
124
131
  };
125
132
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
126
133
  }
@@ -42,6 +42,12 @@ export interface PrepareBuildOptions {
42
42
  * (`fireforge test --build --no-furnace-apply`).
43
43
  */
44
44
  skipFurnaceApply?: boolean | undefined;
45
+ /**
46
+ * Apply only these components (`fireforge test --build --furnace-apply
47
+ * reached`). Undefined applies every component with undeployed edits; an
48
+ * empty list applies none.
49
+ */
50
+ furnaceComponents?: readonly string[] | undefined;
45
51
  }
46
52
  /**
47
53
  * Describes an exit code in the shell's 128+N signal convention. Truncated
@@ -170,14 +170,35 @@ async function reportUnexportedDriftBeforeOverwrite(projectRoot, config, options
170
170
  }
171
171
  notice(message);
172
172
  }
173
+ /**
174
+ * Applies the named components one at a time, each under its own Furnace
175
+ * mutation (one rollback journal per apply), and merges the results.
176
+ */
177
+ async function applySelectedComponents(projectRoot, names) {
178
+ const merged = {
179
+ applied: [],
180
+ skipped: [],
181
+ errors: [],
182
+ warnings: [],
183
+ };
184
+ for (const componentName of names) {
185
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx, componentName }));
186
+ merged.applied.push(...result.applied);
187
+ merged.skipped.push(...result.skipped);
188
+ merged.errors.push(...result.errors);
189
+ merged.warnings.push(...(result.warnings ?? []));
190
+ }
191
+ return merged;
192
+ }
173
193
  /**
174
194
  * Applies Furnace components before the build when `furnace.json` exists.
175
195
  * Prints every warning the apply computed, then the loud sync banner.
176
196
  *
177
197
  * @param projectRoot - Project root
198
+ * @param only - Apply just these components; every component when undefined
178
199
  * @returns Names of the components written to engine/
179
200
  */
180
- async function applyFurnaceBeforeBuild(projectRoot) {
201
+ async function applyFurnaceBeforeBuild(projectRoot, only) {
181
202
  if (!(await furnaceConfigExists(projectRoot)))
182
203
  return [];
183
204
  const furnaceConfig = await loadFurnaceConfig(projectRoot);
@@ -187,7 +208,10 @@ async function applyFurnaceBeforeBuild(projectRoot) {
187
208
  const furnaceSpinner = spinner('Applying Furnace components...');
188
209
  let result;
189
210
  try {
190
- result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
211
+ result =
212
+ only === undefined
213
+ ? await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }))
214
+ : await applySelectedComponents(projectRoot, only);
191
215
  }
192
216
  catch (error) {
193
217
  furnaceSpinner.error('Failed to apply Furnace components');
@@ -349,7 +373,9 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
349
373
  throw error;
350
374
  }
351
375
  }
352
- const furnaceAppliedNames = options.skipFurnaceApply === true ? [] : await applyFurnaceBeforeBuild(projectRoot);
376
+ const furnaceAppliedNames = options.skipFurnaceApply === true || options.furnaceComponents?.length === 0
377
+ ? []
378
+ : await applyFurnaceBeforeBuild(projectRoot, options.furnaceComponents);
353
379
  // Generate mozconfig
354
380
  const mozconfigSpinner = spinner('Generating mozconfig...');
355
381
  let mozconfigWritten;
@@ -7,7 +7,7 @@ export declare const FIREFORGE_DIR = ".fireforge";
7
7
  /** Supported top-level fireforge.json keys backed by the current schema. */
8
8
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "buildAudit", "test", "history", "externalToolchains", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment", "check"];
9
9
  /** Supported config paths that can be read or set without --force. */
10
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "test.reapOrphans", "test.mochitestPorts", "history", "history.path", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchLint.forwardRegistration", "patchLint.fileSizeMetric", "patchLint.patchSizeMetric", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment", "check"];
10
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "test.reapOrphans", "test.mochitestPorts", "test.lockMode", "test.furnaceApply", "history", "history.path", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchLint.forwardRegistration", "patchLint.fileSizeMetric", "patchLint.patchSizeMetric", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment", "check"];
11
11
  /**
12
12
  * Gets all project paths based on a root directory.
13
13
  * @param root - Root directory of the project
@@ -57,6 +57,8 @@ export const SUPPORTED_CONFIG_PATHS = [
57
57
  'test.canaryTimeoutSeconds',
58
58
  'test.reapOrphans',
59
59
  'test.mochitestPorts',
60
+ 'test.lockMode',
61
+ 'test.furnaceApply',
60
62
  'history',
61
63
  'history.path',
62
64
  'externalToolchains',
@@ -78,7 +78,7 @@ function parseExtraStep(raw, field) {
78
78
  if (!isObject(raw)) {
79
79
  throw new ConfigError(`Config field "${field}" must be a plain object with "name" and "command"`);
80
80
  }
81
- assertKnownKeys(raw, field, ['name', 'command', 'cwd', 'timeout']);
81
+ assertKnownKeys(raw, field, ['name', 'command', 'cwd', 'timeout', 'after']);
82
82
  const name = raw['name'];
83
83
  if (typeof name !== 'string' || !STEP_NAME_PATTERN.test(name)) {
84
84
  throw new ConfigError(`Config field "${field}.name" must be lowercase letters, digits and dashes (e.g. "policy-audit")`);
@@ -99,14 +99,63 @@ function parseExtraStep(raw, field) {
99
99
  step.cwd = cwd;
100
100
  }
101
101
  if (raw['timeout'] !== undefined) {
102
- const timeout = parseNonNegativeInteger(raw['timeout'], `${field}.timeout`);
103
- if (timeout === 0) {
104
- throw new ConfigError(`Config field "${field}.timeout" must be a positive number of seconds`);
105
- }
106
- step.timeout = timeout;
102
+ step.timeout = parsePositiveSeconds(raw['timeout'], `${field}.timeout`);
107
103
  }
104
+ if (raw['after'] !== undefined)
105
+ step.after = parseStringList(raw['after'], `${field}.after`);
108
106
  return step;
109
107
  }
108
+ function parsePositiveSeconds(raw, field) {
109
+ const seconds = parseNonNegativeInteger(raw, field);
110
+ if (seconds === 0) {
111
+ throw new ConfigError(`Config field "${field}" must be a positive number of seconds`);
112
+ }
113
+ return seconds;
114
+ }
115
+ /**
116
+ * Checks every `after` names a known step and the extra steps' ordering
117
+ * has no cycle: a cycle would leave its steps waiting forever.
118
+ */
119
+ function assertExtraStepOrdering(steps) {
120
+ const extras = new Map(steps.map((step) => [step.name, step]));
121
+ for (const [index, step] of steps.entries()) {
122
+ for (const dependency of step.after ?? []) {
123
+ if (dependency === step.name) {
124
+ throw new ConfigError(`Config field "check.extraSteps[${String(index)}].after" names the step itself`);
125
+ }
126
+ if (!isBuiltinStep(dependency) && !extras.has(dependency)) {
127
+ throw new ConfigError(`Config field "check.extraSteps[${String(index)}].after" names unknown step "${dependency}"`);
128
+ }
129
+ }
130
+ }
131
+ const state = new Map();
132
+ const visit = (name, path) => {
133
+ if (state.get(name) === 'done')
134
+ return;
135
+ if (state.get(name) === 'visiting') {
136
+ throw new ConfigError(`Config field "check.extraSteps" orders its steps in a cycle: ${[...path, name].join(' -> ')}`);
137
+ }
138
+ state.set(name, 'visiting');
139
+ for (const dependency of extras.get(name)?.after ?? []) {
140
+ if (extras.has(dependency))
141
+ visit(dependency, [...path, name]);
142
+ }
143
+ state.set(name, 'done');
144
+ };
145
+ for (const step of steps)
146
+ visit(step.name, []);
147
+ }
148
+ function parseTimeouts(raw) {
149
+ if (!isObject(raw))
150
+ throw new ConfigError('Config field "check.timeouts" must be a plain object');
151
+ assertKnownKeys(raw, 'check.timeouts', [...CHECK_BUILTIN_STEPS]);
152
+ const out = {};
153
+ for (const [name, value] of Object.entries(raw)) {
154
+ if (isBuiltinStep(name))
155
+ out[name] = parsePositiveSeconds(value, `check.timeouts.${name}`);
156
+ }
157
+ return out;
158
+ }
110
159
  function parseExtraSteps(raw) {
111
160
  if (!Array.isArray(raw))
112
161
  throw new ConfigError('Config field "check.extraSteps" must be an array');
@@ -118,6 +167,7 @@ function parseExtraSteps(raw) {
118
167
  }
119
168
  seen.add(step.name);
120
169
  }
170
+ assertExtraStepOrdering(steps);
121
171
  return steps;
122
172
  }
123
173
  /**
@@ -129,7 +179,16 @@ function parseExtraSteps(raw) {
129
179
  export function parseCheckConfig(raw) {
130
180
  if (!isObject(raw))
131
181
  throw new ConfigError('Config field "check" must be a plain object');
132
- assertKnownKeys(raw, 'check', ['skip', 'status', 'maxWarnings', 'extraSteps']);
182
+ assertKnownKeys(raw, 'check', [
183
+ 'skip',
184
+ 'status',
185
+ 'maxWarnings',
186
+ 'extraSteps',
187
+ 'timeout',
188
+ 'timeouts',
189
+ 'failOnSkipped',
190
+ 'lintReport',
191
+ ]);
133
192
  const out = {};
134
193
  if (raw['skip'] !== undefined)
135
194
  out.skip = parseSkip(raw['skip']);
@@ -139,5 +198,22 @@ export function parseCheckConfig(raw) {
139
198
  out.maxWarnings = parseMaxWarnings(raw['maxWarnings']);
140
199
  if (raw['extraSteps'] !== undefined)
141
200
  out.extraSteps = parseExtraSteps(raw['extraSteps']);
201
+ if (raw['timeout'] !== undefined)
202
+ out.timeout = parsePositiveSeconds(raw['timeout'], 'check.timeout');
203
+ if (raw['timeouts'] !== undefined)
204
+ out.timeouts = parseTimeouts(raw['timeouts']);
205
+ if (raw['failOnSkipped'] !== undefined) {
206
+ if (typeof raw['failOnSkipped'] !== 'boolean') {
207
+ throw new ConfigError('Config field "check.failOnSkipped" must be a boolean');
208
+ }
209
+ out.failOnSkipped = raw['failOnSkipped'];
210
+ }
211
+ if (raw['lintReport'] !== undefined) {
212
+ const path = raw['lintReport'];
213
+ if (typeof path !== 'string' || path.trim() === '' || !isContainedRelativePath(path)) {
214
+ throw new ConfigError('Config field "check.lintReport" must be a project-relative path');
215
+ }
216
+ out.lintReport = path;
217
+ }
142
218
  return out;
143
219
  }
@@ -59,6 +59,20 @@ export function parseTestBlock(rec, config) {
59
59
  }
60
60
  out.mochitestPorts = mochitestPorts;
61
61
  }
62
+ const lockMode = testRec.raw('lockMode');
63
+ if (lockMode !== undefined) {
64
+ if (lockMode !== 'exclusive' && lockMode !== 'shared') {
65
+ throw new ConfigError('Config field "test.lockMode" must be "exclusive" or "shared"');
66
+ }
67
+ out.lockMode = lockMode;
68
+ }
69
+ const furnaceApply = testRec.raw('furnaceApply');
70
+ if (furnaceApply !== undefined) {
71
+ if (furnaceApply !== 'all' && furnaceApply !== 'reached' && furnaceApply !== 'none') {
72
+ throw new ConfigError('Config field "test.furnaceApply" must be "all", "reached" or "none"');
73
+ }
74
+ out.furnaceApply = furnaceApply;
75
+ }
62
76
  config.test = out;
63
77
  }
64
78
  /** Parses optional project-specific external toolchain doctor probes. */
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Validation for `fireforge.json#patchLint.tokenScales`.
3
+ *
4
+ * Unknown keys are refused: a misspelt `allowInCalc` or `minFiles` would
5
+ * otherwise loosen or drop a scale without a word.
3
6
  */
4
7
  import type { PatchLintTokenScale } from '../types/config.js';
5
8
  /** Parses and validates `patchLint.tokenScales`. */
@@ -1,9 +1,23 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * Validation for `fireforge.json#patchLint.tokenScales`.
4
+ *
5
+ * Unknown keys are refused: a misspelt `allowInCalc` or `minFiles` would
6
+ * otherwise loosen or drop a scale without a word.
4
7
  */
5
8
  import { ConfigError } from '../errors/config.js';
6
9
  import { parseObject } from '../utils/parse.js';
10
+ const SCALE_KEYS = [
11
+ 'properties',
12
+ 'prefix',
13
+ 'allowLiterals',
14
+ 'allow',
15
+ 'allowInCalc',
16
+ 'offsetCap',
17
+ 'exclude',
18
+ 'minFiles',
19
+ ];
20
+ const PREFIX_PATTERN = /^--[\w-]+$/;
7
21
  function parseNames(raw, label, required) {
8
22
  if (raw === undefined && !required)
9
23
  return undefined;
@@ -14,6 +28,21 @@ function parseNames(raw, label, required) {
14
28
  }
15
29
  return raw;
16
30
  }
31
+ function parsePrefix(raw, label) {
32
+ const prefixes = Array.isArray(raw) ? raw : [raw];
33
+ if (prefixes.length === 0 ||
34
+ prefixes.some((prefix) => typeof prefix !== 'string' || !PREFIX_PATTERN.test(prefix))) {
35
+ throw new ConfigError(`Config field "${label}.prefix" must be a custom-property prefix such as "--mybrowser-space-", or a non-empty list of them`);
36
+ }
37
+ return Array.isArray(raw) ? prefixes : raw;
38
+ }
39
+ function parseNumber(raw, label, integer) {
40
+ const valid = typeof raw === 'number' && Number.isFinite(raw) && (integer ? Number.isInteger(raw) : true);
41
+ if (!valid || raw < (integer ? 1 : 0)) {
42
+ throw new ConfigError(`Config field "${label}" must be ${integer ? 'a positive integer' : 'a non-negative number'}`);
43
+ }
44
+ return raw;
45
+ }
17
46
  function parseTokenScale(raw, label) {
18
47
  let rec;
19
48
  try {
@@ -22,12 +51,13 @@ function parseTokenScale(raw, label) {
22
51
  catch {
23
52
  throw new ConfigError(`Config field "${label}" must be an object`);
24
53
  }
25
- const properties = parseNames(rec.raw('properties'), `${label}.properties`, true) ?? [];
26
- const prefix = rec.raw('prefix');
27
- if (typeof prefix !== 'string' || !/^--[\w-]+$/.test(prefix)) {
28
- throw new ConfigError(`Config field "${label}.prefix" must be a custom-property prefix such as "--mybrowser-space-"`);
54
+ for (const key of Object.keys(raw)) {
55
+ if (!SCALE_KEYS.includes(key)) {
56
+ throw new ConfigError(`Config field "${label}" has unknown key "${key}"`);
57
+ }
29
58
  }
30
- const scale = { properties, prefix };
59
+ const properties = parseNames(rec.raw('properties'), `${label}.properties`, true) ?? [];
60
+ const scale = { properties, prefix: parsePrefix(rec.raw('prefix'), label) };
31
61
  const allowLiterals = parseNames(rec.raw('allowLiterals'), `${label}.allowLiterals`, false);
32
62
  if (allowLiterals !== undefined)
33
63
  scale.allowLiterals = allowLiterals;
@@ -39,6 +69,18 @@ function parseTokenScale(raw, label) {
39
69
  }
40
70
  scale.allow = allow;
41
71
  }
72
+ const allowInCalc = parseNames(rec.raw('allowInCalc'), `${label}.allowInCalc`, false);
73
+ if (allowInCalc !== undefined)
74
+ scale.allowInCalc = allowInCalc;
75
+ const offsetCap = rec.raw('offsetCap');
76
+ if (offsetCap !== undefined)
77
+ scale.offsetCap = parseNumber(offsetCap, `${label}.offsetCap`, false);
78
+ const exclude = parseNames(rec.raw('exclude'), `${label}.exclude`, false);
79
+ if (exclude !== undefined)
80
+ scale.exclude = exclude;
81
+ const minFiles = rec.raw('minFiles');
82
+ if (minFiles !== undefined)
83
+ scale.minFiles = parseNumber(minFiles, `${label}.minFiles`, true);
42
84
  return scale;
43
85
  }
44
86
  /** Parses and validates `patchLint.tokenScales`. */
@@ -471,6 +471,12 @@ function parseTypecheckBlock(rec) {
471
471
  if (undefinedIdentifiers !== undefined) {
472
472
  out.undefinedIdentifiers = undefinedIdentifiers;
473
473
  }
474
+ const litTypes = rec.raw('litTypes');
475
+ if (litTypes !== undefined && typeof litTypes !== 'boolean') {
476
+ throw new ConfigError('Config field "typecheck.litTypes" must be a boolean');
477
+ }
478
+ if (litTypes !== undefined)
479
+ out.litTypes = litTypes;
474
480
  return out;
475
481
  }
476
482
  /**
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { toError } from '../utils/errors.js';
15
15
  import { verbose } from '../utils/logger.js';
16
- import { collectStagingInputs, createCoverageRecord, findChangedInputs, hashEngineFile, isUnderPath, narrowAnchor, normalizeRequestPath, readEngineContentState, refreshAnchor, } from './coverage-records.js';
16
+ import { collectStagingInputs, createCoverageRecord, findChangedInputs, hashEngineFile, isUnderPath, narrowAnchor, normalizeRequestPath, readEngineContentState, refreshAnchor, widenWithRestagedManifests, } from './coverage-records.js';
17
17
  export { hashEngineFile } from './coverage-records.js';
18
18
  /** Filename of the generated mozconfig, relative to the engine directory. */
19
19
  const MOZCONFIG_FILENAME = 'mozconfig';
@@ -96,7 +96,7 @@ export async function planCoverageRetention(engineDir, previous, requested) {
96
96
  if (previous === undefined)
97
97
  return emptyPlan();
98
98
  const coverage = previous.testPackagingCoverage ?? 'full';
99
- const rebuilt = requested.map(normalizeRequestPath).filter((path) => path !== '');
99
+ const rebuilt = await widenWithRestagedManifests(engineDir, requested.map(normalizeRequestPath).filter((path) => path !== ''));
100
100
  let state;
101
101
  try {
102
102
  state = await readEngineContentState(engineDir);
@@ -67,6 +67,17 @@ export declare function findChangedInputs(state: EngineContentState, anchor: Cov
67
67
  * cannot be read or the anchor cannot be compared.
68
68
  */
69
69
  export declare function refreshAnchor<T extends CoverageAnchor>(state: EngineContentState, anchor: T, rebuilt: readonly string[]): Promise<T | undefined>;
70
+ /**
71
+ * The request paths a scoped build restages, widened by the test manifests
72
+ * of every requested file: packaging one file of a manifest re-processes
73
+ * the manifest itself, so a manifest that gained an entry for that file is
74
+ * restaged too, and must not count as a changed input of the manifest's
75
+ * other files afterwards.
76
+ *
77
+ * @param engineDir - Path to the engine directory
78
+ * @param requested - Normalized request paths of the build
79
+ */
80
+ export declare function widenWithRestagedManifests(engineDir: string, requested: readonly string[]): Promise<string[]>;
70
81
  /**
71
82
  * Engine-relative paths a manifest names outside its own directory. Bare
72
83
  * names stay inside it. `!/` and `/` prefixes are engine-root relative, as
@@ -217,6 +217,40 @@ async function findManifestDirectory(engineDir, dir) {
217
217
  return undefined;
218
218
  }
219
219
  }
220
+ /**
221
+ * The request paths a scoped build restages, widened by the test manifests
222
+ * of every requested file: packaging one file of a manifest re-processes
223
+ * the manifest itself, so a manifest that gained an entry for that file is
224
+ * restaged too, and must not count as a changed input of the manifest's
225
+ * other files afterwards.
226
+ *
227
+ * @param engineDir - Path to the engine directory
228
+ * @param requested - Normalized request paths of the build
229
+ */
230
+ export async function widenWithRestagedManifests(engineDir, requested) {
231
+ const widened = new Set(requested);
232
+ for (const path of requested) {
233
+ if (!TEST_FILE_PATTERN.test(posix.basename(path)))
234
+ continue;
235
+ const dir = await findManifestDirectory(engineDir, posix.dirname(path));
236
+ if (dir === undefined)
237
+ continue;
238
+ let entries;
239
+ try {
240
+ entries = await readdir(join(engineDir, dir));
241
+ }
242
+ catch {
243
+ continue;
244
+ }
245
+ for (const entry of entries) {
246
+ if (TEST_MANIFEST_BASENAMES.has(entry))
247
+ widened.add(dir === '' ? entry : `${dir}/${entry}`);
248
+ }
249
+ }
250
+ return [...widened];
251
+ }
252
+ /** A test implementation file (`browser_*.js`, `test_*.js`, …), not a directory. */
253
+ const TEST_FILE_PATTERN = /^(?:browser|test)_.*\.m?js$/;
220
254
  /** Test manifests at or beneath `dir`, bounded by {@link MAX_WALK_ENTRIES}. */
221
255
  async function findManifestsBeneath(engineDir, dir) {
222
256
  const manifests = [];
@@ -1,3 +1,4 @@
1
+ import type { LockMode } from './file-lock.js';
1
2
  import { type LockStatusSnapshot } from './file-lock.js';
2
3
  export interface EngineSessionLockOptions {
3
4
  /**
@@ -7,6 +8,11 @@ export interface EngineSessionLockOptions {
7
8
  * fail-fast behaviour.
8
9
  */
9
10
  waitLockSeconds?: number | undefined;
11
+ /**
12
+ * `shared` lets build-less test runs overlap each other; see
13
+ * `file-lock-shared.ts`. Every other caller holds the lock exclusively.
14
+ */
15
+ mode?: LockMode | undefined;
10
16
  }
11
17
  /**
12
18
  * Formats the notice printed when the wait budget is extended because the
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { InconclusiveVerdictError } from '../errors/base.js';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { info, warn } from '../utils/logger.js';
6
+ import { MAX_WAIT_LOCK_SECONDS } from '../utils/options.js';
6
7
  import { sleep } from '../utils/sleep.js';
7
8
  import { formatLockQueuePosition, readLockStatus, withFileLock, } from './file-lock.js';
8
9
  import { git } from './git-base.js';
@@ -14,7 +15,7 @@ const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
14
15
  * never gets one longer than the largest wait they could have asked for.
15
16
  */
16
17
  const WAIT_EXTENSION_FACTOR = 4;
17
- const MAX_WAIT_LOCK_MS = 3600 * 1000;
18
+ const MAX_WAIT_LOCK_MS = MAX_WAIT_LOCK_SECONDS * 1000;
18
19
  /**
19
20
  * Formats the periodic operator-facing waiting line printed while `--wait-lock`
20
21
  * polls a contended engine session lock. The holder identification comes from
@@ -66,6 +67,7 @@ export async function withEngineSessionLock(projectRoot, command, operation, opt
66
67
  }
67
68
  const { waitLockSeconds } = options;
68
69
  return withFileLock(join(projectRoot, ENGINE_SESSION_LOCK_PATH), operation, {
70
+ ...(options.mode === 'shared' ? { mode: 'shared' } : {}),
69
71
  timeoutMs: waitLockSeconds !== undefined ? waitLockSeconds * 1000 : 1000,
70
72
  ...(waitLockSeconds !== undefined
71
73
  ? {
@@ -121,8 +123,13 @@ export async function readEngineSessionLockStatus(projectRoot) {
121
123
  * Pure, so the wording is unit-testable without a lock on disk.
122
124
  */
123
125
  export function formatEngineSessionLockStatus(snapshot, holderCpuSeconds) {
126
+ const shared = snapshot.sharedHolders !== undefined && snapshot.sharedHolders > 0
127
+ ? `Shared holders: ${snapshot.sharedHolders} build-less test run(s) under a shared lock.`
128
+ : undefined;
124
129
  if (!snapshot.held) {
125
- return ['Engine session lock: free (no FireForge engine-mutating command is running).'];
130
+ return shared === undefined
131
+ ? ['Engine session lock: free (no FireForge engine-mutating command is running).']
132
+ : ['Engine session lock: no exclusive holder.', shared];
126
133
  }
127
134
  const elapsed = snapshot.heldForMs === undefined
128
135
  ? 'unknown duration'
@@ -135,6 +142,7 @@ export function formatEngineSessionLockStatus(snapshot, holderCpuSeconds) {
135
142
  const lines = [
136
143
  `Engine session lock: ${identity}, for ${elapsed}.`,
137
144
  `Queue depth: ${snapshot.queueDepth} waiter(s).`,
145
+ ...(shared === undefined ? [] : [shared]),
138
146
  ];
139
147
  // Liveness answers "does the process exist". It does not answer "is it
140
148
  // getting anywhere". A holder alive for minutes having used a fraction of
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared holds on a FireForge directory lock.
3
+ *
4
+ * Every lock used to be exclusive, so two build-less `fireforge test` runs
5
+ * on one objdir, each with its own profile and (under auto ports) its own
6
+ * ports, still waited for each other: the gate's browser tiers ran strictly
7
+ * serially on a 14-core host. A shared hold lets such runs overlap while
8
+ * anything that writes (a build, a re-export, a test run that builds)
9
+ * still holds the lock alone.
10
+ *
11
+ * The scheme reuses the exclusive lock as a turnstile, so arrival order and
12
+ * stale-holder reaping come from the machinery that already exists:
13
+ *
14
+ * - A shared holder takes the exclusive lock in the ordinary way, records
15
+ * itself under `<lock>.shared/<uuid>/` (an owner record like the lock's
16
+ * own), and releases the exclusive lock at once.
17
+ * - An exclusive holder takes the exclusive lock, then waits until no live
18
+ * shared holder is left. While it waits it already holds the turnstile,
19
+ * so no new shared holder can slip in ahead of it: a writer queued
20
+ * behind readers is served once they finish, never starved by newcomers.
21
+ *
22
+ * A shared holder whose process died is reaped by the same PID-first
23
+ * liveness check the exclusive lock uses.
24
+ */
25
+ /** How a caller holds a lock. */
26
+ export type LockMode = 'exclusive' | 'shared';
27
+ /** The directory holding a lock's shared-holder records. */
28
+ export declare function sharedHoldersDir(lockPath: string): string;
29
+ /**
30
+ * Counts the live shared holders of a lock, reaping any whose process is
31
+ * gone. A missing directory means none.
32
+ *
33
+ * @param lockPath - The lock directory
34
+ * @param options - `reap: false` only counts (status reads)
35
+ */
36
+ export declare function countLiveSharedHolders(lockPath: string, options?: {
37
+ reap?: boolean;
38
+ }): Promise<number>;
39
+ /** A registered shared hold. */
40
+ export interface SharedHold {
41
+ /** Removes the hold. Never throws. */
42
+ release(): Promise<void>;
43
+ }
44
+ /**
45
+ * Records a shared hold. Call while holding the lock's exclusive turnstile.
46
+ *
47
+ * @param lockPath - The lock directory
48
+ * @param metadata - Owner metadata lines (`command=…`)
49
+ */
50
+ export declare function registerSharedHold(lockPath: string, metadata: readonly string[] | undefined): Promise<SharedHold>;
51
+ /**
52
+ * Removes every shared hold this process registered, for the signal path.
53
+ * Never throws.
54
+ *
55
+ * @returns The holder paths removed
56
+ */
57
+ export declare function releaseSharedHoldsForSignal(): Promise<string[]>;