@hominis/fireforge 0.10.1 → 0.11.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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -2,10 +2,13 @@
2
2
  import { join } from 'node:path';
3
3
  import { isBrandingManagedPath } from '../core/branding.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
5
6
  import { getStatusWithCodes, isGitRepository } from '../core/git.js';
6
7
  import { getUntrackedFilesInDir } from '../core/git-status.js';
7
8
  import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
9
+ import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
8
10
  import { computePatchedContent } from '../core/patch-apply.js';
11
+ import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
9
12
  import { loadPatchesManifest } from '../core/patch-manifest.js';
10
13
  import { GeneralError } from '../errors/base.js';
11
14
  import { toError } from '../utils/errors.js';
@@ -116,7 +119,7 @@ async function expandDirectoryEntries(files, engineDir) {
116
119
  /**
117
120
  * Classifies files into patch-backed, unmanaged, or branding buckets.
118
121
  */
119
- async function classifyFiles(files, engineDir, patchesDir, binaryName) {
122
+ async function classifyFiles(files, engineDir, patchesDir, binaryName, furnacePrefixes) {
120
123
  const manifest = await loadPatchesManifest(patchesDir);
121
124
  // Build set of all patch-claimed file paths
122
125
  const patchClaimedFiles = new Set();
@@ -134,6 +137,20 @@ async function classifyFiles(files, engineDir, patchesDir, binaryName) {
134
137
  results.push({ ...entry, classification: 'branding' });
135
138
  continue;
136
139
  }
140
+ // Furnace-managed component paths
141
+ if (furnacePrefixes.size > 0) {
142
+ let isFurnace = false;
143
+ for (const prefix of furnacePrefixes) {
144
+ if (entry.file.startsWith(prefix)) {
145
+ isFurnace = true;
146
+ break;
147
+ }
148
+ }
149
+ if (isFurnace) {
150
+ results.push({ ...entry, classification: 'furnace' });
151
+ continue;
152
+ }
153
+ }
137
154
  // Not in any patch → unmanaged
138
155
  if (!patchClaimedFiles.has(entry.file)) {
139
156
  results.push({ ...entry, classification: 'unmanaged' });
@@ -169,20 +186,74 @@ async function classifyFiles(files, engineDir, patchesDir, binaryName) {
169
186
  }
170
187
  return results;
171
188
  }
189
+ /**
190
+ * Renders classified file status as machine-readable JSON to stdout.
191
+ */
192
+ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
193
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
194
+ const classified = await classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
195
+ const output = classified.map((f) => ({
196
+ file: f.file,
197
+ status: f.status.trim(),
198
+ classification: f.classification,
199
+ }));
200
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
201
+ }
172
202
  /**
173
203
  * Runs the status command to show modified files.
174
204
  * @param projectRoot - Root directory of the project
175
205
  * @param options - Status display options
176
206
  */
177
207
  export async function statusCommand(projectRoot, options = {}) {
178
- if (options.raw && options.unmanaged) {
179
- throw new GeneralError('Cannot use --raw and --unmanaged together.');
208
+ const modeCount = [options.raw, options.unmanaged, options.ownership, options.json].filter((v) => v === true).length;
209
+ if (modeCount > 1) {
210
+ throw new GeneralError('Cannot use --raw, --unmanaged, --ownership, and --json together. Pick at most one.');
180
211
  }
181
- if (!options.raw) {
212
+ if (!options.raw && !options.json) {
182
213
  intro('FireForge Status');
183
214
  }
184
215
  const paths = getProjectPaths(projectRoot);
185
216
  const config = await loadConfig(projectRoot);
217
+ // Ownership mode is a flat file→patch table; sources are the manifest's
218
+ // filesAffected, any worktree drift, and the cross-patch
219
+ // duplicate-new-file-creation map produced by walking each patch
220
+ // body. The latter is the alignment fix between `status --ownership`
221
+ // and `fireforge verify` — see buildOwnershipTable's header comment.
222
+ // Runs before the default classify path so we can short-circuit
223
+ // without computing patch-backed state.
224
+ if (options.ownership) {
225
+ if (!(await pathExists(paths.engine))) {
226
+ throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
227
+ }
228
+ const manifest = await loadPatchesManifest(paths.patches);
229
+ const rawFilesOwnership = (await isGitRepository(paths.engine))
230
+ ? await expandDirectoryEntries(await getStatusWithCodes(paths.engine), paths.engine)
231
+ : [];
232
+ // Only walk the patch bodies when the directory actually exists.
233
+ // Fresh projects with no patch queue yet pass through with an empty
234
+ // creators map, which degrades to the old filesAffected-only
235
+ // behavior for the empty case.
236
+ const newFileCreatorsByPath = (await pathExists(paths.patches))
237
+ ? collectNewFileCreatorsByPath(await buildPatchQueueContext(paths.patches))
238
+ : new Map();
239
+ const rows = buildOwnershipTable(manifest?.patches ?? [], rawFilesOwnership, newFileCreatorsByPath);
240
+ renderOwnershipTable(rows);
241
+ const conflictCount = rows.filter((r) => r.conflict).length;
242
+ const unmanagedCount = rows.filter((r) => r.unmanaged).length;
243
+ const managedCount = rows.filter((r) => !r.unmanaged).length;
244
+ const parts = [`${managedCount} managed`];
245
+ if (conflictCount > 0)
246
+ parts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`);
247
+ if (unmanagedCount > 0)
248
+ parts.push(`${unmanagedCount} unmanaged`);
249
+ outro(parts.join(', '));
250
+ if (conflictCount > 0) {
251
+ throw new GeneralError(`${conflictCount} path(s) are claimed by more than one patch. ` +
252
+ 'Run "fireforge verify" for full details, then use "re-export --files" or ' +
253
+ '"patch delete" to resolve.');
254
+ }
255
+ return;
256
+ }
186
257
  // Check if engine exists
187
258
  if (!(await pathExists(paths.engine))) {
188
259
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
@@ -203,11 +274,18 @@ export async function statusCommand(projectRoot, options = {}) {
203
274
  renderRawStatus(files);
204
275
  return;
205
276
  }
277
+ // JSON mode and default mode both need classification
278
+ if (options.json) {
279
+ await renderJsonStatus(files, paths, projectRoot, config.binaryName);
280
+ return;
281
+ }
206
282
  // Patch-aware classification
207
- const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName);
283
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
284
+ const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
208
285
  const unmanagedFiles = classified.filter((f) => f.classification === 'unmanaged');
209
286
  const patchBackedFiles = classified.filter((f) => f.classification === 'patch-backed');
210
287
  const brandingFiles = classified.filter((f) => f.classification === 'branding');
288
+ const furnaceFiles = classified.filter((f) => f.classification === 'furnace');
211
289
  // --unmanaged mode: only show unmanaged
212
290
  if (options.unmanaged) {
213
291
  info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${files.length} total modified):\n`);
@@ -242,7 +320,16 @@ export async function statusCommand(projectRoot, options = {}) {
242
320
  warn('Tool-managed branding changes:');
243
321
  printStatusGroups(brandingFiles);
244
322
  }
245
- if (unmanagedFiles.length === 0 && patchBackedFiles.length === 0 && brandingFiles.length === 0) {
323
+ if (furnaceFiles.length > 0) {
324
+ if (unmanagedFiles.length > 0 || patchBackedFiles.length > 0 || brandingFiles.length > 0)
325
+ info('');
326
+ warn('Furnace-managed component changes:');
327
+ printStatusGroups(furnaceFiles);
328
+ }
329
+ if (unmanagedFiles.length === 0 &&
330
+ patchBackedFiles.length === 0 &&
331
+ brandingFiles.length === 0 &&
332
+ furnaceFiles.length === 0) {
246
333
  info('No changes');
247
334
  }
248
335
  const parts = [];
@@ -252,6 +339,8 @@ export async function statusCommand(projectRoot, options = {}) {
252
339
  parts.push(`${patchBackedFiles.length} patch-backed`);
253
340
  if (brandingFiles.length > 0)
254
341
  parts.push(`${brandingFiles.length} branding`);
342
+ if (furnaceFiles.length > 0)
343
+ parts.push(`${furnaceFiles.length} furnace`);
255
344
  outro(parts.join(', '));
256
345
  }
257
346
  /** Registers the status command on the CLI program. */
@@ -261,6 +350,8 @@ export function registerStatus(program, { getProjectRoot, withErrorHandling }) {
261
350
  .description('Show modified files in engine/')
262
351
  .option('--raw', 'Show raw worktree status without patch classification')
263
352
  .option('--unmanaged', 'Show only unmanaged changes (not covered by patches or tools)')
353
+ .option('--ownership', 'Show a flat path → owning patch table (flags files claimed by multiple patches)')
354
+ .option('--json', 'Output classified file status as JSON')
264
355
  .action(withErrorHandling(async (options) => {
265
356
  await statusCommand(getProjectRoot(), options);
266
357
  }));
@@ -1,9 +1,8 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
- import { isBrandingSetup, setupBranding } from '../core/branding.js';
3
+ import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
- import { cleanStories } from '../core/furnace-stories.js';
6
- import { buildArtifactMismatchMessage, buildUI, generateMozconfig, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
5
+ import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
7
6
  import { GeneralError } from '../errors/base.js';
8
7
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
9
8
  import { pathExists } from '../utils/fs.js';
@@ -53,38 +52,6 @@ function hasStaleBuildArtifactsSignal(output) {
53
52
  /resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
54
53
  /browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
55
54
  }
56
- async function prepareIncrementalTestBuild(projectRoot) {
57
- const config = await loadConfig(projectRoot);
58
- const paths = getProjectPaths(projectRoot);
59
- const brandingConfig = {
60
- name: config.name,
61
- vendor: config.vendor,
62
- appId: config.appId,
63
- binaryName: config.binaryName,
64
- };
65
- if (!(await isBrandingSetup(paths.engine, brandingConfig))) {
66
- const brandingSpinner = spinner('Setting up branding...');
67
- try {
68
- await setupBranding(paths.engine, brandingConfig);
69
- brandingSpinner.stop('Branding configured');
70
- }
71
- catch (error) {
72
- brandingSpinner.error('Failed to set up branding');
73
- throw error;
74
- }
75
- }
76
- const mozconfigSpinner = spinner('Generating mozconfig...');
77
- try {
78
- await generateMozconfig(paths.configs, paths.engine, config);
79
- mozconfigSpinner.stop('mozconfig generated');
80
- }
81
- catch (error) {
82
- mozconfigSpinner.error('Failed to generate mozconfig');
83
- throw error;
84
- }
85
- await cleanStories(paths.engine);
86
- return { engineDir: paths.engine };
87
- }
88
55
  function handleNonZeroTestExit(result, normalizedPaths) {
89
56
  if (result.exitCode === 0 || result.exitCode === 130)
90
57
  return;
@@ -133,9 +100,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
133
100
  }
134
101
  // Run incremental build if requested
135
102
  if (options.build) {
136
- const { engineDir } = await prepareIncrementalTestBuild(projectRoot);
103
+ const config = await loadConfig(projectRoot);
104
+ await prepareBuildEnvironment(projectRoot, paths, config);
137
105
  const s = spinner('Running incremental build...');
138
- const buildExitCode = await buildUI(engineDir);
106
+ const buildExitCode = await buildUI(paths.engine);
139
107
  if (buildExitCode !== 0) {
140
108
  s.error('Pre-test build failed');
141
109
  throw new BuildError('Pre-test build failed', 'mach build faster');
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `fireforge verify` — read-only fsck for the patch queue.
3
+ *
4
+ * Combines the manifest consistency check (orphan files, missing entries,
5
+ * files-affected mismatch, duplicate entries) with the cross-patch lint
6
+ * rules (duplicate /dev/null creation, forward imports). Does not run
7
+ * `planExport` per patch — that is intentionally out of scope because it
8
+ * would couple verify to engine state and make the command too slow to be
9
+ * useful as a pre-flight gate. Engine-level patch application issues are
10
+ * still covered by the existing `fireforge doctor` and `fireforge import`
11
+ * paths.
12
+ *
13
+ * Exits non-zero when any error-severity finding is reported so CI can
14
+ * treat the output as pass/fail.
15
+ */
16
+ import { Command } from 'commander';
17
+ import type { CommandContext } from '../types/cli.js';
18
+ /**
19
+ * Runs the `verify` command: manifest consistency + cross-patch lint.
20
+ * Read-only; exits non-zero on any error-severity finding.
21
+ *
22
+ * @param projectRoot - Project root directory
23
+ */
24
+ export declare function verifyCommand(projectRoot: string): Promise<void>;
25
+ /**
26
+ * Registers the `verify` command on the CLI program.
27
+ *
28
+ * @param program - Commander root program
29
+ * @param context - Shared CLI registration context
30
+ */
31
+ export declare function registerVerify(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge verify` — read-only fsck for the patch queue.
4
+ *
5
+ * Combines the manifest consistency check (orphan files, missing entries,
6
+ * files-affected mismatch, duplicate entries) with the cross-patch lint
7
+ * rules (duplicate /dev/null creation, forward imports). Does not run
8
+ * `planExport` per patch — that is intentionally out of scope because it
9
+ * would couple verify to engine state and make the command too slow to be
10
+ * useful as a pre-flight gate. Engine-level patch application issues are
11
+ * still covered by the existing `fireforge doctor` and `fireforge import`
12
+ * paths.
13
+ *
14
+ * Exits non-zero when any error-severity finding is reported so CI can
15
+ * treat the output as pass/fail.
16
+ */
17
+ import { getProjectPaths } from '../core/config.js';
18
+ import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
19
+ import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
20
+ import { GeneralError } from '../errors/base.js';
21
+ import { pathExists } from '../utils/fs.js';
22
+ import { info, intro, outro, success, warn } from '../utils/logger.js';
23
+ /**
24
+ * Reports duplicate `filesAffected` entries across patches — the manifest
25
+ * consistency check only flags per-patch duplicates and orphan files, not
26
+ * the case where two different patches claim the same path. `verify`
27
+ * surfaces that here so it can be caught before `export`, `re-export`, or
28
+ * `rebase` hit it.
29
+ */
30
+ function detectCrossPatchFileClaims(manifestPatches) {
31
+ const claims = new Map();
32
+ for (const patch of manifestPatches) {
33
+ for (const file of patch.filesAffected) {
34
+ const existing = claims.get(file) ?? [];
35
+ existing.push(patch.filename);
36
+ claims.set(file, existing);
37
+ }
38
+ }
39
+ const results = [];
40
+ for (const [path, filenames] of claims) {
41
+ if (filenames.length > 1) {
42
+ results.push({ path, filenames });
43
+ }
44
+ }
45
+ return results;
46
+ }
47
+ /**
48
+ * Runs the `verify` command: manifest consistency + cross-patch lint.
49
+ * Read-only; exits non-zero on any error-severity finding.
50
+ *
51
+ * @param projectRoot - Project root directory
52
+ */
53
+ export async function verifyCommand(projectRoot) {
54
+ intro('FireForge Verify');
55
+ const paths = getProjectPaths(projectRoot);
56
+ if (!(await pathExists(paths.patches))) {
57
+ info('No patches directory. Nothing to verify.');
58
+ outro('Verify clean');
59
+ return;
60
+ }
61
+ let errorCount = 0;
62
+ let warningCount = 0;
63
+ // 1. Manifest consistency: orphan patch files, missing entries,
64
+ // files-affected mismatch, duplicate entries, unparseable manifest.
65
+ const consistencyIssues = await validatePatchesManifestConsistency(paths.patches);
66
+ if (consistencyIssues.length > 0) {
67
+ warn(`Manifest consistency issues (${consistencyIssues.length}):`);
68
+ for (const issue of consistencyIssues) {
69
+ warn(` [${issue.code}] ${issue.message}`);
70
+ errorCount += 1;
71
+ }
72
+ }
73
+ // 2. Cross-patch file claims: two or more manifest entries listing the
74
+ // same path in filesAffected. Not caught by per-patch consistency.
75
+ const manifest = await loadPatchesManifest(paths.patches);
76
+ if (manifest) {
77
+ const crossClaims = detectCrossPatchFileClaims(manifest.patches);
78
+ if (crossClaims.length > 0) {
79
+ warn(`Cross-patch filesAffected conflicts (${crossClaims.length}):`);
80
+ for (const claim of crossClaims) {
81
+ warn(` ${claim.path} claimed by: ${claim.filenames.join(', ')}`);
82
+ errorCount += 1;
83
+ }
84
+ }
85
+ }
86
+ // 3. Cross-patch lint: duplicate /dev/null creation + forward imports.
87
+ const ctx = await buildPatchQueueContext(paths.patches);
88
+ const lintIssues = lintPatchQueue(ctx);
89
+ if (lintIssues.length > 0) {
90
+ warn(`Cross-patch lint issues (${lintIssues.length}):`);
91
+ for (const issue of lintIssues) {
92
+ const label = issue.severity === 'error' ? 'ERROR' : 'WARN';
93
+ warn(` ${label} [${issue.check}] ${issue.file}: ${issue.message}`);
94
+ if (issue.severity === 'error')
95
+ errorCount += 1;
96
+ else
97
+ warningCount += 1;
98
+ }
99
+ }
100
+ if (errorCount === 0 && warningCount === 0) {
101
+ success('Patch queue is consistent.');
102
+ outro('Verify clean');
103
+ return;
104
+ }
105
+ info(`\nVerify: ${errorCount} error(s), ${warningCount} warning(s)`);
106
+ if (errorCount > 0) {
107
+ outro('Verify failed');
108
+ throw new GeneralError(`fireforge verify found ${errorCount} error(s). Fix these before running export/import/rebase.`);
109
+ }
110
+ outro('Verify passed with warnings');
111
+ }
112
+ /**
113
+ * Registers the `verify` command on the CLI program.
114
+ *
115
+ * @param program - Commander root program
116
+ * @param context - Shared CLI registration context
117
+ */
118
+ export function registerVerify(program, { getProjectRoot, withErrorHandling }) {
119
+ program
120
+ .command('verify')
121
+ .description('Read-only fsck for the patch queue (manifest + cross-patch lint)')
122
+ .action(withErrorHandling(async () => {
123
+ await verifyCommand(getProjectRoot());
124
+ }));
125
+ }
126
+ //# sourceMappingURL=verify.js.map
@@ -3,10 +3,13 @@
3
3
  * Shared pre-flight logic for build and package commands:
4
4
  * story cleanup, branding setup, Furnace component application, and mozconfig generation.
5
5
  */
6
+ import { FurnaceError } from '../errors/furnace.js';
7
+ import { pathExists } from '../utils/fs.js';
6
8
  import { spinner, warn } from '../utils/logger.js';
7
9
  import { isBrandingSetup, setupBranding } from './branding.js';
8
10
  import { applyAllComponents } from './furnace-apply.js';
9
- import { furnaceConfigExists, loadFurnaceConfig } from './furnace-config.js';
11
+ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
12
+ import { runFurnaceMutation } from './furnace-operation.js';
10
13
  import { cleanStories } from './furnace-stories.js';
11
14
  import { generateMozconfig } from './mach.js';
12
15
  /**
@@ -22,6 +25,17 @@ import { generateMozconfig } from './mach.js';
22
25
  * @returns Preparation results
23
26
  */
24
27
  export async function prepareBuildEnvironment(projectRoot, paths, config) {
28
+ // Block the build if Furnace has an unresolved repair marker. This prevents
29
+ // building against an engine that may be in an inconsistent state after a
30
+ // failed rollback.
31
+ const furnaceStatePath = getFurnacePaths(projectRoot).furnaceState;
32
+ if (await pathExists(furnaceStatePath)) {
33
+ const furnaceState = await loadFurnaceState(projectRoot);
34
+ if (furnaceState.pendingRepair) {
35
+ throw new FurnaceError(`Furnace has an unresolved repair marker (from ${furnaceState.pendingRepair.operation}). ` +
36
+ 'Run "fireforge doctor --repair-furnace" to reconcile engine state before building.');
37
+ }
38
+ }
25
39
  // Clean stories before build to ensure they don't leak into production binary
26
40
  await cleanStories(paths.engine);
27
41
  // Set up custom branding directory and patch moz.configure
@@ -50,19 +64,26 @@ export async function prepareBuildEnvironment(projectRoot, paths, config) {
50
64
  Object.keys(furnaceConfig.custom).length > 0;
51
65
  if (hasComponents) {
52
66
  const furnaceSpinner = spinner('Applying Furnace components...');
67
+ let result;
53
68
  try {
54
- const result = await applyAllComponents(projectRoot);
55
- furnaceApplied = result.applied.length;
56
- if (furnaceApplied > 0) {
57
- furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
58
- }
59
- else {
60
- furnaceSpinner.stop('Components up to date');
61
- }
62
- if (result.errors.length > 0) {
63
- for (const err of result.errors) {
64
- warn(`Furnace: ${err.name} ${err.error}`);
65
- }
69
+ result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
70
+ }
71
+ catch (error) {
72
+ furnaceSpinner.error('Failed to apply Furnace components');
73
+ throw error;
74
+ }
75
+ furnaceApplied = result.applied.length;
76
+ // Count entries that were "applied" but recorded step-level errors
77
+ // mid-apply (e.g. a post-step failure after file writes succeeded).
78
+ // These are distinct from `result.errors`, which captures
79
+ // components that failed before reaching the applied list at all.
80
+ // The sum of the two is the total count of failed components.
81
+ const appliedWithStepErrorsCount = result.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
82
+ const totalApplyFailures = result.errors.length + appliedWithStepErrorsCount;
83
+ if (totalApplyFailures > 0) {
84
+ furnaceSpinner.error('Failed to apply Furnace components');
85
+ for (const err of result.errors) {
86
+ warn(`Furnace: ${err.name} — ${err.error}`);
66
87
  }
67
88
  for (const applied of result.applied) {
68
89
  if (applied.stepErrors && applied.stepErrors.length > 0) {
@@ -71,10 +92,13 @@ export async function prepareBuildEnvironment(projectRoot, paths, config) {
71
92
  }
72
93
  }
73
94
  }
95
+ throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
74
96
  }
75
- catch (error) {
76
- furnaceSpinner.error('Failed to apply Furnace components');
77
- throw error;
97
+ if (furnaceApplied > 0) {
98
+ furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
99
+ }
100
+ else {
101
+ furnaceSpinner.stop('Components up to date');
78
102
  }
79
103
  }
80
104
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared destructive-operation contract: interactive confirmation, non-TTY
3
+ * refusal, dry-run plumbing, hard-refusal on structural conflicts, and a
4
+ * JSONL audit log.
5
+ *
6
+ * This exists because the repair primitives (`patch delete`, `patch reorder`,
7
+ * `re-export --files`) and the new `export --dry-run`/`export --order` flags
8
+ * all share the same dance: build a change summary, gate it behind a prompt,
9
+ * accept a `--yes` bypass for CI, accept a `--dry-run` no-op, and refuse
10
+ * outright when the change would introduce a structural conflict (e.g. a
11
+ * forward-import that later-patch lint would then block). Without a single
12
+ * helper, every new destructive command would re-implement this and drift.
13
+ */
14
+ /** Filename of the audit log, relative to the patches directory. */
15
+ export declare const HISTORY_LOG_FILENAME = ".fireforge-history.jsonl";
16
+ /**
17
+ * A structural conflict that must block the operation even under `--force`.
18
+ *
19
+ * Intended for cases like "reorder would introduce a forward-import" or
20
+ * "delete would orphan a later patch's import" — situations where the
21
+ * operator almost certainly wants a different fix (re-export --files, etc.)
22
+ * rather than a bypass. `--force-unsafe` is the escape hatch when the
23
+ * operator genuinely accepts the risk.
24
+ */
25
+ export interface ConflictReport {
26
+ /** Short one-line reason the operation is refused. */
27
+ reason: string;
28
+ /** Specific conflicts (patch names, file paths, lint findings). */
29
+ details: string[];
30
+ }
31
+ /** Inputs to {@link confirmDestructive}. */
32
+ export interface DestructiveOpInput {
33
+ /** Operation identifier, used in history entries (e.g. `patch-delete`). */
34
+ operation: string;
35
+ /** Short one-line title shown in the prompt. */
36
+ title: string;
37
+ /**
38
+ * Detailed change summary — every affected patch, file, or renumber row.
39
+ * Generic "proceed? [y/N]" is insufficient per the destructive-op contract;
40
+ * callers must list every concrete change here.
41
+ */
42
+ summary: string[];
43
+ /** Whether the caller passed `--yes`. */
44
+ yes: boolean;
45
+ /** Whether the caller passed `--dry-run`. */
46
+ dryRun: boolean;
47
+ /** Whether the caller passed `--force-unsafe`. Only this flag bypasses conflicts. */
48
+ unsafeOverride?: boolean;
49
+ /** Structural conflicts that should block the operation unless `unsafeOverride`. */
50
+ conflicts?: ConflictReport | null;
51
+ }
52
+ /** Outcome of {@link confirmDestructive}. */
53
+ export type DestructiveOpResult = 'proceed' | 'dry-run' | 'cancelled';
54
+ /**
55
+ * Inputs to {@link appendHistory}. Entries are appended as one JSON record
56
+ * per line. Callers should only append after a mutation succeeds so
57
+ * rolled-back failures never leave ghost entries.
58
+ */
59
+ export interface HistoryEntry {
60
+ /** Operation identifier matching the DestructiveOpInput. */
61
+ operation: string;
62
+ /** Serializable argument payload (flags, targets, renumber map, etc.). */
63
+ args: Record<string, unknown>;
64
+ /** True if `--yes` was used. */
65
+ yes?: boolean;
66
+ /** True if `--force-unsafe` was used. */
67
+ unsafeOverride?: boolean;
68
+ /** Result: `'ok'` on success; other strings describe failure. */
69
+ result: string;
70
+ }
71
+ /**
72
+ * Executes the destructive-operation contract: summary → conflict refusal →
73
+ * dry-run / force / prompt / non-TTY refusal.
74
+ *
75
+ * Returns the decision for the caller to act on; callers must not execute the
76
+ * mutation when the result is `'dry-run'` or `'cancelled'`, and must call
77
+ * {@link appendHistory} only after the mutation succeeds (never on dry-run or
78
+ * cancellation).
79
+ *
80
+ * @param input - Operation description, flags, and optional conflict report
81
+ * @returns `'proceed'` to execute, `'dry-run'` to skip execution, or
82
+ * `'cancelled'` when the user declined the prompt
83
+ */
84
+ export declare function confirmDestructive(input: DestructiveOpInput): Promise<DestructiveOpResult>;
85
+ /**
86
+ * Appends a single JSONL record to `patches/.fireforge-history.jsonl`.
87
+ *
88
+ * Call order matters: append only after the mutation succeeds, never
89
+ * pre-emptively, so rolled-back failures do not leave ghost entries. The log
90
+ * is append-only and advisory — no code path reads it back; it exists purely
91
+ * so operators have a post-hoc audit trail when something goes wrong.
92
+ *
93
+ * @param patchesDir - Path to the patches directory
94
+ * @param entry - Serializable history record
95
+ */
96
+ export declare function appendHistory(patchesDir: string, entry: HistoryEntry): Promise<void>;