@hominis/fireforge 0.40.0 → 0.42.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 (256) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +13 -3
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +16 -7
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +12 -59
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/branding.d.ts +16 -0
  118. package/dist/src/core/branding.js +18 -2
  119. package/dist/src/core/build-audit-platform.js +6 -1
  120. package/dist/src/core/build-audit-registration.js +2 -0
  121. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  122. package/dist/src/core/build-audit-resolve.js +34 -13
  123. package/dist/src/core/build-audit.js +4 -20
  124. package/dist/src/core/build-baseline-types.d.ts +29 -8
  125. package/dist/src/core/build-baseline-types.js +2 -1
  126. package/dist/src/core/build-baseline.d.ts +10 -2
  127. package/dist/src/core/build-baseline.js +39 -8
  128. package/dist/src/core/build-prepare.d.ts +4 -0
  129. package/dist/src/core/build-prepare.js +7 -1
  130. package/dist/src/core/config-paths.d.ts +1 -1
  131. package/dist/src/core/config-paths.js +2 -0
  132. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  133. package/dist/src/core/config-validate.js +34 -36
  134. package/dist/src/core/config.js +16 -3
  135. package/dist/src/core/coverage-extend.d.ts +89 -0
  136. package/dist/src/core/coverage-extend.js +188 -0
  137. package/dist/src/core/destructive.js +3 -1
  138. package/dist/src/core/discard-baseline.js +18 -4
  139. package/dist/src/core/engine-precondition.d.ts +35 -0
  140. package/dist/src/core/engine-precondition.js +50 -0
  141. package/dist/src/core/engine-session-lock.d.ts +38 -1
  142. package/dist/src/core/engine-session-lock.js +78 -4
  143. package/dist/src/core/file-lock.d.ts +7 -0
  144. package/dist/src/core/file-lock.js +20 -30
  145. package/dist/src/core/firefox.js +5 -2
  146. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  147. package/dist/src/core/furnace-apply-helpers.js +12 -3
  148. package/dist/src/core/furnace-apply-output.js +9 -0
  149. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  150. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  151. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  152. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  153. package/dist/src/core/furnace-apply.d.ts +16 -4
  154. package/dist/src/core/furnace-apply.js +39 -43
  155. package/dist/src/core/furnace-config.js +2 -0
  156. package/dist/src/core/furnace-css-fragments.js +2 -0
  157. package/dist/src/core/furnace-jsconfig.js +14 -7
  158. package/dist/src/core/furnace-registration-ast.js +8 -8
  159. package/dist/src/core/furnace-registration-remove.js +6 -3
  160. package/dist/src/core/furnace-registration.js +9 -12
  161. package/dist/src/core/furnace-rollback.js +1 -1
  162. package/dist/src/core/furnace-scanner.js +2 -1
  163. package/dist/src/core/furnace-stale-export.js +2 -1
  164. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  165. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  166. package/dist/src/core/furnace-validate-helpers.js +33 -1
  167. package/dist/src/core/furnace-validate-registration.js +16 -0
  168. package/dist/src/core/furnace-validate.js +4 -2
  169. package/dist/src/core/git-diff.js +37 -2
  170. package/dist/src/core/lint-cache.d.ts +1 -1
  171. package/dist/src/core/lint-cache.js +33 -10
  172. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  173. package/dist/src/core/mach-build-artifacts.js +108 -2
  174. package/dist/src/core/mach-mozconfig.js +9 -1
  175. package/dist/src/core/mach-resource-shim.js +1 -0
  176. package/dist/src/core/marionette-port.d.ts +16 -0
  177. package/dist/src/core/marionette-port.js +93 -8
  178. package/dist/src/core/marionette-preflight.js +6 -5
  179. package/dist/src/core/patch-apply.d.ts +20 -13
  180. package/dist/src/core/patch-apply.js +32 -33
  181. package/dist/src/core/patch-export.js +3 -5
  182. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  183. package/dist/src/core/patch-lint-checkjs.js +78 -7
  184. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  185. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  186. package/dist/src/core/patch-lint-creators.js +45 -0
  187. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  188. package/dist/src/core/patch-lint-cross.js +16 -32
  189. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  190. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  191. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  192. package/dist/src/core/patch-lint-module-registration.js +88 -0
  193. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  194. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  195. package/dist/src/core/patch-lint-reexports.js +1 -0
  196. package/dist/src/core/patch-lint.d.ts +8 -0
  197. package/dist/src/core/patch-lint.js +84 -6
  198. package/dist/src/core/patch-lock.d.ts +12 -1
  199. package/dist/src/core/patch-lock.js +40 -4
  200. package/dist/src/core/patch-manifest-consistency.js +15 -3
  201. package/dist/src/core/patch-parse.d.ts +8 -0
  202. package/dist/src/core/patch-parse.js +10 -0
  203. package/dist/src/core/patch-policy.d.ts +7 -0
  204. package/dist/src/core/patch-policy.js +4 -1
  205. package/dist/src/core/patch-transform.d.ts +10 -0
  206. package/dist/src/core/patch-transform.js +13 -2
  207. package/dist/src/core/rebase-session.d.ts +44 -2
  208. package/dist/src/core/rebase-session.js +114 -17
  209. package/dist/src/core/register-browser-content.js +2 -1
  210. package/dist/src/core/register-shared-css.js +2 -1
  211. package/dist/src/core/smoke-patterns.d.ts +0 -8
  212. package/dist/src/core/smoke-patterns.js +3 -2
  213. package/dist/src/core/status-binary.d.ts +55 -0
  214. package/dist/src/core/status-binary.js +124 -0
  215. package/dist/src/core/status-classify.d.ts +6 -1
  216. package/dist/src/core/status-classify.js +101 -73
  217. package/dist/src/core/test-harness-crash.d.ts +32 -1
  218. package/dist/src/core/test-harness-crash.js +59 -4
  219. package/dist/src/core/test-path-scope.js +2 -0
  220. package/dist/src/core/test-stale-check.js +1 -20
  221. package/dist/src/core/toolchain-preflight.js +24 -26
  222. package/dist/src/core/tree-guard.d.ts +1 -1
  223. package/dist/src/core/tree-guard.js +67 -12
  224. package/dist/src/core/tree-store.d.ts +86 -10
  225. package/dist/src/core/tree-store.js +310 -72
  226. package/dist/src/core/typecheck.js +2 -1
  227. package/dist/src/core/wire-destroy.js +14 -3
  228. package/dist/src/core/wire-init.js +37 -40
  229. package/dist/src/core/wire-subscript.js +11 -3
  230. package/dist/src/core/wire-utils.js +4 -4
  231. package/dist/src/core/xpcshell-appdir.js +3 -0
  232. package/dist/src/errors/base.d.ts +13 -0
  233. package/dist/src/errors/base.js +13 -0
  234. package/dist/src/errors/rebase.d.ts +13 -0
  235. package/dist/src/errors/rebase.js +17 -0
  236. package/dist/src/types/commands/options.d.ts +86 -1
  237. package/dist/src/types/commands/patches.d.ts +8 -0
  238. package/dist/src/types/furnace.d.ts +7 -0
  239. package/dist/src/utils/build-info.d.ts +31 -0
  240. package/dist/src/utils/build-info.js +98 -0
  241. package/dist/src/utils/concurrency.d.ts +8 -0
  242. package/dist/src/utils/concurrency.js +21 -0
  243. package/dist/src/utils/errors.d.ts +39 -0
  244. package/dist/src/utils/errors.js +55 -0
  245. package/dist/src/utils/fs.js +5 -12
  246. package/dist/src/utils/logger.d.ts +14 -2
  247. package/dist/src/utils/logger.js +43 -13
  248. package/dist/src/utils/options.d.ts +21 -4
  249. package/dist/src/utils/options.js +26 -6
  250. package/dist/src/utils/package-root.js +7 -4
  251. package/dist/src/utils/process-group.js +13 -2
  252. package/dist/src/utils/stdio-drain.d.ts +28 -0
  253. package/dist/src/utils/stdio-drain.js +54 -0
  254. package/dist/src/utils/validation.d.ts +23 -2
  255. package/dist/src/utils/validation.js +29 -3
  256. package/package.json +9 -9
