@hominis/fireforge 0.42.0 → 0.43.1

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 (199) hide show
  1. package/CHANGELOG.md +76 -34
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/cli.js +21 -2
  4. package/dist/src/commands/build.js +7 -1
  5. package/dist/src/commands/config.js +16 -27
  6. package/dist/src/commands/discard.js +1 -1
  7. package/dist/src/commands/export-flow.js +13 -3
  8. package/dist/src/commands/export-placement-conflicts.js +1 -1
  9. package/dist/src/commands/export-shared.d.ts +1 -1
  10. package/dist/src/commands/export-shared.js +4 -5
  11. package/dist/src/commands/furnace/chrome-doc-remove.js +3 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +3 -0
  13. package/dist/src/commands/furnace/create.js +3 -0
  14. package/dist/src/commands/furnace/override.js +3 -0
  15. package/dist/src/commands/furnace/preview.js +3 -0
  16. package/dist/src/commands/furnace/refresh.js +3 -0
  17. package/dist/src/commands/furnace/remove.js +3 -0
  18. package/dist/src/commands/furnace/rename.js +3 -0
  19. package/dist/src/commands/furnace/scan.js +3 -0
  20. package/dist/src/commands/import.js +1 -1
  21. package/dist/src/commands/lint-per-patch.js +16 -8
  22. package/dist/src/commands/lint-per-run-checkjs.d.ts +4 -4
  23. package/dist/src/commands/lint-per-run-checkjs.js +4 -4
  24. package/dist/src/commands/lint-report.js +1 -1
  25. package/dist/src/commands/lint.js +2 -2
  26. package/dist/src/commands/patch/lint-ignore.js +1 -1
  27. package/dist/src/commands/patch/move-files-create.js +5 -3
  28. package/dist/src/commands/patch/move-files-into.d.ts +1 -1
  29. package/dist/src/commands/patch/move-files-into.js +5 -3
  30. package/dist/src/commands/patch/move-files.js +1 -1
  31. package/dist/src/commands/patch/rename.js +1 -1
  32. package/dist/src/commands/patch/reorder.d.ts +1 -2
  33. package/dist/src/commands/patch/reorder.js +1 -2
  34. package/dist/src/commands/patch/split.js +5 -3
  35. package/dist/src/commands/patch/staged-dependency-validate.js +1 -1
  36. package/dist/src/commands/patch/staged-dependency.js +4 -4
  37. package/dist/src/commands/re-export-adjacent.js +1 -1
  38. package/dist/src/commands/re-export-bulk-scan.d.ts +1 -1
  39. package/dist/src/commands/re-export-bulk-scan.js +5 -4
  40. package/dist/src/commands/re-export-drift.d.ts +4 -4
  41. package/dist/src/commands/re-export-drift.js +47 -8
  42. package/dist/src/commands/re-export-lint.d.ts +7 -5
  43. package/dist/src/commands/re-export-lint.js +9 -8
  44. package/dist/src/commands/re-export-single.d.ts +1 -1
  45. package/dist/src/commands/re-export-single.js +5 -4
  46. package/dist/src/commands/re-export.js +26 -14
  47. package/dist/src/commands/reset.js +1 -1
  48. package/dist/src/commands/run.js +1 -1
  49. package/dist/src/commands/source.js +6 -6
  50. package/dist/src/commands/status-check.d.ts +1 -1
  51. package/dist/src/commands/status-check.js +1 -1
  52. package/dist/src/commands/status-json.d.ts +2 -2
  53. package/dist/src/commands/status-json.js +1 -1
  54. package/dist/src/commands/status-output.d.ts +1 -1
  55. package/dist/src/commands/status-output.js +1 -1
  56. package/dist/src/commands/status.js +21 -7
  57. package/dist/src/commands/test-build-phase.d.ts +2 -2
  58. package/dist/src/commands/test-build-phase.js +9 -6
  59. package/dist/src/commands/test-diagnose.js +8 -1
  60. package/dist/src/commands/test-modes.d.ts +2 -2
  61. package/dist/src/commands/test-modes.js +3 -3
  62. package/dist/src/commands/test-register.js +7 -2
  63. package/dist/src/commands/test-run.d.ts +6 -0
  64. package/dist/src/commands/test-run.js +32 -3
  65. package/dist/src/commands/test-stale-gate.js +7 -2
  66. package/dist/src/commands/test.js +4 -4
  67. package/dist/src/commands/tree.js +2 -2
  68. package/dist/src/commands/typecheck.d.ts +2 -1
  69. package/dist/src/commands/typecheck.js +59 -3
  70. package/dist/src/commands/verify.js +26 -1
  71. package/dist/src/core/ast-utils.js +14 -2
  72. package/dist/src/core/browser-wire.js +9 -1
  73. package/dist/src/core/build-baseline-types.d.ts +1 -1
  74. package/dist/src/core/build-baseline.d.ts +1 -1
  75. package/dist/src/core/build-baseline.js +1 -1
  76. package/dist/src/core/build-overwrite-guard.d.ts +49 -0
  77. package/dist/src/core/build-overwrite-guard.js +165 -0
  78. package/dist/src/core/build-prepare.d.ts +13 -5
  79. package/dist/src/core/build-prepare.js +42 -9
  80. package/dist/src/core/config-mutate.d.ts +3 -2
  81. package/dist/src/core/config-mutate.js +5 -2
  82. package/dist/src/core/config.d.ts +3 -2
  83. package/dist/src/core/config.js +2 -0
  84. package/dist/src/core/coverage-extend.d.ts +1 -1
  85. package/dist/src/core/coverage-extend.js +1 -1
  86. package/dist/src/core/destructive.d.ts +23 -0
  87. package/dist/src/core/destructive.js +45 -4
  88. package/dist/src/core/diff-hunks.js +31 -17
  89. package/dist/src/core/discard-baseline.d.ts +1 -1
  90. package/dist/src/core/discard-baseline.js +1 -1
  91. package/dist/src/core/display-state.d.ts +52 -0
  92. package/dist/src/core/display-state.js +89 -0
  93. package/dist/src/core/engine-session-lock.d.ts +20 -0
  94. package/dist/src/core/engine-session-lock.js +84 -14
  95. package/dist/src/core/export-moved-code-shape.d.ts +60 -0
  96. package/dist/src/core/export-moved-code-shape.js +129 -0
  97. package/dist/src/core/file-lock.d.ts +44 -0
  98. package/dist/src/core/file-lock.js +157 -42
  99. package/dist/src/core/furnace-apply-helpers.d.ts +1 -1
  100. package/dist/src/core/furnace-apply-helpers.js +1 -1
  101. package/dist/src/core/furnace-apply-output.js +1 -1
  102. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +2 -2
  103. package/dist/src/core/furnace-apply-overwrite-warn.js +2 -2
  104. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +1 -1
  105. package/dist/src/core/furnace-apply-undeploy-actions.js +1 -1
  106. package/dist/src/core/furnace-apply.js +13 -1
  107. package/dist/src/core/furnace-config-custom.d.ts +2 -1
  108. package/dist/src/core/furnace-config-order.d.ts +2 -1
  109. package/dist/src/core/furnace-config-order.js +9 -4
  110. package/dist/src/core/furnace-config-validate.d.ts +4 -3
  111. package/dist/src/core/furnace-config-validate.js +3 -3
  112. package/dist/src/core/furnace-config.js +5 -0
  113. package/dist/src/core/furnace-operation.d.ts +12 -0
  114. package/dist/src/core/furnace-operation.js +125 -1
  115. package/dist/src/core/furnace-rollback.d.ts +17 -0
  116. package/dist/src/core/furnace-rollback.js +35 -1
  117. package/dist/src/core/git-base.js +7 -2
  118. package/dist/src/core/git-diff.js +11 -2
  119. package/dist/src/core/git-readonly-index.d.ts +59 -0
  120. package/dist/src/core/git-readonly-index.js +137 -0
  121. package/dist/src/core/license-headers.d.ts +1 -1
  122. package/dist/src/core/license-headers.js +1 -1
  123. package/dist/src/core/lint-cache.d.ts +15 -5
  124. package/dist/src/core/lint-cache.js +35 -8
  125. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  126. package/dist/src/core/mach-known-noise-filter.js +23 -0
  127. package/dist/src/core/mach-mozconfig.js +12 -2
  128. package/dist/src/core/module-resolution-preflight.d.ts +51 -0
  129. package/dist/src/core/module-resolution-preflight.js +155 -0
  130. package/dist/src/core/patch-export.js +2 -1
  131. package/dist/src/core/patch-identifier-suggest.js +29 -3
  132. package/dist/src/core/patch-lint-checkjs.d.ts +5 -5
  133. package/dist/src/core/patch-lint-checkjs.js +6 -6
  134. package/dist/src/core/patch-lint-creators.d.ts +1 -1
  135. package/dist/src/core/patch-lint-creators.js +1 -1
  136. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  137. package/dist/src/core/patch-lint-cross.js +4 -4
  138. package/dist/src/core/patch-lint-css.js +1 -1
  139. package/dist/src/core/patch-lint-diff.d.ts +1 -1
  140. package/dist/src/core/patch-lint-diff.js +1 -1
  141. package/dist/src/core/patch-lint-jsdoc.js +4 -2
  142. package/dist/src/core/patch-lint-module-registration.d.ts +13 -0
  143. package/dist/src/core/patch-lint-module-registration.js +13 -4
  144. package/dist/src/core/patch-lint-mozbuild.d.ts +1 -1
  145. package/dist/src/core/patch-lint-mozbuild.js +1 -1
  146. package/dist/src/core/patch-lint-ownership.d.ts +2 -2
  147. package/dist/src/core/patch-lint-ownership.js +2 -2
  148. package/dist/src/core/patch-lint.d.ts +2 -2
  149. package/dist/src/core/patch-lint.js +3 -3
  150. package/dist/src/core/patch-lock.d.ts +21 -0
  151. package/dist/src/core/patch-lock.js +29 -2
  152. package/dist/src/core/patch-manifest-io.d.ts +2 -1
  153. package/dist/src/core/patch-manifest-io.js +49 -0
  154. package/dist/src/core/patch-parse.d.ts +18 -0
  155. package/dist/src/core/patch-parse.js +24 -1
  156. package/dist/src/core/patch-policy.d.ts +2 -2
  157. package/dist/src/core/patch-policy.js +2 -2
  158. package/dist/src/core/smoke-patterns.d.ts +1 -1
  159. package/dist/src/core/status-binary.d.ts +1 -1
  160. package/dist/src/core/status-binary.js +1 -1
  161. package/dist/src/core/status-classify.d.ts +1 -1
  162. package/dist/src/core/status-classify.js +2 -2
  163. package/dist/src/core/test-harness-crash.d.ts +69 -3
  164. package/dist/src/core/test-harness-crash.js +174 -9
  165. package/dist/src/core/test-stale-check.d.ts +1 -1
  166. package/dist/src/core/test-stale-check.js +1 -1
  167. package/dist/src/core/token-category.js +1 -1
  168. package/dist/src/core/token-manager.d.ts +1 -1
  169. package/dist/src/core/token-manager.js +5 -5
  170. package/dist/src/core/tree-cow.js +1 -1
  171. package/dist/src/core/tree-guard.js +2 -2
  172. package/dist/src/core/tree-store.js +1 -1
  173. package/dist/src/core/typecheck-shim.d.ts +2 -2
  174. package/dist/src/core/typecheck-shim.js +2 -2
  175. package/dist/src/core/typecheck.js +10 -0
  176. package/dist/src/core/xpcshell-profile-dir.js +1 -1
  177. package/dist/src/errors/base.d.ts +22 -1
  178. package/dist/src/errors/base.js +32 -1
  179. package/dist/src/errors/codes.d.ts +10 -0
  180. package/dist/src/errors/codes.js +10 -0
  181. package/dist/src/types/commands/options.d.ts +27 -11
  182. package/dist/src/types/commands/patches.d.ts +1 -1
  183. package/dist/src/types/config.d.ts +2 -2
  184. package/dist/src/types/furnace.d.ts +1 -1
  185. package/dist/src/types/json.d.ts +22 -0
  186. package/dist/src/types/json.js +11 -0
  187. package/dist/src/utils/assert.d.ts +46 -0
  188. package/dist/src/utils/assert.js +83 -0
  189. package/dist/src/utils/build-info.d.ts +1 -1
  190. package/dist/src/utils/build-info.js +1 -1
  191. package/dist/src/utils/logger.d.ts +20 -0
  192. package/dist/src/utils/logger.js +26 -0
  193. package/dist/src/utils/options.d.ts +12 -0
  194. package/dist/src/utils/options.js +39 -0
  195. package/dist/src/utils/stdio-drain.d.ts +1 -1
  196. package/dist/src/utils/stdio-drain.js +1 -1
  197. package/dist/src/utils/validation.d.ts +12 -1
  198. package/dist/src/utils/validation.js +13 -1
  199. package/package.json +3 -3
