@hominis/fireforge 0.39.0 → 0.41.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 (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -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 +27 -9
  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 +30 -10
  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 +73 -51
  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-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -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) {
@@ -5,7 +5,7 @@
5
5
  * is an implementation detail.
6
6
  */
7
7
  export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
8
- export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, type PatchManifestRowMutation, type PatchManifestRowMutationResult, type PatchRenameEntry, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
8
+ export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, type PatchRenameEntry, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
9
9
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
10
10
  export { resolvePatchIdentifier } from './patch-manifest-resolve.js';
11
11
  export { validatePatchesManifest } from './patch-manifest-validate.js';
@@ -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;
@@ -10,36 +10,44 @@
10
10
  * warning (e.g. `"pending JavaScript error cleanup"`) do not start with
11
11
  * the prefix and should not trip the scanner.
12
12
  */
13
- /**
14
- * Line prefixes that signal an actual runtime error in a Firefox chrome
15
- * process. A hit on any of these patterns is a smoke failure unless the
16
- * line also matches the caller-supplied allowlist.
17
- *
18
- * Additions here should be conservative — false positives turn every
19
- * smoke run into noise for operators and every CI run into flake.
20
- */
21
13
  export declare const SMOKE_ERROR_PATTERNS: readonly RegExp[];
22
14
  /**
23
15
  * Returns `true` when `line` matches any pattern in
24
16
  * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
25
- * lives in {@link matchesAllowlist}, so the smoke runner can count
17
+ * lives in {@link matchAllowlist}, so the smoke runner can count
26
18
  * allowlisted hits separately from raw error matches for its summary.
27
19
  */
28
20
  export declare function matchesSmokeError(line: string): boolean;
29
21
  /**
30
- * Returns `true` when `line` matches any regex in `allow`. Safe to call
31
- * with an empty allowlist (always returns `false`).
22
+ * One compiled allowlist entry with its provenance retained so the smoke
23
+ * summary can attribute hits per entry (FORGE G8) — an entry that silently
24
+ * stops matching (its suppressed shape changed upstream) is only
25
+ * detectable when zero-hit entries are visible.
26
+ */
27
+ export interface CompiledAllowlistEntry {
28
+ pattern: RegExp;
29
+ /** Verbatim source text of the entry. */
30
+ source: string;
31
+ /** Human-readable origin: `allow.txt:12` or `--console-allow #2`. */
32
+ origin: string;
33
+ }
34
+ /**
35
+ * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
36
+ * First-match attribution is deterministic and cheap; a line matching
37
+ * several entries credits the earliest one. Safe to call with an empty
38
+ * allowlist (always returns -1).
32
39
  */
33
- export declare function matchesAllowlist(line: string, allow: readonly RegExp[]): boolean;
40
+ export declare function matchAllowlist(line: string, allow: readonly CompiledAllowlistEntry[]): number;
34
41
  /**
35
42
  * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
36
43
  * lines and `#`-prefixed comments are skipped. Each remaining line is
37
- * compiled as a RegExp. A bad pattern throws immediately — better to fail
38
- * fast at CLI parse time than to silently let a typo match nothing.
44
+ * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
45
+ * pattern throws immediately — better to fail fast at CLI parse time than
46
+ * to silently let a typo match nothing.
39
47
  */
40
- export declare function compileAllowlistFromFile(body: string, sourcePath: string): RegExp[];
48
+ export declare function compileAllowlistFromFile(body: string, sourcePath: string): CompiledAllowlistEntry[];
41
49
  /**
42
50
  * Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
43
51
  * flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
44
52
  */
45
- export declare function compileAllowlistFromStrings(sources: readonly string[]): RegExp[];
53
+ export declare function compileAllowlistFromStrings(sources: readonly string[]): CompiledAllowlistEntry[];
@@ -19,6 +19,7 @@
19
19
  * Additions here should be conservative — false positives turn every
20
20
  * smoke run into noise for operators and every CI run into flake.
21
21
  */
22
+ import { toError } from '../utils/errors.js';
22
23
  export const SMOKE_ERROR_PATTERNS = [
23
24
  // Firefox chrome error lines — `JavaScript error: chrome://…, line N: TypeError: …`.
24
25
  /^\s*JavaScript error:/i,
@@ -32,7 +33,7 @@ export const SMOKE_ERROR_PATTERNS = [
32
33
  /**
33
34
  * Returns `true` when `line` matches any pattern in
34
35
  * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
35
- * lives in {@link matchesAllowlist}, so the smoke runner can count
36
+ * lives in {@link matchAllowlist}, so the smoke runner can count
36
37
  * allowlisted hits separately from raw error matches for its summary.
37
38
  */
38
39
  export function matchesSmokeError(line) {
@@ -44,22 +45,25 @@ export function matchesSmokeError(line) {
44
45
  return false;
45
46
  }
46
47
  /**
47
- * Returns `true` when `line` matches any regex in `allow`. Safe to call
48
- * with an empty allowlist (always returns `false`).
48
+ * Returns the index of the FIRST entry in `allow` matching `line`, or -1.
49
+ * First-match attribution is deterministic and cheap; a line matching
50
+ * several entries credits the earliest one. Safe to call with an empty
51
+ * allowlist (always returns -1).
49
52
  */
50
- export function matchesAllowlist(line, allow) {
51
- for (const pattern of allow) {
52
- if (pattern.test(line)) {
53
- return true;
53
+ export function matchAllowlist(line, allow) {
54
+ for (let i = 0; i < allow.length; i++) {
55
+ if (allow[i]?.pattern.test(line)) {
56
+ return i;
54
57
  }
55
58
  }
56
- return false;
59
+ return -1;
57
60
  }
58
61
  /**
59
62
  * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
60
63
  * lines and `#`-prefixed comments are skipped. Each remaining line is
61
- * compiled as a RegExp. A bad pattern throws immediately — better to fail
62
- * fast at CLI parse time than to silently let a typo match nothing.
64
+ * compiled as a RegExp with its `<file>:<line>` origin retained. A bad
65
+ * pattern throws immediately — better to fail fast at CLI parse time than
66
+ * to silently let a typo match nothing.
63
67
  */
64
68
  export function compileAllowlistFromFile(body, sourcePath) {
65
69
  const lines = body.split(/\r?\n/);
@@ -69,10 +73,14 @@ export function compileAllowlistFromFile(body, sourcePath) {
69
73
  if (!line || line.startsWith('#'))
70
74
  return;
71
75
  try {
72
- compiled.push(new RegExp(line));
76
+ compiled.push({
77
+ pattern: new RegExp(line),
78
+ source: line,
79
+ origin: `${sourcePath}:${String(index + 1)}`,
80
+ });
73
81
  }
74
82
  catch (error) {
75
- const message = error instanceof Error ? error.message : String(error);
83
+ const message = toError(error).message;
76
84
  throw new Error(`Invalid allowlist regex at ${sourcePath}:${String(index + 1)}: ${message}`, {
77
85
  cause: error,
78
86
  });
@@ -88,10 +96,14 @@ export function compileAllowlistFromStrings(sources) {
88
96
  const compiled = [];
89
97
  sources.forEach((source, index) => {
90
98
  try {
91
- compiled.push(new RegExp(source));
99
+ compiled.push({
100
+ pattern: new RegExp(source),
101
+ source,
102
+ origin: `--console-allow #${String(index + 1)}`,
103
+ });
92
104
  }
93
105
  catch (error) {
94
- const message = error instanceof Error ? error.message : String(error);
106
+ const message = toError(error).message;
95
107
  throw new Error(`Invalid --console-allow regex at position ${String(index + 1)} ("${source}"): ${message}`, { cause: error });
96
108
  }
97
109
  });