@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
@@ -0,0 +1,452 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Verification-tree storage and lifecycle for `fireforge tree` (FORGE G15).
4
+ *
5
+ * A tree is a FULL project-root CoW snapshot under
6
+ * `.fireforge/trees/<name>` — `getProjectRoot()` walks up from cwd, so
7
+ * every read command (status, lint, typecheck, verify, doctor,
8
+ * `export --dry-run`) runs inside the tree unmodified and its build /
9
+ * engine-session locks key on the tree root automatically. Trees are
10
+ * snapshots by design: there is no merge-back model, mutation commands
11
+ * are refused via the tree marker (see `tree-guard.ts`), and refresh is
12
+ * `tree remove` + `tree create`.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync } from 'node:fs';
16
+ import { lstat, readdir, readFile, realpath, rm } from 'node:fs/promises';
17
+ import { basename, join, resolve, sep } from 'node:path';
18
+ import { GeneralError } from '../errors/base.js';
19
+ import { mapWithConcurrency } from '../utils/concurrency.js';
20
+ import { getNodeErrorCode, isProcessAlive, toError } from '../utils/errors.js';
21
+ import { pathExists, readJson, writeJson } from '../utils/fs.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';
26
+ import { git } from './git-base.js';
27
+ import { getAllDiff } from './git-diff.js';
28
+ import { attemptMozinfoRewrite } from './mach-build-artifacts.js';
29
+ import { cloneEntry } from './tree-cow.js';
30
+ const TREE_MARKER_FILENAME = 'tree.json';
31
+ const TREES_DIRNAME = 'trees';
32
+ const TREE_CLONE_CONCURRENCY = 8;
33
+ /**
34
+ * Top-level entries never cloned into a tree: per-tree state must start
35
+ * clean (`.fireforge/`), locks are never inherited, the destructive-
36
+ * operation audit log stays primary-only, and dependency dirs are
37
+ * neither needed nor cheap.
38
+ */
39
+ function isExcludedRootEntry(name) {
40
+ return (name === '.fireforge' ||
41
+ name === 'node_modules' ||
42
+ name === '.fireforge-history.jsonl' ||
43
+ name.endsWith('.lock'));
44
+ }
45
+ /** Absolute trees directory for a primary root. */
46
+ export function getTreesDir(primaryRoot) {
47
+ return join(primaryRoot, '.fireforge', TREES_DIRNAME);
48
+ }
49
+ /** Serialises tree create/remove against each other (primary-side sidecar lock). */
50
+ export async function withTreeLifecycleLock(primaryRoot, operation) {
51
+ return withFileLock(join(primaryRoot, '.fireforge', 'trees.lock'), operation, {
52
+ timeoutMs: 30_000,
53
+ onTimeoutMessage: 'Another fireforge tree create/remove is in progress. Wait for it to finish, then retry.',
54
+ });
55
+ }
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);
97
+ try {
98
+ const raw = await readJson(markerPath);
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 };
104
+ }
105
+ catch (error) {
106
+ const code = getNodeErrorCode(error);
107
+ if (code === 'ENOENT' || code === 'ENOTDIR')
108
+ return { kind: 'absent' };
109
+ verbose(`Unreadable tree marker at ${markerPath}: ${String(error)}`);
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).');
142
+ }
143
+ }
144
+ /** Current primary-side snapshot identity, including dirty source and patch bodies. */
145
+ export async function computePrimaryFingerprint(primaryRoot) {
146
+ const engineDir = join(primaryRoot, 'engine');
147
+ let engineHead;
148
+ try {
149
+ engineHead = (await git(['rev-parse', 'HEAD'], engineDir)).trim();
150
+ }
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'.
155
+ engineHead = null;
156
+ }
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 };
187
+ }
188
+ /** Validates a tree name: one path segment, no traversal. */
189
+ export function assertValidTreeName(name) {
190
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes('..')) {
191
+ throw new GeneralError(`Invalid tree name "${name}". Use a single path segment of letters, digits, ".", "_" or "-".`);
192
+ }
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
+ }
237
+ /**
238
+ * Materialises the tree at `treeRoot` from `primaryRoot` using
239
+ * `capability` ('none' = plain copy, already gated on --force-copy by
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.
250
+ */
251
+ export async function cloneTree(args) {
252
+ const { primaryRoot, treeRoot, name, capability, createdAt, withObjdir } = args;
253
+ const { mkdir, cp } = await import('node:fs/promises');
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');
259
+ }
260
+ await mkdir(treeRoot, { recursive: true });
261
+ const rootEntries = (await readdir(primaryRoot)).filter((entry) => !isExcludedRootEntry(entry));
262
+ const engineDir = join(treeRoot, 'engine');
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;
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)) {
279
+ // Defensive: never inherit a mid-operation git index lock.
280
+ await rm(join(engineDir, '.git', 'index.lock'), { force: true });
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
+ }
290
+ await mkdir(join(treeRoot, '.fireforge'), { recursive: true });
291
+ const stateJson = join(primaryRoot, '.fireforge', 'state.json');
292
+ if (await pathExists(stateJson)) {
293
+ await cp(stateJson, join(treeRoot, '.fireforge', 'state.json'));
294
+ }
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);
305
+ const marker = {
306
+ schemaVersion: 1,
307
+ name,
308
+ primaryRoot,
309
+ createdAt,
310
+ engineHead,
311
+ engineFingerprint: engineFingerprint ?? null,
312
+ patchesFingerprint,
313
+ ...(withObjdir ? { clonedObjdir: withObjdir.objDir } : {}),
314
+ };
315
+ await writeJson(join(treeRoot, '.fireforge', TREE_MARKER_FILENAME), marker);
316
+ return marker;
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
+ }
344
+ /** Lists trees with staleness against the current primary state. */
345
+ export async function listTrees(primaryRoot) {
346
+ const treesDir = getTreesDir(primaryRoot);
347
+ if (!(await pathExists(treesDir)))
348
+ return [];
349
+ const current = await computePrimaryFingerprint(primaryRoot);
350
+ const entries = [];
351
+ for (const name of (await readdir(treesDir)).sort((a, b) => a.localeCompare(b))) {
352
+ if (name.endsWith('.lock'))
353
+ continue;
354
+ const path = join(treesDir, name);
355
+ const marker = await readTreeMarker(path);
356
+ if (!marker) {
357
+ entries.push({ name, path, createdAt: '(no marker)', staleness: 'unknown' });
358
+ continue;
359
+ }
360
+ const staleness = marker.engineHead !== current.engineHead
361
+ ? 'stale (engine advanced)'
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';
368
+ entries.push({ name, path, createdAt: marker.createdAt, staleness });
369
+ }
370
+ return entries;
371
+ }
372
+ /**
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.
392
+ */
393
+ async function inspectTreeLock(lockPath) {
394
+ if (!existsSync(lockPath))
395
+ return { kind: 'free' };
396
+ let pidLine;
397
+ try {
398
+ pidLine = (await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8')).split('\n')[0] ?? '';
399
+ }
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' };
414
+ }
415
+ return isProcessAlive(pid) ? { kind: 'held', pid } : { kind: 'free' };
416
+ }
417
+ /**
418
+ * Removes one tree. Refuses when a live process holds the tree's build
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`).
422
+ */
423
+ export async function removeTree(primaryRoot, name, options = {}) {
424
+ assertValidTreeName(name);
425
+ const treesDir = getTreesDir(primaryRoot);
426
+ const treeRoot = resolve(treesDir, name);
427
+ if (!treeRoot.startsWith(treesDir + sep) || basename(treeRoot) !== name) {
428
+ throw new GeneralError(`Refusing to remove "${name}": resolved path escapes the trees directory.`);
429
+ }
430
+ if (!(await pathExists(treeRoot))) {
431
+ throw new GeneralError(`No verification tree named "${name}" exists under ${treesDir}.`);
432
+ }
433
+ for (const lockPath of [
434
+ join(treeRoot, '.fireforge-build.lock'),
435
+ join(treeRoot, '.fireforge', 'engine-session.lock'),
436
+ ]) {
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}. ` +
440
+ 'Wait for it to finish, then retry.');
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
+ }
450
+ }
451
+ await rm(treeRoot, { recursive: true, force: true });
452
+ }
@@ -11,6 +11,20 @@
11
11
  */
12
12
  /** Filename used for the synthetic Firefox-globals shim source file. */
13
13
  export declare const SHIM_FILENAME = "__fireforge_firefox_globals.d.ts";
14
+ /**
15
+ * Loose declarations for Firefox test-harness globals (mochitest
16
+ * browser-chrome and xpcshell), appended AFTER the composed
17
+ * Firefox-globals + consumer shim when `patchLint.checkJsTestFiles`
18
+ * extends the checkJs pass to patch-adopted test scripts (FORGE G5).
19
+ * Deliberately `any`-typed — the pragmatic posture matches the main
20
+ * shim. A consumer that wants TYPED harness members (so e.g. a call to
21
+ * a method the harness does not declare fails at the patch boundary)
22
+ * declares them in `patchLint.checkJsTestShim`; because that shim
23
+ * composes BEFORE this baseline and TypeScript resolves conflicting
24
+ * `declare var` redeclarations to the FIRST declaration, the typed
25
+ * consumer declaration wins over the loose fallback here.
26
+ */
27
+ export declare const TEST_HARNESS_SHIM = "\n// \u2500\u2500 FireForge test-harness shim (loose baseline) \u2500\u2500\ndeclare var TestUtils: any;\ndeclare var BrowserTestUtils: any;\ndeclare var SpecialPowers: any;\ndeclare var EventUtils: any;\ndeclare var SimpleTest: any;\ndeclare var Assert: any;\ndeclare var gTestPath: string;\ndeclare var content: any;\ndeclare function add_task(task: (...args: any[]) => any): any;\ndeclare function add_setup(task: (...args: any[]) => any): any;\ndeclare function ok(condition: any, message?: string): void;\ndeclare function is(actual: any, expected: any, message?: string): void;\ndeclare function isnot(actual: any, expected: any, message?: string): void;\ndeclare function todo(condition: any, message?: string): void;\ndeclare function todo_is(actual: any, expected: any, message?: string): void;\ndeclare function info(message: string): void;\ndeclare function registerCleanupFunction(callback: (...args: any[]) => any): void;\ndeclare function waitForExplicitFinish(): void;\ndeclare function finish(): void;\ndeclare function do_get_profile(): any;\ndeclare function run_test(): any;\n\n";
14
28
  /**
15
29
  * TS diagnostic codes suppressed by both the patch-lint checkJs pass
16
30
  * and the whole-project typecheck command. Each is a known false
@@ -128,6 +128,44 @@ declare var Localization: LocalizationShim;
128
128
  declare module 'resource:*';
129
129
  declare module 'chrome:*';
130
130
 
131
+ `;
132
+ /**
133
+ * Loose declarations for Firefox test-harness globals (mochitest
134
+ * browser-chrome and xpcshell), appended AFTER the composed
135
+ * Firefox-globals + consumer shim when `patchLint.checkJsTestFiles`
136
+ * extends the checkJs pass to patch-adopted test scripts (FORGE G5).
137
+ * Deliberately `any`-typed — the pragmatic posture matches the main
138
+ * shim. A consumer that wants TYPED harness members (so e.g. a call to
139
+ * a method the harness does not declare fails at the patch boundary)
140
+ * declares them in `patchLint.checkJsTestShim`; because that shim
141
+ * composes BEFORE this baseline and TypeScript resolves conflicting
142
+ * `declare var` redeclarations to the FIRST declaration, the typed
143
+ * consumer declaration wins over the loose fallback here.
144
+ */
145
+ export const TEST_HARNESS_SHIM = `
146
+ // ── FireForge test-harness shim (loose baseline) ──
147
+ declare var TestUtils: any;
148
+ declare var BrowserTestUtils: any;
149
+ declare var SpecialPowers: any;
150
+ declare var EventUtils: any;
151
+ declare var SimpleTest: any;
152
+ declare var Assert: any;
153
+ declare var gTestPath: string;
154
+ declare var content: any;
155
+ declare function add_task(task: (...args: any[]) => any): any;
156
+ declare function add_setup(task: (...args: any[]) => any): any;
157
+ declare function ok(condition: any, message?: string): void;
158
+ declare function is(actual: any, expected: any, message?: string): void;
159
+ declare function isnot(actual: any, expected: any, message?: string): void;
160
+ declare function todo(condition: any, message?: string): void;
161
+ declare function todo_is(actual: any, expected: any, message?: string): void;
162
+ declare function info(message: string): void;
163
+ declare function registerCleanupFunction(callback: (...args: any[]) => any): void;
164
+ declare function waitForExplicitFinish(): void;
165
+ declare function finish(): void;
166
+ declare function do_get_profile(): any;
167
+ declare function run_test(): any;
168
+
131
169
  `;
132
170
  /**
133
171
  * TS diagnostic codes suppressed by both the patch-lint checkJs pass
@@ -226,8 +264,9 @@ async function inlineTripleSlashReferences(source, baseDir, seen) {
226
264
  * @param extraShimPath - Optional project-relative path to an extra `.d.ts`
227
265
  */
228
266
  export async function composeShimSource(projectRoot, extraShimPath) {
267
+ const base = FIREFOX_GLOBALS_SHIM;
229
268
  if (!extraShimPath) {
230
- return { source: FIREFOX_GLOBALS_SHIM, extraShimAppended: false };
269
+ return { source: base, extraShimAppended: false };
231
270
  }
232
271
  const absoluteShim = resolve(projectRoot, extraShimPath);
233
272
  if (!(await pathExists(absoluteShim))) {
@@ -237,7 +276,7 @@ export async function composeShimSource(projectRoot, extraShimPath) {
237
276
  const extra = await readText(absoluteShim);
238
277
  const inlinedExtra = await inlineTripleSlashReferences(extra, dirname(absoluteShim), new Set([absoluteShim]));
239
278
  return {
240
- source: `${FIREFOX_GLOBALS_SHIM}\n// ── extraShim: ${extraShimPath} ──\n${inlinedExtra}`,
279
+ source: `${base}\n// ── extraShim: ${extraShimPath} ──\n${inlinedExtra}`,
241
280
  extraShimAppended: true,
242
281
  };
243
282
  }
@@ -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
  }