@hominis/fireforge 0.40.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 (253) hide show
  1. package/CHANGELOG.md +79 -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/build-audit-platform.js +6 -1
  118. package/dist/src/core/build-audit-registration.js +2 -0
  119. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  120. package/dist/src/core/build-audit-resolve.js +34 -13
  121. package/dist/src/core/build-audit.js +4 -20
  122. package/dist/src/core/build-baseline-types.d.ts +29 -8
  123. package/dist/src/core/build-baseline-types.js +2 -1
  124. package/dist/src/core/build-baseline.d.ts +10 -2
  125. package/dist/src/core/build-baseline.js +39 -8
  126. package/dist/src/core/build-prepare.d.ts +4 -0
  127. package/dist/src/core/build-prepare.js +7 -1
  128. package/dist/src/core/config-paths.d.ts +1 -1
  129. package/dist/src/core/config-paths.js +2 -0
  130. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  131. package/dist/src/core/config-validate.js +34 -36
  132. package/dist/src/core/config.js +16 -3
  133. package/dist/src/core/coverage-extend.d.ts +89 -0
  134. package/dist/src/core/coverage-extend.js +188 -0
  135. package/dist/src/core/destructive.js +3 -1
  136. package/dist/src/core/discard-baseline.js +18 -4
  137. package/dist/src/core/engine-precondition.d.ts +35 -0
  138. package/dist/src/core/engine-precondition.js +50 -0
  139. package/dist/src/core/engine-session-lock.d.ts +38 -1
  140. package/dist/src/core/engine-session-lock.js +78 -4
  141. package/dist/src/core/file-lock.d.ts +7 -0
  142. package/dist/src/core/file-lock.js +20 -30
  143. package/dist/src/core/firefox.js +5 -2
  144. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  145. package/dist/src/core/furnace-apply-helpers.js +12 -3
  146. package/dist/src/core/furnace-apply-output.js +9 -0
  147. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  148. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  149. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  150. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  151. package/dist/src/core/furnace-apply.d.ts +16 -4
  152. package/dist/src/core/furnace-apply.js +39 -43
  153. package/dist/src/core/furnace-config.js +2 -0
  154. package/dist/src/core/furnace-css-fragments.js +2 -0
  155. package/dist/src/core/furnace-jsconfig.js +14 -7
  156. package/dist/src/core/furnace-registration-ast.js +8 -8
  157. package/dist/src/core/furnace-registration-remove.js +6 -3
  158. package/dist/src/core/furnace-registration.js +9 -12
  159. package/dist/src/core/furnace-rollback.js +1 -1
  160. package/dist/src/core/furnace-scanner.js +2 -1
  161. package/dist/src/core/furnace-stale-export.js +2 -1
  162. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  163. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  164. package/dist/src/core/furnace-validate-helpers.js +33 -1
  165. package/dist/src/core/furnace-validate-registration.js +16 -0
  166. package/dist/src/core/furnace-validate.js +4 -2
  167. package/dist/src/core/git-diff.js +37 -2
  168. package/dist/src/core/lint-cache.d.ts +1 -1
  169. package/dist/src/core/lint-cache.js +33 -10
  170. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  171. package/dist/src/core/mach-build-artifacts.js +108 -2
  172. package/dist/src/core/mach-resource-shim.js +1 -0
  173. package/dist/src/core/marionette-port.d.ts +16 -0
  174. package/dist/src/core/marionette-port.js +93 -8
  175. package/dist/src/core/marionette-preflight.js +6 -5
  176. package/dist/src/core/patch-apply.d.ts +20 -13
  177. package/dist/src/core/patch-apply.js +32 -33
  178. package/dist/src/core/patch-export.js +3 -5
  179. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  180. package/dist/src/core/patch-lint-checkjs.js +78 -7
  181. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  182. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  183. package/dist/src/core/patch-lint-creators.js +45 -0
  184. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  185. package/dist/src/core/patch-lint-cross.js +16 -32
  186. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  187. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  188. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  189. package/dist/src/core/patch-lint-module-registration.js +88 -0
  190. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  191. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  192. package/dist/src/core/patch-lint-reexports.js +1 -0
  193. package/dist/src/core/patch-lint.d.ts +8 -0
  194. package/dist/src/core/patch-lint.js +84 -6
  195. package/dist/src/core/patch-lock.d.ts +12 -1
  196. package/dist/src/core/patch-lock.js +40 -4
  197. package/dist/src/core/patch-manifest-consistency.js +15 -3
  198. package/dist/src/core/patch-parse.d.ts +8 -0
  199. package/dist/src/core/patch-parse.js +10 -0
  200. package/dist/src/core/patch-policy.d.ts +7 -0
  201. package/dist/src/core/patch-policy.js +4 -1
  202. package/dist/src/core/patch-transform.d.ts +10 -0
  203. package/dist/src/core/patch-transform.js +13 -2
  204. package/dist/src/core/rebase-session.d.ts +44 -2
  205. package/dist/src/core/rebase-session.js +114 -17
  206. package/dist/src/core/register-browser-content.js +2 -1
  207. package/dist/src/core/register-shared-css.js +2 -1
  208. package/dist/src/core/smoke-patterns.d.ts +0 -8
  209. package/dist/src/core/smoke-patterns.js +3 -2
  210. package/dist/src/core/status-binary.d.ts +55 -0
  211. package/dist/src/core/status-binary.js +124 -0
  212. package/dist/src/core/status-classify.d.ts +6 -1
  213. package/dist/src/core/status-classify.js +101 -73
  214. package/dist/src/core/test-harness-crash.d.ts +32 -1
  215. package/dist/src/core/test-harness-crash.js +59 -4
  216. package/dist/src/core/test-path-scope.js +2 -0
  217. package/dist/src/core/test-stale-check.js +1 -20
  218. package/dist/src/core/toolchain-preflight.js +24 -26
  219. package/dist/src/core/tree-guard.d.ts +1 -1
  220. package/dist/src/core/tree-guard.js +67 -12
  221. package/dist/src/core/tree-store.d.ts +86 -10
  222. package/dist/src/core/tree-store.js +310 -72
  223. package/dist/src/core/typecheck.js +2 -1
  224. package/dist/src/core/wire-destroy.js +14 -3
  225. package/dist/src/core/wire-init.js +37 -40
  226. package/dist/src/core/wire-subscript.js +11 -3
  227. package/dist/src/core/wire-utils.js +4 -4
  228. package/dist/src/core/xpcshell-appdir.js +3 -0
  229. package/dist/src/errors/base.d.ts +13 -0
  230. package/dist/src/errors/base.js +13 -0
  231. package/dist/src/errors/rebase.d.ts +13 -0
  232. package/dist/src/errors/rebase.js +17 -0
  233. package/dist/src/types/commands/options.d.ts +86 -1
  234. package/dist/src/types/commands/patches.d.ts +8 -0
  235. package/dist/src/types/furnace.d.ts +7 -0
  236. package/dist/src/utils/build-info.d.ts +31 -0
  237. package/dist/src/utils/build-info.js +98 -0
  238. package/dist/src/utils/concurrency.d.ts +8 -0
  239. package/dist/src/utils/concurrency.js +21 -0
  240. package/dist/src/utils/errors.d.ts +39 -0
  241. package/dist/src/utils/errors.js +55 -0
  242. package/dist/src/utils/fs.js +5 -12
  243. package/dist/src/utils/logger.d.ts +14 -2
  244. package/dist/src/utils/logger.js +43 -13
  245. package/dist/src/utils/options.d.ts +21 -4
  246. package/dist/src/utils/options.js +26 -6
  247. package/dist/src/utils/package-root.js +7 -4
  248. package/dist/src/utils/process-group.js +13 -2
  249. package/dist/src/utils/stdio-drain.d.ts +28 -0
  250. package/dist/src/utils/stdio-drain.js +54 -0
  251. package/dist/src/utils/validation.d.ts +23 -2
  252. package/dist/src/utils/validation.js +29 -3
  253. package/package.json +9 -9
