@hominis/fireforge 0.50.0 → 0.51.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 (205) hide show
  1. package/CHANGELOG.md +47 -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/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -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[]>;
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Shared holds on a FireForge directory lock.
4
+ *
5
+ * Every lock used to be exclusive, so two build-less `fireforge test` runs
6
+ * on one objdir, each with its own profile and (under auto ports) its own
7
+ * ports, still waited for each other: the gate's browser tiers ran strictly
8
+ * serially on a 14-core host. A shared hold lets such runs overlap while
9
+ * anything that writes (a build, a re-export, a test run that builds)
10
+ * still holds the lock alone.
11
+ *
12
+ * The scheme reuses the exclusive lock as a turnstile, so arrival order and
13
+ * stale-holder reaping come from the machinery that already exists:
14
+ *
15
+ * - A shared holder takes the exclusive lock in the ordinary way, records
16
+ * itself under `<lock>.shared/<uuid>/` (an owner record like the lock's
17
+ * own), and releases the exclusive lock at once.
18
+ * - An exclusive holder takes the exclusive lock, then waits until no live
19
+ * shared holder is left. While it waits it already holds the turnstile,
20
+ * so no new shared holder can slip in ahead of it: a writer queued
21
+ * behind readers is served once they finish, never starved by newcomers.
22
+ *
23
+ * A shared holder whose process died is reaped by the same PID-first
24
+ * liveness check the exclusive lock uses.
25
+ */
26
+ import { randomUUID } from 'node:crypto';
27
+ import { mkdir, readdir, rm } from 'node:fs/promises';
28
+ import { join } from 'node:path';
29
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
30
+ import { verbose } from '../utils/logger.js';
31
+ import { isLockOwnerAlive, readLockOwner, writeLockOwner } from './file-lock-owner.js';
32
+ /** The directory holding a lock's shared-holder records. */
33
+ export function sharedHoldersDir(lockPath) {
34
+ return `${lockPath}.shared`;
35
+ }
36
+ /** Holder directories this process registered, for the signal sweep. */
37
+ const heldShares = new Set();
38
+ /**
39
+ * Counts the live shared holders of a lock, reaping any whose process is
40
+ * gone. A missing directory means none.
41
+ *
42
+ * @param lockPath - The lock directory
43
+ * @param options - `reap: false` only counts (status reads)
44
+ */
45
+ export async function countLiveSharedHolders(lockPath, options = {}) {
46
+ const dir = sharedHoldersDir(lockPath);
47
+ let entries;
48
+ try {
49
+ entries = await readdir(dir);
50
+ }
51
+ catch (error) {
52
+ if (getNodeErrorCode(error) === 'ENOENT')
53
+ return 0;
54
+ if (options.reap === false)
55
+ return 0;
56
+ throw toError(error);
57
+ }
58
+ let live = 0;
59
+ for (const entry of entries) {
60
+ const holder = join(dir, entry);
61
+ const owner = await readLockOwner(holder);
62
+ if (owner.present && isLockOwnerAlive(owner)) {
63
+ live += 1;
64
+ continue;
65
+ }
66
+ // A record still being written has no owner yet; only an owner that
67
+ // names a dead process is reaped, so a racing registration survives.
68
+ if (owner.present && options.reap !== false) {
69
+ verbose(`Reaping shared hold ${holder}: PID ${owner.pid} is gone.`);
70
+ await rm(holder, { recursive: true, force: true });
71
+ }
72
+ }
73
+ return live;
74
+ }
75
+ /**
76
+ * Records a shared hold. Call while holding the lock's exclusive turnstile.
77
+ *
78
+ * @param lockPath - The lock directory
79
+ * @param metadata - Owner metadata lines (`command=…`)
80
+ */
81
+ export async function registerSharedHold(lockPath, metadata) {
82
+ const holder = join(sharedHoldersDir(lockPath), randomUUID());
83
+ await mkdir(holder, { recursive: true });
84
+ await writeLockOwner(holder, randomUUID(), [...(metadata ?? []), 'mode=shared']);
85
+ heldShares.add(holder);
86
+ return {
87
+ async release() {
88
+ heldShares.delete(holder);
89
+ try {
90
+ await rm(holder, { recursive: true, force: true });
91
+ }
92
+ catch (error) {
93
+ verbose(`Could not remove shared hold ${holder}: ${toError(error).message}`);
94
+ }
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Removes every shared hold this process registered, for the signal path.
100
+ * Never throws.
101
+ *
102
+ * @returns The holder paths removed
103
+ */
104
+ export async function releaseSharedHoldsForSignal() {
105
+ const removed = [];
106
+ for (const holder of [...heldShares]) {
107
+ heldShares.delete(holder);
108
+ try {
109
+ await rm(holder, { recursive: true, force: true });
110
+ removed.push(holder);
111
+ }
112
+ catch {
113
+ // Shutdown path: nothing to do.
114
+ }
115
+ }
116
+ return removed;
117
+ }
@@ -1,7 +1,9 @@
1
1
  import { LOCK_PID_FILE } from './file-lock-owner.js';
2
2
  import { type LockQueueState } from './file-lock-queue.js';
3
+ import { type LockMode } from './file-lock-shared.js';
3
4
  export { LOCK_PID_FILE };
4
5
  export { formatLockQueuePosition, type LockQueueState } from './file-lock-queue.js';
6
+ export type { LockMode } from './file-lock-shared.js';
5
7
  /**
6
8
  * Snapshot of the current lock holder handed to {@link FileLockOptions.onWaitProgress}.
7
9
  * `metadata` carries the pid file's human-diagnostic lines (line 3 onward,
@@ -84,6 +86,11 @@ export interface FileLockOptions {
84
86
  onStaleLockMessage?: (ageMs: number) => string | undefined;
85
87
  /** Extra owner-file lines for human diagnostics. Lock mechanics ignore them. */
86
88
  ownerMetadata?: readonly string[];
89
+ /**
90
+ * `exclusive` (default) or `shared`. Shared holders overlap each other and
91
+ * exclude exclusive ones; see `file-lock-shared.ts`.
92
+ */
93
+ mode?: LockMode | undefined;
87
94
  }
88
95
  /** Derives the sibling lock-directory path used to guard a file-based resource. */
89
96
  export declare function createSiblingLockPath(filePath: string, suffix?: string): string;
@@ -97,6 +104,8 @@ export interface LockStatusSnapshot {
97
104
  /** Milliseconds since the lock directory was created, when held. */
98
105
  heldForMs?: number | undefined;
99
106
  queueDepth: number;
107
+ /** Live shared holders (build-less test runs under a shared lock). */
108
+ sharedHolders?: number | undefined;
100
109
  }
101
110
  /**
102
111
  * Inspects a lock without acquiring it. Read-only and fail-open: a missing
@@ -9,6 +9,7 @@ import { verbose, warn } from '../utils/logger.js';
9
9
  import { sleep } from '../utils/sleep.js';
10
10
  import { isLockOwnerAlive, LOCK_PID_FILE, readLockOwner, writeLockOwner, } from './file-lock-owner.js';
11
11
  import { readLockQueue, registerWaiter, } from './file-lock-queue.js';
12
+ import { countLiveSharedHolders, registerSharedHold, releaseSharedHoldsForSignal, } from './file-lock-shared.js';
12
13
  export { LOCK_PID_FILE };
13
14
  export { formatLockQueuePosition } from './file-lock-queue.js';
14
15
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
@@ -38,6 +39,8 @@ export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
38
39
  */
39
40
  export async function readLockStatus(lockPath) {
40
41
  const queue = await readLockQueue(lockPath);
42
+ const shared = await countLiveSharedHolders(lockPath, { reap: false });
43
+ const sharedPart = shared > 0 ? { sharedHolders: shared } : {};
41
44
  let heldForMs;
42
45
  try {
43
46
  // Clamped: filesystem mtime resolution can round a just-created lock
@@ -46,13 +49,13 @@ export async function readLockStatus(lockPath) {
46
49
  heldForMs = Math.max(0, Date.now() - (await stat(lockPath)).mtimeMs);
47
50
  }
48
51
  catch {
49
- return { held: false, queueDepth: queue.depth };
52
+ return { held: false, queueDepth: queue.depth, ...sharedPart };
50
53
  }
51
54
  const owner = await readLockOwner(lockPath);
52
55
  const holder = owner.present
53
56
  ? { pid: owner.pid, alive: isLockOwnerAlive(owner), metadata: owner.metadata }
54
57
  : undefined;
55
- return { held: true, holder, heldForMs, queueDepth: queue.depth };
58
+ return { held: true, holder, heldForMs, queueDepth: queue.depth, ...sharedPart };
56
59
  }
57
60
  /**
58
61
  * Reads the contended lock's owner and queue in one go. One probe per cycle
@@ -334,6 +337,7 @@ export async function forceReleaseHeldLocksForSignal() {
334
337
  removed.push(lockPath);
335
338
  }
336
339
  }
340
+ removed.push(...(await releaseSharedHoldsForSignal()));
337
341
  return removed;
338
342
  }
339
343
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
@@ -467,8 +471,36 @@ export async function withFileLock(lockPath, operation, options = {}) {
467
471
  // `writeLockOwner` removes the just-created directory before throwing.
468
472
  const ownerToken = randomUUID();
469
473
  await writeLockOwner(lockPath, ownerToken, options.ownerMetadata);
474
+ return runWhileHeld(lockPath, ownerToken, operation, options, {
475
+ deadline: Math.max(deadline, Date.now() + pollMs),
476
+ pollMs,
477
+ });
478
+ }
479
+ /**
480
+ * Runs `operation` once the turnstile is ours: as a shared holder (turnstile
481
+ * released at once), or exclusively once every shared holder has drained.
482
+ */
483
+ async function runWhileHeld(lockPath, ownerToken, operation, options, drain) {
484
+ if (options.mode === 'shared') {
485
+ // The exclusive lock was only the turnstile: record the shared hold and
486
+ // let the next waiter in. See file-lock-shared.ts.
487
+ let hold;
488
+ try {
489
+ hold = await registerSharedHold(lockPath, options.ownerMetadata);
490
+ }
491
+ finally {
492
+ await releaseLock(lockPath, ownerToken);
493
+ }
494
+ try {
495
+ return await operation();
496
+ }
497
+ finally {
498
+ await hold.release();
499
+ }
500
+ }
470
501
  heldLocks.add(lockPath);
471
502
  try {
503
+ await waitForSharedHoldersToDrain(lockPath, drain.deadline, drain.pollMs);
472
504
  return await operation();
473
505
  }
474
506
  finally {
@@ -476,3 +508,27 @@ export async function withFileLock(lockPath, operation, options = {}) {
476
508
  await releaseLock(lockPath, ownerToken);
477
509
  }
478
510
  }
511
+ /**
512
+ * Holds an exclusive acquisition until every live shared holder has gone.
513
+ * The turnstile is already ours, so no new shared holder can join meanwhile.
514
+ *
515
+ * @throws LockContentionError when shared holders outlast the budget
516
+ */
517
+ async function waitForSharedHoldersToDrain(lockPath, deadline, pollMs) {
518
+ let announced = false;
519
+ for (;;) {
520
+ const live = await countLiveSharedHolders(lockPath);
521
+ if (live === 0)
522
+ return;
523
+ if (Date.now() >= deadline) {
524
+ throw new LockContentionError(`Timed out waiting for file lock ${lockPath}: ${live} shared holder(s) (build-less ` +
525
+ 'test runs under a shared lock) still hold it. Wait for them to finish, or retry ' +
526
+ 'with a larger --wait-lock.');
527
+ }
528
+ if (!announced) {
529
+ verbose(`Waiting for ${live} shared holder(s) of ${lockPath} to finish.`);
530
+ announced = true;
531
+ }
532
+ await sleep(Math.max(pollMs, 100));
533
+ }
534
+ }
@@ -17,7 +17,8 @@ import { topologicalSortCustom } from './furnace-graph-utils.js';
17
17
  import { resolveFurnaceMarkerComment } from './furnace-marker.js';
18
18
  import { recordFurnaceRollbackFailure } from './furnace-operation.js';
19
19
  import { assertFurnaceEngineDirReady } from './furnace-precondition.js';
20
- import { addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from './furnace-registration.js';
20
+ import { addJarMnEntries, removeCustomElementRegistration } from './furnace-registration.js';
21
+ import { removeJarMnFileEntries } from './furnace-registration-jar-inplace.js';
21
22
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotFile, } from './furnace-rollback.js';
22
23
  import { blockingStepErrors, hasBlockingStepErrors } from './furnace-step-errors.js';
23
24
  import { runPostApplyConsistencyChecks } from './furnace-validate-registration.js';
@@ -132,8 +133,8 @@ async function applyOverrideBatch(ctx) {
132
133
  /**
133
134
  * After undeploying deleted files, the in-engine jar.mn and
134
135
  * customElements.js still carry entries for the removed files. Re-sync them
135
- * by removing all of `name`'s jar.mn entries and re-adding only those that
136
- * still exist in the workspace. If the .mjs itself was deleted, also drop
136
+ * by removing only the deleted files' jar.mn lines in place and adding any
137
+ * live file that has none. If the .mjs itself was deleted, also drop
137
138
  * the customElements.js registration. Snapshots are taken under the same
138
139
  * journal so the undo path is symmetric with apply.
139
140
  */
@@ -144,18 +145,17 @@ async function reconcileCustomRegistrationAfterUndeploy(args) {
144
145
  const deletedRegistrationFiles = deletedFiles.filter((f) => f.endsWith('.mjs') || f.endsWith('.css'));
145
146
  if (deletedRegistrationFiles.length === 0)
146
147
  return;
147
- // jar.mn re-sync. addJarMnEntries is idempotent on duplicates but does not
148
- // drop stale entries, so we need a remove-then-add cycle. The journal
149
- // snapshot before each mutation gives us a clean rollback target.
148
+ // jar.mn re-sync: drop only the deleted files' lines, so the rest of the
149
+ // widget's block keeps its order and the owning patch carries no reorder
150
+ // hunks. addJarMnEntries is idempotent, so the add pass inserts only a
151
+ // live file that has no line yet. The journal snapshot before the
152
+ // mutation gives us a clean rollback target.
150
153
  if (rollbackJournal) {
151
154
  await snapshotFile(rollbackJournal, join(engineDir, JAR_MN));
152
155
  }
153
- await removeJarMnEntries(engineDir, name);
156
+ await removeJarMnFileEntries(engineDir, name, deletedRegistrationFiles);
154
157
  const liveJarFiles = Object.keys(currentChecksums).filter((f) => f.endsWith('.mjs') || f.endsWith('.css'));
155
158
  if (liveJarFiles.length > 0) {
156
- // applyCustomComponent has already added live entries, and the remove
157
- // above dropped them too, so re-add now to leave jar.mn in the correct
158
- // state.
159
159
  await addJarMnEntries(engineDir, name, liveJarFiles);
160
160
  }
161
161
  filesAffected.push(JAR_MN);
@@ -1,9 +1,16 @@
1
1
  import { type FurnaceOptInCheck, type FurnaceValidationException } from '../types/furnace.js';
2
2
  /**
3
- * Parses `validationExceptions[]`: `{ component, check, reason }` objects,
4
- * no other keys, every field a non-empty string, and no two entries for the
5
- * same component and check.
3
+ * Parses `validationExceptions[]`: `{ component, check, tag?, reason }`
4
+ * objects, no other keys, every field a non-empty string, and no two
5
+ * entries for the same component, check and tag.
6
6
  */
7
7
  export declare function parseValidationExceptions(raw: unknown): FurnaceValidationException[];
8
+ /**
9
+ * Parses `tokenSiblingSheets`: an object from a sheet glob to a non-empty
10
+ * list of partner paths or globs.
11
+ */
12
+ export declare function parseTokenSiblingSheets(raw: unknown): Record<string, string[]>;
13
+ /** Parses `minComponents`: a positive integer. */
14
+ export declare function parseMinComponents(raw: unknown): number;
8
15
  /** Parses `optInChecks[]`: names from {@link FURNACE_OPT_IN_CHECKS}. */
9
16
  export declare function parseOptInChecks(raw: unknown): FurnaceOptInCheck[];