@@ -0,0 +1,52 @@
1
+ /**
2
+ * macOS display power-state probe for the headed no-output stall.
3
+ *
4
+ * `TIMEOUT … application timed out after N seconds with no output` /
5
+ * `Ran 0 checks` has three recorded causes, and one of them is purely
6
+ * environmental: a HEADED run on an unattended machine whose display is
7
+ * asleep or locked. A headed Firefox on a sleeping display never paints,
8
+ * never reaches its first test, and dies at the no-output timeout —
9
+ * indistinguishable, from the log alone, from a product hang.
10
+ *
11
+ * `caffeinate -disu` does not cure it. It PREVENTS sleep; it cannot WAKE a
12
+ * display that is already asleep, so wrapping the run changes nothing once
13
+ * the machine has already dimmed. `fireforge test` defaults to headed, so
14
+ * an unattended run walks straight into this and the operator burns whole
15
+ * cycles hunting a product cause.
16
+ *
17
+ * The probe is advisory and fail-open: anything unexpected reports
18
+ * `'unknown'` and the caller degrades to the generic triage list rather
19
+ * than asserting an environment it could not measure.
20
+ */
21
+ /** Measured display power state, or `'unknown'` when nothing was measured. */
22
+ export type DisplaySleepState = 'asleep' | 'awake' | 'unknown';
23
+ /**
24
+ * Parses `pmset -g powerstate IODisplayWrangler` output.
25
+ *
26
+ * The command prints a header row and then one row per driver, e.g.
27
+ *
28
+ * ```
29
+ * Current power states:
30
+ * IODisplayWrangler 4 12 -1
31
+ * ```
32
+ *
33
+ * Only the `IODisplayWrangler` row matters, and only its first numeric
34
+ * column (the current power state). Any other shape — no such row, a
35
+ * non-numeric column, an error message — is `'unknown'`, never a guess.
36
+ *
37
+ * Pure; exported for direct unit testing.
38
+ *
39
+ * @param stdout - Raw `pmset -g powerstate IODisplayWrangler` output
40
+ * @returns The measured state, or `'unknown'`
41
+ */
42
+ export declare function parseDisplayPowerState(stdout: string): DisplaySleepState;
43
+ /**
44
+ * Probes the display's power state. Non-darwin platforms report
45
+ * `'unknown'` without spawning anything — the stall shape this serves is
46
+ * a macOS field report and no equivalent single-command probe is wired for
47
+ * other platforms.
48
+ *
49
+ * @param platform - `process.platform`-style id (injected for testability)
50
+ * @returns The measured state, or `'unknown'` when unmeasurable
51
+ */
52
+ export declare function probeDisplaySleepState(platform: string): Promise<DisplaySleepState>;
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * macOS display power-state probe for the headed no-output stall.
4
+ *
5
+ * `TIMEOUT … application timed out after N seconds with no output` /
6
+ * `Ran 0 checks` has three recorded causes, and one of them is purely
7
+ * environmental: a HEADED run on an unattended machine whose display is
8
+ * asleep or locked. A headed Firefox on a sleeping display never paints,
9
+ * never reaches its first test, and dies at the no-output timeout —
10
+ * indistinguishable, from the log alone, from a product hang.
11
+ *
12
+ * `caffeinate -disu` does not cure it. It PREVENTS sleep; it cannot WAKE a
13
+ * display that is already asleep, so wrapping the run changes nothing once
14
+ * the machine has already dimmed. `fireforge test` defaults to headed, so
15
+ * an unattended run walks straight into this and the operator burns whole
16
+ * cycles hunting a product cause.
17
+ *
18
+ * The probe is advisory and fail-open: anything unexpected reports
19
+ * `'unknown'` and the caller degrades to the generic triage list rather
20
+ * than asserting an environment it could not measure.
21
+ */
22
+ import { toError } from '../utils/errors.js';
23
+ import { verbose } from '../utils/logger.js';
24
+ import { exec } from '../utils/process.js';
25
+ /** Bound on the probe so a wedged `pmset` cannot extend a failing run. */
26
+ const DISPLAY_PROBE_TIMEOUT_MS = 5_000;
27
+ /**
28
+ * IOKit power state at which `IODisplayWrangler` is fully lit. Lower
29
+ * states are dimmed or off; macOS reports 4 for a live display.
30
+ */
31
+ const DISPLAY_AWAKE_POWER_STATE = 4;
32
+ /**
33
+ * Parses `pmset -g powerstate IODisplayWrangler` output.
34
+ *
35
+ * The command prints a header row and then one row per driver, e.g.
36
+ *
37
+ * ```
38
+ * Current power states:
39
+ * IODisplayWrangler 4 12 -1
40
+ * ```
41
+ *
42
+ * Only the `IODisplayWrangler` row matters, and only its first numeric
43
+ * column (the current power state). Any other shape — no such row, a
44
+ * non-numeric column, an error message — is `'unknown'`, never a guess.
45
+ *
46
+ * Pure; exported for direct unit testing.
47
+ *
48
+ * @param stdout - Raw `pmset -g powerstate IODisplayWrangler` output
49
+ * @returns The measured state, or `'unknown'`
50
+ */
51
+ export function parseDisplayPowerState(stdout) {
52
+ for (const line of stdout.split(/\r?\n/)) {
53
+ if (!line.includes('IODisplayWrangler'))
54
+ continue;
55
+ const match = /IODisplayWrangler\s+(\d+)/.exec(line);
56
+ const state = match?.[1];
57
+ if (state === undefined)
58
+ continue;
59
+ return Number.parseInt(state, 10) >= DISPLAY_AWAKE_POWER_STATE ? 'awake' : 'asleep';
60
+ }
61
+ return 'unknown';
62
+ }
63
+ /**
64
+ * Probes the display's power state. Non-darwin platforms report
65
+ * `'unknown'` without spawning anything — the stall shape this serves is
66
+ * a macOS field report and no equivalent single-command probe is wired for
67
+ * other platforms.
68
+ *
69
+ * @param platform - `process.platform`-style id (injected for testability)
70
+ * @returns The measured state, or `'unknown'` when unmeasurable
71
+ */
72
+ export async function probeDisplaySleepState(platform) {
73
+ if (platform !== 'darwin')
74
+ return 'unknown';
75
+ try {
76
+ const result = await exec('pmset', ['-g', 'powerstate', 'IODisplayWrangler'], {
77
+ timeout: DISPLAY_PROBE_TIMEOUT_MS,
78
+ });
79
+ if (result.exitCode !== 0) {
80
+ verbose(`Display power-state probe failed (pmset exit ${result.exitCode}).`);
81
+ return 'unknown';
82
+ }
83
+ return parseDisplayPowerState(result.stdout);
84
+ }
85
+ catch (error) {
86
+ verbose(`Display power-state probe failed: ${toError(error).message}`);
87
+ return 'unknown';
88
+ }
89
+ }
@@ -1,3 +1,4 @@
1
+ import { type LockStatusSnapshot } from './file-lock.js';
1
2
  export interface EngineSessionLockOptions {
2
3
  /**
3
4
  * Wait up to this many seconds for the engine session lock instead of the
@@ -12,6 +13,19 @@ export interface EngineSessionLockOptions {
12
13
  * one engine-mutating FireForge command touches `engine/` at a time.
13
14
  */
14
15
  export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>, options?: EngineSessionLockOptions): Promise<T>;
