@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
@@ -12,16 +12,24 @@
12
12
  * `tree remove` + `tree create`.
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
- import { readdir, readFile, rm } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { lstat, readdir, readFile, realpath, rm } from 'node:fs/promises';
16
17
  import { basename, join, resolve, sep } from 'node:path';
17
18
  import { GeneralError } from '../errors/base.js';
19
+ import { mapWithConcurrency } from '../utils/concurrency.js';
20
+ import { getNodeErrorCode, isProcessAlive, toError } from '../utils/errors.js';
18
21
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
19
- import { verbose } from '../utils/logger.js';
20
- import { withFileLock } from './file-lock.js';
22
+ import { verbose, warn } from '../utils/logger.js';
23
+ import { isObject } from '../utils/validation.js';
24
+ import { getBuildBaselinePath } from './build-baseline.js';
25
+ import { LOCK_PID_FILE, withFileLock } from './file-lock.js';
21
26
  import { git } from './git-base.js';
27
+ import { getAllDiff } from './git-diff.js';
28
+ import { attemptMozinfoRewrite } from './mach-build-artifacts.js';
22
29
  import { cloneEntry } from './tree-cow.js';
23
30
  const TREE_MARKER_FILENAME = 'tree.json';
24
31
  const TREES_DIRNAME = 'trees';
