@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
@@ -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,17 @@
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?, file?, reason }`
4
+ * objects, no other keys, every field a non-empty string, at most one of
5
+ * `tag` and `file` (a bare file name of the component), and no two entries
6
+ * for the same component, check and scope.
6
7
  */
7
8
  export declare function parseValidationExceptions(raw: unknown): FurnaceValidationException[];
9
+ /**
10
+ * Parses `tokenSiblingSheets`: an object from a sheet glob to a non-empty
11
+ * list of partner paths or globs.
12
+ */
13
+ export declare function parseTokenSiblingSheets(raw: unknown): Record<string, string[]>;
14
+ /** Parses `minComponents`: a positive integer. */
15
+ export declare function parseMinComponents(raw: unknown): number;
8
16
  /** Parses `optInChecks[]`: names from {@link FURNACE_OPT_IN_CHECKS}. */
9
17
  export declare function parseOptInChecks(raw: unknown): FurnaceOptInCheck[];
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Validators for the two `furnace validate` policy fields of furnace.json:
4
- * `validationExceptions[]` and `optInChecks[]`.
3
+ * Validators for the `furnace validate` and `token lint` policy fields of
4
+ * furnace.json: `validationExceptions[]`, `optInChecks[]`, `minComponents`
5
+ * and `tokenSiblingSheets`.
5
6
  *
6
7
  * Strict on purpose, like `buildAudit.unpackaged`: every exception is a
7
8
  * standing suppression of a finding, so a malformed one must fail at load
@@ -10,7 +11,7 @@
10
11
  import { FurnaceError } from '../errors/furnace.js';
11
12
  import { FURNACE_OPT_IN_CHECKS, } from '../types/furnace.js';
12
13
  import { isArray, isObject, isString } from '../utils/validation.js';
13
- const EXCEPTION_KEYS = new Set(['component', 'check', 'reason']);
14
+ const EXCEPTION_KEYS = new Set(['component', 'check', 'tag', 'file', 'reason']);
14
15
  function requireNonEmptyString(entry, key, field) {
15
16
  const value = entry[key];
16
17
  if (!isString(value) || value.trim().length === 0) {
@@ -19,9 +20,10 @@ function requireNonEmptyString(entry, key, field) {
19
20
  return value;
20
21
  }
21
22
  /**
22
- * Parses `validationExceptions[]`: `{ component, check, reason }` objects,
23
- * no other keys, every field a non-empty string, and no two entries for the
24
- * same component and check.
23
+ * Parses `validationExceptions[]`: `{ component, check, tag?, file?, reason }`
24
+ * objects, no other keys, every field a non-empty string, at most one of
25
+ * `tag` and `file` (a bare file name of the component), and no two entries
26
+ * for the same component, check and scope.
25
27
  */
26
28
  export function parseValidationExceptions(raw) {
27
29
  if (!isArray(raw)) {
@@ -43,9 +45,31 @@ export function parseValidationExceptions(raw) {
43
45
  check: requireNonEmptyString(entry, 'check', field),
44
46
  reason: requireNonEmptyString(entry, 'reason', field),
45
47
  };
46
- const identity = `${exception.component}\u0000${exception.check}`;
48
+ if (entry['tag'] !== undefined)
49
+ exception.tag = requireNonEmptyString(entry, 'tag', field);
50
+ if (entry['file'] !== undefined) {
51
+ if (exception.tag !== undefined) {
52
+ throw new FurnaceError(`Furnace config: "${field}" may name a "tag" or a "file", not both`);
53
+ }
54
+ const file = requireNonEmptyString(entry, 'file', field);
55
+ if (/[\\/]/.test(file) || file === '.' || file === '..') {
56
+ throw new FurnaceError(`Furnace config: "${field}.file" must be a file name in the component's directory, not a path`);
57
+ }
58
+ exception.file = file;
59
+ }
60
+ const scope = exception.tag !== undefined
61
+ ? `tag:${exception.tag}`
62
+ : exception.file !== undefined
63
+ ? `file:${exception.file}`
64
+ : '';
65
+ const identity = `${exception.component}\u0000${exception.check}\u0000${scope}`;
47
66
  if (seen.has(identity)) {
48
- throw new FurnaceError(`Furnace config: "${field}" repeats the exception for ${exception.component} [${exception.check}]`);
67
+ const on = exception.tag !== undefined
68
+ ? ` on <${exception.tag}>`
69
+ : exception.file !== undefined
70
+ ? ` in ${exception.file}`
71
+ : '';
72
+ throw new FurnaceError(`Furnace config: "${field}" repeats the exception for ${exception.component} [${exception.check}]${on}`);
49
73
  }