16
+ /**
17
+ * Inspects the project's engine-session lock without acquiring it —
18
+ * backing `fireforge status --lock`.
19
+ *
20
+ * @param projectRoot - Project root containing `.fireforge/`
21
+ * @returns Holder identity, hold duration, and queue depth
22
+ */
23
+ export declare function readEngineSessionLockStatus(projectRoot: string): Promise<LockStatusSnapshot>;
24
+ /**
25
+ * Renders the engine-session lock snapshot as operator-facing lines.
26
+ * Pure, so the wording is unit-testable without a lock on disk.
27
+ */
28
+ export declare function formatEngineSessionLockStatus(snapshot: LockStatusSnapshot): string[];
15
29
  /**
16
30
  * Captures an opaque token describing the engine's current commit and working
17
31
  * tree, for comparison by {@link assertEngineGenerationUnchanged}.
@@ -21,6 +35,12 @@ export declare function withEngineSessionLock<T>(projectRoot: string, command: s
21
35
  * because a probe failure means "nothing was measured", not "nothing changed".
22
36
  */
23
37
  export declare function snapshotEngineGeneration(engineDir: string): Promise<string>;
38
+ /**
39
+ * True when a git failure is the transient index-lock contention shape
40
+ * (`Unable to create '…/.git/index.lock': File exists`) rather than a
41
+ * durable problem with the checkout. Exported for direct unit testing.
42
+ */
43
+ export declare function isIndexLockError(error: unknown): boolean;
24
44
  /**
25
45
  * True when a generation token records a failed probe — i.e. nothing was
26
46
  * measured, so the token must never be compared (or hashed) as if it
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { GeneralError } from '../errors/base.js';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { info, warn } from '../utils/logger.js';
6
- import { withFileLock } from './file-lock.js';
6
+ import { readLockStatus, withFileLock } from './file-lock.js';
7
7
  import { git } from './git-base.js';
8
8
  const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
9
9
  /**
@@ -12,13 +12,20 @@ const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
12
12
  * the lock's owner-metadata lines (`command=…`, `started=…`); an unreadable
13
13
  * owner file degrades to the anonymous form.
14
14
  */