32
+ const TREE_CLONE_CONCURRENCY = 8;
25
33
  /**
26
34
  * Top-level entries never cloned into a tree: per-tree state must start
27
35
  * clean (`.fireforge/`), locks are never inherited, the destructive-
@@ -45,38 +53,137 @@ export async function withTreeLifecycleLock(primaryRoot, operation) {
45
53
  onTimeoutMessage: 'Another fireforge tree create/remove is in progress. Wait for it to finish, then retry.',
46
54
  });
47
55
  }
48
- /** Reads the tree marker for a project root, or undefined when not a tree. */
49
- export async function readTreeMarker(root) {
50
- const markerPath = join(root, '.fireforge', TREE_MARKER_FILENAME);
51
- if (!(await pathExists(markerPath)))
52
- return undefined;
56
+ /**
57
+ * Validates every field of a tree marker.
58
+ *
59
+ * The read below checked only `schemaVersion` and `name` and asserted the
60
+ * rest, but the unvalidated fields are consumed unguarded: `primaryRoot`
61
+ * reaches a user-facing refusal message (`tree-guard.ts:105`), and
62
+ * `engineHead`/`patchesFingerprint` feed the staleness compare, where an
63
+ * `undefined` from a truncated marker reports every tree as stale.
64
+ */
65
+ function isTreeMarker(value) {
66
+ if (!isObject(value))
67
+ return false;
68
+ return (value['schemaVersion'] === 1 &&
69
+ typeof value['name'] === 'string' &&
70
+ typeof value['primaryRoot'] === 'string' &&
71
+ typeof value['createdAt'] === 'string' &&
72
+ (typeof value['engineHead'] === 'string' || value['engineHead'] === null) &&
73
+ (value['engineFingerprint'] === undefined ||
74
+ typeof value['engineFingerprint'] === 'string' ||
75
+ value['engineFingerprint'] === null) &&
76
+ (typeof value['patchesFingerprint'] === 'string' || value['patchesFingerprint'] === null) &&
77
+ (value['clonedObjdir'] === undefined || typeof value['clonedObjdir'] === 'string'));
78
+ }
79
+ /** Absolute path of a root's tree marker. */
80
+ export function getTreeMarkerPath(root) {
81
+ return join(root, '.fireforge', TREE_MARKER_FILENAME);
82
+ }
83
+ /**
84
+ * Reads the tree marker for a project root, distinguishing "not a tree" from
85
+ * "a tree whose marker we could not read". Callers that make a safety decision
86
+ * (the guard hook, the no-nesting refusal) must use this rather than
87
+ * {@link readTreeMarker}.
88
+ *
89
+ * Only ENOENT/ENOTDIR mean `absent`: any other probe failure (EACCES on the
90
+ * `.fireforge` directory, EIO, …) reports `corrupt`, because "we cannot read
91
+ * the marker" is not evidence that there is no marker — a `pathExists`
92
+ * pre-check here classified a tree with an unreadable `.fireforge` as
93
+ * `absent` and handed it the full mutating command set.
94
+ */
95
+ export async function readTreeMarkerState(root) {
96
+ const markerPath = getTreeMarkerPath(root);
53
97
  try {
54
98
  const raw = await readJson(markerPath);
55
- if (raw.schemaVersion !== 1 || typeof raw.name !== 'string')
56
- return undefined;
57
- return raw;
99
+ if (!isTreeMarker(raw)) {
100
+ verbose(`Malformed tree marker at ${markerPath}`);
101
+ return { kind: 'corrupt', reason: 'the marker is missing or mistypes a required field' };
102
+ }
103
+ return { kind: 'valid', marker: raw };
58
104
  }
59
105
  catch (error) {
106
+ const code = getNodeErrorCode(error);
107
+ if (code === 'ENOENT' || code === 'ENOTDIR')
108
+ return { kind: 'absent' };
60
109
  verbose(`Unreadable tree marker at ${markerPath}: ${String(error)}`);
61
- return undefined;
110
+ return { kind: 'corrupt', reason: `the marker could not be read (${toError(error).message})` };
111
+ }
112
+ }
113
+ /**
114
+ * Reads the tree marker for a project root, or undefined when it is absent
115
+ * OR unreadable.
116
+ *
117
+ * For display and best-effort paths only (`tree list` renders an unreadable
118
+ * marker as staleness 'unknown'). Anything that decides whether a mutation may
119
+ * proceed must call {@link readTreeMarkerState} and handle `corrupt`.
120
+ */
121
+ export async function readTreeMarker(root) {
122
+ const state = await readTreeMarkerState(root);
123
+ return state.kind === 'valid' ? state.marker : undefined;
124
+ }
125
+ /**
126
+ * Refuses a build-less in-tree `test` when the objdir preflight found is not
127
+ * the one this tree's marker vouched for as rewritten-and-reconfigured. A
128
+ * mismatch means an objdir appeared in the tree through some path other than
129
+ * `tree create --with-objdir` (manual copy, partial restore), so nothing
130
+ * proves it was relocated — running against it could consult primary paths.
131
+ * No-op outside a tree; a corrupt marker is already refused by the guard.
132
+ */
133
+ export async function assertObjdirMatchesTreeMarker(projectRoot, objDir) {
134
+ const state = await readTreeMarkerState(projectRoot);
135
+ if (state.kind !== 'valid' || state.marker.clonedObjdir === undefined)
136
+ return;
137
+ if (objDir !== state.marker.clonedObjdir) {
138
+ throw new GeneralError(`This verification tree's marker records "${state.marker.clonedObjdir}" as its cloned build, ` +
139
+ `but the preflight found ${objDir === undefined ? 'no objdir' : `"${objDir}"`}. ` +
140
+ 'Only the objdir cloned by tree create --with-objdir is proven to be relocated to this tree. ' +
141
+ 'Remove and re-create the tree (fireforge tree remove/create).');
62
142
  }
63
143
  }
64
- /** Current primary-side snapshot identity (engine HEAD + patches.json hash). */
144
+ /** Current primary-side snapshot identity, including dirty source and patch bodies. */
65
145
  export async function computePrimaryFingerprint(primaryRoot) {
146
+ const engineDir = join(primaryRoot, 'engine');
66
147
  let engineHead;
67
148
  try {
68
- engineHead = (await git(['rev-parse', 'HEAD'], join(primaryRoot, 'engine'))).trim();
149
+ engineHead = (await git(['rev-parse', 'HEAD'], engineDir)).trim();
69
150
  }
70
151
  catch {
152
+ // An engine that is missing, unborn, or not a git checkout has no HEAD to
153
+ // fingerprint. `null` is a legal marker value and reads as 'unknown', which
154
+ // `listTrees` renders as staleness 'unknown' rather than a false 'fresh'.
71
155
  engineHead = null;
72
156
  }
73
- const patchesJson = join(primaryRoot, 'patches', 'patches.json');
74
- const patchesFingerprint = (await pathExists(patchesJson))
75
- ? createHash('sha256')
76
- .update(await readFile(patchesJson))
77
- .digest('hex')
78
- : null;
79
- return { engineHead, patchesFingerprint };
157
+ let engineFingerprint;
158
+ try {
159
+ engineFingerprint = createHash('sha256')
160
+ .update(await getAllDiff(engineDir))
161
+ .digest('hex');
162
+ }
163
+ catch {
164
+ engineFingerprint = null;
165
+ }
166
+ const patchesDir = join(primaryRoot, 'patches');
167
+ let patchesFingerprint = null;
168
+ try {
169
+ if (await pathExists(patchesDir)) {
170
+ const hash = createHash('sha256');
171
+ for (const name of (await readdir(patchesDir))
172
+ .filter((entry) => entry.endsWith('.patch') || entry === 'patches.json')
173
+ .sort()) {
174
+ hash
175
+ .update(name)
176
+ .update('\0')
177
+ .update(await readFile(join(patchesDir, name)))
178
+ .update('\0');
179
+ }
180
+ patchesFingerprint = hash.digest('hex');
181
+ }
182
+ }
183
+ catch {
184
+ patchesFingerprint = null;
185
+ }
186
+ return { engineHead, engineFingerprint, patchesFingerprint };
80
187
  }
81
188
  /** Validates a tree name: one path segment, no traversal. */
82
189
  export function assertValidTreeName(name) {
@@ -84,54 +191,156 @@ export function assertValidTreeName(name) {
84
191
  throw new GeneralError(`Invalid tree name "${name}". Use a single path segment of letters, digits, ".", "_" or "-".`);
85
192
  }
86
193
  }
194
+ /**
195
+ * Refuses an objdir that is not a plain directory physically inside
196
+ * `engineDir` — every `cp` mode `cloneEntry` uses preserves symlinks, so
197
+ * a symlinked `engine/obj-*` would be cloned as a symlink pointing back
198
+ * at the original (primary or fully external) build, and the clone's
199
+ * mozinfo rewrite and `_virtualenvs` removal would then mutate that
200
+ * original through the link. Checks lstat (the entry itself must be a
201
+ * real directory) AND realpath containment (a real directory reached
202
+ * through a symlinked parent resolves elsewhere) — the rewriter's
203
+ * lexical `resolve()` paths cannot detect either. Also validates the
204
+ * name as a single `obj-*` path segment. Role `'primary'` guards before
205
+ * any copying starts; `'cloned'` re-checks the tree's copy before any
206
+ * write goes through it (defense in depth).
207
+ */
208
+ async function assertCloneSafeObjdir(engineDir, objDir, role) {
209
+ const where = role === 'primary' ? 'engine/' : "the tree's engine/";
210
+ if (!/^obj-[A-Za-z0-9][A-Za-z0-9._-]*$/.test(objDir) || objDir.includes('..')) {
211
+ throw new GeneralError(`Invalid objdir name "${objDir}". Expected a single obj-* path segment of letters, digits, ".", "_" or "-".`);
212
+ }
213
+ const objDirPath = join(engineDir, objDir);
214
+ let stats;
215
+ try {
216
+ stats = await lstat(objDirPath);
217
+ }
218
+ catch (error) {
219
+ throw new GeneralError(`tree create --with-objdir cannot stat ${where}${objDir}: ${toError(error).message}`);
220
+ }
221
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
222
+ throw new GeneralError(`tree create --with-objdir refuses ${where}${objDir}: it is ${stats.isSymbolicLink() ? 'a symlink' : 'not a directory'}. A cloned symlinked objdir would be rewritten through the link, mutating the ` +
223
+ 'original build it points at. Replace it with a real directory or create the tree ' +
224
+ 'without --with-objdir.');
225
+ }
226
+ const [realObjDir, realEngineDir] = await Promise.all([
227
+ realpath(objDirPath),
228
+ realpath(engineDir),
229
+ ]);
230
+ if (realObjDir !== join(realEngineDir, objDir)) {
231
+ throw new GeneralError(`tree create --with-objdir refuses ${where}${objDir}: it resolves to ${realObjDir}, ` +
232
+ `outside ${realEngineDir}. An objdir reached through a symlink would be rewritten ` +
233
+ 'in place, mutating the original build. Replace it with a real directory or create ' +
234
+ 'the tree without --with-objdir.');
235
+ }
236
+ }
87
237
  /**
88
238
  * Materialises the tree at `treeRoot` from `primaryRoot` using
89
239
  * `capability` ('none' = plain copy, already gated on --force-copy by
90
- * the caller). The engine tree is cloned whole and its `obj-*` build
91
- * directories removed afterwards — CoW removal is cheap, and per-entry
92
- * recursion into a Firefox tree is fragile. A fresh `.fireforge/` is
93
- * created with only `state.json` (when present) and the tree marker.
240
+ * the caller). The engine is cloned one TOP-LEVEL entry at a time so unwanted
241
+ * `obj-*` directories are never traversed or materialised. That distinction
242
+ * is substantial on Firefox trees: even CoW-cloning and deleting an objdir
243
+ * touches metadata for millions of build outputs. With `withObjdir` exactly
244
+ * that objdir is included — after {@link assertCloneSafeObjdir}
245
+ * refuses symlinked or engine-escaping objdirs before any copying —
246
+ * rewritten to the tree (see below), and
247
+ * reconfigured in-tree via the caller-supplied `reconfigure` hook. A
248
+ * fresh `.fireforge/` is created with only `state.json` (when present),
249
+ * the last-build baseline (withObjdir only), and the tree marker.
94
250
  */