50
74
  seen.add(identity);
51
75
  return exception;
@@ -54,6 +78,33 @@ export function parseValidationExceptions(raw) {
54
78
  function isOptInCheck(value) {
55
79
  return FURNACE_OPT_IN_CHECKS.includes(value);
56
80
  }
81
+ /**
82
+ * Parses `tokenSiblingSheets`: an object from a sheet glob to a non-empty
83
+ * list of partner paths or globs.
84
+ */
85
+ export function parseTokenSiblingSheets(raw) {
86
+ if (!isObject(raw)) {
87
+ throw new FurnaceError('Furnace config: "tokenSiblingSheets" must be an object');
88
+ }
89
+ const out = {};
90
+ for (const [pattern, partners] of Object.entries(raw)) {
91
+ const valid = isArray(partners) &&
92
+ partners.length > 0 &&
93
+ partners.every((partner) => isString(partner) && partner.trim() !== '');
94
+ if (pattern.includes('..') || !valid || partners.some((p) => String(p).includes('..'))) {
95
+ throw new FurnaceError(`Furnace config: "tokenSiblingSheets.${pattern}" must be a non-empty list of engine-relative paths or globs (no "..")`);
96
+ }
97
+ out[pattern] = partners;
98
+ }
99
+ return out;
100
+ }
101
+ /** Parses `minComponents`: a positive integer. */
102
+ export function parseMinComponents(raw) {
103
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) {
104
+ throw new FurnaceError('Furnace config: "minComponents" must be a positive integer');
105
+ }
106
+ return raw;
107
+ }
57
108
  /** Parses `optInChecks[]`: names from {@link FURNACE_OPT_IN_CHECKS}. */