15
- function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
15
+ function formatWaitProgressLine(waitedMs, timeoutMs, holder, queue) {
16
16
  const progress = `${String(Math.round(waitedMs / 1000))}s of up to ${String(Math.round(timeoutMs / 1000))}s`;
17
+ // Queue position: under several concurrent sessions the wait
18
+ // line alone left operators inferring their place from `ps`.
19
+ const position = queue === undefined || queue.depth === 0
20
+ ? ''
21
+ : queue.ahead === 0
22
+ ? ` You are next in a queue of ${String(queue.depth)}.`
23
+ : ` ${String(queue.ahead)} ahead of you (queue of ${String(queue.depth)}).`;
17
24
  if (holder === undefined) {
18
- return `Waiting for the FireForge engine lock — ${progress}.`;
25
+ return `Waiting for the FireForge engine lock — ${progress}.${position}`;
19
26
  }
20
27
  const details = holder.metadata.length > 0 ? ` (${holder.metadata.join(', ')})` : '';
21
- return `Waiting for the FireForge engine lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
28
+ return `Waiting for the FireForge engine lock held by PID ${String(holder.pid)}${details} — ${progress}.${position}`;
22
29
  }
23
30
  /**
24
31
  * Runs `operation` while holding the project's engine-session lock, so only
@@ -43,13 +50,13 @@ export async function withEngineSessionLock(projectRoot, command, operation, opt
43
50
  pollMs: 100,
44
51
  pollMaxMs: 2000,
45
52
  waitProgressMs: 5000,
46
- onWaitProgress: ({ waitedMs, timeoutMs, holder }) => {
47
- info(formatWaitProgressLine(waitedMs, timeoutMs, holder));
53
+ onWaitProgress: ({ waitedMs, timeoutMs, holder, queue }) => {
54
+ info(formatWaitProgressLine(waitedMs, timeoutMs, holder, queue));
48
55
  },
49
56
  }
50
57
  : {}),
51
58
  ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`],
