@hominis/fireforge 0.42.0 → 0.43.1

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 (199) hide show
  1. package/CHANGELOG.md +76 -34
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/cli.js +21 -2
  4. package/dist/src/commands/build.js +7 -1
  5. package/dist/src/commands/config.js +16 -27
  6. package/dist/src/commands/discard.js +1 -1
  7. package/dist/src/commands/export-flow.js +13 -3
  8. package/dist/src/commands/export-placement-conflicts.js +1 -1
  9. package/dist/src/commands/export-shared.d.ts +1 -1
  10. package/dist/src/commands/export-shared.js +4 -5
  11. package/dist/src/commands/furnace/chrome-doc-remove.js +3 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +3 -0
  13. package/dist/src/commands/furnace/create.js +3 -0
  14. package/dist/src/commands/furnace/override.js +3 -0
  15. package/dist/src/commands/furnace/preview.js +3 -0
  16. package/dist/src/commands/furnace/refresh.js +3 -0
  17. package/dist/src/commands/furnace/remove.js +3 -0
  18. package/dist/src/commands/furnace/rename.js +3 -0
  19. package/dist/src/commands/furnace/scan.js +3 -0
  20. package/dist/src/commands/import.js +1 -1
  21. package/dist/src/commands/lint-per-patch.js +16 -8
  22. package/dist/src/commands/lint-per-run-checkjs.d.ts +4 -4
  23. package/dist/src/commands/lint-per-run-checkjs.js +4 -4
  24. package/dist/src/commands/lint-report.js +1 -1
  25. package/dist/src/commands/lint.js +2 -2
  26. package/dist/src/commands/patch/lint-ignore.js +1 -1
  27. package/dist/src/commands/patch/move-files-create.js +5 -3
  28. package/dist/src/commands/patch/move-files-into.d.ts +1 -1
  29. package/dist/src/commands/patch/move-files-into.js +5 -3
  30. package/dist/src/commands/patch/move-files.js +1 -1
  31. package/dist/src/commands/patch/rename.js +1 -1
  32. package/dist/src/commands/patch/reorder.d.ts +1 -2
  33. package/dist/src/commands/patch/reorder.js +1 -2
  34. package/dist/src/commands/patch/split.js +5 -3
  35. package/dist/src/commands/patch/staged-dependency-validate.js +1 -1
  36. package/dist/src/commands/patch/staged-dependency.js +4 -4
  37. package/dist/src/commands/re-export-adjacent.js +1 -1
  38. package/dist/src/commands/re-export-bulk-scan.d.ts +1 -1
  39. package/dist/src/commands/re-export-bulk-scan.js +5 -4
  40. package/dist/src/commands/re-export-drift.d.ts +4 -4
  41. package/dist/src/commands/re-export-drift.js +47 -8
  42. package/dist/src/commands/re-export-lint.d.ts +7 -5
  43. package/dist/src/commands/re-export-lint.js +9 -8
  44. package/dist/src/commands/re-export-single.d.ts +1 -1
  45. package/dist/src/commands/re-export-single.js +5 -4
  46. package/dist/src/commands/re-export.js +26 -14
  47. package/dist/src/commands/reset.js +1 -1
  48. package/dist/src/commands/run.js +1 -1
  49. package/dist/src/commands/source.js +6 -6
  50. package/dist/src/commands/status-check.d.ts +1 -1
  51. package/dist/src/commands/status-check.js +1 -1
  52. package/dist/src/commands/status-json.d.ts +2 -2
  53. package/dist/src/commands/status-json.js +1 -1
  54. package/dist/src/commands/status-output.d.ts +1 -1
  55. package/dist/src/commands/status-output.js +1 -1
  56. package/dist/src/commands/status.js +21 -7
  57. package/dist/src/commands/test-build-phase.d.ts +2 -2
  58. package/dist/src/commands/test-build-phase.js +9 -6
  59. package/dist/src/commands/test-diagnose.js +8 -1
  60. package/dist/src/commands/test-modes.d.ts +2 -2
  61. package/dist/src/commands/test-modes.js +3 -3
  62. package/dist/src/commands/test-register.js +7 -2
  63. package/dist/src/commands/test-run.d.ts +6 -0
  64. package/dist/src/commands/test-run.js +32 -3
  65. package/dist/src/commands/test-stale-gate.js +7 -2
  66. package/dist/src/commands/test.js +4 -4
  67. package/dist/src/commands/tree.js +2 -2
  68. package/dist/src/commands/typecheck.d.ts +2 -1
  69. package/dist/src/commands/typecheck.js +59 -3
  70. package/dist/src/commands/verify.js +26 -1
  71. package/dist/src/core/ast-utils.js +14 -2
  72. package/dist/src/core/browser-wire.js +9 -1
  73. package/dist/src/core/build-baseline-types.d.ts +1 -1
  74. package/dist/src/core/build-baseline.d.ts +1 -1
  75. package/dist/src/core/build-baseline.js +1 -1
  76. package/dist/src/core/build-overwrite-guard.d.ts +49 -0
  77. package/dist/src/core/build-overwrite-guard.js +165 -0
  78. package/dist/src/core/build-prepare.d.ts +13 -5
  79. package/dist/src/core/build-prepare.js +42 -9
  80. package/dist/src/core/config-mutate.d.ts +3 -2
  81. package/dist/src/core/config-mutate.js +5 -2
  82. package/dist/src/core/config.d.ts +3 -2
  83. package/dist/src/core/config.js +2 -0
  84. package/dist/src/core/coverage-extend.d.ts +1 -1
  85. package/dist/src/core/coverage-extend.js +1 -1
  86. package/dist/src/core/destructive.d.ts +23 -0
  87. package/dist/src/core/destructive.js +45 -4
  88. package/dist/src/core/diff-hunks.js +31 -17
  89. package/dist/src/core/discard-baseline.d.ts +1 -1
  90. package/dist/src/core/discard-baseline.js +1 -1
  91. package/dist/src/core/display-state.d.ts +52 -0
  92. package/dist/src/core/display-state.js +89 -0
  93. package/dist/src/core/engine-session-lock.d.ts +20 -0
  94. package/dist/src/core/engine-session-lock.js +84 -14
  95. package/dist/src/core/export-moved-code-shape.d.ts +60 -0
  96. package/dist/src/core/export-moved-code-shape.js +129 -0
  97. package/dist/src/core/file-lock.d.ts +44 -0
  98. package/dist/src/core/file-lock.js +157 -42
  99. package/dist/src/core/furnace-apply-helpers.d.ts +1 -1
  100. package/dist/src/core/furnace-apply-helpers.js +1 -1
  101. package/dist/src/core/furnace-apply-output.js +1 -1
  102. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +2 -2
  103. package/dist/src/core/furnace-apply-overwrite-warn.js +2 -2
  104. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +1 -1
  105. package/dist/src/core/furnace-apply-undeploy-actions.js +1 -1
  106. package/dist/src/core/furnace-apply.js +13 -1
  107. package/dist/src/core/furnace-config-custom.d.ts +2 -1
  108. package/dist/src/core/furnace-config-order.d.ts +2 -1
  109. package/dist/src/core/furnace-config-order.js +9 -4
  110. package/dist/src/core/furnace-config-validate.d.ts +4 -3
  111. package/dist/src/core/furnace-config-validate.js +3 -3
  112. package/dist/src/core/furnace-config.js +5 -0
  113. package/dist/src/core/furnace-operation.d.ts +12 -0
  114. package/dist/src/core/furnace-operation.js +125 -1
  115. package/dist/src/core/furnace-rollback.d.ts +17 -0
  116. package/dist/src/core/furnace-rollback.js +35 -1
  117. package/dist/src/core/git-base.js +7 -2
  118. package/dist/src/core/git-diff.js +11 -2
  119. package/dist/src/core/git-readonly-index.d.ts +59 -0
  120. package/dist/src/core/git-readonly-index.js +137 -0
  121. package/dist/src/core/license-headers.d.ts +1 -1
  122. package/dist/src/core/license-headers.js +1 -1
  123. package/dist/src/core/lint-cache.d.ts +15 -5
  124. package/dist/src/core/lint-cache.js +35 -8
  125. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  126. package/dist/src/core/mach-known-noise-filter.js +23 -0
  127. package/dist/src/core/mach-mozconfig.js +12 -2
  128. package/dist/src/core/module-resolution-preflight.d.ts +51 -0
  129. package/dist/src/core/module-resolution-preflight.js +155 -0
  130. package/dist/src/core/patch-export.js +2 -1
  131. package/dist/src/core/patch-identifier-suggest.js +29 -3
  132. package/dist/src/core/patch-lint-checkjs.d.ts +5 -5
  133. package/dist/src/core/patch-lint-checkjs.js +6 -6
  134. package/dist/src/core/patch-lint-creators.d.ts +1 -1
  135. package/dist/src/core/patch-lint-creators.js +1 -1
  136. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  137. package/dist/src/core/patch-lint-cross.js +4 -4
  138. package/dist/src/core/patch-lint-css.js +1 -1
  139. package/dist/src/core/patch-lint-diff.d.ts +1 -1
  140. package/dist/src/core/patch-lint-diff.js +1 -1
  141. package/dist/src/core/patch-lint-jsdoc.js +4 -2
  142. package/dist/src/core/patch-lint-module-registration.d.ts +13 -0
  143. package/dist/src/core/patch-lint-module-registration.js +13 -4
  144. package/dist/src/core/patch-lint-mozbuild.d.ts +1 -1
  145. package/dist/src/core/patch-lint-mozbuild.js +1 -1
  146. package/dist/src/core/patch-lint-ownership.d.ts +2 -2
  147. package/dist/src/core/patch-lint-ownership.js +2 -2
  148. package/dist/src/core/patch-lint.d.ts +2 -2
  149. package/dist/src/core/patch-lint.js +3 -3
  150. package/dist/src/core/patch-lock.d.ts +21 -0
  151. package/dist/src/core/patch-lock.js +29 -2
  152. package/dist/src/core/patch-manifest-io.d.ts +2 -1
  153. package/dist/src/core/patch-manifest-io.js +49 -0
  154. package/dist/src/core/patch-parse.d.ts +18 -0
  155. package/dist/src/core/patch-parse.js +24 -1
  156. package/dist/src/core/patch-policy.d.ts +2 -2
  157. package/dist/src/core/patch-policy.js +2 -2
  158. package/dist/src/core/smoke-patterns.d.ts +1 -1
  159. package/dist/src/core/status-binary.d.ts +1 -1
  160. package/dist/src/core/status-binary.js +1 -1
  161. package/dist/src/core/status-classify.d.ts +1 -1
  162. package/dist/src/core/status-classify.js +2 -2
  163. package/dist/src/core/test-harness-crash.d.ts +69 -3
  164. package/dist/src/core/test-harness-crash.js +174 -9
  165. package/dist/src/core/test-stale-check.d.ts +1 -1
  166. package/dist/src/core/test-stale-check.js +1 -1
  167. package/dist/src/core/token-category.js +1 -1
  168. package/dist/src/core/token-manager.d.ts +1 -1
  169. package/dist/src/core/token-manager.js +5 -5
  170. package/dist/src/core/tree-cow.js +1 -1
  171. package/dist/src/core/tree-guard.js +2 -2
  172. package/dist/src/core/tree-store.js +1 -1
  173. package/dist/src/core/typecheck-shim.d.ts +2 -2
  174. package/dist/src/core/typecheck-shim.js +2 -2
  175. package/dist/src/core/typecheck.js +10 -0
  176. package/dist/src/core/xpcshell-profile-dir.js +1 -1
  177. package/dist/src/errors/base.d.ts +22 -1
  178. package/dist/src/errors/base.js +32 -1
  179. package/dist/src/errors/codes.d.ts +10 -0
  180. package/dist/src/errors/codes.js +10 -0
  181. package/dist/src/types/commands/options.d.ts +27 -11
  182. package/dist/src/types/commands/patches.d.ts +1 -1
  183. package/dist/src/types/config.d.ts +2 -2
  184. package/dist/src/types/furnace.d.ts +1 -1
  185. package/dist/src/types/json.d.ts +22 -0
  186. package/dist/src/types/json.js +11 -0
  187. package/dist/src/utils/assert.d.ts +46 -0
  188. package/dist/src/utils/assert.js +83 -0
  189. package/dist/src/utils/build-info.d.ts +1 -1
  190. package/dist/src/utils/build-info.js +1 -1
  191. package/dist/src/utils/logger.d.ts +20 -0
  192. package/dist/src/utils/logger.js +26 -0
  193. package/dist/src/utils/options.d.ts +12 -0
  194. package/dist/src/utils/options.js +39 -0
  195. package/dist/src/utils/stdio-drain.d.ts +1 -1
  196. package/dist/src/utils/stdio-drain.js +1 -1
  197. package/dist/src/utils/validation.d.ts +12 -1
  198. package/dist/src/utils/validation.js +13 -1
  199. package/package.json +3 -3