@@ -23,13 +23,11 @@
23
23
  * plugin, etc.) can legitimately have a fresh `dist/` with no
24
24
  * FireForge-recorded baseline update.
25
25
  */
26
- import { createHash } from 'node:crypto';
27
- import { readFile } from 'node:fs/promises';
28
- import { join } from 'node:path';
29
26
  import { toError } from '../utils/errors.js';
30
27
  import { verbose, warn } from '../utils/logger.js';
31
28
  import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
32
29
  import { readBuildBaseline } from './build-baseline.js';
30
+ import { hashEngineFile } from './coverage-extend.js';
33
31
  import { collectChangedEnginePaths } from './engine-changes.js';
34
32
  export { isXpcomManifestPath };
35
33
  /** Cap on the number of changed paths rendered inline. */
@@ -83,23 +81,6 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
83
81
  const truncated = Math.max(0, packageable.length - head.length);
84
82
  return { stale: true, changedPaths: head, truncated, baseline };
85
83
  }
86
- /**
87
- * Reads a file under the engine directory and returns a hex-encoded
88
- * SHA-256 of its contents, matching the hash the baseline writer
89
- * produces. Returns `undefined` on any IO error (missing file,
90
- * permission denied, etc.) so the caller can treat the path as still
91
- * stale rather than crashing the preflight.
92
- */
93
- async function hashEngineFile(engineDir, relPath) {
94
- try {
95
- const buffer = await readFile(join(engineDir, relPath));
96
- return createHash('sha256').update(buffer).digest('hex');
97
- }
98
- catch (error) {
99
- verbose(`Stale-build preflight: could not hash ${relPath} for baseline comparison — ${toError(error).message}`);
100
- return undefined;
101
- }
102
- }
103
84
  /**
104
85
  * Formats a human-readable warning body from a {@link StaleBuildResult}.
105
86
  * Kept separate from the probe so test code can assert on the structured
@@ -30,6 +30,7 @@
30
30
  import { execFile } from 'node:child_process';
31
31
  import { homedir } from 'node:os';
32
32
  import { join } from 'node:path';
33
+ import { toError } from '../utils/errors.js';
33
34
  import { pathExists, readText } from '../utils/fs.js';
34
35
  import { verbose } from '../utils/logger.js';
35
36
  /**
@@ -133,28 +134,29 @@ export function formatMajorVersionHopNotice(previousVersion, newVersion) {
133
134
  * yields undefined for that tool — never an error.
134
135
  */