52
- // Reason first, remedy second (FORGE H5) — the no-flag default waits
59
+ // Reason first, remedy second — the no-flag default waits
53
60
  // only ~1 s, so contention is the common case and `--wait-lock` is the
54
61
  // genuine remedy. withFileLock appends the holder identification from
55
62
  // the lock's owner metadata. The leading sentence is a message contract
@@ -60,6 +67,37 @@ export async function withEngineSessionLock(projectRoot, command, operation, opt
60
67
  onStaleLockMessage: (ageMs) => `Removed stale FireForge engine session lock (${Math.round(ageMs / 1000)}s old).`,
61
68
  });
62
69
  }
70
+ /**
71
+ * Inspects the project's engine-session lock without acquiring it —
72
+ * backing `fireforge status --lock`.
73
+ *
74
+ * @param projectRoot - Project root containing `.fireforge/`
75
+ * @returns Holder identity, hold duration, and queue depth
76
+ */
77
+ export async function readEngineSessionLockStatus(projectRoot) {
78
+ return readLockStatus(join(projectRoot, ENGINE_SESSION_LOCK_PATH));
79
+ }
80
+ /**
81
+ * Renders the engine-session lock snapshot as operator-facing lines.
82
+ * Pure, so the wording is unit-testable without a lock on disk.
83
+ */
84
+ export function formatEngineSessionLockStatus(snapshot) {
85
+ if (!snapshot.held) {
86
+ return ['Engine session lock: free (no FireForge engine-mutating command is running).'];
87
+ }
88
+ const elapsed = snapshot.heldForMs === undefined
89
+ ? 'unknown duration'
90
+ : `${String(Math.round(snapshot.heldForMs / 1000))}s`;
91
+ const holder = snapshot.holder;
92
+ const identity = holder === undefined
93
+ ? 'held by an unidentified process (no readable owner record)'
94
+ : `held by PID ${String(holder.pid)}${holder.alive ? '' : ' (NOT RUNNING — stale lock)'}` +
95
+ (holder.metadata.length > 0 ? ` (${holder.metadata.join(', ')})` : '');
96
+ return [
97
+ `Engine session lock: ${identity}, for ${elapsed}.`,
98
+ `Queue depth: ${String(snapshot.queueDepth)} waiter(s).`,
99
+ ];
100
+ }
63
101
  /** Prefix marking a generation token whose probe failed. */