@@ -0,0 +1,165 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Loud guard against silently reverting UNEXPORTED engine drift.
4
+ *
5
+ * `build-prepare` rewrites engine files from FireForge-owned sources
6
+ * before every build: the branding tree, every Furnace-managed component,
7
+ * and `mozconfig`. On a multi-session checkout that is a silent
8
+ * destructive write. The recorded incident: one session edited a file in
9
+ * `engine/` while another held the engine lock; the lock-holder's build
10
+ * rewrote the file back to its patch baseline; the editing session's later
11
+ * `re-export --wait-lock` then captured a HYBRID file — and every gate
12
+ * passed on the hybrid capture. Only a hand-run grep of the patch body
13
+ * caught it.
14
+ *
15
+ * The `setupBranding` half of this class was closed in `31fdf744` (the
16
+ * branding writer preserves unmanaged lines). The general shape — any
17
+ * build-prepare overwrite destroying local drift that no patch records —
18
+ * stayed silent, and silence is what made the hybrid capture possible.
19
+ *
20
+ * This guard runs BEFORE the writes, classifies the engine's dirty files
21
+ * with the same classifier `fireforge status` uses, and reports the ones
22
+ * whose content is explained by neither a patch body nor the pristine
23
+ * baseline. Advisory by default (a warning naming every file), refusing
24
+ * under an explicit flag.
25
+ */
26
+ import { toError } from '../utils/errors.js';
27
+ import { verbose } from '../utils/logger.js';
28
+ import { getProjectPaths } from './config.js';
29
+ import { collectFurnaceManagedPrefixes } from './furnace-config.js';
30
+ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from './git-status.js';
31
+ import { classifyFiles } from './status-classify.js';
32
+ /**
33
+ * Classifications whose on-disk content is explained by NEITHER a patch
34
+ * body nor the pristine baseline — exactly the content a build-prepare
35
+ * overwrite would destroy without a record anywhere.
36
+ *
37
+ * `patch-backed` and `furnace` are excluded because their content IS the
38
+ * recorded state; `branding` is excluded because the branding writer
39
+ * preserves unmanaged lines rather than overwriting them.
40
+ */
41
+ const AT_RISK_CLASSIFICATIONS = new Set(['unmanaged', 'patch-owned-drift', 'conflict']);
42
+ /**
43
+ * Every path prefix build-prepare rewrites from a FireForge-owned source:
44
+ * the branding tree plus every Furnace-managed prefix. (`mozconfig` is a
45
+ * single file, not a prefix, and is tested separately.)
46
+ */
47
+ function buildPrepareOwnedPrefixes(binaryName, furnacePrefixes) {
48
+ return [`browser/branding/${binaryName}/`, ...furnacePrefixes];
49
+ }
50
+ /**
51
+ * True when `file` sits under a path build-prepare rewrites from a
52
+ * FireForge-owned source: the branding tree, a Furnace-managed prefix, or
53
+ * the generated `mozconfig`.
54
+ */
55
+ function isOverwrittenByBuildPrepare(file, binaryName, furnacePrefixes) {
56
+ if (file === 'mozconfig')
57
+ return true;
58
+ for (const prefix of buildPrepareOwnedPrefixes(binaryName, furnacePrefixes)) {
59
+ if (file === prefix || file.startsWith(prefix))
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ /**
65
+ * Narrows the status entries to the ones build-prepare can overwrite,
66
+ * expanding collapsed untracked directories on the way.
67
+ *
68
+ * Git reports a WHOLLY untracked directory as a single `?? dir/` entry
69
+ * rather than listing the files under it. A directory entry has no
70
+ * content to compare, so `classifyFiles` matched it against neither a
71
+ * patch body nor the pristine baseline and bucketed it `unmanaged` — an
72
+ * at-risk classification. 0.43.0 shipped the guard on the raw status and
73
+ * so warned about every untracked branding subdirectory on every build,
74
+ * while `status --unmanaged` (which expands) reported the same tree
75
+ * clean. Expanding here is what makes the two agree by construction.
76
+ *
77
+ * Expansion is scoped to build-prepare-owned prefixes on both sides of
78
+ * the walk: a collapsed directory that merely CONTAINS an owned prefix
79
+ * (`components/` when `components/custom/` is Furnace-managed) is
80
+ * rewritten to the owned prefixes beneath it, so the enumeration never
81
+ * walks an unrelated subtree — and never trips the per-directory
82
+ * expansion cap on a tree whose owned prefixes are small.
83
+ */
84
+ async function collectOverwrittenEntries(engineDir, status, binaryName, furnacePrefixes) {
85
+ const ownedPrefixes = buildPrepareOwnedPrefixes(binaryName, furnacePrefixes);
86
+ const candidates = [];
87
+ for (const entry of status) {
88
+ if (isOverwrittenByBuildPrepare(entry.file, binaryName, furnacePrefixes)) {
89
+ candidates.push(entry);
90
+ continue;
91
+ }
92
+ if (!entry.isUntracked || !entry.file.endsWith('/'))
93
+ continue;
94
+ // A collapsed untracked ancestor of an owned prefix: walk only the
95
+ // owned subtrees under it, never the whole directory.
96
+ for (const prefix of ownedPrefixes) {
97
+ if (prefix.startsWith(entry.file)) {
98
+ candidates.push({ ...entry, file: prefix });
99
+ }
100
+ }
101
+ }
102
+ if (candidates.length === 0)
103
+ return [];
104
+ const expanded = await expandUntrackedDirectoryEntries(engineDir, candidates);
105
+ // Re-test after expansion: an ancestor rewrite can only widen, and a
106
+ // directory that survived expansion (nothing untracked under it any
107
+ // more) carries no content to classify.
108
+ return expanded.filter((entry) => !entry.file.endsWith('/') &&
109
+ isOverwrittenByBuildPrepare(entry.file, binaryName, furnacePrefixes));
110
+ }
111
+ /**
112
+ * Finds engine files that build-prepare is about to overwrite and whose
113
+ * current content is recorded nowhere.
114
+ *
115
+ * Fail-open: any probe failure is reported verbosely and yields an empty
116
+ * list. A build must not fail because an advisory guard could not run.
117
+ *
118
+ * @param projectRoot - Project root
119
+ * @param config - Loaded FireForge config (reads `binaryName`)
120
+ * @returns At-risk files, sorted by path
121
+ */
122
+ export async function findUnexportedDriftAtRisk(projectRoot, config) {
123
+ const paths = getProjectPaths(projectRoot);
124
+ try {
125
+ const status = await getWorkingTreeStatus(paths.engine);
126
+ if (status.length === 0)
127
+ return [];
128
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
129
+ const candidates = await collectOverwrittenEntries(paths.engine, status, config.binaryName, furnacePrefixes);
130
+ if (candidates.length === 0)
131
+ return [];
132
+ const classified = await classifyFiles(candidates, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
133
+ return classified
134
+ .filter((entry) => AT_RISK_CLASSIFICATIONS.has(entry.classification))
135
+ .map((entry) => ({
136
+ file: entry.file,
137
+ classification: entry.classification,
138
+ owner: entry.owner,
139
+ }))
140
+ .sort((a, b) => a.file.localeCompare(b.file));
141
+ }
142
+ catch (error) {
143
+ verbose(`Unexported-drift guard could not run: ${toError(error).message}`);
144
+ return [];
145
+ }
146
+ }
147
+ /**
148
+ * Renders the operator-facing warning (or refusal body) naming every file
149
+ * whose unexported content the build is about to overwrite.
150
+ */
151
+ export function formatUnexportedDriftWarning(files) {
152
+ const rows = files.map((entry) => {
153
+ const owner = entry.owner !== undefined ? ` (owned by ${entry.owner})` : '';
154
+ return ` ${entry.file} [${entry.classification}]${owner}`;
155
+ });
156
+ return (`This build is about to rewrite ${String(files.length)} engine file(s) from FireForge-owned ` +
157
+ 'sources, and their current content matches NEITHER a patch body NOR the pristine ' +
158
+ 'baseline — so the edits below are recorded nowhere and the build will destroy them:\n' +
159
+ `${rows.join('\n')}\n\n` +
160
+ 'On a multi-session checkout this is how a hybrid capture happens: the overwrite lands, ' +
161
+ "the editing session's later re-export captures a half-reverted file, and every gate " +
162
+ 'passes on it.\n\n' +
163
+ 'Export the edits first (`fireforge re-export <patch>`, or `fireforge export` for new ' +
164
+ 'work), or pass --refuse-unexported-drift to make this a hard stop in scripted runs.');
165
+ }
@@ -25,6 +25,11 @@ export interface PrepareBuildOptions {
25
25
  * means.
26
26
  */
27
27
  previousBaseline?: BuildBaseline | undefined;
28
+ /**
29
+ * Turn the unexported-drift warning into a hard refusal.
30
+ * Scripted gates want the stop; an interactive build wants the warning.
31
+ */
32
+ refuseUnexportedDrift?: boolean | undefined;
28
33
  }
29
34
  /** Packaging manifests whose graph/destination directories need a full build. */
30
35
  export declare function requiresFullBuildForIncrementalTest(path: string): boolean;
@@ -33,7 +38,7 @@ export declare function requiresFullBuildForIncrementalTest(path: string): boole
33
38
  * configure/build logs with a signal-shaped exit (e.g. 144 = 128+16, SIGURG
34
39
  * on macOS) are environmental interruptions, not compiler failures — naming
35
40
  * that in the failure text saves the operator a fruitless log hunt
36
- * (FORGE F16). Returns undefined for codes <= 128 (regular failures) and
41
+ *. Returns undefined for codes <= 128 (regular failures) and
37
42
  * for codes past the conventional signal range, so callers append nothing.
38
43
  */
39
44
  export declare function describeSignalShapedExit(exitCode: number): string | undefined;
@@ -44,14 +49,17 @@ export declare function describeSignalShapedExit(exitCode: number): string | und
44
49
  export declare function isBackendInvalidatingFile(path: string): boolean;
45
50
  /**
46
51
  * Runs the shared pre-flight steps for build and package commands:
47
- * 1. Cleans Furnace stories from engine (prevents leaking into production)
48
- * 2. Sets up branding directory if not already done
49
- * 3. Applies Furnace components if furnace.json exists
50
- * 4. Generates mozconfig
52
+ * 1. Warns (or refuses) about unexported engine drift the writes below
53
+ * would destroy
54
+ * 2. Cleans Furnace stories from engine (prevents leaking into production)
55
+ * 3. Sets up branding directory if not already done
56
+ * 4. Applies Furnace components if furnace.json exists
57
+ * 5. Generates mozconfig
51
58
  *
52
59
  * @param projectRoot - Root directory of the project
53
60
  * @param paths - Resolved project paths
54
61
  * @param config - Loaded FireForge configuration
62
+ * @param options - Baseline and drift-refusal options
55
63
  * @returns Preparation results
56
64
  */
57
65
  export declare function prepareBuildEnvironment(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig, options?: PrepareBuildOptions): Promise<BuildPreparation>;
@@ -4,12 +4,14 @@
4
4
  * story cleanup, branding setup, Furnace component application, and mozconfig generation.
5
5
  */
6
6
  import { constants as osConstants } from 'node:os';
7
+ import { GeneralError } from '../errors/base.js';
7
8
  import { BuildError } from '../errors/build.js';
8
9
  import { FurnaceError } from '../errors/furnace.js';
9
10
  import { toError } from '../utils/errors.js';
10
11
  import { pathExists } from '../utils/fs.js';
11
- import { info, spinner, verbose, warn } from '../utils/logger.js';
12
+ import { info, notice, spinner, verbose, warn } from '../utils/logger.js';
12
13
  import { isBrandingSetup, setupBranding } from './branding.js';
14
+ import { findUnexportedDriftAtRisk, formatUnexportedDriftWarning, } from './build-overwrite-guard.js';
13
15
  import { collectChangedEnginePaths } from './engine-changes.js';
14
16
  import { applyAllComponents } from './furnace-apply.js';
15
17
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
@@ -43,7 +45,7 @@ function extractMachConfigureError(result) {
43
45
  * configure/build logs with a signal-shaped exit (e.g. 144 = 128+16, SIGURG
44
46
  * on macOS) are environmental interruptions, not compiler failures — naming
45
47
  * that in the failure text saves the operator a fruitless log hunt
46
- * (FORGE F16). Returns undefined for codes <= 128 (regular failures) and
48
+ *. Returns undefined for codes <= 128 (regular failures) and
47
49
  * for codes past the conventional signal range, so callers append nothing.
48
50
  */
49
51
  export function describeSignalShapedExit(exitCode) {
@@ -87,16 +89,44 @@ export function isBackendInvalidatingFile(path) {
87
89
  }
88
90
  return false;
89
91
  }
92
+ /**
93
+ * Loud before the overwrite, not silent after it.
94
+ *
95
+ * Every write `prepareBuildEnvironment` performs — branding, Furnace
96
+ * components, `mozconfig` — rewrites engine files from a FireForge-owned
97
+ * source. Content recorded in neither a patch body nor the pristine
98
+ * baseline is destroyed by that, and on a multi-session checkout the
99
+ * destruction is what lets a later re-export capture a half-reverted
100
+ * hybrid that every gate then passes.
101
+ *
102
+ * @param projectRoot - Project root
103
+ * @param config - Loaded FireForge config
104
+ * @param options - Build-prepare options (reads `refuseUnexportedDrift`)
105
+ * @throws GeneralError under `--refuse-unexported-drift`
106
+ */
107
+ async function reportUnexportedDriftBeforeOverwrite(projectRoot, config, options) {
108
+ const atRisk = await findUnexportedDriftAtRisk(projectRoot, config);
109
+ if (atRisk.length === 0)
110
+ return;
111
+ const message = formatUnexportedDriftWarning(atRisk);
112
+ if (options.refuseUnexportedDrift === true) {
113
+ throw new GeneralError(message);
114
+ }
115
+ notice(message);
116
+ }
90
117
  /**
91
118
  * Runs the shared pre-flight steps for build and package commands:
92
- * 1. Cleans Furnace stories from engine (prevents leaking into production)
93
- * 2. Sets up branding directory if not already done
94
- * 3. Applies Furnace components if furnace.json exists
95
- * 4. Generates mozconfig
119
+ * 1. Warns (or refuses) about unexported engine drift the writes below
120
+ * would destroy
121
+ * 2. Cleans Furnace stories from engine (prevents leaking into production)
122
+ * 3. Sets up branding directory if not already done
123
+ * 4. Applies Furnace components if furnace.json exists
124
+ * 5. Generates mozconfig
96
125
  *
97
126
  * @param projectRoot - Root directory of the project
98
127
  * @param paths - Resolved project paths
99
128
  * @param config - Loaded FireForge configuration
129
+ * @param options - Baseline and drift-refusal options
100
130
  * @returns Preparation results
101
131
  */
102
132
  export async function prepareBuildEnvironment(projectRoot, paths, config, options = {}) {
@@ -111,6 +141,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
111
141
  'Run "fireforge doctor --repair-furnace" to reconcile engine state before building.');
112
142
  }
113
143
  }
144
+ await reportUnexportedDriftBeforeOverwrite(projectRoot, config, options);
114
145
  // Auto-configure: if any backend-invalidating file (moz.build, moz.configure,
115
146
  // Makefile.in) changed since the last successful build, run `mach configure`
116
147
  // before the build step. Prevents incremental builds from silently skipping
@@ -122,8 +153,10 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
122
153
  const invalidating = changed.filter(isBackendInvalidatingFile);
123
154
  fullBuildRequired = changed.some(requiresFullBuildForIncrementalTest);
124
155
  if (invalidating.length > 0) {
125
- info(`Backend config changed; running backend regeneration first (${invalidating.length} file${invalidating.length === 1 ? '' : 's'} touched).`);
126
- info(`Backend command: mach configure`);
156
+ // Warning severity: this is the "why is this run slow" explanation,
157
+ // and an info-level line does not survive an agent output filter that
158
+ // keeps only warnings and errors.
159
+ notice(`Backend config changed; running backend regeneration first (${invalidating.length} file${invalidating.length === 1 ? '' : 's'} touched). Backend command: mach configure.`);
127
160
  const configureSpinner = spinner('Running mach configure...');
128
161
  try {
129
162
  const captured = await runMachCapture(['configure'], paths.engine);
@@ -222,7 +255,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
222
255
  // Loud banner: the build operator needs to see that engine/ was
223
256
  // updated before this build, otherwise a silent re-apply is
224
257
  // indistinguishable from a build that shipped stale components.
225
- info(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
258
+ notice(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
226
259
  }
227
260
  else {
228
261
  furnaceSpinner.stop('Components up to date');
@@ -2,6 +2,7 @@
2
2
  * Immutable config mutation with dot-path keys.
3
3
  */
4
4
  import type { FireForgeConfig } from '../types/config.js';
5
+ import type { JsonObject, JsonValue } from '../types/json.js';
5
6
  /**
6
7
  * Creates a mutated copy of a config with a nested key set to a new value,
7
8
  * optionally re-validated.
@@ -11,5 +12,5 @@ import type { FireForgeConfig } from '../types/config.js';
11
12
  * @param skipValidation - If true, skip re-validation (for --force)
12
13
  * @returns The mutated config
13
14
  */
14
- export declare function mutateConfig(config: FireForgeConfig, key: string, value: unknown, skipValidation?: false): FireForgeConfig;
15
- export declare function mutateConfig(config: FireForgeConfig | Record<string, unknown>, key: string, value: unknown, skipValidation: true): Record<string, unknown>;
15
+ export declare function mutateConfig(config: FireForgeConfig, key: string, value: JsonValue, skipValidation?: false): FireForgeConfig;
16
+ export declare function mutateConfig(config: FireForgeConfig | JsonObject, key: string, value: JsonValue, skipValidation: true): JsonObject;
@@ -5,13 +5,16 @@
5
5
  import { ConfigError } from '../errors/config.js';
6
6
  import { toError } from '../utils/errors.js';
7
7
  import { verbose } from '../utils/logger.js';
8
- import { isObject } from '../utils/validation.js';
8
+ import { isJsonObject, isObject } from '../utils/validation.js';
9
9
  import { validateConfig } from './config-validate.js';
10
10
  function cloneConfigDocument(config) {
11
11
  const cloned = structuredClone(config);
12
12
  if (!isObject(cloned)) {
13
13
  throw new ConfigError('Config clone unexpectedly produced a non-object value');
14
14
  }
15
+ // Both input shapes hold only JSON data (`FireForgeConfig` is plain
16
+ // parsed-config data), and `structuredClone` preserves that, so the
17
+ // object check above is the only invariant left to establish.
15
18
  return cloned;
16
19
  }
17
20
  /**
@@ -33,7 +36,7 @@ function assertNoSentinelSegments(key, parts) {
33
36
  }
34
37
  function getOrCreateChildRecord(parent, key) {
35
38
  const existing = parent[key];
36
- if (isObject(existing)) {
39
+ if (isJsonObject(existing)) {
37
40
  return existing;
38
41
  }
39
42
  const child = {};
@@ -8,6 +8,7 @@
8
8
  * config-state.ts — state file management
9
9
  */
10
10
  import type { FireForgeConfig } from '../types/config.js';
11
+ import type { JsonObject } from '../types/json.js';
11
12
  export { mutateConfig } from './config-mutate.js';
12
13
  export { CONFIG_FILENAME, FIREFORGE_DIR, getProjectPaths, STATE_FILENAME, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, } from './config-paths.js';
13
14
  export { loadState, saveState, updateState } from './config-state.js';
@@ -41,7 +42,7 @@ export declare function loadConfig(root: string): Promise<FireForgeConfig>;
41
42
  * @throws ConfigNotFoundError when fireforge.json is missing
42
43
  * @throws ConfigError when the file is not valid JSON
43
44
  */
44
- export declare function loadRawConfigDocument(root: string): Promise<Record<string, unknown>>;
45
+ export declare function loadRawConfigDocument(root: string): Promise<JsonObject>;
45
46
  /**
46
47
  * Writes a configuration to fireforge.json.
47
48
  * @param root - Root directory of the project
@@ -61,7 +62,7 @@ export declare function writeConfig(root: string, config: FireForgeConfig): Prom
61
62
  * {@link withConfigFileLock} for the full round-trip to serialise
62
63
  * against other writers.
63
64
  */
64
- export declare function writeConfigDocument(root: string, config: FireForgeConfig | Record<string, unknown>): Promise<void>;
65
+ export declare function writeConfigDocument(root: string, config: FireForgeConfig | JsonObject): Promise<void>;
65
66
  /**
66
67
  * Runs an operation while holding a sidecar lock on `fireforge.json`.
67
68
  *
@@ -95,6 +95,8 @@ export async function loadRawConfigDocument(root) {
95
95
  if (!isObject(data)) {
96
96
  throw new ConfigError(`Invalid fireforge.json at ${paths.config}: expected an object`);
97
97
  }
98
+ // `data` came out of JSON.parse, which can only ever produce JSON
99
+ // values, so with the object check above the JsonObject contract holds.
98
100
  return data;
99
101
  }
100
102
  catch (error) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `test --build/--build-only --extend-coverage` (FORGE L1): unions the
2
+ * `test --build/--build-only --extend-coverage`: unions the
3
3
  * requested paths into the recorded `testPackagingCoverage` instead of
4
4
  * replacing it, under an anchor guard.
5
5
  *
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * `test --build/--build-only --extend-coverage` (FORGE L1): unions the
3
+ * `test --build/--build-only --extend-coverage`: unions the
4
4
  * requested paths into the recorded `testPackagingCoverage` instead of
5
5
  * replacing it, under an anchor guard.
6
6
  *
@@ -68,6 +68,29 @@ export interface HistoryEntry {
68
68
  /** Result: `'ok'` on success; other strings describe failure. */
69
69
  result: string;
70
70
  }
71
+ /**
72
+ * UP-FRONT non-interactive refusal for a command that will end at
73
+ * {@link confirmDestructive}.
74
+ *
75
+ * `confirmDestructive` already refuses a prompt-less run rather than
76
+ * proceeding silently — but it does so at the END, after the command has
77
+ * built diffs and run projected per-patch lint. On `patch move-files
78
+ * --create` that is minutes of work before a scripted run learns it needed
79
+ * `--yes`, and the failure reads as an obscure late error rather than a
80
+ * usage problem. Calling this at command entry turns it into a usage
81
+ * refusal that names the flag, in the same shape as the 0.41.0
82
+ * `move-files --create` / `--description` refusal.
83
+ *
84
+ * A `--yes` or `--dry-run` run never prompts, so neither is refused.
85
+ *
86
+ * @param operation - Stable operation id, e.g. `patch-move-files-create`
87
+ * @param options - The run's `--yes` / `--dry-run` state
88
+ * @throws InvalidArgumentError when the run would prompt but cannot
89
+ */
90
+ export declare function assertConfirmationAvailable(operation: string, options: {
91
+ yes?: boolean | undefined;
92
+ dryRun?: boolean | undefined;
93
+ }): void;
71
94
  /**
72
95
  * Executes the destructive-operation contract: summary → conflict refusal →
73
96
  * dry-run / force / prompt / non-TTY refusal.
@@ -39,6 +39,50 @@ function printConflicts(conflicts) {
39
39
  info(` ${detail}`);
40
40
  }
41
41
  }
42
+ /**
43
+ * True when BOTH standard handles are real TTYs, i.e. a `confirm()` prompt
44
+ * can actually be answered.
45
+ *
46
+ * Node types `isTTY` as `boolean`, but at runtime it is `true | undefined`
47
+ * (absent when the handle is not a TTY). Unusual harnesses (vitest stdio
48
+ * capture, CI spawners, mocked pipes) can also leave it unset on only one
49
+ * handle, so every falsy variant — false, undefined, a null-patched mock —
50
+ * must route to the non-interactive path.
51
+ */
52
+ function confirmationIsAnswerable() {
53
+ const stdinIsTTY = process.stdin.isTTY;
54
+ const stdoutIsTTY = process.stdout.isTTY;
55
+ return stdinIsTTY === true && stdoutIsTTY === true;
56
+ }
57
+ /**
58
+ * UP-FRONT non-interactive refusal for a command that will end at
59
+ * {@link confirmDestructive}.
60
+ *
61
+ * `confirmDestructive` already refuses a prompt-less run rather than
62
+ * proceeding silently — but it does so at the END, after the command has
63
+ * built diffs and run projected per-patch lint. On `patch move-files
64
+ * --create` that is minutes of work before a scripted run learns it needed
65
+ * `--yes`, and the failure reads as an obscure late error rather than a
66
+ * usage problem. Calling this at command entry turns it into a usage
67
+ * refusal that names the flag, in the same shape as the 0.41.0
68
+ * `move-files --create` / `--description` refusal.
69
+ *
70
+ * A `--yes` or `--dry-run` run never prompts, so neither is refused.
71
+ *
72
+ * @param operation - Stable operation id, e.g. `patch-move-files-create`
73
+ * @param options - The run's `--yes` / `--dry-run` state
74
+ * @throws InvalidArgumentError when the run would prompt but cannot
75
+ */
76
+ export function assertConfirmationAvailable(operation, options) {
77
+ if (options.yes === true || options.dryRun === true)
78
+ return;
79
+ if (confirmationIsAnswerable())
80
+ return;
81
+ throw new InvalidArgumentError(`"${operation}" ends in an interactive confirmation, but this run has no TTY to answer it ` +
82
+ '(stdin/stdout are not both terminals). Re-run with --yes to confirm non-interactively ' +
83
+ '(required for CI and agent-driven runs), or with --dry-run to preview without ' +
84
+ 'confirming. Refusing up front rather than after the planning and lint work.', '--yes');
85
+ }
42
86
  /**
43
87
  * Executes the destructive-operation contract: summary → conflict refusal →
44
88
  * dry-run / force / prompt / non-TTY refusal.
@@ -93,10 +137,7 @@ export async function confirmDestructive(input) {
93
137
  // one handle. We cast to the real runtime type and compare against
94
138
  // literal `true` so any falsy variant — false, undefined, or a
95
139
  // null-patched mock — routes to the non-interactive path.
96
- const stdinIsTTY = process.stdin.isTTY;
97
- const stdoutIsTTY = process.stdout.isTTY;
98
- const isInteractive = stdinIsTTY === true && stdoutIsTTY === true;
99
- if (!isInteractive) {
140
+ if (!confirmationIsAnswerable()) {
100
141
  printSummary(title, summary);
101
142
  throw new InvalidArgumentError(`Interactive confirmation not available for "${operation}". ` +
102
143
  'Use --yes to run non-interactively (required for CI).', '--yes');
@@ -1,13 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- /* eslint-disable @typescript-eslint/no-non-null-assertion --
3
- * This module is a tight DP/backtrack line-diff whose bounds are checked
4
- * structurally (every index into `ops`, `dp`, or the line arrays is either
5
- * guarded by an explicit `k < range.end` / `i <= m` loop condition or sits
6
- * inside an array built and consumed in the same function). Wrapping every
7
- * access in an `if (value === undefined) throw` branch would bloat the hot
8
- * path without catching a real class of bugs — the non-null assertions here
9
- * encode the invariant the surrounding logic already guarantees.
10
- */
2
+ import { assert, expectDefined } from '../utils/assert.js';
11
3
  /**
12
4
  * Maximum combined LCS table side-length before we bail out of the exact
13
5
  * O(m·n) diff and fall back to the simpler single-region coalesce. Chosen so
@@ -37,8 +29,28 @@ function splitLines(text) {
37
29
  export function computeLineDiff(oldLines, newLines) {
38
30
  const m = oldLines.length;
39
31
  const n = newLines.length;
32
+ // The DP below indexes `dp`, `oldLines`, and `newLines` about 4·m·n times
33
+ // for a worst-case pair, so its bounds are established once here rather
34
+ // than re-checked per access. Callers gate on LCS_LINE_LIMIT before
35
+ // reaching this function; if that gate is ever moved or removed, this is
36
+ // where the resulting allocation blow-up surfaces as a named failure
37
+ // instead of an out-of-memory crash.
38
+ assert(m <= LCS_LINE_LIMIT && n <= LCS_LINE_LIMIT, () => `line diff inputs are within LCS_LINE_LIMIT (got ${m}x${n}, limit ${LCS_LINE_LIMIT})`);
40
39
  const dp = new Int32Array((m + 1) * (n + 1));
41
40
  const stride = n + 1;
41
+ // Every index into `dp` is `row * stride + column` with row ≤ m and
42
+ // column ≤ n, which is exactly the extent allocated above.
43
+ assert(dp.length === (m + 1) * stride, 'DP table is sized to its stride');
44
+ /* eslint-disable @typescript-eslint/no-non-null-assertion --
45
+ * The DP fill and backtrack below are the one hot path in this module:
46
+ * O(m·n) iterations, up to ~4M for a LCS_LINE_LIMIT-sized pair. Every
47
+ * index is structurally bounded by the loop conditions (`i <= m`,
48
+ * `j <= n`, `i > 0`, `j > 0`) against the extents asserted immediately
49
+ * above, so the non-null assertions encode a property already checked
50
+ * once per call rather than one that is never checked at all. The
51
+ * rendering loops further down, which run O(ops), use `expectDefined`
52
+ * instead — this exemption is for the quadratic block only.
53
+ */
42
54
  for (let i = 1; i <= m; i++) {
43
55
  const oldLine = oldLines[i - 1];
44
56
  const rowOffset = i * stride;
@@ -83,6 +95,7 @@ export function computeLineDiff(oldLines, newLines) {
83
95
  ops.push({ type: 'insert', newIndex: j - 1, line: newLines[j - 1] });
84
96
  j--;
85
97
  }
98
+ /* eslint-enable @typescript-eslint/no-non-null-assertion */
86
99
  ops.reverse();
87
100
  return ops;
88
101
  }
@@ -97,9 +110,10 @@ export function buildHunks(ops, context) {
97
110
  const editRanges = [];
98
111
  let i = 0;
99
112
  while (i < ops.length) {
100
- if (ops[i].type !== 'equal') {
113
+ if (expectDefined(ops[i], () => `diff op at index ${i}`).type !== 'equal') {
101
114
  const start = i;
102
- while (i < ops.length && ops[i].type !== 'equal')
115
+ while (i < ops.length &&
116
+ expectDefined(ops[i], () => `diff op at index ${i}`).type !== 'equal')
103
117
  i++;
104
118
  editRanges.push({ start, end: i });
105
119
  }
@@ -126,7 +140,7 @@ export function buildHunks(ops, context) {
126
140
  let oldLine = 1;
127
141
  let newLine = 1;
128
142
  for (let k = 0; k < range.start; k++) {
129
- const op = ops[k];
143
+ const op = expectDefined(ops[k], () => `diff op at index ${k}`);
130
144
  if (op.type === 'equal') {
131
145
  oldLine++;
132
146
  newLine++;
@@ -142,7 +156,7 @@ export function buildHunks(ops, context) {
142
156
  let oldLength = 0;
143
157
  let newLength = 0;
144
158
  for (let k = range.start; k < range.end; k++) {
145
- const op = ops[k];
159
+ const op = expectDefined(ops[k], () => `diff op at index ${k}`);
146
160
  if (op.type === 'equal') {
147
161
  hunkLines.push({ marker: ' ', content: op.line });
148
162
  oldLength++;
@@ -197,13 +211,13 @@ function coalescedHunk(oldLines, newLines, context) {
197
211
  const contextEndNew = Math.min(newLines.length - 1, lastNewDiff + context);
198
212
  const hunkLines = [];
199
213
  for (let k = contextStart; k < firstDiff; k++) {
200
- hunkLines.push({ marker: ' ', content: oldLines[k] });
214
+ hunkLines.push({ marker: ' ', content: expectDefined(oldLines[k], () => `old line ${k}`) });
201
215
  }
202
216
  for (let k = firstDiff; k <= lastOldDiff; k++) {
203
- hunkLines.push({ marker: '-', content: oldLines[k] });
217
+ hunkLines.push({ marker: '-', content: expectDefined(oldLines[k], () => `old line ${k}`) });
204
218
  }
205
219
  for (let k = firstDiff; k <= lastNewDiff; k++) {
206
- hunkLines.push({ marker: '+', content: newLines[k] });
220
+ hunkLines.push({ marker: '+', content: expectDefined(newLines[k], () => `new line ${k}`) });
207
221
  }
208
222
  // Trailing context comes from the common suffix, which lives at
209
223
  // DIFFERENT indices on each side (lastOldDiff+1… vs lastNewDiff+1…).
@@ -212,7 +226,7 @@ function coalescedHunk(oldLines, newLines, context) {
212
226
  // lines were excluded from the @@ header lengths entirely — so on
213
227
  // >LCS_LINE_LIMIT files the rendered header disagreed with the body.
214
228
  for (let k = lastNewDiff + 1; k <= contextEndNew; k++) {
215
- hunkLines.push({ marker: ' ', content: newLines[k] });
229
+ hunkLines.push({ marker: ' ', content: expectDefined(newLines[k], () => `new line ${k}`) });
216
230
  }
217
231
  const leadingContext = firstDiff - contextStart;
218
232
  // Common-suffix symmetry: oldLen-1-lastOldDiff === newLen-1-lastNewDiff,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
2
+ * Patch-aware restore planning for `fireforge discard` (P0).
3
3
  *
4
4
  * The engine convention is: HEAD = pristine upstream Firefox, and the
5
5
  * applied patch queue lives as UNCOMMITTED worktree changes on top of it
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
3
+ * Patch-aware restore planning for `fireforge discard` (P0).
4
4
  *
5
5
  * The engine convention is: HEAD = pristine upstream Firefox, and the
6
6
  * applied patch queue lives as UNCOMMITTED worktree changes on top of it