@@ -4,3 +4,4 @@
4
4
  */
5
5
  export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
6
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
7
+ export { lintPatchQueueModuleRegistrations } from './patch-lint-module-registration.js';
@@ -5,3 +5,4 @@
5
5
  */
6
6
  export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
7
7
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
8
+ export { lintPatchQueueModuleRegistrations } from './patch-lint-module-registration.js';
@@ -4,6 +4,12 @@ import { type CommentStyle } from './license-headers.js';
4
4
  import { lintPatchedCss } from './patch-lint-css.js';
5
5
  export * from './patch-lint-reexports.js';
6
6
  export { lintPatchedCss };
7
+ /**
8
+ * Canonical `[check] file: message` rendering of a patch-lint issue — the
9
+ * one string shape every reporter and refusal message uses, so operators
10
+ * can grep one format across lint output and refusal details.
11
+ */
12
+ export declare function formatPatchLintIssue(issue: Pick<PatchLintIssue, 'check' | 'file' | 'message'>): string;
7
13
  /**
8
14
  * Counts the total lines in a unified diff and the number of non-binary
9
15
  * text lines, so binary hunks do not inflate patch size checks.
@@ -24,6 +30,8 @@ export declare function countNonBinaryDiffLines(diffContent: string): {
24
30
  * `browser_`, `test_`, or `xpcshell_` (all `.js`).
25
31
  */
26
32
  export declare function isTestFile(file: string): boolean;
33
+ /** True when `strippedContent` contains at least one assertion call. */
34
+ export declare function hasBrowserChromeAssertion(strippedContent: string): boolean;
27
35
  /**
28
36
  * Detects comment style from file extension for license header checks.
29
37
  */
@@ -49,6 +49,14 @@ function countContentLines(content) {
49
49
  lines.pop();
50
50
  return lines.length;
51
51
  }
