@hominis/fireforge 0.42.0 → 0.43.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 (199) hide show
  1. package/CHANGELOG.md +75 -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 +112 -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
@@ -2,14 +2,32 @@
2
2
  import { rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { FurnaceError } from '../errors/furnace.js';
5
+ import { assert } from '../utils/assert.js';
5
6
  import { toError } from '../utils/errors.js';
6
7
  import { verbose, warn } from '../utils/logger.js';
7
8
  import { FIREFORGE_DIR } from './config-paths.js';
8
- import { withFileLock } from './file-lock.js';
9
+ import { readLockStatus, withFileLock } from './file-lock.js';
9
10
  import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
10
11
  import { restoreRollbackJournal } from './furnace-rollback.js';
11
12
  /** Sidecar lock filename used to serialize concurrent furnace mutations. */
12
13
  const FURNACE_LOCK_FILENAME = 'furnace.lock';
14
+ /**
15
+ * Claims the rollback for one operation, or reports that another path
16
+ * already has it.
17
+ *
18
+ * Must stay synchronous and must be called before the caller's first
19
+ * await — that is what makes the claim atomic against the signal handler.
20
+ *
21
+ * @param operation - The in-flight operation to claim
22
+ * @returns True when the caller now owns the rollback
23
+ */
24
+ function claimRollback(operation) {
25
+ if (operation.rollbackState !== undefined) {
26
+ return false;
27
+ }
28
+ operation.rollbackState = 'in-flight';
29
+ return true;
30
+ }
13
31
  const activeOperations = new Map();
14
32
  let nextOperationToken = 1;
15
33
  let signalRollbackInFlight = false;
@@ -69,6 +87,13 @@ export async function rollbackActiveOperationsForSignal(signal) {
69
87
  signalRollbackInFlight = true;
70
88
  warn(`Received ${signal}; rolling back in-flight furnace mutations…`);
71
89
  for (const op of snapshot) {
90
+ // The body may already be rolling back on its own throw path. Claim the
91
+ // operation (or skip it) before the first await so the two paths cannot
92
+ // both restore the same journal.
93
+ if (!claimRollback(op)) {
94
+ verbose(`Rollback for ${op.kind} already in progress; leaving it to the throw path.`);
95
+ continue;
96
+ }
72
97
  const cleanupErrors = [];
73
98
  // Run extra cleanup callbacks first (e.g. preview's cleanStories), so the
74
99
  // engine is in its tidiest possible shape before the journal restore
@@ -98,6 +123,7 @@ export async function rollbackActiveOperationsForSignal(signal) {
98
123
  catch (error) {
99
124
  rollbackError = toError(error).message;
100
125
  }
126
+ op.rollbackState = 'done';
101
127
  // A clean signal-driven rollback is not itself a repairable problem:
102
128
  // preview/apply/deploy/remove were interrupted, but the engine was restored
103
129
  // successfully and the next `doctor` run should remain green. Persist a
@@ -119,6 +145,75 @@ export async function rollbackActiveOperationsForSignal(signal) {
119
145
  }
120
146
  }
121
147
  }
148
+ /**
149
+ * Rolls back one operation whose body threw, mirroring the signal path.
150
+ *
151
+ * Never throws: the body's own error is what the operator needs to see, so
152
+ * a rollback failure becomes a `pendingRepair` marker plus a warning
153
+ * rather than replacing it. Skips entirely when the body already restored
154
+ * its own journal and said so via `ctx.markRolledBack()`.
155
+ *
156
+ * @param operation - The in-flight operation whose body threw
157
+ */
158
+ async function rollbackOperationForThrow(operation) {
159
+ if (!claimRollback(operation)) {
160
+ return;
161
+ }
162
+ const cleanupErrors = [];
163
+ // Cleanups before the journal restore, for the same reason the signal path
164
+ // does it in that order: the restore writes original contents back, and it
165
+ // should do so over the tidiest possible tree.
166
+ for (const cleanup of operation.cleanups) {
167
+ try {
168
+ await withTimeout(cleanup(), SIGNAL_ROLLBACK_TIMEOUT_MS, 'Cleanup callback');
169
+ }
170
+ catch (error) {
171
+ cleanupErrors.push(toError(error).message);
172
+ }
173
+ }
174
+ if (!operation.journal) {
175
+ // Nothing was captured, so there is nothing to restore. Unlike the signal
176
+ // path we do NOT write a pendingRepair marker here: a body that threw
177
+ // before registering a journal is overwhelmingly a refusal raised during
178
+ // pre-flight (a validation failure, a missing file), and marking the root
179
+ // as needing repair would block every later furnace mutation behind a
180
+ // `doctor --repair-furnace` that has nothing to reconcile. Bodies that
181
+ // genuinely mutate before registering a journal are the ones the
182
+ // journal-before-mutation assertions catch.
183
+ operation.rollbackState = 'done';
184
+ if (cleanupErrors.length > 0) {
185
+ warn(`Cleanup after a failed ${operation.kind} reported: ${cleanupErrors.join('; ')}`);
186
+ }
187
+ return;
188
+ }
189
+ let rollbackError;
190
+ try {
191
+ await withTimeout(restoreRollbackJournal(operation.journal), SIGNAL_ROLLBACK_TIMEOUT_MS, 'Rollback journal restore');
192
+ }
193
+ catch (error) {
194
+ rollbackError = toError(error).message;
195
+ }
196
+ operation.rollbackState = 'done';
197
+ if (!rollbackError && cleanupErrors.length === 0) {
198
+ verbose(`Rolled back ${operation.kind} after a failure; engine restored.`);
199
+ return;
200
+ }
201
+ const reasonParts = [`${operation.kind} failed`];
202
+ if (rollbackError) {
203
+ reasonParts.push(`automatic rollback failed: ${rollbackError}`);
204
+ warn(`Automatic rollback after a failed ${operation.kind} did not complete: ${rollbackError}. ` +
205
+ 'Run "fireforge doctor --repair-furnace" to reconcile.');
206
+ }
207
+ else {
208
+ reasonParts.push('automatic rollback succeeded');
209
+ }
210
+ if (cleanupErrors.length > 0) {
211
+ reasonParts.push(`cleanup errors: ${cleanupErrors.join('; ')}`);
212
+ }
213
+ await persistPendingRepair(operation.root, operation.kind, reasonParts.join('; ')).catch((error) => {
214
+ warn(`Could not persist pending-repair marker: ${toError(error).message}`);
215
+ });
216
+ }
122
217
  async function persistPendingRepair(root, operation, reason) {
123
218
  await updateFurnaceState(root, (state) => ({
124
219
  ...state,
@@ -189,6 +284,7 @@ export async function runFurnaceMutation(root, kind, body, options = {}) {
189
284
  return body({
190
285
  registerJournal: () => undefined,
191
286
  registerCleanup: () => undefined,
287
+ markRolledBack: () => undefined,
192
288
  });
193
289
  }
194
290
  // Pre-flight: refuse to mutate when a previous operation left the engine in
@@ -214,6 +310,16 @@ export async function runFurnaceMutation(root, kind, body, options = {}) {
214
310
  'A previous fireforge process may have crashed.',
215
311
  };
216
312
  return withFileLock(lockPath, async () => {
313
+ // Everything the body does from here is serialized only by the furnace
314
+ // lock, so confirm we are actually inside it before registering as a
315
+ // live mutation. The owner record is deliberately best-effort in
316
+ // `withFileLock` (a live holder can legitimately have no readable PID),
317
+ // so the PID half only tightens the check when a record is present —
318
+ // asserting on its existence would fire on a lock we really do hold.
319
+ const lockStatus = await readLockStatus(lockPath);
320
+ assert(lockStatus.held, 'furnace lock held before the mutation body runs');
321
+ assert(lockStatus.holder === undefined || lockStatus.holder.pid === process.pid, () => `furnace lock is owned by this process ` +
322
+ `(held by PID ${String(lockStatus.holder?.pid)}, we are ${String(process.pid)})`);
217
323
  activeOperations.set(token, operation);
218
324
  try {
219
325
  return await body({
@@ -223,8 +329,26 @@ export async function runFurnaceMutation(root, kind, body, options = {}) {
223
329
  registerCleanup: (cleanup) => {
224
330
  operation.cleanups.push(cleanup);
225
331
  },
332
+ markRolledBack: () => {
333
+ operation.rollbackState = 'done';
334
+ },
226
335
  });
227
336
  }
337
+ catch (error) {
338
+ // A thrown error leaves the engine mutated exactly as a signal does,
339
+ // so it gets the same treatment. Before 0.43.0 this was a bare
340
+ // finally: the throw path deregistered the operation and marked it
341
+ // completed, which removed it from the signal handler's view too, so
342
+ // a body that threw outside its own catch (notably anywhere in
343
+ // `applyAllComponents` outside its two per-component try blocks) left
344
+ // the checkout torn with no marker.
345
+ //
346
+ // This runs inside the withFileLock callback, so the furnace lock is
347
+ // still held for the duration of the restore — a competing process
348
+ // cannot start mutating half-way through it.
349
+ await rollbackOperationForThrow(operation);
350
+ throw error;
351
+ }
228
352
  finally {
229
353
  operation.completed = true;
230
354
  activeOperations.delete(token);
@@ -24,6 +24,23 @@ export declare function recordCreatedDir(journal: RollbackJournal, dirPath: stri
24
24
  export declare function snapshotDir(journal: RollbackJournal, dirPath: string): Promise<void>;
25
25
  /** Snapshots a file once so rollback can restore its previous contents or absence. */
26
26
  export declare function snapshotFile(journal: RollbackJournal, filePath: string): Promise<void>;
27
+ /**
28
+ * Asserts that a file about to be mutated was already snapshotted into the
29
+ * journal that will undo the mutation.
30
+ *
31
+ * The snapshot list and the mutation list are separately maintained in every
32
+ * caller that writes conditionally, and they drift: a new conditional write
33
+ * whose matching snapshot condition was not updated produces a file the
34
+ * rollback cannot restore, and nothing notices until a real failure. This
35
+ * checks the pairing at the point of the write, where the two lists are
36
+ * supposed to agree.
37
+ *
38
+ * @param journal - The journal that will restore this file
39
+ * @param filePath - Absolute path about to be written
40
+ * @param context - What is about to write it, for the failure message
41
+ * @throws {@link InternalInvariantError} when the file was never snapshotted.
42
+ */
43
+ export declare function assertSnapshotted(journal: RollbackJournal, filePath: string, context: string): void;
27
44
  /** Restores all snapshotted files and removes directories created during the operation. */
28
45
  export declare function restoreRollbackJournal(journal: RollbackJournal): Promise<void>;
29
46
  /** Restores a rollback journal and wraps rollback failures in a FurnaceError. */
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { chmod, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, isAbsolute, join } from 'node:path';
4
4
  import { FurnaceError } from '../errors/furnace.js';
5
+ import { assert } from '../utils/assert.js';
5
6
  import { toError } from '../utils/errors.js';
6
7
  import { pathExists } from '../utils/fs.js';
7
8
  /** Creates an empty rollback journal for tracking touched files and created directories. */
@@ -45,6 +46,13 @@ export async function snapshotDir(journal, dirPath) {
45
46
  }
46
47
  /** Snapshots a file once so rollback can restore its previous contents or absence. */
47
48
  export async function snapshotFile(journal, filePath) {
49
+ // Journal keys are the paths rollback will later write back to, and the
50
+ // restore runs from whatever cwd the process happens to be in by then. A
51
+ // relative key would therefore restore somewhere other than where the
52
+ // snapshot was taken — silently, and only on the failure path where nobody
53
+ // is watching. Every caller composes an absolute path already; this keeps
54
+ // it that way.
55
+ assert(isAbsolute(filePath), () => `snapshot path is absolute (got "${filePath}")`);
48
56
  if (journal.files.has(filePath)) {
49
57
  return;
50
58
  }
@@ -59,6 +67,25 @@ export async function snapshotFile(journal, filePath) {
59
67
  mode: fileStat.mode,
60
68
  });
61
69
  }
70
+ /**
71
+ * Asserts that a file about to be mutated was already snapshotted into the
72
+ * journal that will undo the mutation.
73
+ *
74
+ * The snapshot list and the mutation list are separately maintained in every
75
+ * caller that writes conditionally, and they drift: a new conditional write
76
+ * whose matching snapshot condition was not updated produces a file the
77
+ * rollback cannot restore, and nothing notices until a real failure. This
78
+ * checks the pairing at the point of the write, where the two lists are
79
+ * supposed to agree.
80
+ *
81
+ * @param journal - The journal that will restore this file
82
+ * @param filePath - Absolute path about to be written
83
+ * @param context - What is about to write it, for the failure message
84
+ * @throws {@link InternalInvariantError} when the file was never snapshotted.
85
+ */
86
+ export function assertSnapshotted(journal, filePath, context) {
87
+ assert(journal.files.has(filePath), () => `${context} snapshotted "${filePath}" before mutating it`);
88
+ }
62
89
  async function restoreFile(filePath, snapshot) {
63
90
  if (!snapshot.existed) {
64
91
  await rm(filePath, { force: true });
@@ -119,6 +146,13 @@ export async function restoreRollbackJournal(journal) {
119
146
  const summary = errors.map((e) => `${e.path}: ${e.error}`).join('; ');
120
147
  throw new FurnaceError(`Rollback failed to restore ${errors.length} file(s): ${summary}`);
121
148
  }
149
+ // Directory removal is only safe once every file is back where it belongs:
150
+ // a created directory may hold a restored file, and removing it recursively
151
+ // would undo the restore we just performed. The throw above is what
152
+ // normally enforces this; the assertion names it so a future early-return
153
+ // or error-swallowing edit cannot quietly reach the rm loop with failures
154
+ // outstanding.
155
+ assert(errors.length === 0, 'all journal files restored before created directories are removed');
122
156
  const createdDirs = [...journal.createdDirs].sort((left, right) => right.length - left.length);
123
157
  for (const dirPath of createdDirs) {
124
158
  await rm(dirPath, { recursive: true, force: true });
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { GitError, GitNotFoundError } from '../errors/git.js';
3
3
  import { exec, executableExists } from '../utils/process.js';
4
+ import { readOnlyGitIndexEnv } from './git-readonly-index.js';
4
5
  /**
5
6
  * Environment variable that overrides the monolithic `git add -A` timeout
6
7
  * (milliseconds). 2026-04-24 eval Finding 10: operators on slow or loaded
@@ -63,8 +64,12 @@ export async function git(args, cwd, options) {
63
64
  if (options?.timeout !== undefined) {
64
65
  execOptions.timeout = options.timeout;
65
66
  }
66
- if (options?.env !== undefined) {
67
- execOptions.env = options.env;
67
+ // A read-only command's private-index scope overlays GIT_INDEX_FILE so
68
+ // index refreshes never touch the primary checkout.
69
+ // Merged UNDER the caller's own env so an explicit override still wins.
70
+ const readOnlyIndexEnv = readOnlyGitIndexEnv(cwd);
71
+ if (options?.env !== undefined || readOnlyIndexEnv !== undefined) {
72
+ execOptions.env = { ...readOnlyIndexEnv, ...options?.env };
68
73
  }
69
74
  const result = await exec('git', args, execOptions);
70
75
  if (result.exitCode !== 0) {
@@ -9,9 +9,18 @@ import { verbose } from '../utils/logger.js';
9
9
  import { exec } from '../utils/process.js';
10
10
  import { chunkPathspecs, ensureGit, git } from './git-base.js';
11
11
  import { fileExistsInHead, hashObjectBatch, isBinaryFile, listTrackedInHead, } from './git-file-ops.js';
12
+ import { readOnlyGitIndexEnv } from './git-readonly-index.js';
12
13
  import { getUntrackedFiles, getUntrackedFilesInDir } from './git-status.js';
13
14
  async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
14
- const result = await exec('git', args, { cwd: repoDir });
15
+ // Honour a read-only command's private-index scope: the `--intent-to-add`
16
+ // / `update-index` staging dance below is a genuine
17
+ // index write, and inside a read-only command it must land on the
18
+ // private index, not the primary checkout's.
19
+ const readOnlyIndexEnv = readOnlyGitIndexEnv(repoDir);
20
+ const result = await exec('git', args, {
21
+ cwd: repoDir,
22
+ ...(readOnlyIndexEnv !== undefined ? { env: readOnlyIndexEnv } : {}),
23
+ });
15
24
  if (allowedExitCodes.includes(result.exitCode)) {
16
25
  return result;
17
26
  }
@@ -466,7 +475,7 @@ export async function generateBinaryFilePatch(repoDir, filePath) {
466
475
  // *removing* whatever entry the index carried, so any staged state that
467
476
  // arrived between the read-only diff above and this staging block (a
468
477
  // concurrent writer, or a staged-add whose worktree file was deleted)
469
- // was silently discarded (FORGE H1). Restoring the captured entry
478
+ // was silently discarded. Restoring the captured entry
470
479
  // instead makes the cleanup exact regardless of how the entry got there.
471
480
  const priorEntry = (await execGitWithAllowedExitCodes(repoDir, ['ls-files', '--stage', '--', filePath])).stdout;
472
481
  try {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Private-index scope for FireForge's READ-ONLY git plumbing.
3
+ *
4
+ * `git status` and `git diff HEAD` are read-only in the sense operators
5
+ * care about — they never change tracked content — but they are NOT
6
+ * read-only to git: both refresh the index's stat cache and write
7
+ * `.git/index` (through `.git/index.lock`) when a file's stat data has
8
+ * moved. That makes `fireforge verify` / `lint --per-patch` / `typecheck`
9
+ * a second WRITER of the primary `engine/` checkout, even when the command
10
+ * was pointed at a CoW tree clone.
11
+ *
12
+ * The consequence is not cosmetic. A concurrent `fireforge test`
13
+ * fingerprints `engine/` before and after the harness run and refuses a
14
+ * verdict taken across a change (`FAIL reason=inconclusive`). That refusal
15
+ * is CORRECT and stays; the defect is that FireForge's own read-only
16
+ * commands were tripping it. Measured downstream: perfect correlation
17
+ * between lane overlap and inconclusive verdicts, on suites that had
18
+ * actually passed.
19
+ *
20
+ * The fix is the standard git one: point the plumbing at a PRIVATE index
21
+ * (`GIT_INDEX_FILE`) seeded from the repository's own, in a temp
22
+ * directory. Refreshes land there and are thrown away; the primary
23
+ * `.git/index` — and `.git/index.lock`, which is what made a concurrent
24
+ * `git status` fail outright rather than merely churn — is never touched.
25
+ *
26
+ * Fail-open by design: if the git dir or the index cannot be resolved or
27
+ * copied, the scope simply does not activate and the commands behave
28
+ * exactly as before. A read-only command must not start failing because
29
+ * an optimisation could not be set up.
30
+ *
31
+ * The scope is process-global and non-reentrant on purpose: FireForge
32
+ * commands are one-shot CLI invocations, and nesting two different private
33
+ * indexes for the same run has no meaning. A nested call reuses the outer
34
+ * scope.
35
+ */
36
+ /**
37
+ * Environment overlay a git invocation in `cwd` must carry, or undefined
38
+ * when no private-index scope covers it.
39
+ *
40
+ * Matching is by resolved path prefix: plumbing frequently runs with `cwd`
41
+ * set to a subdirectory of the scoped repository, and those invocations
42
+ * share the same index.
43
+ *
44
+ * @param cwd - Working directory the git process will run in
45
+ * @returns `{ GIT_INDEX_FILE }` when scoped, otherwise undefined
46
+ */
47
+ export declare function readOnlyGitIndexEnv(cwd: string): Record<string, string> | undefined;
48
+ /** True while a private-index scope is installed (diagnostics and tests). */
49
+ export declare function hasReadOnlyGitIndexScope(): boolean;
50
+ /**
51
+ * Runs `operation` with FireForge's git plumbing pointed at a private
52
+ * index seeded from `repoDir`'s own, so the primary checkout's
53
+ * `.git/index` (and `.git/index.lock`) are never written.
54
+ *
55
+ * @param repoDir - Repository whose plumbing should use a private index
56
+ * @param operation - The read-only command body
57
+ * @returns Whatever `operation` returns
58
+ */
59
+ export declare function withPrivateGitIndex<T>(repoDir: string, operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,137 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Private-index scope for FireForge's READ-ONLY git plumbing.
4
+ *
5
+ * `git status` and `git diff HEAD` are read-only in the sense operators
6
+ * care about — they never change tracked content — but they are NOT
7
+ * read-only to git: both refresh the index's stat cache and write
8
+ * `.git/index` (through `.git/index.lock`) when a file's stat data has
9
+ * moved. That makes `fireforge verify` / `lint --per-patch` / `typecheck`
10
+ * a second WRITER of the primary `engine/` checkout, even when the command
11
+ * was pointed at a CoW tree clone.
12
+ *
13
+ * The consequence is not cosmetic. A concurrent `fireforge test`
14
+ * fingerprints `engine/` before and after the harness run and refuses a
15
+ * verdict taken across a change (`FAIL reason=inconclusive`). That refusal
16
+ * is CORRECT and stays; the defect is that FireForge's own read-only
17
+ * commands were tripping it. Measured downstream: perfect correlation
18
+ * between lane overlap and inconclusive verdicts, on suites that had
19
+ * actually passed.
20
+ *
21
+ * The fix is the standard git one: point the plumbing at a PRIVATE index
22
+ * (`GIT_INDEX_FILE`) seeded from the repository's own, in a temp
23
+ * directory. Refreshes land there and are thrown away; the primary
24
+ * `.git/index` — and `.git/index.lock`, which is what made a concurrent
25
+ * `git status` fail outright rather than merely churn — is never touched.
26
+ *
27
+ * Fail-open by design: if the git dir or the index cannot be resolved or
28
+ * copied, the scope simply does not activate and the commands behave
29
+ * exactly as before. A read-only command must not start failing because
30
+ * an optimisation could not be set up.
31
+ *
32
+ * The scope is process-global and non-reentrant on purpose: FireForge
33
+ * commands are one-shot CLI invocations, and nesting two different private
34
+ * indexes for the same run has no meaning. A nested call reuses the outer
35
+ * scope.
36
+ */
37
+ import { copyFile } from 'node:fs/promises';
38
+ import { mkdtemp, rm } from 'node:fs/promises';
39
+ import { tmpdir } from 'node:os';
40
+ import { join, resolve } from 'node:path';
41
+ import { toError } from '../utils/errors.js';
42
+ import { pathExists } from '../utils/fs.js';
43
+ import { verbose } from '../utils/logger.js';
44
+ import { exec } from '../utils/process.js';
45
+ /** The active private-index scope, when a read-only command installed one. */
46
+ let activeScope;
47
+ /** Bound on the git-dir probe so a wedged git cannot stall a read-only run. */
48
+ const GIT_DIR_PROBE_TIMEOUT_MS = 30_000;
49
+ /**
50
+ * Environment overlay a git invocation in `cwd` must carry, or undefined
51
+ * when no private-index scope covers it.
52
+ *
53
+ * Matching is by resolved path prefix: plumbing frequently runs with `cwd`
54
+ * set to a subdirectory of the scoped repository, and those invocations
55
+ * share the same index.
56
+ *
57
+ * @param cwd - Working directory the git process will run in
58
+ * @returns `{ GIT_INDEX_FILE }` when scoped, otherwise undefined
59
+ */
60
+ export function readOnlyGitIndexEnv(cwd) {
61
+ if (activeScope === undefined)
62
+ return undefined;
63
+ const resolved = resolve(cwd);
64
+ const scoped = activeScope.repoDir;
65
+ if (resolved !== scoped && !resolved.startsWith(`${scoped}/`))
66
+ return undefined;
67
+ return { GIT_INDEX_FILE: activeScope.indexFile };
68
+ }
69
+ /** True while a private-index scope is installed (diagnostics and tests). */
70
+ export function hasReadOnlyGitIndexScope() {
71
+ return activeScope !== undefined;
72
+ }
73
+ /**
74
+ * Resolves a repository's git directory. Returns undefined for anything
75
+ * that is not a readable git checkout — `engine/` is not always one
76
+ * (`fireforge download` extracts a source tarball).
77
+ */
78
+ async function resolveGitDir(repoDir) {
79
+ try {
80
+ const result = await exec('git', ['rev-parse', '--absolute-git-dir'], {
81
+ cwd: repoDir,
82
+ timeout: GIT_DIR_PROBE_TIMEOUT_MS,
83
+ });
84
+ if (result.exitCode !== 0)
85
+ return undefined;
86
+ const gitDir = result.stdout.trim();
87
+ return gitDir.length > 0 ? gitDir : undefined;
88
+ }
89
+ catch (error) {
90
+ verbose(`Private git index: could not resolve the git dir — ${toError(error).message}`);
91
+ return undefined;
92
+ }
93
+ }
94
+ /**
95
+ * Runs `operation` with FireForge's git plumbing pointed at a private
96
+ * index seeded from `repoDir`'s own, so the primary checkout's
97
+ * `.git/index` (and `.git/index.lock`) are never written.
98
+ *
99
+ * @param repoDir - Repository whose plumbing should use a private index
100
+ * @param operation - The read-only command body
101
+ * @returns Whatever `operation` returns
102
+ */
103
+ export async function withPrivateGitIndex(repoDir, operation) {
104
+ if (activeScope !== undefined)
105
+ return operation();
106
+ const gitDir = await resolveGitDir(repoDir);
107
+ if (gitDir === undefined)
108
+ return operation();
109
+ let tempDir;
110
+ try {
111
+ tempDir = await mkdtemp(join(tmpdir(), 'fireforge-roindex-'));
112
+ const privateIndex = join(tempDir, 'index');
113
+ const sourceIndex = join(gitDir, 'index');
114
+ // A repository with no index yet (a fresh init) is fine: git creates
115
+ // the private one on first use.
116
+ if (await pathExists(sourceIndex)) {
117
+ await copyFile(sourceIndex, privateIndex);
118
+ }
119
+ activeScope = { repoDir: resolve(repoDir), indexFile: privateIndex };
120
+ verbose(`Private git index active for ${repoDir} (${privateIndex}).`);
121
+ }
122
+ catch (error) {
123
+ // Fail-open: an unset-up scope must never fail a read-only command.
124
+ verbose(`Private git index unavailable — ${toError(error).message}`);
125
+ activeScope = undefined;
126
+ if (tempDir !== undefined)
127
+ await rm(tempDir, { recursive: true, force: true });
128
+ return operation();
129
+ }
130
+ try {
131
+ return await operation();
132
+ }
133
+ finally {
134
+ activeScope = undefined;
135
+ await rm(tempDir, { recursive: true, force: true });
136
+ }
137
+ }
@@ -86,7 +86,7 @@ export declare function containsUpstreamLicenseText(content: string, maxLines?:
86
86
  * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
87
87
  * comment style. Used by `export` to treat such files as vendored: offering
88
88
  * to prepend the project's own license header onto a byte-identity-pinned
89
- * upstream bundle would mislicense third-party code (FORGE F15).
89
+ * upstream bundle would mislicense third-party code.
90
90
  *
91
91
  * @param content - File content to check
92
92
  * @param maxLines - Number of leading lines to inspect (default 30 — full
@@ -257,7 +257,7 @@ export function containsUpstreamLicenseText(content, maxLines = 10) {
257
257
  * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
258
258
  * comment style. Used by `export` to treat such files as vendored: offering
259
259
  * to prepend the project's own license header onto a byte-identity-pinned
260
- * upstream bundle would mislicense third-party code (FORGE F15).
260
+ * upstream bundle would mislicense third-party code.
261
261
  *
262
262
  * @param content - File content to check
263
263
  * @param maxLines - Number of leading lines to inspect (default 30 — full
@@ -1,15 +1,20 @@
1
1
  import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type PatchQueueContext } from './patch-lint.js';
4
- export declare const LINT_CACHE_SCHEMA_VERSION = 3;
4
+ export declare const LINT_CACHE_SCHEMA_VERSION = 4;
5
5
  export interface PerPatchLintCacheEntry {
6
6
  key: string;
7
7
  patchFilename: string;
8
8
  issues: PatchLintIssue[];
9
- /** Issues dropped by the patch's lintIgnore waivers (FORGE G10). */
9
+ /** Issues dropped by the patch's lintIgnore waivers. */
10
10
  suppressed: PatchLintIssue[];
11
11
  /** Non-binary diff line count measured by the cached lint run. */
12
12
  lineCount: number;
13
+ /**
14
+ * Sorted lint-ignore waiver ids in force when this entry was computed
15
+ *. A lookup presenting a different effective set misses.
16
+ */
17
+ lintIgnore: string[];
13
18
  updatedAt: string;
14
19
  }
15
20
  export interface PerPatchLintCacheFile {
@@ -46,7 +51,12 @@ export interface CachedPerPatchLint {
46
51
  suppressed: PatchLintIssue[];
47
52
  lineCount: number;
48
53
  }
49
- /** Returns the cached lint payload for a patch when the stored key still matches. */
50
- export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): CachedPerPatchLint | undefined;
54
+ /**
55
+ * Returns the cached lint payload for a patch when the stored key still
56
+ * matches AND the entry was computed under the same lint-ignore waiver set
57
+ *. A waiver change makes a replayed verdict actively wrong, so
58
+ * the waiver set is compared explicitly rather than trusted to the key.
59
+ */
60
+ export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, lintIgnore?: Iterable<string>): CachedPerPatchLint | undefined;
51
61
  /** Stores the per-patch lint payload after a successful lint calculation. */
52
- export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[], suppressed: PatchLintIssue[], lineCount: number): void;
62
+ export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[], suppressed: PatchLintIssue[], lineCount: number, lintIgnore?: Iterable<string>): void;