58
109
  export function parseOptInChecks(raw) {
59
110
  if (!isArray(raw)) {
@@ -8,7 +8,7 @@
8
8
  import { FurnaceError } from '../errors/furnace.js';
9
9
  import { isJsonObject, isString } from '../utils/validation.js';
10
10
  import { parseStringArray } from './furnace-config-array-utils.js';
11
- import { parseOptInChecks, parseValidationExceptions, } from './furnace-config-validate-exceptions.js';
11
+ import { parseMinComponents, parseOptInChecks, parseTokenSiblingSheets, parseValidationExceptions, } from './furnace-config-validate-exceptions.js';
12
12
  /**
13
13
  * Validates an override component config object.
14
14
  * @param data - Raw data to validate
@@ -94,6 +94,9 @@ export function applyOptionalFurnaceFields(migrated, config) {
94
94
  if (migrated['platformPrefixes'] !== undefined) {
95
95
  config.platformPrefixes = parseStringArray(migrated['platformPrefixes'], 'platformPrefixes');
96
96
  }
97
+ if (migrated['tokenSiblingSheets'] !== undefined) {
98
+ config.tokenSiblingSheets = parseTokenSiblingSheets(migrated['tokenSiblingSheets']);
99
+ }
97
100
  if (migrated['runtimeVariables'] !== undefined) {
98
101
  config.runtimeVariables = parseStringArray(migrated['runtimeVariables'], 'runtimeVariables');
99
102
  }
@@ -137,4 +140,7 @@ export function applyOptionalFurnaceFields(migrated, config) {
137
140
  if (migrated['optInChecks'] !== undefined) {
138
141
  config.optInChecks = parseOptInChecks(migrated['optInChecks']);
139
142
  }
143
+ if (migrated['minComponents'] !== undefined) {
144
+ config.minComponents = parseMinComponents(migrated['minComponents']);
145
+ }
140
146
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Removes the lines registering `files` for `tagName`, leaving every other
3
+ * line of the block where it is. Returns how many lines were removed.
4
+ */
5
+ export declare function removeJarMnFileEntries(engineDir: string, tagName: string, files: readonly string[]): Promise<number>;
6
+ /**
7
+ * Rewrites `oldTag`'s lines in place as `newTag`'s. A line whose renamed
8
+ * file is in `liveFiles` keeps its position and layout; one whose file is
9
+ * gone is dropped. Returns the renamed files that already had a line; the
10
+ * caller's `addJarMnEntries` pass then inserts only the rest.
11
+ */
12
+ export declare function renameJarMnEntriesInPlace(engineDir: string, oldTag: string, newTag: string, liveFiles: readonly string[], renameFile: (file: string) => string): Promise<string[]>;
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * In-place edits to a widget's `toolkit/content/jar.mn` block.
4
+ *
5
+ * Undeploying one file and renaming a component used to drop every line of
6
+ * the widget and re-add the live ones, which re-sorted the whole block and
7
+ * left the owning patch carrying reorder hunks. These helpers touch only
8
+ * the lines that change: a deleted file's line goes, a renamed component's
9
+ * lines are rewritten where they stand, and anything still missing is left
10
+ * for `addJarMnEntries` to insert.
11
+ */
12
+ import { join } from 'node:path';
13
+ import { pathExists, readText, writeText } from '../utils/fs.js';
14
+ import { escapeRegex } from '../utils/regex.js';
15
+ import { JAR_MN } from './furnace-constants.js';
16
+ /**
17
+ * The file a widget jar.mn line registers: the `(widgets/<tag>/<file>)`
18
+ * source mapping, or the legacy `content/global/elements/<tag>.<ext>`
19
+ * target written before the mapping existed. Undefined when the line is
20
+ * not one of this tag's.
21
+ */
22
+ function registeredFile(line, tagName) {
23
+ const tag = escapeRegex(tagName);
24
+ const source = new RegExp(`\\(widgets/${tag}/([^)]+)\\)`).exec(line);
25
+ if (source)
26
+ return source[1];
27
+ const legacy = new RegExp(`content/global/elements/(${tag}\\.[^\\s]+)`).exec(line);
28
+ return legacy?.[1];
29
+ }
30
+ /**
31
+ * Removes the lines registering `files` for `tagName`, leaving every other
32
+ * line of the block where it is. Returns how many lines were removed.
33
+ */
34
+ export async function removeJarMnFileEntries(engineDir, tagName, files) {
35
+ const filePath = join(engineDir, JAR_MN);
36
+ if (files.length === 0 || !(await pathExists(filePath)))
37
+ return 0;
38
+ const doomed = new Set(files);
39
+ const lines = (await readText(filePath)).split('\n');
40
+ const kept = lines.filter((line) => {
41
+ const file = registeredFile(line, tagName);
42
+ return file === undefined || !doomed.has(file);
43
+ });
44
+ const removed = lines.length - kept.length;
45
+ if (removed > 0)
46
+ await writeText(filePath, kept.join('\n'));
47
+ return removed;
48
+ }
49
+ /**
50
+ * Rewrites `oldTag`'s lines in place as `newTag`'s. A line whose renamed
51
+ * file is in `liveFiles` keeps its position and layout; one whose file is
52
+ * gone is dropped. Returns the renamed files that already had a line; the
53
+ * caller's `addJarMnEntries` pass then inserts only the rest.
54
+ */
55
+ export async function renameJarMnEntriesInPlace(engineDir, oldTag, newTag, liveFiles, renameFile) {
56
+ const filePath = join(engineDir, JAR_MN);
57
+ if (!(await pathExists(filePath)))
58
+ return [];
59
+ const live = new Set(liveFiles);
60
+ const covered = [];
61
+ const out = [];
62
+ for (const line of (await readText(filePath)).split('\n')) {
63
+ const file = registeredFile(line, oldTag);
64
+ if (file === undefined) {
65
+ out.push(line);
66
+ continue;
67
+ }
68
+ const renamed = renameFile(file);
69
+ if (!live.has(renamed) || covered.includes(renamed))
70
+ continue;
71
+ // Textual substitution keeps the indent, column spacing and any
72
+ // trailing comment the line carried.
73
+ out.push(line
74
+ .replace(`content/global/elements/${file}`, `content/global/elements/${renamed}`)
75
+ .replace(`(widgets/${oldTag}/${file})`, `(widgets/${newTag}/${renamed})`));
76
+ covered.push(renamed);
77
+ }
78
+ await writeText(filePath, out.join('\n'));
79
+ return covered;
80
+ }
@@ -1,7 +1,9 @@
1
1
  import type { CustomComponentConfig, FurnaceOptInCheck, ValidationIssue } from '../types/furnace.js';
2
2
  /**
3
- * Validates accessibility patterns in a component's .mjs file.
4
- * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
3
+ * Validates accessibility patterns in a component's composed source: its
4
+ * host module and every companion module (see
5
+ * {@link readComposedComponentSource}). Checks for ARIA roles, keyboard
6
+ * handlers, l10n, and focus delegation.
5
7
  *
6
8
  * @param componentDir - Directory holding the component's sources
7
9
  * @param tagName - Custom element tag name, used to locate `<tagName>.mjs`
@@ -32,6 +34,6 @@ export declare function validateAccessibility(componentDir: string, tagName: str
32
34
  * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
33
35
  *
34
36
  * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
35
- * findings (matches the pre-existing escape hatch).
37
+ * findings in that file (matches the pre-existing escape hatch).
36
38
  */
37
39
  export declare function containsHardcodedTemplateText(content: string): boolean;