52
+ /**
53
+ * Canonical `[check] file: message` rendering of a patch-lint issue — the
54
+ * one string shape every reporter and refusal message uses, so operators
55
+ * can grep one format across lint output and refusal details.
56
+ */
57
+ export function formatPatchLintIssue(issue) {
58
+ return `[${issue.check}] ${issue.file}: ${issue.message}`;
59
+ }
52
60
  /**
53
61
  * Counts the total lines in a unified diff and the number of non-binary
54
62
  * text lines, so binary hunks do not inflate patch size checks.
@@ -136,6 +144,16 @@ const PATCH_FILES_THRESHOLDS = {
136
144
  test: 5,
137
145
  branding: 60,
138
146
  };
147
+ /**
148
+ * Shared remedy sentence for the over-budget findings (FORGE H4): the two
149
+ * places an operator stands when a patch outgrows its budget must name the
150
+ * sanctioned one-transaction way out, or they reach for the wrong tools
151
+ * first (the field report tried two before finding it). One constant, four
152
+ * emission sites — the wording cannot drift.
153
+ */
154
+ const SPLIT_HINT = 'Consider splitting into smaller, focused patches — ' +
155
+ '"fireforge patch move-files <from> <to> --file <path> --create --order <n>" ' +
156
+ 'moves files into a new patch in one transaction.';
139
157
  /**
140
158
  * Fixed allowlist of non-branding sibling paths that real-world Firefox
141
159
  * branding patches legitimately need to touch to register the new
@@ -214,6 +232,67 @@ function isBrowserChromeTestFile(file) {
214
232
  return false;
215
233
  return basename.startsWith('browser_');
216
234
  }
235
+ /**
236
+ * Bare assertion-call detection for the `test-needs-assertion` floor.
237
+ *
238
+ * Each alternative must sit at a call boundary: preceded by something that is
239
+ * not an identifier character, `.`, `#`, or `-`. Until 0.41.0 this was a plain
240
+ * `strippedContent.includes(tok)` over the tokens `Assert.`, `ok(`, `is(`,
241
+ * `isnot(`, `isDeeply(`, so any identifier merely *ending* in one satisfied
242
+ * the gate — `this.axis(3)` contains `is(`, `book(` contains `ok(`,
243
+ * `promiseIsDeeply(` contains `isDeeply(`. An assertion-free smoke test using
244
+ * any such call passed the floor, which is exactly what the rule exists to
245
+ * catch.
246
+ *
247
+ * The namespaced arm must name a member and a call. Matching the namespace and
248
+ * its dot alone — as `(?:Assert|SimpleTest)\.` did — re-opened the same hole
249
+ * from the other side: `SimpleTest` is mostly *harness* API, so
250
+ * `SimpleTest.finish()`, `SimpleTest.waitForExplicitFinish()` and
251
+ * `SimpleTest.requestCompleteLog()` each cleared the floor on their own. Those
252
+ * are the exact calls an assertion-free smoke test contains. The enumeration
253
+ * below is the mochitest/xpcshell assertion surface both namespaces share, and
254
+ * it is what the user-facing message already names.
255
+ *
256
+ * Two qualified spellings of the bare four still count: `window.ok(...)` (the
257
+ * globals ARE window properties) and an optional-chained `win?.ok(...)`.
258
+ * Blocking those flagged tests whose only assertions are real as
259
+ * assertion-free. `SpecialPowers.ok(` stays excluded — that is API surface,
260
+ * not an assertion.
261
+ *
262
+ * Deliberately still textual, not AST-based: this runs over patch bodies
263
+ * rather than resolvable sources, and the surrounding rule is a floor, not a
264
+ * proof.
265
+ */
266
+ const ASSERTION_MEMBERS = [
267
+ 'ok',
268
+ 'is',
269
+ 'isnot',
270
+ 'isDeeply',
271
+ 'equal',
272
+ 'notEqual',
273
+ 'deepEqual',
274
+ 'notDeepEqual',
275
+ 'strictEqual',
276
+ 'notStrictEqual',
277
+ 'greater',
278
+ 'greaterOrEqual',
279
+ 'less',
280
+ 'lessOrEqual',
281
+ 'stringMatches',
282
+ 'stringContains',
283
+ 'throws',
284
+ 'rejects',
285
+ // `todo`, `todo_is`, `todo_isnot`, `info`-free — todo* record an expected
286
+ // failure, which is still a recorded assertion outcome.
287
+ 'todo',
288
+ 'todo_is',
289
+ 'todo_isnot',
290
+ ].join('|');
291
+ const BROWSER_CHROME_ASSERTION = new RegExp(`(?<![\\w.#$-])(?:(?:Assert|SimpleTest)\\.(?:${ASSERTION_MEMBERS})\\s*\\(|(?:window\\.|[\\w$]+\\?\\.)?(?:ok|is|isnot|isDeeply)\\s*\\()`);
292
+ /** True when `strippedContent` contains at least one assertion call. */
293
+ export function hasBrowserChromeAssertion(strippedContent) {
294
+ return BROWSER_CHROME_ASSERTION.test(strippedContent);
295
+ }
217
296
  /**
218
297
  * Detects comment style from file extension for license header checks.
219
298
  */
@@ -406,8 +485,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
406
485
  // browser_*.js silently passed under the old `isNew` gate).
407
486
  const assertionFloor = config.patchLint?.testAssertionFloor;