64
102
  const UNAVAILABLE_PREFIX = 'unavailable:';
65
103
  /**
@@ -71,14 +109,46 @@ const UNAVAILABLE_PREFIX = 'unavailable:';
71
109
  * because a probe failure means "nothing was measured", not "nothing changed".
72
110
  */
73
111
  export async function snapshotEngineGeneration(engineDir) {
74
- try {
75
- const head = (await git(['rev-parse', 'HEAD'], engineDir)).trim();
76
- const status = await git(['status', '--porcelain=v1', '-z'], engineDir);
77
- return `${head}\0${status}`;
78
- }
79
- catch (error) {
80
- return `${UNAVAILABLE_PREFIX}${toError(error).message}`;
112
+ let lastError;
113
+ for (let attempt = 0; attempt < GENERATION_PROBE_ATTEMPTS; attempt += 1) {
114
+ try {
115
+ const head = (await git(['rev-parse', 'HEAD'], engineDir)).trim();
116
+ const status = await git(['status', '--porcelain=v1', '-z'], engineDir);
117
+ return `${head}\0${status}`;
118
+ }
119
+ catch (error) {
120
+ lastError = error;
121
+ // A contended `.git/index.lock` is TRANSIENT, not a state: some
122
+ // other writer held the index for the instant we looked.
123
+ // Treating it as "unmeasurable" turns a perfectly good suite into
124
+ // `FAIL reason=inconclusive` on a one-sided probe failure, which is
125
+ // exactly the false alarm this guard must not raise. Anything else
126
+ // — an unreadable `.git`, a non-git `engine/` — is a real state and
127
+ // is reported immediately.
128
+ if (!isIndexLockError(error) || attempt === GENERATION_PROBE_ATTEMPTS - 1)
129
+ break;
130
+ await delay(GENERATION_PROBE_RETRY_MS);
131
+ }
81
132
  }
133
+ return `${UNAVAILABLE_PREFIX}${toError(lastError).message}`;
134
+ }
135
+ /** Probe attempts before an engine generation is declared unmeasurable. */
136
+ const GENERATION_PROBE_ATTEMPTS = 3;
137
+ /** Pause between generation-probe attempts. */
138
+ const GENERATION_PROBE_RETRY_MS = 250;
139
+ function delay(ms) {
140
+ return new Promise((resolveDelay) => {
141
+ setTimeout(resolveDelay, ms);
142
+ });
143
+ }
144
+ /**
145
+ * True when a git failure is the transient index-lock contention shape
146
+ * (`Unable to create '…/.git/index.lock': File exists`) rather than a
147
+ * durable problem with the checkout. Exported for direct unit testing.
148
+ */
149
+ export function isIndexLockError(error) {
150
+ const message = toError(error).message;
151
+ return /index\.lock/i.test(message);
82
152
  }
83
153
  /**
84
154
  * True when a generation token records a failed probe — i.e. nothing was
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Detects the new-file-plus-moved-code slice shape and names the working
3
+ * sequence through it.
4
+ *
5
+ * A slice that ADDS new files and MOVES lines into them from an existing
6
+ * patch has no path through the two refusal flags:
7
+ *
8
+ * - `re-export --refuse-adjacent-unmanaged` refuses while the new files
9
+ * are still unmanaged; and
10
+ * - exporting the new files as their own patch fails cross-patch lint,
11
+ * because at the projected placement the OLD patch still contains the
12
+ * moved code.
13
+ *
14
+ * Each guard is individually correct. Together they dead-end, and the way
15
+ * out is non-obvious: adopt the new files into the OLD patch first
16
+ * (`re-export --scan --scan-file`), then split them out into a patch of
17
+ * their own (`patch move-files --create --order N`).
18
+ *
19
+ * This module recognises the shape from evidence FireForge already has —
20
+ * the pending diff's new-file content versus the added lines the existing
21
+ * patches carry — so the refusal can name the sequence instead of leaving
22
+ * the operator to derive it.
23
+ */
24
+ /** One existing patch that already carries lines the new files would add. */
25
+ export interface MovedCodeOverlap {
26
+ /** Filename of the patch that still contains the moved lines. */
27
+ sourcePatch: string;
28
+ /** New-file paths in the pending diff whose lines it overlaps. */
29
+ files: string[];
30
+ /** Count of distinct non-trivial lines shared. */
31
+ sharedLines: number;
32
+ }
33
+ /** Minimal queue view this detector needs. */
34
+ export interface MovedCodeQueueEntry {
35
+ filename: string;
36
+ diff: string;
37
+ }
38
+ /**
39
+ * Finds existing patches that still carry substantive lines the pending
40
+ * diff's NEW files would add — the fingerprint of code moved out of an
41
+ * existing patch into a new file.
42
+ *
43
+ * Pure. Overlaps are returned strongest-first so the message names the
44
+ * most likely source patch.
45
+ *
46
+ * @param pendingDiff - The diff the export would write
47
+ * @param entries - The existing queue's patches
48
+ * @returns Overlaps, or an empty list when the shape is not present
49
+ */
50
+ export declare function detectMovedCodeOverlaps(pendingDiff: string, entries: readonly MovedCodeQueueEntry[]): MovedCodeOverlap[];
51
+ /**
52
+ * Renders the adopt-then-split remedy for the strongest overlap, as lines
53
+ * appended to the refusal's detail list.
54
+ *
55
+ * @param overlaps - Result of {@link detectMovedCodeOverlaps}
56
+ * @param newPatchName - The filename the pending export would take
57
+ * @param insertionOrder - The order the pending export would take
58
+ * @returns Detail lines, or an empty list when the shape is not present
59
+ */
60
+ export declare function formatAdoptThenSplitRemedy(overlaps: readonly MovedCodeOverlap[], newPatchName: string, insertionOrder: number): string[];
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Detects the new-file-plus-moved-code slice shape and names the working
4
+ * sequence through it.
5
+ *
6
+ * A slice that ADDS new files and MOVES lines into them from an existing
7
+ * patch has no path through the two refusal flags:
8
+ *
9
+ * - `re-export --refuse-adjacent-unmanaged` refuses while the new files
10
+ * are still unmanaged; and
11
+ * - exporting the new files as their own patch fails cross-patch lint,
12
+ * because at the projected placement the OLD patch still contains the
13
+ * moved code.
14
+ *
15
+ * Each guard is individually correct. Together they dead-end, and the way
16
+ * out is non-obvious: adopt the new files into the OLD patch first
17
+ * (`re-export --scan --scan-file`), then split them out into a patch of
18
+ * their own (`patch move-files --create --order N`).
19
+ *
20
+ * This module recognises the shape from evidence FireForge already has —
21
+ * the pending diff's new-file content versus the added lines the existing
22
+ * patches carry — so the refusal can name the sequence instead of leaving
23
+ * the operator to derive it.
24
+ */
25
+ import { extractAddedLinesPerFile } from './patch-lint-diff.js';
26
+ /**
27
+ * Lines too generic to be evidence of a move: closing braces, blank
28
+ * lines, bare keywords, import punctuation. Requiring substance keeps the
29
+ * detector from firing on every patch that happens to contain a `}`.
30
+ */
31
+ function isSubstantiveLine(line) {
32
+ const trimmed = line.trim();
33
+ if (trimmed.length < 12)
34
+ return false;
35
+ if (/^[)\]};,]+$/.test(trimmed))
36
+ return false;
37
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*'))
38
+ return false;
39
+ return true;
40
+ }
41
+ /** Minimum shared substantive lines before the shape is claimed. */
42
+ const MOVED_CODE_LINE_THRESHOLD = 3;
43
+ /**
44
+ * Finds existing patches that still carry substantive lines the pending
45
+ * diff's NEW files would add — the fingerprint of code moved out of an
46
+ * existing patch into a new file.
47
+ *
48
+ * Pure. Overlaps are returned strongest-first so the message names the
49
+ * most likely source patch.
50
+ *
51
+ * @param pendingDiff - The diff the export would write
52
+ * @param entries - The existing queue's patches
53
+ * @returns Overlaps, or an empty list when the shape is not present
54
+ */
55
+ export function detectMovedCodeOverlaps(pendingDiff, entries) {
56
+ const pendingByFile = extractAddedLinesPerFile(pendingDiff);
57
+ const pendingLines = new Map();
58
+ for (const [file, lines] of pendingByFile) {
59
+ const substantive = new Set(lines.map((line) => line.trim()).filter(isSubstantiveLine));
60
+ if (substantive.size > 0)
61
+ pendingLines.set(file, substantive);
62
+ }
63
+ if (pendingLines.size === 0)
64
+ return [];
65
+ const overlaps = [];
66
+ for (const entry of entries) {
67
+ const existing = new Set();
68
+ for (const lines of extractAddedLinesPerFile(entry.diff).values()) {
69
+ for (const line of lines) {
70
+ const trimmed = line.trim();
71
+ if (isSubstantiveLine(trimmed))
72
+ existing.add(trimmed);
73
+ }
74
+ }
75
+ if (existing.size === 0)
76
+ continue;
77
+ const files = [];
78
+ const shared = new Set();
79
+ for (const [file, lines] of pendingLines) {
80
+ let fileShared = 0;
81
+ for (const line of lines) {
82
+ if (!existing.has(line))
83
+ continue;
84
+ shared.add(line);
85
+ fileShared += 1;
86
+ }
87
+ if (fileShared > 0)
88
+ files.push(file);
89
+ }
90
+ if (shared.size >= MOVED_CODE_LINE_THRESHOLD && files.length > 0) {
91
+ overlaps.push({ sourcePatch: entry.filename, files: files.sort(), sharedLines: shared.size });
92
+ }
93
+ }
94
+ return overlaps.sort((a, b) => b.sharedLines - a.sharedLines || a.sourcePatch.localeCompare(b.sourcePatch));
95
+ }
96
+ /**
97
+ * Renders the adopt-then-split remedy for the strongest overlap, as lines
98
+ * appended to the refusal's detail list.
99
+ *
100
+ * @param overlaps - Result of {@link detectMovedCodeOverlaps}
101
+ * @param newPatchName - The filename the pending export would take
102
+ * @param insertionOrder - The order the pending export would take
103
+ * @returns Detail lines, or an empty list when the shape is not present
104
+ */
105
+ export function formatAdoptThenSplitRemedy(overlaps, newPatchName, insertionOrder) {
106
+ const strongest = overlaps[0];
107
+ if (strongest === undefined)
108
+ return [];
109
+ const fileArgs = strongest.files.map((file) => `--scan-file ${file}`).join(' ');
110
+ const moveArgs = strongest.files.map((file) => `--file ${file}`).join(' ');
111
+ // `patch move-files --create` takes the new patch's NAME, not a
112
+ // filename: strip the order prefix and the `.patch` suffix so the
113
+ // suggested command is copy-pasteable.
114
+ const suggestedName = newPatchName.replace(/^\d+-/, '').replace(/\.patch$/, '');
115
+ return [
116
+ '',
117
+ `This looks like a new-file + moved-code slice: ${String(strongest.sharedLines)} substantive ` +
118
+ `line(s) the new file(s) would add are still carried by ${strongest.sourcePatch}. ` +
119
+ 'Exporting them as their own patch cannot pass cross-patch lint while the source patch ' +
120
+ 'still owns the same code, and re-exporting cannot adopt them while they are unmanaged — ' +
121
+ 'each guard is right, and together they dead-end.',
122
+ 'Working sequence (adopt, then split):',
123
+ ` 1. fireforge re-export ${strongest.sourcePatch} --scan ${fileArgs}`,
124
+ ` 2. fireforge patch move-files ${strongest.sourcePatch} ${suggestedName} --create --order ${String(insertionOrder)} ${moveArgs}`,
125
+ 'Step 1 adopts the new files into the patch that already owns the moved lines; step 2 ' +
126
+ 'splits them back out as one transaction, so the queue is never in a state where two ' +
127
+ 'patches own the same code.',
128
+ ];
129
+ }
@@ -9,6 +9,21 @@ export interface LockHolder {
9
9
  alive: boolean;
10
10
  metadata: string[];
11
11
  }