135
136
  export async function readDeclaredToolchainMinimums(engineDir) {
136
- const minimums = {};
137
- for (const [tool, declaration] of Object.entries(MINIMUM_DECLARATIONS)) {
137
+ const entries = Object.entries(MINIMUM_DECLARATIONS);
138
+ const resolved = await Promise.all(entries.map(async ([tool, declaration]) => {
138
139
  const filePath = join(engineDir, declaration.relPath);
139
140
  if (!(await pathExists(filePath))) {
140
141
  verbose(`Toolchain preflight: ${declaration.relPath} not found; skipping ${tool}.`);
141
- continue;
142
+ return undefined;
142
143
  }
143
144
  try {
144
145
  const content = await readText(filePath);
145
146
  const match = declaration.pattern.exec(content);
146
147
  if (match?.[1]) {
147
- minimums[tool] = match[1];
148
+ return [tool, match[1]];
148
149
  }
149
150
  else {
150
151
  verbose(`Toolchain preflight: no ${tool} minimum declaration recognized in ${declaration.relPath}; skipping.`);
151
152
  }
152
153
  }
153
154
  catch (error) {
154
- verbose(`Toolchain preflight: could not read ${declaration.relPath} (${error instanceof Error ? error.message : String(error)}); skipping ${tool}.`);
155
+ verbose(`Toolchain preflight: could not read ${declaration.relPath} (${toError(error).message}); skipping ${tool}.`);
155
156
  }
156
- }
157
- return minimums;
157
+ return undefined;
158
+ }));
159
+ return Object.fromEntries(resolved.filter((entry) => entry !== undefined));
158
160
  }