95
251
  export async function cloneTree(args) {
96
- const { primaryRoot, treeRoot, name, capability, createdAt } = args;
252
+ const { primaryRoot, treeRoot, name, capability, createdAt, withObjdir } = args;
97
253
  const { mkdir, cp } = await import('node:fs/promises');
98
- await mkdir(treeRoot, { recursive: true });
99
- for (const entry of await readdir(primaryRoot)) {
100
- if (isExcludedRootEntry(entry))
101
- continue;
102
- await cloneEntry(capability, join(primaryRoot, entry), join(treeRoot, entry));
254
+ if (withObjdir) {
255
+ // Refuse a symlinked/escaping primary objdir BEFORE any copying: the
256
+ // clone would carry the symlink and every later write would land in
257
+ // the original build (see assertCloneSafeObjdir).
258
+ await assertCloneSafeObjdir(join(primaryRoot, 'engine'), withObjdir.objDir, 'primary');
103
259
  }
104
- // A cloned objdir embeds absolute topsrcdir/topobjdir paths back into
105
- // the PRIMARY tree (mach-build-artifacts.ts parses exactly those), so
106
- // in-tree builds/tests would silently operate against the primary —
107
- // remove the clones; `tree` is read-verification only in 0.40.0.
260
+ await mkdir(treeRoot, { recursive: true });
261
+ const rootEntries = (await readdir(primaryRoot)).filter((entry) => !isExcludedRootEntry(entry));
108
262
  const engineDir = join(treeRoot, 'engine');
109
- if (await pathExists(engineDir)) {
110
- for (const entry of await readdir(engineDir)) {
111
- if (entry.startsWith('obj-')) {
112
- await rm(join(engineDir, entry), { recursive: true, force: true });
113
- }
263
+ await mapWithConcurrency(rootEntries, TREE_CLONE_CONCURRENCY, async (entry) => {
264
+ if (entry !== 'engine') {
265
+ await cloneEntry(capability, join(primaryRoot, entry), join(treeRoot, entry));
266
+ return;
114
267
  }
268
+ const primaryEngineDir = join(primaryRoot, 'engine');
269
+ await mkdir(engineDir, { recursive: true });
270
+ const engineEntries = (await readdir(primaryEngineDir)).filter((engineEntry) => !engineEntry.startsWith('obj-') || engineEntry === withObjdir?.objDir);
271
+ await mapWithConcurrency(engineEntries, TREE_CLONE_CONCURRENCY, async (engineEntry) => {
272
+ await cloneEntry(capability, join(primaryEngineDir, engineEntry), join(engineDir, engineEntry));
273
+ });
274
+ });
275
+ // A selected objdir embeds absolute topsrcdir/topobjdir paths back into
276
+ // the PRIMARY tree. It is rewritten below before the marker records it as
277
+ // usable; no other objdir ever enters the snapshot.
278
+ if (await pathExists(engineDir)) {
115
279
  // Defensive: never inherit a mid-operation git index lock.
116
280
  await rm(join(engineDir, '.git', 'index.lock'), { force: true });
117
281
  }
282
+ if (withObjdir) {
283
+ await rewriteClonedObjdir(engineDir, withObjdir.objDir);
284
+ // Regenerate configure output (config.status, backend.mk, Makefile,
285
+ // config/autoconf.mk) against the tree's paths — the mozinfo rewrite
286
+ // alone leaves those naming the primary. A failed reconfigure throws
287
+ // before the marker below records the objdir as usable (fail-closed).
288
+ await withObjdir.reconfigure(engineDir);
289
+ }
118
290
  await mkdir(join(treeRoot, '.fireforge'), { recursive: true });
119
291
  const stateJson = join(primaryRoot, '.fireforge', 'state.json');
120
292
  if (await pathExists(stateJson)) {
121
293
  await cp(stateJson, join(treeRoot, '.fireforge', 'state.json'));
122
294
  }
123
- const { engineHead, patchesFingerprint } = await computePrimaryFingerprint(primaryRoot);
295
+ if (withObjdir) {
296
+ // The in-tree stale-build and static-components gates anchor on the
297
+ // last-build baseline; without the copy they would read `undefined`
298
+ // and degrade to first-build semantics inside a fully built tree.
299
+ const baseline = getBuildBaselinePath(primaryRoot);
300
+ if (await pathExists(baseline)) {
301
+ await cp(baseline, getBuildBaselinePath(treeRoot));
302
+ }
303
+ }
304
+ const { engineHead, engineFingerprint, patchesFingerprint } = await computePrimaryFingerprint(primaryRoot);
124
305
  const marker = {
125
306
  schemaVersion: 1,
126
307
  name,
127
308
  primaryRoot,
128
309
  createdAt,
129
310
  engineHead,
311
+ engineFingerprint: engineFingerprint ?? null,
130
312
  patchesFingerprint,
313
+ ...(withObjdir ? { clonedObjdir: withObjdir.objDir } : {}),
131
314
  };
132
315
  await writeJson(join(treeRoot, '.fireforge', TREE_MARKER_FILENAME), marker);
133
316
  return marker;
134
317
  }
318
+ /**
319
+ * Points the cloned objdir at the tree: rewrites mozinfo.json's
320
+ * topsrcdir/topobjdir (and an in-tree mozconfig) via the same
321
+ * safe-relocation rewriter `build --rewrite-mozinfo` uses, refusing
322
+ * fail-closed when its safety rules cannot prove the rewrite correct —
323
+ * a tree must never hold an objdir that still names the primary. Before
324
+ * any write, the cloned objdir is re-checked as a real directory inside
325
+ * the tree ({@link assertCloneSafeObjdir}) so no write ever goes through
326
+ * a symlink into the original build. The cloned `_virtualenvs` are
327
+ * removed outright: their scripts carry primary-tree shebang paths, and
328
+ * mach rebuilds venvs in-tree on first use. The configure-generated root
329
+ * files (config.status, backend.mk, Makefile, config/autoconf.mk) still
330
+ * name the primary after this rewrite; they are regenerated by the
331
+ * in-tree `mach configure` the caller supplies to {@link cloneTree},
332
+ * which runs before the marker is written.
333
+ */
334
+ async function rewriteClonedObjdir(engineDir, objDir) {
335
+ await assertCloneSafeObjdir(engineDir, objDir, 'cloned');
336
+ const rewrite = await attemptMozinfoRewrite(engineDir, objDir);
337
+ if (!rewrite.rewritten) {
338
+ throw new GeneralError(`Cannot keep the cloned build: ${rewrite.reason ?? 'mozinfo.json rewrite refused'}. ` +
339
+ 'The objdir would still operate against the primary tree. ' +
340
+ 'Re-run tree create without --with-objdir, or rebuild the primary objdir with "fireforge build" and retry.');
341
+ }
342
+ await rm(join(engineDir, objDir, '_virtualenvs'), { recursive: true, force: true });
343
+ }
135
344
  /** Lists trees with staleness against the current primary state. */
136
345
  export async function listTrees(primaryRoot) {
137
346
  const treesDir = getTreesDir(primaryRoot);
@@ -150,47 +359,68 @@ export async function listTrees(primaryRoot) {
150
359
  }
151
360
  const staleness = marker.engineHead !== current.engineHead
152
361
  ? 'stale (engine advanced)'
153
- : marker.patchesFingerprint !== current.patchesFingerprint
154
- ? 'stale (patches changed)'
155
- : 'fresh';
362
+ : marker.engineFingerprint !== undefined &&
363
+ marker.engineFingerprint !== current.engineFingerprint
364
+ ? 'stale (engine worktree changed)'
365
+ : marker.patchesFingerprint !== current.patchesFingerprint
366
+ ? 'stale (patches changed)'
367
+ : 'fresh';
156
368
  entries.push({ name, path, createdAt: marker.createdAt, staleness });
157
369
  }
158
370
  return entries;
159
371
  }
160
- /** Uses `kill(pid, 0)` — no signal sent, just an existence check. */
161
- function isProcessAlive(pid) {
162
- try {
163
- process.kill(pid, 0);
164
- return true;
165
- }
166
- catch {
167
- return false;
168
- }
169
- }
170
372
  /**
171
- * Returns the live holder PID of a mkdir-style lock directory, if any.
172
- * Reads the lock's `pid` file directly (line 1 = PID — the format
173
- * `file-lock.ts` documents as stable) instead of exporting file-lock
174
- * internals.
373
+ * Classifies a mkdir-style lock directory. Reads the lock's owner file
374
+ * directly (line 1 = PID — the format `file-lock.ts` documents as stable,
375
+ * whose name it shares via {@link LOCK_PID_FILE}) instead of exporting
376
+ * file-lock internals.
377
+ *
378
+ * Liveness comes from the shared {@link isProcessAlive}, which treats EPERM
379
+ * as alive. The local copy this replaced returned `false` for EPERM, so a
380
+ * tree whose build lock was held by a live process running under a different
381
+ * uid passed the guard below and was `rm -rf`'d mid-build.
382
+ *
383
+ * The `unknown` state is not a theoretical case. `withFileLock` takes the lock
384
+ * by creating the directory and only *then* writes the owner file, treating a
385
+ * write failure as non-fatal — so every acquisition passes through a window in
386
+ * which the lock is genuinely held and has no readable PID, and a read-only or
387
+ * full filesystem leaves it that way for the lock's whole life. Reporting that
388
+ * as `free` let `removeTree` recursively delete a tree out from under a live
389
+ * build. `file-lock.ts` reaches the same conclusion for its own stale
390
+ * recovery, where an unreadable PID falls back to an age-only heuristic rather
391
+ * than assuming the lock is abandoned.
175
392
  */
176
- async function liveLockHolder(lockPath) {
393
+ async function inspectTreeLock(lockPath) {
394
+ if (!existsSync(lockPath))
395
+ return { kind: 'free' };
396
+ let pidLine;
177
397
  try {
178
- const pidLine = (await readFile(join(lockPath, 'pid'), 'utf-8')).split('\n')[0] ?? '';
179
- const pid = parseInt(pidLine.trim(), 10);
180
- if (Number.isFinite(pid) && isProcessAlive(pid))
181
- return pid;
398
+ pidLine = (await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8')).split('\n')[0] ?? '';
182
399
  }
183
- catch {
184
- // No lock / unreadable pid file — treat as not held.
400
+ catch (error) {
401
+ // A lock cleanly released between the existence probe above and this read
402
+ // leaves no directory behind — that is a release, not an unreadable owner,
403
+ // and demanding --force for it would be a spurious refusal.
404
+ if (!existsSync(lockPath))
405
+ return { kind: 'free' };
406
+ return {
407
+ kind: 'unknown',
408
+ reason: `its owner record is missing or unreadable (${toError(error).message})`,
409
+ };
410
+ }
411
+ const pid = parseInt(pidLine.trim(), 10);
412
+ if (!Number.isFinite(pid)) {
413
+ return { kind: 'unknown', reason: 'its owner record does not name a process id' };
185
414
  }
186
- return undefined;
415
+ return isProcessAlive(pid) ? { kind: 'held', pid } : { kind: 'free' };
187
416
  }
188
417
  /**
189
418
  * Removes one tree. Refuses when a live process holds the tree's build
190
- * or engine-session lock, and never deletes a path outside the trees
191
- * directory (containment check before `rm -rf`).
419
+ * or engine-session lock — or when a lock directory exists whose ownership
420
+ * cannot be established, which `--force` overrides — and never deletes a path
421
+ * outside the trees directory (containment check before `rm -rf`).
192
422
  */
193
- export async function removeTree(primaryRoot, name) {
423
+ export async function removeTree(primaryRoot, name, options = {}) {
194
424
  assertValidTreeName(name);
195
425
  const treesDir = getTreesDir(primaryRoot);
196
426
  const treeRoot = resolve(treesDir, name);
@@ -204,11 +434,19 @@ export async function removeTree(primaryRoot, name) {
204
434
  join(treeRoot, '.fireforge-build.lock'),
205
435
  join(treeRoot, '.fireforge', 'engine-session.lock'),
206
436
  ]) {
207
- const holder = await liveLockHolder(lockPath);
208
- if (holder !== undefined) {
209
- throw new GeneralError(`Refusing to remove tree "${name}": a live process (pid ${String(holder)}) holds ${lockPath}. ` +
437
+ const state = await inspectTreeLock(lockPath);
438
+ if (state.kind === 'held') {
439
+ throw new GeneralError(`Refusing to remove tree "${name}": a live process (pid ${String(state.pid)}) holds ${lockPath}. ` +
210
440
  'Wait for it to finish, then retry.');
211
441
  }
442
+ if (state.kind === 'unknown' && options.force !== true) {
443
+ throw new GeneralError(`Refusing to remove tree "${name}": ${lockPath} exists but ${state.reason}, so it may be ` +
444
+ 'held by a live process. Wait for any in-flight build or test to finish; if none is ' +
445
+ 'running, re-run with --force to delete the tree and the stale lock.');
446
+ }
447
+ if (state.kind === 'unknown') {
448
+ warn(`Removing tree "${name}" through ${lockPath}, whose ownership is unknown (--force).`);
449
+ }
212
450
  }
213
451
  await rm(treeRoot, { recursive: true, force: true });
214
452
  }
@@ -19,6 +19,7 @@
19
19
  * at `npm install typescript`.
20
20
  */
21
21
  import { dirname, isAbsolute, relative, resolve } from 'node:path';
22
+ import { toError } from '../utils/errors.js';
22
23
  import { pathExists } from '../utils/fs.js';
23
24
  import { verbose } from '../utils/logger.js';
24
25
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
@@ -113,7 +114,7 @@ export async function runTypecheck(projectRoot, cfg) {
113
114
  catch (err) {
114
115
  // A missing or unreadable shim fails only the project(s) that use it,
115
116
  // not the whole run — projects with a different (or no) shim still run.
116
- const message = err instanceof Error ? err.message : String(err);
117
+ const message = toError(err).message;
117
118
  results.push({
118
119
  project: projectPath,
119
120
  issues: [
@@ -8,7 +8,7 @@ import { GeneralError } from '../errors/base.js';
8
8
  import { BuildError } from '../errors/build.js';
9
9
  import { pathExists, readText, writeText } from '../utils/fs.js';
10
10
  import { escapeRegex } from '../utils/regex.js';
11
- import { detectIndent, parseScript } from './ast-utils.js';
11
+ import { asEstree, detectIndent, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
13
  import { assertBraceBalancePreserved, coerceToCall, extractNameFromExpression, findMethodBody, findMethodBraceIndex, validateWireName, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
@@ -36,7 +36,10 @@ export function addDestroyAST(content, expression, marker = DEFAULT_MARKER) {
36
36
  throw new BuildError('Could not find onUnload/uninit method body via AST');
37
37
  }
38
38
  // Insert at top of method body (LIFO ordering)
39
- const firstStmt = body.body[0];
39
+ // `noUncheckedIndexedAccess` makes this `Statement | undefined`; narrow
40
+ // before casting rather than laundering the undefined through the cast.
41
+ const firstRaw = body.body[0];
42
+ const firstStmt = firstRaw ? asEstree(firstRaw) : undefined;
40
43
  let insertPos;
41
44
  let indent;
42
45
  if (firstStmt) {
@@ -114,7 +117,15 @@ export async function addDestroyToBrowserInit(engineDir, expression, marker = DE
114
117
  if (destroyPattern.test(content)) {
115
118
  return false;
116
119
  }
117
- const { value, usedFallback } = withParserFallback(() => addDestroyAST(content, expression, marker), () => legacyAddDestroy(content, expression, marker), BROWSER_INIT_JS);
120
+ const { value, usedFallback } = withParserFallback(() => addDestroyAST(content, expression, marker), () => legacyAddDestroy(content, expression, marker), BROWSER_INIT_JS,
121
+ // Rethrow only the internal-invariant GeneralErrors ("Unexpected empty
122
+ // …array"): those are programming bugs, and retrying the legacy scanner
123
+ // cannot fix a broken invariant — it just buries the stack. Everything
124
+ // else still falls back, which is load-bearing: the AST path raises a raw
125
+ // acorn SyntaxError on chrome sources acorn cannot parse (preprocessor
126
+ // directives), and BuildError when the file's shape is unexpected. Both
127
+ // are exactly what the legacy scanner is here to handle.
128
+ (error) => error instanceof GeneralError);
118
129
  if (usedFallback) {
119
130
  assertBraceBalancePreserved(content, value, BROWSER_INIT_JS);
120
131
  }
@@ -8,7 +8,7 @@ import { GeneralError } from '../errors/base.js';
8
8
  import { BuildError } from '../errors/build.js';
9
9
  import { pathExists, readText, writeText } from '../utils/fs.js';
10
10
  import { escapeRegex } from '../utils/regex.js';
11
- import { detectIndent, getNodeSource, parseScript } from './ast-utils.js';
11
+ import { asEstree, detectIndent, getNodeSource, parseScript, } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
13
  import { assertBraceBalancePreserved, coerceToCall, extractNameFromExpression, findInsertionAfterFireforgeBlocks, findMethodBody, findMethodBraceIndex, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
@@ -18,6 +18,30 @@ const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
18
18
  * fallback code paths agree on the shape.
19
19
  */
20
20
  const DEFAULT_MARKER = 'FIREFORGE:';
21
+ /**
22
+ * Default insertion point: after the last consecutive fireforge block, or at
23
+ * the top of the method body when there is none.
24
+ *
25
+ * Both callers below — the `--after`-target-not-found fallthrough and the
26
+ * no-`--after` default — ran a character-for-character copy of this ladder,
27
+ * including both throw guards. `browser-init.js` is written through those two
28
+ * independent paths and they MUST emit identical blocks, so having one
29
+ * implementation enforces the invariant rather than merely tidying.
30
+ */
31
+ function resolveDefaultInsertion(content, body, fireforgeBlocks) {
32
+ if (fireforgeBlocks.length > 0) {
33
+ const lastBlock = fireforgeBlocks[fireforgeBlocks.length - 1];
34
+ if (!lastBlock)
35
+ throw new GeneralError('Unexpected empty fireforgeBlocks array');
36
+ return { insertPos: lastBlock.end, indent: detectIndent(content, lastBlock.start) };
37
+ }
38
+ const firstStmt = body.body[0];
39
+ if (firstStmt) {
40
+ const insertPos = asEstree(firstStmt).start;
41
+ return { insertPos, indent: detectIndent(content, insertPos) };
42
+ }
43
+ return { insertPos: body.start + 1, indent: ' ' };
44
+ }
21
45
  /**
22
46
  * AST-based implementation: finds onLoad() method body, locates existing
23
47
  * fireforge init blocks (TryStatements containing typeof guards), and inserts
@@ -47,7 +71,7 @@ export function addInitAST(content, expression, after, marker = DEFAULT_MARKER)
47
71
  const fireforgeBlocks = [];
48
72
  for (const stmt of body.body) {
49
73
  if (stmt.type === 'TryStatement') {
50
- const tryNode = stmt;
74
+ const tryNode = asEstree(stmt);
51
75
  const src = getNodeSource(content, tryNode);
52
76
  if (/typeof\s+\w+\s*!==\s*"undefined"/.test(src)) {
53
77
  fireforgeBlocks.push(tryNode);
@@ -68,47 +92,12 @@ export function addInitAST(content, expression, after, marker = DEFAULT_MARKER)
68
92
  }
69
93
  else {
70
94
  // --after target not found: fall through to default (after last fireforge block)
71
- if (fireforgeBlocks.length > 0) {
72
- const lastBlock = fireforgeBlocks[fireforgeBlocks.length - 1];
73
- if (!lastBlock)
74
- throw new GeneralError('Unexpected empty fireforgeBlocks array');
75
- insertPos = lastBlock.end;
76
- indent = detectIndent(content, lastBlock.start);
77
- }
78
- else {
79
- // No fireforge blocks, insert at top of method body
80
- const firstStmt = body.body[0];
81
- if (firstStmt) {
82
- insertPos = firstStmt.start;
83
- indent = detectIndent(content, insertPos);
84
- }
85
- else {
86
- insertPos = body.start + 1;
87
- indent = ' ';
88
- }
89
- }
95
+ ({ insertPos, indent } = resolveDefaultInsertion(content, body, fireforgeBlocks));
90
96
  }
91
97
  }
92
98
  else {
93
99
  // Default: insert after the last consecutive fireforge block at the start
94
- if (fireforgeBlocks.length > 0) {
95
- const lastBlock = fireforgeBlocks[fireforgeBlocks.length - 1];
96
- if (!lastBlock)
97
- throw new GeneralError('Unexpected empty fireforgeBlocks array');
98
- insertPos = lastBlock.end;
99
- indent = detectIndent(content, lastBlock.start);
100
- }
101
- else {
102
- const firstStmt = body.body[0];
103
- if (firstStmt) {
104
- insertPos = firstStmt.start;
105
- indent = detectIndent(content, insertPos);
106
- }
107
- else {
108
- insertPos = body.start + 1;
109
- indent = ' ';
110
- }
111
- }
100
+ ({ insertPos, indent } = resolveDefaultInsertion(content, body, fireforgeBlocks));
112
101
  }
113
102
  const block = [
114
103
  `${indent}// ${marker} wire-init ${name} — must be first, before Firefox subsystem`,
@@ -224,7 +213,15 @@ export async function addInitToBrowserInit(engineDir, expression, after, marker
224
213
  if (initPattern.test(content)) {
225
214
  return false;
226
215
  }
227
- const { value, usedFallback } = withParserFallback(() => addInitAST(content, expression, after, marker), () => legacyAddInit(content, expression, after, marker), BROWSER_INIT_JS);
216
+ const { value, usedFallback } = withParserFallback(() => addInitAST(content, expression, after, marker), () => legacyAddInit(content, expression, after, marker), BROWSER_INIT_JS,
217
+ // Rethrow only the internal-invariant GeneralErrors ("Unexpected empty
218
+ // …array"): those are programming bugs, and retrying the legacy scanner
219
+ // cannot fix a broken invariant — it just buries the stack. Everything
220
+ // else still falls back, which is load-bearing: the AST path raises a raw
221
+ // acorn SyntaxError on chrome sources acorn cannot parse (preprocessor
222
+ // directives), and BuildError when the file's shape is unexpected. Both
223
+ // are exactly what the legacy scanner is here to handle.
224
+ (error) => error instanceof GeneralError);
228
225
  if (usedFallback) {
229
226
  assertBraceBalancePreserved(content, value, BROWSER_INIT_JS);
230
227
  }