12
+ /**
13
+ * Live queue state around a contended lock.
14
+ *
15
+ * Under several concurrent sessions the lock itself behaved correctly; the
16
+ * gap was VISIBILITY — operators inferred queue state from `ps` and their
17
+ * own wait lines. Waiters advertise themselves in a sibling directory so
18
+ * both the wait output and `fireforge status --lock` can say how many are
19
+ * queued and how many are ahead of you.
20
+ */
21
+ export interface LockQueueState {
22
+ /** Live waiters currently queued for the lock, including this process. */
23
+ depth: number;
24
+ /** Live waiters that started waiting BEFORE this process. */
25
+ ahead: number;
26
+ }
12
27
  export interface FileLockOptions {
13
28
  timeoutMs?: number;
14
29
  pollMs?: number;
@@ -40,6 +55,8 @@ export interface FileLockOptions {
40
55
  waitedMs: number;
41
56
  timeoutMs: number;
42
57
  holder: LockHolder | undefined;
58
+ /** Queue state at this poll, when the waiter registry is available. */
59
+ queue?: LockQueueState | undefined;
43
60
  }) => void;
44
61
  onTimeoutMessage?: string;
45
62
  onStaleLockMessage?: (ageMs: number) => string | undefined;
@@ -55,5 +72,32 @@ export declare function createSiblingLockPath(filePath: string, suffix?: string)
55
72
  * re-spelling `'pid'`; the file *format* is documented on {@link readLockOwner}.