159
161
  /** Runs `<binary> --version` and parses the leading version number. */
160
162
  async function probeBinaryVersion(binary, versionPattern) {
@@ -185,17 +187,14 @@ async function probeHostToolCandidates(tool) {
185
187
  const binaries = envOverride
186
188
  ? [envOverride]
187
189
  : [...probe.stateDirRelPaths.map((rel) => join(mozbuildStateDir(), rel)), tool];
188
- const candidates = [];
189
- for (const binary of binaries) {
190
+ const candidates = await Promise.all(binaries.map(async (binary) => {
190
191
  const version = await probeBinaryVersion(binary, probe.versionPattern);
191
- if (version !== undefined) {
192
- candidates.push({ binary, version });
193
- }
194
- else {
195
- verbose(`Toolchain preflight: "${binary} --version" failed or not found; skipping.`);
196
- }
197
- }
198
- return candidates;
192
+ if (version !== undefined)
193
+ return { binary, version };
194
+ verbose(`Toolchain preflight: "${binary} --version" failed or not found; skipping.`);
195
+ return undefined;
196
+ }));
197
+ return candidates.filter((candidate) => candidate !== undefined);
199
198
  }
200
199
  /**
201
200
  * Compares the tree-declared toolchain minimums against the host binaries
@@ -210,31 +209,30 @@ async function probeHostToolCandidates(tool) {
210
209
  */
211
210
  export async function runToolchainPreflight(engineDir) {
212
211
  const minimums = await readDeclaredToolchainMinimums(engineDir);
213
- const mismatches = [];
214
- for (const [tool, minimumVersion] of Object.entries(minimums)) {
212
+ const results = await Promise.all(Object.entries(minimums).map(async ([tool, minimumVersion]) => {
215
213
  const minimum = parseVersionComponents(minimumVersion);
216
214
  if (!minimum)
217
- continue;
215
+ return undefined;
218
216
  const candidates = await probeHostToolCandidates(tool);
219
217
  const parsed = candidates.filter((c) => parseVersionComponents(c.version) !== undefined);
220
218
  if (parsed.length === 0)
221
- continue;
219
+ return undefined;
222
220
  const satisfying = parsed.find((c) => {
223
221
  const components = parseVersionComponents(c.version);
224
222
  return components !== undefined && !isVersionLower(components, minimum);
225
223
  });
226
224
  if (satisfying) {
227
225
  verbose(`Toolchain preflight: ${tool} ${satisfying.version} (${satisfying.binary}) satisfies minimum ${minimumVersion}.`);
228
- continue;
226
+ return undefined;
229
227
  }
230
- mismatches.push({
228
+ return {
231
229
  tool,
232
230
  minimumVersion,
233
231
  declaredIn: MINIMUM_DECLARATIONS[tool].relPath,
234
232
  candidates: parsed,
235
- });
236
- }
237
- return mismatches;
233
+ };
234
+ }));
235
+ return results.filter((mismatch) => mismatch !== undefined);
238
236
  }
239
237
  /**
240
238
  * Formats the fail-fast message for definitive preflight mismatches,
@@ -2,7 +2,7 @@ import { type TreeMarker } from './tree-store.js';
2
2
  /**
3
3
  * Per-command verdicts. `allowed` runs unconditionally; `refused` never
4
4
  * runs in a tree; `conditional` consults `TREE_CONDITIONAL_CHECKS` with
5
- * the invoked subcommand chain and parsed options.
5
+ * the invoked subcommand chain, parsed options, and the tree marker.
6
6
  */
7
7
  export declare const TREE_COMMAND_VERDICTS: Readonly<Record<string, 'allowed' | 'refused' | 'conditional'>>;
8
8
  interface ConditionalInput {
@@ -15,11 +15,11 @@
15
15
  import { existsSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
17
  import { GeneralError } from '../errors/base.js';
18
- import { readTreeMarker } from './tree-store.js';
18
+ import { getTreeMarkerPath, readTreeMarkerState, } from './tree-store.js';
19
19
  /**
20
20
  * Per-command verdicts. `allowed` runs unconditionally; `refused` never
21
21
  * runs in a tree; `conditional` consults `TREE_CONDITIONAL_CHECKS` with
22
- * the invoked subcommand chain and parsed options.
22
+ * the invoked subcommand chain, parsed options, and the tree marker.
23
23
  */
24
24
  export const TREE_COMMAND_VERDICTS = {
25
25
  setup: 'refused',
@@ -35,13 +35,14 @@ export const TREE_COMMAND_VERDICTS = {
35
35
  discard: 'refused',
36
36
  export: 'conditional',
37
37
  'export-all': 'conditional',
38
- 're-export': 'refused',
38
+ 're-export': 'conditional',
39
39
  patch: 'refused',
40
40
  rebase: 'refused',
41
41
  package: 'refused',
42
42
  watch: 'refused',
43
- // Deferred with the objdir clone: obj-* embeds absolute primary paths.
44
- test: 'refused',
43
+ // Build-less runs only, and only in trees whose marker records a cloned
44
+ // (mozinfo-rewritten) objdir — see the conditional check below.
45
+ test: 'conditional',
45
46
  config: 'conditional',
46
47
  doctor: 'conditional',
47
48
  register: 'refused',
@@ -57,18 +58,44 @@ export const TREE_COMMAND_VERDICTS = {
57
58
  };
58
59
  /** The command set named in refusal messages. */
59
60
  const ALLOWED_SUMMARY = 'Verification trees support: status, lint, typecheck, verify, doctor (read-only), ' +
60
- 'config (read), export --dry-run, and export-all --dry-run.';
61
+ 'config (read), export --dry-run, export-all --dry-run, re-export --dry-run, and — ' +
62
+ 'in trees created with --with-objdir — build-less test.';
61
63
  const TREE_CONDITIONAL_CHECKS = {
62
64
  // Exports write patches/ + patches.json; only the dry-run preview is a read.
63
65
  export: ({ options }) => options['dryRun'] === true,
64
66
  'export-all': ({ options }) => options['dryRun'] === true,
67
+ // Same rule for refreshing existing patches: the dry-run projection is a
68
+ // read (proven side-effect free and runtime-enforced by
69
+ // withDryRunPurityGuard — FORGE H1/H3); a real re-export writes patches/.
70
+ 're-export': ({ options }) => options['dryRun'] === true,
65
71
  // `config` with no positional value prints; with a value it writes.
66
72
  config: ({ args }) => args.filter((a) => typeof a === 'string').length <= 1,
67
73
  // Doctor reads unless a repair flag is set.
68
74
  doctor: ({ options }) => options['repairFurnace'] !== true && options['fix'] !== true,
75
+ // Build-less test needs the objdir the marker records as cloned,
76
+ // mozinfo-rewritten AND reconfigured in-tree; `test --build` rebuilds
77
+ // the engine and stays primary-only (a tree is a snapshot with no
78
+ // merge-back model, so build outputs mutated in-tree go nowhere).
79
+ test: ({ options }, marker) => marker.clonedObjdir !== undefined && options['build'] !== true,
69
80
  // No tree lifecycle inside a tree; `tree list` is the only read.
70
81
  tree: ({ subcommands }) => subcommands[0] === 'list',
71
82
  };
83
+ /**
84
+ * Command-specific refusal hints appended after the generic sentence —
85
+ * for verdicts where "mutates project or engine state" alone would
86
+ * misdiagnose the actual reason.
87
+ */
88
+ const TREE_REFUSAL_HINTS = {
89
+ test: ({ options }, marker) => {
90
+ if (options['build'] === true) {
91
+ return '"test --build" rebuilds the engine and must run in the primary tree; run build-less "fireforge test" here instead.';
92
+ }
93
+ if (marker.clonedObjdir === undefined) {
94
+ return 'In-tree test requires a tree created with "fireforge tree create <name> --with-objdir".';
95
+ }
96
+ return undefined;
97
+ },
98
+ };
72
99
  /**
73
100
  * Throws when `commandName` (with its subcommand chain and options) must
74
101
  * not run inside the tree described by `marker`. Exported for the
@@ -79,10 +106,12 @@ export function enforceTreeGuard(marker, commandName, input) {
79
106
  if (verdict === 'allowed')
80
107
  return;
81
108
  const conditional = TREE_CONDITIONAL_CHECKS[commandName];
82
- if (verdict === 'conditional' && conditional && conditional(input))
109
+ if (verdict === 'conditional' && conditional && conditional(input, marker))
83
110
  return;
111
+ const hint = TREE_REFUSAL_HINTS[commandName]?.(input, marker);
84
112
  throw new GeneralError(`This is a FireForge verification tree ("${marker.name}", created from ${marker.primaryRoot}). ` +
85
113
  `"${[commandName, ...input.subcommands].join(' ')}" mutates project or engine state and must run in the primary tree. ` +
114
+ (hint === undefined ? '' : `${hint} `) +
86
115
  ALLOWED_SUMMARY);
87
116
  }
88
117
  /**
@@ -101,11 +130,12 @@ export async function runTreeGuardHook(rootCommandName, actionCommand) {
101
130
  const [commandName, ...subcommands] = chain;
102
131
  if (commandName === undefined || commandName === rootCommandName)
103
132
  return;
104
- const marker = await (async () => {
133
+ const options = actionCommand.optsWithGlobals();
134
+ const located = await (async () => {
105
135
  let current = resolve(process.cwd());
106
136
  for (let depth = 0; depth < 50; depth++) {
107
137
  if (existsSync(join(current, 'fireforge.json'))) {
108
- return readTreeMarker(current);
138
+ return { root: current, state: await readTreeMarkerState(current) };
109
139
  }
110
140
  const parent = dirname(current);
111
141
  if (parent === current)
@@ -114,11 +144,36 @@ export async function runTreeGuardHook(rootCommandName, actionCommand) {
114
144
  }
115
145
  return undefined;
116
146
  })();
117
- if (!marker)
147
+ if (located === undefined)
118
148
  return;
119
- enforceTreeGuard(marker, commandName, {
149
+ const state = located.state;
150
+ if (state.kind === 'absent')
151
+ return;
152
+ // A marker we cannot read is not evidence that this is not a tree — it is
153
+ // evidence that we cannot tell. Treating it as "not a tree" would grant the
154
+ // full mutating command set to a snapshot on the strength of a file that
155
+ // failed to parse, which is exactly backwards for a default-deny guard.
156
+ if (state.kind === 'corrupt') {
157
+ if (options['ignoreCorruptTreeMarker'] === true)
158
+ return;
159
+ // Unconditionally-allowed commands never consult marker fields — their
160
+ // verdict is the same in every tree — so an unreadable marker cannot
161
+ // change their answer. Letting them through keeps read-only diagnostics
162
+ // (status, lint) usable on the very tree the operator needs to inspect.
163
+ // 'conditional' verdicts stay refused: their predicates can write.
164
+ if (TREE_COMMAND_VERDICTS[commandName] === 'allowed')
165
+ return;
166
+ const markerPath = getTreeMarkerPath(located.root);
167
+ throw new GeneralError(`${markerPath} identifies this directory as a FireForge verification tree, but ${state.reason}. ` +
168
+ `Refusing "${[commandName, ...subcommands].join(' ')}" because it cannot be established ` +
169
+ 'whether this is a snapshot or the primary tree.\n\n' +
170
+ 'Recreate the tree (fireforge tree remove <name> && fireforge tree create <name>), or — if ' +
171
+ 'this really is the primary tree — delete the stray marker. ' +
172
+ 'Pass --ignore-corrupt-tree-marker to proceed anyway.');
173
+ }
174
+ enforceTreeGuard(state.marker, commandName, {
120
175
  subcommands,
121
- options: actionCommand.optsWithGlobals(),
176
+ options,
122
177
  args: actionCommand.args,
123
178
  });
124
179
  }
@@ -6,24 +6,87 @@ export interface TreeMarker {
6
6
  primaryRoot: string;
7
7
  createdAt: string;
8
8
  engineHead: string | null;
9
+ /** Hash of the primary engine's tracked + untracked working-tree diff. */
10
+ engineFingerprint?: string | null;
9
11
  patchesFingerprint: string | null;
12
+ /**
13
+ * Name of the obj-* directory cloned into this tree, written only after
14
+ * its mozinfo.json was successfully rewritten to the tree's paths AND the
15
+ * caller's in-tree reconfigure regenerated the remaining configure output.
16
+ * Its presence is what lets the guard admit build-less `test` in-tree
17
+ * (`tree-guard.ts`); absent on trees created without `--with-objdir`.
18
+ */
19
+ clonedObjdir?: string;
10
20
  }
11
21
  /** One row of `tree list`. */
12
22
  export interface TreeListEntry {
13
23
  name: string;
14
24
  path: string;
15
25
  createdAt: string;
16
- staleness: 'fresh' | 'stale (engine advanced)' | 'stale (patches changed)' | 'unknown';
26
+ staleness: 'fresh' | 'stale (engine advanced)' | 'stale (engine worktree changed)' | 'stale (patches changed)' | 'unknown';
17
27
  }
18
28
  /** Absolute trees directory for a primary root. */
19
29
  export declare function getTreesDir(primaryRoot: string): string;
20
30
  /** Serialises tree create/remove against each other (primary-side sidecar lock). */
21
31
  export declare function withTreeLifecycleLock<T>(primaryRoot: string, operation: () => Promise<T>): Promise<T>;
22
- /** Reads the tree marker for a project root, or undefined when not a tree. */
32
+ /**
33
+ * Outcome of reading a tree marker.
34
+ *
35
+ * The three states must stay distinct because they carry opposite safety
36
+ * meanings. `absent` means "this directory is not a tree", which is what lets
37
+ * every mutating command run. `corrupt` means "this directory claims to be a
38
+ * tree but we cannot read the claim" — collapsing it into `absent` (as a bare
39
+ * `undefined` return does) hands a snapshot the full mutating command set on
40
+ * the strength of a file we failed to parse, inverting `tree-guard.ts`'s
41
+ * default-deny design. Deleting one field from a marker was enough to run
42
+ * `reset` inside a clone.
43
+ */
44
+ export type TreeMarkerRead = {
45
+ kind: 'absent';
46
+ } | {
47
+ kind: 'valid';
48
+ marker: TreeMarker;
49
+ } | {
50
+ kind: 'corrupt';
51
+ reason: string;
52
+ };
53
+ /** Absolute path of a root's tree marker. */
54
+ export declare function getTreeMarkerPath(root: string): string;
55
+ /**
56
+ * Reads the tree marker for a project root, distinguishing "not a tree" from
57
+ * "a tree whose marker we could not read". Callers that make a safety decision
58
+ * (the guard hook, the no-nesting refusal) must use this rather than
59
+ * {@link readTreeMarker}.
60
+ *
61
+ * Only ENOENT/ENOTDIR mean `absent`: any other probe failure (EACCES on the
62
+ * `.fireforge` directory, EIO, …) reports `corrupt`, because "we cannot read
63
+ * the marker" is not evidence that there is no marker — a `pathExists`
64
+ * pre-check here classified a tree with an unreadable `.fireforge` as
65
+ * `absent` and handed it the full mutating command set.
66
+ */
67
+ export declare function readTreeMarkerState(root: string): Promise<TreeMarkerRead>;
68
+ /**
69
+ * Reads the tree marker for a project root, or undefined when it is absent
70
+ * OR unreadable.
71
+ *
72
+ * For display and best-effort paths only (`tree list` renders an unreadable
73
+ * marker as staleness 'unknown'). Anything that decides whether a mutation may
74
+ * proceed must call {@link readTreeMarkerState} and handle `corrupt`.
75
+ */
23
76
  export declare function readTreeMarker(root: string): Promise<TreeMarker | undefined>;
24
- /** Current primary-side snapshot identity (engine HEAD + patches.json hash). */
77
+ /**
78
+ * Refuses a build-less in-tree `test` when the objdir preflight found is not
79
+ * the one this tree's marker vouched for as rewritten-and-reconfigured. A
80
+ * mismatch means an objdir appeared in the tree through some path other than
81
+ * `tree create --with-objdir` (manual copy, partial restore), so nothing
82
+ * proves it was relocated — running against it could consult primary paths.
83
+ * No-op outside a tree; a corrupt marker is already refused by the guard.
84
+ */
85
+ export declare function assertObjdirMatchesTreeMarker(projectRoot: string, objDir: string | undefined): Promise<void>;
86
+ /** Current primary-side snapshot identity, including dirty source and patch bodies. */
25
87
  export declare function computePrimaryFingerprint(primaryRoot: string): Promise<{
26
88
  engineHead: string | null;
89
+ engineFingerprint?: string | null;
27
90
  patchesFingerprint: string | null;
28
91
  }>;
29
92
  /** Validates a tree name: one path segment, no traversal. */
@@ -31,10 +94,16 @@ export declare function assertValidTreeName(name: string): void;
31
94
  /**
32
95
  * Materialises the tree at `treeRoot` from `primaryRoot` using
33
96
  * `capability` ('none' = plain copy, already gated on --force-copy by
34
- * the caller). The engine tree is cloned whole and its `obj-*` build
35
- * directories removed afterwards — CoW removal is cheap, and per-entry
36
- * recursion into a Firefox tree is fragile. A fresh `.fireforge/` is
37
- * created with only `state.json` (when present) and the tree marker.
97
+ * the caller). The engine is cloned one TOP-LEVEL entry at a time so unwanted
98
+ * `obj-*` directories are never traversed or materialised. That distinction
99
+ * is substantial on Firefox trees: even CoW-cloning and deleting an objdir
100
+ * touches metadata for millions of build outputs. With `withObjdir` exactly
101
+ * that objdir is included — after {@link assertCloneSafeObjdir}
102
+ * refuses symlinked or engine-escaping objdirs before any copying —
103
+ * rewritten to the tree (see below), and
104
+ * reconfigured in-tree via the caller-supplied `reconfigure` hook. A
105
+ * fresh `.fireforge/` is created with only `state.json` (when present),
106
+ * the last-build baseline (withObjdir only), and the tree marker.
38
107
  */
39
108
  export declare function cloneTree(args: {
40
109
  primaryRoot: string;
@@ -42,12 +111,19 @@ export declare function cloneTree(args: {
42
111
  name: string;
43
112
  capability: CowCapability;
44
113
  createdAt: string;
114
+ withObjdir?: {
115
+ objDir: string;
116
+ reconfigure: (treeEngineDir: string) => Promise<void>;
117
+ };
45
118
  }): Promise<TreeMarker>;
46
119
  /** Lists trees with staleness against the current primary state. */
47
120
  export declare function listTrees(primaryRoot: string): Promise<TreeListEntry[]>;
48
121
  /**
49
122
  * Removes one tree. Refuses when a live process holds the tree's build
50
- * or engine-session lock, and never deletes a path outside the trees
51
- * directory (containment check before `rm -rf`).
123
+ * or engine-session lock — or when a lock directory exists whose ownership
124
+ * cannot be established, which `--force` overrides — and never deletes a path
125
+ * outside the trees directory (containment check before `rm -rf`).
52
126
  */
53
- export declare function removeTree(primaryRoot: string, name: string): Promise<void>;
127
+ export declare function removeTree(primaryRoot: string, name: string, options?: {
128
+ force?: boolean;
129
+ }): Promise<void>;