408
487
  if (assertionFloor && assertionFloor !== 'off' && isBrowserChromeTestFile(file)) {
409
- const ASSERTION_TOKENS = ['Assert.', 'ok(', 'is(', 'isnot(', 'isDeeply('];
410
- const hasAssertion = ASSERTION_TOKENS.some((tok) => strippedContent.includes(tok));
488
+ const hasAssertion = hasBrowserChromeAssertion(strippedContent);
411
489
  if (!hasAssertion) {
412
490
  issues.push({
413
491
  file,
@@ -535,7 +613,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
535
613
  issues.push({
536
614
  file: AGGREGATE_PATCH_FILE,
537
615
  check: 'large-patch-files',
538
- message: `Patch affects ${filesAffected.length} files (recommended: ≤${fileThreshold}). Consider splitting into smaller, focused patches.`,
616
+ message: `Patch affects ${filesAffected.length} files (recommended: ≤${fileThreshold}). ${SPLIT_HINT}`,
539
617
  severity: 'warning',
540
618
  });
541
619
  }
@@ -543,7 +621,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
543
621
  issues.push({
544
622
  file: AGGREGATE_PATCH_FILE,
545
623
  check: 'large-patch-lines',
546
- message: `Patch is ${lineCount} lines (hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
624
+ message: `Patch is ${lineCount} lines (hard limit: ${lineThresholds.error}). ${SPLIT_HINT}`,
547
625
  severity: 'error',
548
626
  });
549
627
  }
@@ -551,7 +629,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
551
629
  issues.push({
552
630
  file: AGGREGATE_PATCH_FILE,
553
631
  check: 'large-patch-lines',
554
- message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
632
+ message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). ${SPLIT_HINT}`,
555
633
  severity: 'warning',
556
634
  });
557
635
  }
@@ -559,7 +637,7 @@ export function lintPatchSize(filesAffected, lineCount, patchTier) {
559
637
  issues.push({
560
638
  file: AGGREGATE_PATCH_FILE,
561
639
  check: 'large-patch-lines',
562
- message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). Consider splitting into smaller, focused patches.`,
640
+ message: `Patch is ${lineCount} lines (soft limit: ${lineThresholds.warning}, hard limit: ${lineThresholds.error}). ${SPLIT_HINT}`,
563
641
  severity: 'notice',
564
642
  });
565
643
  }
@@ -1,8 +1,19 @@
1
1
  /**
2
2
  * Filesystem-based lock for serializing patch directory mutations.
3
3
  */
4
+ export interface PatchDirectoryLockOptions {
5
+ /**
6
+ * Wait up to this many seconds for the patch directory lock instead of the
7
+ * default ~30 s budget. Enables exponential poll backoff (100 ms → 2 s)
8
+ * and a holder-identified progress line roughly every 5 s. `undefined`
9
+ * preserves the historical behavior exactly.
10
+ */
11
+ waitLockSeconds?: number | undefined;
12
+ /** Command name recorded in the lock's owner metadata for holder diagnostics. */
13
+ command?: string | undefined;
14
+ }
4
15
  /**
5
16
  * Runs a patch directory mutation while holding an exclusive filesystem lock.
6
17
  * This serializes filename allocation and manifest writes across parallel exports.
7
18
  */
8
- export declare function withPatchDirectoryLock<T>(patchesDir: string, operation: () => Promise<T>): Promise<T>;
19
+ export declare function withPatchDirectoryLock<T>(patchesDir: string, operation: () => Promise<T>, options?: PatchDirectoryLockOptions): Promise<T>;
@@ -6,18 +6,54 @@ import { join } from 'node:path';
6
6
  import { FireForgeError } from '../errors/base.js';
7
7
  import { PatchError } from '../errors/patch.js';
8
8
  import { toError } from '../utils/errors.js';
9
+ import { info } from '../utils/logger.js';
9
10
  import { withFileLock } from './file-lock.js';
10
11
  const PATCH_DIRECTORY_LOCK = '.fireforge-patches.lock';
12
+ /**
13
+ * Formats the periodic operator-facing waiting line printed while
14
+ * `--wait-lock` polls a contended patch directory lock. Mirrors the engine
15
+ * session lock's progress line so operators read one shape for both locks.
16
+ */
17
+ function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
18
+ const progress = `${String(Math.round(waitedMs / 1000))}s of up to ${String(Math.round(timeoutMs / 1000))}s`;
19
+ if (holder === undefined) {
20
+ return `Waiting for the FireForge patch directory lock — ${progress}.`;
21
+ }
22
+ const details = holder.metadata.length > 0 ? ` (${holder.metadata.join(', ')})` : '';
23
+ return `Waiting for the FireForge patch directory lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
24
+ }
11
25
  /**
12
26
  * Runs a patch directory mutation while holding an exclusive filesystem lock.
13
27
  * This serializes filename allocation and manifest writes across parallel exports.
14
28
  */
15
- export async function withPatchDirectoryLock(patchesDir, operation) {
29
+ export async function withPatchDirectoryLock(patchesDir, operation, options = {}) {
16
30
  const lockDir = join(patchesDir, PATCH_DIRECTORY_LOCK);
31
+ const { waitLockSeconds, command } = options;
17
32
  return withFileLock(lockDir, operation, {
18
- onTimeoutMessage: `Timed out waiting for another patch export to finish in ${patchesDir}.\n` +
19
- `If no other fireforge process is running, the lock may be stale. ` +
20
- `Remove it manually:\n rm -rf "${lockDir}"`,
33
+ ...(waitLockSeconds !== undefined
34
+ ? {
35
+ timeoutMs: waitLockSeconds * 1000,
36
+ pollMs: 100,
37
+ pollMaxMs: 2000,
38
+ waitProgressMs: 5000,
39
+ onWaitProgress: ({ waitedMs, timeoutMs, holder }) => {
40
+ info(formatWaitProgressLine(waitedMs, timeoutMs, holder));
41
+ },
42
+ }
43
+ : {}),
44
+ ...(command !== undefined
45
+ ? { ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`] }
46
+ : {}),
47
+ // Reason first, remedy second (FORGE H5). The remedy is waiting, not
48
+ // deleting: bulk exports/re-exports legitimately hold this lock for
49
+ // minutes, FireForge reaps genuinely stale locks on its own, and the
50
+ // old advice ("rm -rf" while a holder was alive) destroyed a live
51
+ // lock. The leading sentence is a message contract; extend, don't
52
+ // reword. withFileLock appends the holder identification.
53
+ onTimeoutMessage: `Timed out waiting for another FireForge command mutating ${patchesDir}.\n` +
54
+ `Pass --wait-lock [seconds] to wait longer (bare --wait-lock waits up to 60 seconds). ` +
55
+ `FireForge removes genuinely stale locks automatically; only if no other fireforge ` +
56
+ `process is running and the timeout persists, remove the lock manually:\n rm -rf "${lockDir}"`,
21
57
  onStaleLockMessage: (ageMs) => `Removing stale patch lock (age: ${Math.round(ageMs / 1000)}s). ` +
22
58
  'A previous fireforge process may have crashed.',
23
59
  }).catch((error) => {
@@ -112,10 +112,22 @@ async function rebuildPatchesManifestUnderLock(patchesDir, fallbackSourceEsrVers
112
112
  return Number.isFinite(patch.order) ? Math.max(highest, patch.order) : highest;
113
113
  }, 0);
114
114
  let nextRecoveredOrder = highestFiniteOrder + 1;
115
- for (const patch of patches) {
115
+ // Prefetch the two independent per-patch reads concurrently. This runs
116
+ // while the patch-directory lock is held, and it was two serialised awaits
117
+ // per patch across the whole queue — 58 round-trips on a 29-patch queue.
118
+ // The fold below stays sequential because `nextRecoveredOrder` is
119
+ // loop-carried: parallelising the assignment would scramble the recovered
120
+ // ordinals.
121
+ const prefetched = await Promise.all(patches.map(async (patch) => ({
122
+ filesAffected: normalizeFiles(await getAllTargetFilesFromPatch(patch.path)),
123
+ patchStats: await stat(patch.path),
124
+ })));
125
+ for (const [index, patch] of patches.entries()) {
116
126
  const existing = existingEntries.get(patch.filename);
117
- const filesAffected = normalizeFiles(await getAllTargetFilesFromPatch(patch.path));
118
- const patchStats = await stat(patch.path);
127
+ const prefetch = prefetched[index];
128
+ if (!prefetch)
129
+ continue;
130
+ const { filesAffected, patchStats } = prefetch;
119
131
  const inferred = inferPatchMetadataFromFilename(patch.filename);
120
132
  const recoveredOrder = Number.isFinite(patch.order) ? patch.order : nextRecoveredOrder++;
121
133
  if (!existing) {
@@ -60,6 +60,14 @@ export interface DiffSection {
60
60
  * refuse them rather than treat base85 lines starting with `+` as content.
61
61
  */
62
62
  isBinary: boolean;
63
+ /**
64
+ * Old-side blob hash from the section's `index <old>..<new>` line
65
+ * (possibly abbreviated). Undefined when the metadata zone carries no
66
+ * parseable index line (hand-written diffs, `Binary files … differ`).
67
+ */
68
+ indexOldHash?: string;
69
+ /** New-side blob hash from the `index` line; see {@link indexOldHash}. */
70
+ indexNewHash?: string;
63
71
  /** Parsed hunks, in file order. Empty for binary or metadata-only sections. */
64
72
  hunks: ParsedHunk[];
65
73
  }
@@ -169,6 +169,16 @@ export function parseDiffSections(diffContent) {
169
169
  current.isBinary = true;
170
170
  continue;
171
171
  }
172
+ // `git diff --binary` always records full blob hashes here (git
173
+ // apply requires them); text sections carry abbreviated ones. The
174
+ // hashes let binary sections be compared by identity when their
175
+ // payload cannot be applied as text (FORGE J3).
176
+ const indexMatch = /^index ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})(?: \d{6})?$/.exec(line);
177
+ if (indexMatch?.[1] !== undefined && indexMatch[2] !== undefined) {
178
+ current.indexOldHash = indexMatch[1];
179
+ current.indexNewHash = indexMatch[2];
180
+ continue;
181
+ }
172
182
  }
173
183
  if (current.isBinary)
174
184
  continue;
@@ -23,6 +23,13 @@ export interface PatchPolicyEnforcementInput {
23
23
  manifest: PatchesManifest;
24
24
  command: string;
25
25
  forceUnsafe?: boolean;
26
+ /**
27
+ * Per-issue-code remediation lines appended to matching details, so a
28
+ * refusal can name the exact flag or command that fixes it on the
29
+ * command it was raised from (e.g. `--description` on
30
+ * `patch move-files --create`).
31
+ */
32
+ hints?: Partial<Record<PatchPolicyIssueCode, string>>;
26
33
  }
27
34
  /** Returns valid categories for prompts and CLI validation under the config. */
28
35
  export declare function getPatchPolicyCategories(config: FireForgeConfig): string[];
@@ -341,7 +341,10 @@ export function enforcePatchPolicy(input) {
341
341
  if (issues.length === 0)
342
342
  return;
343
343
  const mode = mutationMode(input.config);
344
- const details = issues.map((issue) => ` [${issue.code}] ${issue.message}`);
344
+ const details = issues.map((issue) => {
345
+ const hint = input.hints?.[issue.code];
346
+ return ` [${issue.code}] ${issue.message}${hint ? `\n → ${hint}` : ''}`;
347
+ });
345
348
  if (mode === 'warn') {
346
349
  warn(`${input.command}: patch policy warning(s):`);
347
350
  for (const detail of details)
@@ -35,3 +35,13 @@ export declare function extractNewFileContent(patchPath: string, targetFile?: st
35
35
  * @returns Modified content
36
36
  */
37
37
  export declare function applyPatchToContent(content: string | null, patchPath: string, targetFile: string): Promise<string>;
38
+ /**
39
+ * Applies an already-read patch body to content. Batched callers that hold
40
+ * many (patch, file) pairs use this to read each patch body once instead of
41
+ * once per pair.
42
+ * @param content - Original content (null for new files)
43
+ * @param patchContent - Full text of the patch body
44
+ * @param targetFile - The file path within the patch
45
+ * @returns Modified content
46
+ */
47
+ export declare function applyPatchTextToContent(content: string | null, patchContent: string, targetFile: string): string;
@@ -71,11 +71,22 @@ export async function extractNewFileContent(patchPath, targetFile) {
71
71
  * @returns Modified content
72
72
  */
73
73
  export async function applyPatchToContent(content, patchPath, targetFile) {
74
- const patchContent = await readText(patchPath);
74
+ return applyPatchTextToContent(content, await readText(patchPath), targetFile);
75
+ }
76
+ /**
77
+ * Applies an already-read patch body to content. Batched callers that hold
78
+ * many (patch, file) pairs use this to read each patch body once instead of
79
+ * once per pair.
80
+ * @param content - Original content (null for new files)
81
+ * @param patchContent - Full text of the patch body
82
+ * @param targetFile - The file path within the patch
83
+ * @returns Modified content
84
+ */
85
+ export function applyPatchTextToContent(content, patchContent, targetFile) {
75
86
  // Check if this is a new file patch for the target file specifically
76
87
  if (content === null) {
77
88
  if (isNewFileInPatch(patchContent, targetFile)) {
78
- return await extractNewFileContent(patchPath, targetFile);
89
+ return extractNewFileContentFromDiff(patchContent, targetFile);
79
90
  }
80
91
  // If not a new file patch but content is null, return empty
81
92
  return '';
@@ -35,7 +35,47 @@ export interface RebaseSession {
35
35
  currentIndex: number;
36
36
  }
37
37
  /**
38
- * Loads an existing rebase session, or returns `null` if none exists.
38
+ * Outcome of reading the session file, following the {@link LockOwner}
39
+ * discriminated-union form rather than collapsing three states into `null`.
40
+ *
41
+ * Distinguishing `present: false` from `present: true, valid: false` is
42
+ * load-bearing. Before 0.41.0 both returned `null` while
43
+ * {@link hasActiveRebaseSession} reported liveness from `pathExists` alone, so
44
+ * an invalid session file wedged the operator in a closed cycle: `rebase` said
45
+ * "already in progress — use --continue or --abort", and both of those said
46
+ * "no rebase session in progress". No CLI path deleted the file and no message
47
+ * named it, so the only escape was knowing to `rm` it by hand.
48
+ */
49
+ export type RebaseSessionRead = {
50
+ present: false;
51
+ } | {
52
+ present: true;
53
+ valid: true;
54
+ session: RebaseSession;
55
+ } | {
56
+ present: true;
57
+ valid: false;
58
+ reason: string;
59
+ };
60
+ /** Absolute path of the session file, for messages that must name it. */
61
+ export declare function getRebaseSessionPath(projectRoot: string): string;
62
+ /**
63
+ * Reads the session file, reporting absent, valid, and corrupt as three
64
+ * distinct outcomes. Never throws for a malformed file: `readJson` calls
65
+ * `JSON.parse` with no guard of its own, so an interrupted write surfaced a
66
+ * raw `SyntaxError` out of `--continue`/`--abort` before 0.41.0.
67
+ *
68
+ * A single read determines liveness and validity: only ENOENT/ENOTDIR from
69
+ * that read mean `absent`. A pathExists pre-probe both raced deletion (a file
70
+ * removed between probe and read misreported an absent session as corrupt)
71
+ * and swallowed EACCES (an unreadable `.fireforge/` misreported a session as
72
+ * absent) — the same failure `readTreeMarkerState` fixed for tree markers.
73
+ */
74
+ export declare function readRebaseSession(projectRoot: string): Promise<RebaseSessionRead>;
75
+ /**
76
+ * Loads an existing rebase session, or returns `null` when none exists or the
77
+ * file on disk is unusable. Callers that must tell those two apart — every
78
+ * command that reports to an operator — should use {@link readRebaseSession}.
39
79
  */
40
80
  export declare function loadRebaseSession(projectRoot: string): Promise<RebaseSession | null>;
41
81
  /**
@@ -47,6 +87,8 @@ export declare function saveRebaseSession(projectRoot: string, session: RebaseSe
47
87
  */
48
88
  export declare function clearRebaseSession(projectRoot: string): Promise<void>;
49
89
  /**
50
- * Returns `true` when an active rebase session exists on disk.
90
+ * Returns `true` when a rebase session file exists on disk, valid or not.
91
+ * Test-facing convenience — production code reads `readRebaseSession` once
92
+ * instead, so liveness and validity come from the same read.
51
93
  */
52
94
  export declare function hasActiveRebaseSession(projectRoot: string): Promise<boolean>;
@@ -6,8 +6,9 @@
6
6
  * resume with `fireforge rebase --continue`.
7
7
  */
8
8
  import { join } from 'node:path';
9
+ import { getNodeErrorCode, toError } from '../utils/errors.js';
9
10
  import { pathExists, readJson, removeFile, writeJson } from '../utils/fs.js';
10
- import { isArray, isObject, isString } from '../utils/validation.js';
11
+ import { isArray, isNumber, isObject, isString, isValidFirefoxProduct, isValidFirefoxVersion, } from '../utils/validation.js';
11
12
  import { getProjectPaths } from './config-paths.js';
12
13
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
13
14
  // ── Helpers ──
@@ -15,28 +16,122 @@ const SESSION_FILENAME = 'rebase-session.json';
15
16
  function sessionPath(projectRoot) {
16
17
  return join(getProjectPaths(projectRoot).fireforgeDir, SESSION_FILENAME);
17
18
  }
19
+ const REBASE_PATCH_STATUSES = [
20
+ 'pending',
21
+ 'applied-clean',
22
+ 'applied-fuzz',
23
+ 'failed',
24
+ 'resolved',
25
+ 'skipped',
26
+ ];
27
+ /** Validates one `patches[]` element to its declared shape. */
28
+ function isValidPatchEntry(value) {
29
+ if (!isObject(value))
30
+ return false;
31
+ if (!isString(value['filename']) || value['filename'].length === 0)
32
+ return false;
33
+ if (!REBASE_PATCH_STATUSES.includes(value['status']))
34
+ return false;
35
+ if (value['fuzzFactor'] !== undefined && !isNumber(value['fuzzFactor']))
36
+ return false;
37
+ if (value['error'] !== undefined && !isString(value['error']))
38
+ return false;
39
+ const conflicting = value['conflictingFiles'];
40
+ if (conflicting !== undefined && (!isArray(conflicting) || !conflicting.every(isString))) {
41
+ return false;
42
+ }
43
+ return true;
44
+ }
45
+ /** Optional product field: absent is legal, present must be a real product. */
46
+ function isValidOptionalProduct(value) {
47
+ return value === undefined || (isString(value) && isValidFirefoxProduct(value));
48
+ }
49
+ /**
50
+ * Validates a session file against the shape the rebase commands actually
51
+ * consume — not merely against broad field types.
52
+ *
53
+ * The looser predicate this replaces checked six `typeof`s and nothing else,
54
+ * which admitted sessions the resume path then acted on. `patches: [null]`
55
+ * reached `patch-loop.ts`, which reads `.filename` off each entry and hands it
56
+ * to `stampPatchVersions`. A `currentIndex` of `NaN` (legal for
57
+ * `typeof === 'number'`) made the resume loop run zero iterations and report
58
+ * success; a negative one indexed out of range in `continue.ts`. `toVersion:
59
+ * ""` passed `isString` and was stamped verbatim onto every Furnace override's
60
+ * `baseVersion`. `fromProduct`/`toProduct` were not checked at all despite
61
+ * being typed `FirefoxProduct`.
62
+ *
63
+ * The session file is written only by `rebase/index.ts` from validated CLI and
64
+ * config values, so every one of these is a corrupt-file or hand-edit case —
65
+ * which is exactly the case this predicate exists to catch.
66
+ */
18
67
  function isValidSession(data) {
19
68
  if (!isObject(data))
20
69
  return false;
21
- return (isString(data['startedAt']) &&
22
- isString(data['fromVersion']) &&
23
- isString(data['toVersion']) &&
24
- isString(data['preRebaseCommit']) &&
25
- isArray(data['patches']) &&
26
- typeof data['currentIndex'] === 'number');
70
+ if (!isString(data['startedAt']) || Number.isNaN(Date.parse(data['startedAt'])))
71
+ return false;
72
+ // 4 is git's own minimum abbreviation length; 40/64 are full SHA-1/SHA-256.
73
+ if (!isString(data['preRebaseCommit']) || !/^[0-9a-f]{4,64}$/i.test(data['preRebaseCommit'])) {
74
+ return false;
75
+ }
76
+ if (!isString(data['fromVersion']) || !isValidFirefoxVersion(data['fromVersion']))
77
+ return false;
78
+ if (!isString(data['toVersion']) || !isValidFirefoxVersion(data['toVersion']))
79
+ return false;
80
+ if (!isValidOptionalProduct(data['fromProduct']))
81
+ return false;
82
+ if (!isValidOptionalProduct(data['toProduct']))
83
+ return false;
84
+ const patches = data['patches'];
85
+ if (!isArray(patches) || !patches.every(isValidPatchEntry))
86
+ return false;
87
+ // `<= patches.length` because the resume point legally sits one past the
88
+ // last entry once every patch has been processed.
89
+ const currentIndex = data['currentIndex'];
90
+ if (!isNumber(currentIndex) || !Number.isInteger(currentIndex))
91
+ return false;
92
+ return currentIndex >= 0 && currentIndex <= patches.length;
93
+ }
94
+ /** Absolute path of the session file, for messages that must name it. */
95
+ export function getRebaseSessionPath(projectRoot) {
96
+ return sessionPath(projectRoot);
27
97
  }
28
- // ── Public API ──
29
98
  /**
30
- * Loads an existing rebase session, or returns `null` if none exists.
99
+ * Reads the session file, reporting absent, valid, and corrupt as three
100
+ * distinct outcomes. Never throws for a malformed file: `readJson` calls
101
+ * `JSON.parse` with no guard of its own, so an interrupted write surfaced a
102
+ * raw `SyntaxError` out of `--continue`/`--abort` before 0.41.0.
103
+ *
104
+ * A single read determines liveness and validity: only ENOENT/ENOTDIR from
105
+ * that read mean `absent`. A pathExists pre-probe both raced deletion (a file
106
+ * removed between probe and read misreported an absent session as corrupt)
107
+ * and swallowed EACCES (an unreadable `.fireforge/` misreported a session as
108
+ * absent) — the same failure `readTreeMarkerState` fixed for tree markers.
31
109
  */
32
- export async function loadRebaseSession(projectRoot) {
110
+ export async function readRebaseSession(projectRoot) {
33
111
  const path = sessionPath(projectRoot);
34
- if (!(await pathExists(path)))
35
- return null;
36
- const data = await readJson(path);
37
- if (!isValidSession(data))
38
- return null;
39
- return data;
112
+ let data;
113
+ try {
114
+ data = await readJson(path);
115
+ }
116
+ catch (error) {
117
+ const code = getNodeErrorCode(error);
118
+ if (code === 'ENOENT' || code === 'ENOTDIR')
119
+ return { present: false };
120
+ return { present: true, valid: false, reason: toError(error).message };
121
+ }
122
+ if (!isValidSession(data)) {
123
+ return { present: true, valid: false, reason: 'the file is not a valid rebase session' };
124
+ }
125
+ return { present: true, valid: true, session: data };
126
+ }
127
+ /**
128
+ * Loads an existing rebase session, or returns `null` when none exists or the
129
+ * file on disk is unusable. Callers that must tell those two apart — every
130
+ * command that reports to an operator — should use {@link readRebaseSession}.
131
+ */
132
+ export async function loadRebaseSession(projectRoot) {
133
+ const result = await readRebaseSession(projectRoot);
134
+ return result.present && result.valid ? result.session : null;
40
135
  }
41
136
  /**
42
137
  * Persists a rebase session atomically.
@@ -57,7 +152,9 @@ export async function clearRebaseSession(projectRoot) {
57
152
  }
58
153
  }
59
154
  /**
60
- * Returns `true` when an active rebase session exists on disk.
155
+ * Returns `true` when a rebase session file exists on disk, valid or not.
156
+ * Test-facing convenience — production code reads `readRebaseSession` once
157
+ * instead, so liveness and validity come from the same read.
61
158
  */
62
159
  export async function hasActiveRebaseSession(projectRoot) {
63
160
  return pathExists(sessionPath(projectRoot));
@@ -5,6 +5,7 @@
5
5
  import { join } from 'node:path';
6
6
  import { GeneralError } from '../errors/base.js';
7
7
  import { pathExists, readText, writeText } from '../utils/fs.js';
8
+ import { escapeRegex } from '../utils/regex.js';
8
9
  import { findAlphabeticalPosition, findAlphabeticalTokenPosition } from './manifest-helpers.js';
9
10
  import { tokenizeJarMn } from './manifest-tokenizers.js';
10
11
  import { withParserFallback } from './parser-fallback.js';
@@ -18,7 +19,7 @@ function registerBrowserContentTokenized(content, fileName, entry, after) {
18
19
  let insertIndex;
19
20
  let previousEntry;
20
21
  if (after) {
21
- const afterPattern = new RegExp(`(?:^|/)${after.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|\\)|$)`);
22
+ const afterPattern = new RegExp(`(?:^|/)${escapeRegex(after)}(?:\\s|\\)|$)`);
22
23
  const afterToken = tokens.find((t) => afterPattern.test(t.raw));
23
24
  if (afterToken) {
24
25
  insertIndex = afterToken.lineIndex + 1;
@@ -5,6 +5,7 @@
5
5
  import { basename, join } from 'node:path';
6
6
  import { GeneralError } from '../errors/base.js';
7
7
  import { pathExists, readText, writeText } from '../utils/fs.js';
8
+ import { escapeRegex } from '../utils/regex.js';
8
9
  import { findAlphabeticalPosition, findAlphabeticalTokenPosition } from './manifest-helpers.js';
9
10
  import { tokenizeJarMn } from './manifest-tokenizers.js';
10
11
  import { withParserFallback } from './parser-fallback.js';
@@ -18,7 +19,7 @@ function registerSharedCSSTokenized(content, name, entry, after) {
18
19
  let insertIndex;
19
20
  let previousEntry;
20
21
  if (after) {
21
- const afterPattern = new RegExp(`(?:^|/)${after.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|\\)|$)`);
22
+ const afterPattern = new RegExp(`(?:^|/)${escapeRegex(after)}(?:\\s|\\)|$)`);
22
23
  const afterToken = tokens.find((t) => afterPattern.test(t.raw));
23
24
  if (afterToken) {
24
25
  insertIndex = afterToken.lineIndex + 1;