56
73
  */
57
74
  export declare const LOCK_PID_FILE = "pid";
75
+ /**
76
+ * Reads the live waiter queue around `lockPath`, dropping entries whose
77
+ * process is gone (a killed waiter must not inflate the depth forever).
78
+ *
79
+ * Advisory and fail-open: an unreadable registry reports an empty queue
80
+ * rather than failing whatever asked.
81
+ *
82
+ * @param lockPath - The lock directory whose queue to inspect
83
+ * @param selfStartedAt - This process's wait start, to compute `ahead`
84
+ */
85
+ export declare function readLockQueue(lockPath: string, selfStartedAt?: number): Promise<LockQueueState>;
86
+ /**
87
+ * Snapshot of a lock for `fireforge status --lock`: who holds
88
+ * it, for how long, and how deep the queue behind it is.
89
+ */
90
+ export interface LockStatusSnapshot {
91
+ held: boolean;
92
+ holder?: LockHolder | undefined;
93
+ /** Milliseconds since the lock directory was created, when held. */
94
+ heldForMs?: number | undefined;
95
+ queueDepth: number;
96
+ }
97
+ /**
98
+ * Inspects a lock without acquiring it. Read-only and fail-open: a missing
99
+ * lock is reported as free, never as an error.
100
+ */
101
+ export declare function readLockStatus(lockPath: string): Promise<LockStatusSnapshot>;
58
102
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
59
103
  export declare function withFileLock<T>(lockPath: string, operation: () => Promise<T>, options?: FileLockOptions): Promise<T>;