@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
@@ -1,4 +1,5 @@
1
1
  import { configExists, getProjectPaths, loadConfig, loadState } from '../core/config.js';
2
+ import { furnaceConfigExists as checkFurnaceConfigExists } from '../core/furnace-config.js';
2
3
  import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
3
4
  import { ensureGit } from '../core/git-base.js';
4
5
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
@@ -9,169 +10,338 @@ import { ExitCode } from '../errors/codes.js';
9
10
  import { toError } from '../utils/errors.js';
10
11
  import { pathExists } from '../utils/fs.js';
11
12
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
13
+ import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
12
14
  /**
13
- * Runs a doctor check and returns the result.
15
+ * Builds a DoctorCheck object representing a successful "OK" check.
16
+ * Exported for sibling check modules that declare `DoctorCheckDefinition`
17
+ * entries out-of-file (e.g. `doctor-furnace.ts`).
14
18
  */
15
- async function runCheck(name, check, fix) {
19
+ export function ok(name) {
20
+ return { name, passed: true, severity: 'ok', message: 'OK' };
21
+ }
22
+ /**
23
+ * Builds a DoctorCheck object representing a warning result.
24
+ * Exported for sibling check modules — see {@link ok}.
25
+ */
26
+ export function warning(name, message, fix) {
27
+ return {
28
+ name,
29
+ passed: true,
30
+ severity: 'warning',
31
+ warning: true,
32
+ message,
33
+ ...(fix ? { fix } : {}),
34
+ };
35
+ }
36
+ /**
37
+ * Builds a DoctorCheck object representing a failure result.
38
+ * Exported for sibling check modules — see {@link ok}.
39
+ */
40
+ export function failure(name, message, fix) {
41
+ return { name, passed: false, severity: 'error', message, ...(fix ? { fix } : {}) };
42
+ }
43
+ /**
44
+ * Runs a single check definition, converting thrown errors into
45
+ * DoctorCheck failure rows. Always returns an array so the caller can
46
+ * flatten results uniformly.
47
+ */
48
+ async function executeCheck(definition, ctx) {
49
+ if (definition.skipIf?.(ctx)) {
50
+ return [];
51
+ }
16
52
  try {
17
- await check();
18
- return { name, passed: true, severity: 'ok', message: 'OK' };
53
+ const result = await definition.run(ctx);
54
+ return Array.isArray(result) ? result : [result];
19
55
  }
20
- catch (error) {
21
- const message = toError(error).message;
22
- const result = { name, passed: false, severity: 'error', message };
23
- if (fix !== undefined) {
24
- result.fix = fix;
25
- }
26
- return result;
56
+ catch (err) {
57
+ return [failure(definition.name, toError(err).message, definition.fix)];
27
58
  }
28
59
  }
29
60
  function summarizeWorkingTreeChangeCount(changeCount) {
30
61
  return `Engine working tree has ${changeCount} local change${changeCount === 1 ? '' : 's'}. Some FireForge commands assume a clean baseline and may behave differently until these are exported, discarded, or committed.`;
31
62
  }
32
- async function collectEngineChecks(paths, state, engineExists) {
33
- const checks = [];
34
- if (!engineExists) {
35
- return checks;
63
+ /**
64
+ * Runs the subset of engine checks that depend on a healthy git repository
65
+ * and HEAD. This group shares mutable state (currentHead, canValidateBranch),
66
+ * so it lives as a single definition returning multiple rows.
67
+ */
68
+ async function runEngineGitChecks(ctx) {
69
+ const { paths, state } = ctx;
70
+ const rows = [];
71
+ let currentHead;
72
+ let canValidateBranch = true;
73
+ if (state.baseCommit) {
74
+ try {
75
+ currentHead = await getHead(paths.engine);
76
+ }
77
+ catch (err) {
78
+ if (!isMissingHeadError(err)) {
79
+ throw err;
80
+ }
81
+ canValidateBranch = false;
82
+ rows.push(failure('Engine state consistency', 'Engine repository has no baseline commit yet. A previous "fireforge download" likely stopped after git init but before the initial Firefox commit was created.', 'Re-run "fireforge download --force" to recreate the baseline repository cleanly.'));
83
+ }
84
+ if (canValidateBranch && currentHead !== state.baseCommit) {
85
+ rows.push(failure('Engine state consistency', 'HEAD differs from baseCommit. FireForge expects the engine repository to remain at the downloaded baseline commit; branch switches or commits inside engine/ can break import, resolve, and patch regeneration workflows.', 'Reset engine/ to the baseline commit or re-run "fireforge download --force".'));
86
+ }
87
+ else if (canValidateBranch) {
88
+ rows.push(ok('Engine state consistency'));
89
+ }
36
90
  }
37
- // Check 6: Engine is a git repository
38
- const isGitRepo = await isGitRepository(paths.engine);
39
- checks.push({
40
- name: 'Engine is git repository',
41
- passed: isGitRepo,
42
- severity: isGitRepo ? 'ok' : 'error',
43
- message: isGitRepo ? 'OK' : 'engine/ is not a git repository',
44
- ...(!isGitRepo ? { fix: 'Run "fireforge download --force" to reinitialize' } : {}),
45
- });
46
- // Only run git-dependent checks if the engine is actually a git repo
47
- if (isGitRepo) {
48
- let currentHead;
49
- let canValidateBranch = true;
50
- // Engine consistency checks
51
- if (state.baseCommit) {
52
- try {
53
- currentHead = await getHead(paths.engine);
91
+ const rawStatus = await getWorkingTreeStatus(paths.engine);
92
+ const workingTreeStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
93
+ if (workingTreeStatus.length > 0) {
94
+ rows.push(warning('Engine working tree', summarizeWorkingTreeChangeCount(workingTreeStatus.length), 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.'));
95
+ }
96
+ else {
97
+ rows.push(ok('Engine working tree'));
98
+ }
99
+ let branch;
100
+ if (canValidateBranch) {
101
+ try {
102
+ branch = await getCurrentBranch(paths.engine);
103
+ }
104
+ catch (err) {
105
+ if (!isMissingHeadError(err)) {
106
+ throw err;
54
107
  }
55
- catch (error) {
56
- if (!isMissingHeadError(error)) {
57
- throw error;
108
+ canValidateBranch = false;
109
+ rows.push(failure('Engine branch', 'Engine repository has no baseline commit yet. A previous "fireforge download" likely stopped before git created the initial Firefox commit.', 'Re-run "fireforge download --force" to recreate the baseline repository cleanly.'));
110
+ }
111
+ }
112
+ if (!canValidateBranch &&
113
+ branch === undefined &&
114
+ currentHead === undefined &&
115
+ !state.baseCommit) {
116
+ // Unborn repository with no recorded baseline — the earlier failure row
117
+ // explains recovery; avoid adding a second near-identical row.
118
+ }
119
+ else if (!canValidateBranch) {
120
+ rows.push(warning('Engine branch', 'Skipped branch validation because the baseline commit is missing.', 'Finish recreating the engine baseline with "fireforge download --force".'));
121
+ }
122
+ else if (branch === 'firefox') {
123
+ rows.push(ok('Engine branch'));
124
+ }
125
+ else if (branch === 'HEAD' && state.baseCommit && currentHead === state.baseCommit) {
126
+ rows.push(warning('Engine branch', 'Engine is detached at the recorded base commit. This is acceptable for disposable worktrees and audit clones.', 'If this is your primary workspace, checkout the "firefox" branch to match FireForge defaults.'));
127
+ }
128
+ else {
129
+ rows.push(failure('Engine branch', `Engine is on branch "${branch}", but expected "firefox".`));
130
+ }
131
+ return rows;
132
+ }
133
+ /**
134
+ * Validates that every check's `dependsOn` entries appear earlier in the
135
+ * registry. Called once at module load time so a broken reorder surfaces
136
+ * immediately as a thrown error rather than producing a subtle
137
+ * context-population bug at runtime.
138
+ */
139
+ function validateCheckDependencies(checks) {
140
+ const seen = new Set();
141
+ for (const check of checks) {
142
+ if (check.dependsOn) {
143
+ for (const dep of check.dependsOn) {
144
+ if (!seen.has(dep)) {
145
+ throw new Error(`Doctor check "${check.name}" declares dependsOn "${dep}", ` +
146
+ `but "${dep}" does not appear earlier in the registry. ` +
147
+ 'Fix the ordering in DOCTOR_CHECKS.');
58
148
  }
59
- canValidateBranch = false;
60
- checks.push({
61
- name: 'Engine state consistency',
62
- passed: false,
63
- severity: 'error',
64
- message: 'Engine repository has no baseline commit yet. A previous "fireforge download" likely stopped after git init but before the initial Firefox commit was created.',
65
- fix: 'Re-run "fireforge download --force" to recreate the baseline repository cleanly.',
66
- });
67
149
  }
68
- if (canValidateBranch && currentHead !== state.baseCommit) {
69
- checks.push({
70
- name: 'Engine state consistency',
71
- passed: false,
72
- severity: 'error',
73
- message: 'HEAD differs from baseCommit. FireForge expects the engine repository to remain at the downloaded baseline commit; branch switches or commits inside engine/ can break import, resolve, and patch regeneration workflows.',
74
- fix: 'Reset engine/ to the baseline commit or re-run "fireforge download --force".',
75
- });
150
+ }
151
+ seen.add(check.name);
152
+ }
153
+ }
154
+ /**
155
+ * The declarative doctor check registry. The order of entries here is the
156
+ * order checks appear in the report. Adding a new check is a one-entry
157
+ * edit; each check only contains its own inspection logic.
158
+ *
159
+ * ## Ordering dependency chain
160
+ *
161
+ * Later checks may read state populated by earlier ones via the shared
162
+ * {@link DoctorCheckContext}. Dependencies are declared via the
163
+ * `dependsOn` field and enforced by {@link validateCheckDependencies}
164
+ * at module load time.
165
+ *
166
+ * {@link DOCTOR_CHECK_ORDER} is exported so tests can pin the sequence.
167
+ */
168
+ const DOCTOR_CHECKS = [
169
+ {
170
+ name: 'Git installed',
171
+ run: async () => {
172
+ await ensureGit();
173
+ return ok('Git installed');
174
+ },
175
+ fix: 'Install git from https://git-scm.com/',
176
+ },
177
+ {
178
+ name: 'Python supported by mach',
179
+ run: async (ctx) => {
180
+ await ensurePython(ctx.paths.engine);
181
+ return ok('Python supported by mach');
182
+ },
183
+ fix: 'Install a Python version supported by engine/mach, then re-run "fireforge doctor".',
184
+ },
185
+ {
186
+ name: 'fireforge.json exists',
187
+ run: async (ctx) => {
188
+ if (!(await configExists(ctx.projectRoot))) {
189
+ throw new Error('fireforge.json not found');
190
+ }
191
+ return ok('fireforge.json exists');
192
+ },
193
+ fix: 'Run "fireforge setup" to create a project',
194
+ },
195
+ {
196
+ name: 'fireforge.json is valid',
197
+ run: async (ctx) => {
198
+ ctx.config = await loadConfig(ctx.projectRoot);
199
+ return ok('fireforge.json is valid');
200
+ },
201
+ fix: 'Check fireforge.json for syntax errors or missing fields',
202
+ },
203
+ {
204
+ name: 'Engine directory exists',
205
+ run: (ctx) => {
206
+ if (!ctx.engineExists) {
207
+ throw new Error('engine/ directory not found');
76
208
  }
77
- else if (canValidateBranch) {
78
- checks.push({
79
- name: 'Engine state consistency',
80
- passed: true,
81
- severity: 'ok',
82
- message: 'OK',
83
- });
209
+ return ok('Engine directory exists');
210
+ },
211
+ fix: 'Run "fireforge download" to download Firefox source',
212
+ },
213
+ {
214
+ name: 'Pending Resolution',
215
+ skipIf: (ctx) => !ctx.state.pendingResolution,
216
+ run: (ctx) => {
217
+ const patchFilename = ctx.state.pendingResolution?.patchFilename ?? 'unknown';
218
+ return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed.');
219
+ },
220
+ },
221
+ {
222
+ name: 'Engine is git repository',
223
+ skipIf: (ctx) => !ctx.engineExists,
224
+ run: async (ctx) => {
225
+ const isRepo = await isGitRepository(ctx.paths.engine);
226
+ if (!isRepo) {
227
+ return failure('Engine is git repository', 'engine/ is not a git repository', 'Run "fireforge download --force" to reinitialize');
84
228
  }
85
- }
86
- const rawStatus = await getWorkingTreeStatus(paths.engine);
87
- const workingTreeStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
88
- if (workingTreeStatus.length > 0) {
89
- checks.push({
90
- name: 'Engine working tree',
229
+ // Git-dependent follow-up checks share mutable currentHead/branch
230
+ // state, so they live in a helper that returns all rows at once.
231
+ return [ok('Engine is git repository'), ...(await runEngineGitChecks(ctx))];
232
+ },
233
+ },
234
+ {
235
+ name: 'mach available',
236
+ skipIf: (ctx) => !ctx.engineExists,
237
+ run: async (ctx) => {
238
+ await ensureMach(ctx.paths.engine);
239
+ return ok('mach available');
240
+ },
241
+ fix: 'Firefox source may be corrupted. Re-download with "fireforge download --force"',
242
+ },
243
+ {
244
+ name: 'Patches directory exists',
245
+ run: async (ctx) => {
246
+ const patchesExist = await pathExists(ctx.paths.patches);
247
+ return {
248
+ name: 'Patches directory exists',
91
249
  passed: true,
92
- severity: 'warning',
93
- warning: true,
94
- message: summarizeWorkingTreeChangeCount(workingTreeStatus.length),
95
- fix: 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.',
96
- });
97
- }
98
- else {
99
- checks.push({
100
- name: 'Engine working tree',
250
+ severity: 'ok',
251
+ message: patchesExist ? 'OK' : 'No patches/ directory (optional)',
252
+ };
253
+ },
254
+ },
255
+ {
256
+ name: 'Patches found',
257
+ run: async (ctx) => {
258
+ if (!(await pathExists(ctx.paths.patches))) {
259
+ return [];
260
+ }
261
+ const patchCount = await countPatches(ctx.paths.patches);
262
+ return {
263
+ name: 'Patches found',
101
264
  passed: true,
102
265
  severity: 'ok',
103
- message: 'OK',
104
- });
105
- }
106
- let branch;
107
- if (canValidateBranch) {
266
+ message: `${patchCount} patch${patchCount === 1 ? '' : 'es'} found`,
267
+ };
268
+ },
269
+ },
270
+ {
271
+ name: 'Patch manifest consistency',
272
+ dependsOn: ['fireforge.json is valid'],
273
+ run: async (ctx) => {
274
+ if (!(await pathExists(ctx.paths.patches))) {
275
+ return [];
276
+ }
277
+ const manifestConsistencyIssues = await validatePatchesManifestConsistency(ctx.paths.patches);
278
+ if (manifestConsistencyIssues.length === 0) {
279
+ return ok('Patch manifest consistency');
280
+ }
281
+ if (!ctx.options.repairPatchesManifest) {
282
+ return failure('Patch manifest consistency', manifestConsistencyIssues.map((issue) => issue.message).join(' '), 'Run "fireforge doctor --repair-patches-manifest" to rebuild patches.json from patch files.');
283
+ }
284
+ // Repair stamps sourceEsrVersion into every recovered entry. If the
285
+ // earlier "fireforge.json is valid" check failed, ctx.config is
286
+ // undefined and we must refuse rather than fabricate a fallback —
287
+ // persisting 'unknown' into manifest metadata is hard to reverse
288
+ // and would mislead every later command that reads it.
289
+ if (!ctx.config) {
290
+ return failure('Patch manifest consistency', 'Cannot repair patches.json: fireforge.json could not be loaded, so the Firefox version to stamp into recovered manifest entries is unknown.', 'Fix the fireforge.json errors reported above and re-run "fireforge doctor --repair-patches-manifest".');
291
+ }
108
292
  try {
109
- branch = await getCurrentBranch(paths.engine);
293
+ const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version);
294
+ return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.patches.length} patch${repaired.patches.length === 1 ? '' : 'es'}. Review recovered metadata before release.`);
110
295
  }
111
- catch (error) {
112
- if (!isMissingHeadError(error)) {
113
- throw error;
114
- }
115
- canValidateBranch = false;
116
- checks.push({
117
- name: 'Engine branch',
118
- passed: false,
119
- severity: 'error',
120
- message: 'Engine repository has no baseline commit yet. A previous "fireforge download" likely stopped before git created the initial Firefox commit.',
121
- fix: 'Re-run "fireforge download --force" to recreate the baseline repository cleanly.',
122
- });
296
+ catch (err) {
297
+ return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
123
298
  }
124
- }
125
- if (!canValidateBranch &&
126
- branch === undefined &&
127
- currentHead === undefined &&
128
- !state.baseCommit) {
129
- // An unborn repository can fail branch detection before state.json records baseCommit.
130
- // The error above already explains the recovery path, so avoid adding extra noise here.
131
- }
132
- else if (!canValidateBranch) {
133
- checks.push({
134
- name: 'Engine branch',
135
- passed: true,
136
- severity: 'warning',
137
- warning: true,
138
- message: 'Skipped branch validation because the baseline commit is missing.',
139
- fix: 'Finish recreating the engine baseline with "fireforge download --force".',
140
- });
141
- }
142
- else if (branch === 'firefox') {
143
- checks.push({
144
- name: 'Engine branch',
145
- passed: true,
146
- severity: 'ok',
147
- message: 'OK',
148
- });
149
- }
150
- else if (branch === 'HEAD' && state.baseCommit && currentHead === state.baseCommit) {
151
- checks.push({
152
- name: 'Engine branch',
153
- passed: true,
154
- severity: 'warning',
155
- warning: true,
156
- message: 'Engine is detached at the recorded base commit. This is acceptable for disposable worktrees and audit clones.',
157
- fix: 'If this is your primary workspace, checkout the "firefox" branch to match FireForge defaults.',
158
- });
159
- }
160
- else {
161
- checks.push({
162
- name: 'Engine branch',
163
- passed: false,
164
- severity: 'error',
165
- message: `Engine is on branch "${branch}", but expected "firefox".`,
166
- });
167
- }
168
- }
169
- // Check 7: mach available
170
- checks.push(await runCheck('mach available', async () => {
171
- await ensureMach(paths.engine);
172
- }, 'Firefox source may be corrupted. Re-download with "fireforge download --force"'));
173
- return checks;
174
- }
299
+ },
300
+ },
301
+ {
302
+ name: 'Patch integrity',
303
+ skipIf: (ctx) => !ctx.engineExists,
304
+ run: async (ctx) => {
305
+ if (!(await pathExists(ctx.paths.patches))) {
306
+ return [];
307
+ }
308
+ const issues = await validatePatchIntegrity(ctx.paths.patches, ctx.paths.engine);
309
+ if (issues.length === 0) {
310
+ return ok('Patch integrity');
311
+ }
312
+ const fileList = issues.map((issue) => issue.targetFile).filter(Boolean);
313
+ throw new Error(`${issues.length} patch(es) are modification patches for non-existent files: ${fileList.join(', ')}`);
314
+ },
315
+ fix: 'Re-export affected files with "fireforge export <paths...>" to create full-file patches',
316
+ },
317
+ // Furnace checks live in a sibling module so this file stays under the
318
+ // max-lines threshold. Splicing them in as an array preserves the
319
+ // declarative registry contract — each entry remains a single
320
+ // `DoctorCheckDefinition` with its own skipIf/run/fix, and the order
321
+ // here is the order they appear in the report.
322
+ ...FURNACE_DOCTOR_CHECKS,
323
+ {
324
+ name: 'Configs directory exists',
325
+ run: async (ctx) => {
326
+ if (!(await pathExists(ctx.paths.configs))) {
327
+ throw new Error('configs/ directory not found');
328
+ }
329
+ return ok('Configs directory exists');
330
+ },
331
+ fix: 'Run "fireforge setup" to create configs',
332
+ },
333
+ ];
334
+ // Validate dependency ordering at module load time so broken reorders
335
+ // fail immediately instead of producing subtle runtime bugs.
336
+ validateCheckDependencies(DOCTOR_CHECKS);
337
+ /**
338
+ * Ordered list of the doctor check names, exported for tests. Pinning
339
+ * the order here is intentional: any reorder that breaks the
340
+ * context-population dependency chain (see {@link DOCTOR_CHECKS}) must
341
+ * also update this list, which gives us a single place to notice and
342
+ * think through the consequences.
343
+ */
344
+ export const DOCTOR_CHECK_ORDER = DOCTOR_CHECKS.map((check) => check.name);
175
345
  function reportDoctorResults(checks) {
176
346
  info('');
177
347
  let passedCount = 0;
@@ -211,132 +381,30 @@ function reportDoctorResults(checks) {
211
381
  }
212
382
  return ExitCode.SUCCESS;
213
383
  }
214
- async function collectProjectChecks(paths, engineExists, firefoxVersion, options) {
215
- const checks = [];
216
- const patchesExist = await pathExists(paths.patches);
217
- checks.push({
218
- name: 'Patches directory exists',
219
- passed: true,
220
- severity: 'ok',
221
- message: patchesExist ? 'OK' : 'No patches/ directory (optional)',
222
- });
223
- if (patchesExist) {
224
- const patchCount = await countPatches(paths.patches);
225
- checks.push({
226
- name: 'Patches found',
227
- passed: true,
228
- severity: 'ok',
229
- message: `${patchCount} patch${patchCount === 1 ? '' : 'es'} found`,
230
- });
231
- const manifestConsistencyIssues = await validatePatchesManifestConsistency(paths.patches);
232
- if (manifestConsistencyIssues.length > 0) {
233
- if (options.repairPatchesManifest) {
234
- try {
235
- const repairedManifest = await rebuildPatchesManifest(paths.patches, firefoxVersion ?? 'unknown');
236
- checks.push({
237
- name: 'Patch manifest consistency',
238
- passed: true,
239
- severity: 'warning',
240
- warning: true,
241
- message: `Rebuilt patches.json from ${repairedManifest.patches.length} patch` +
242
- `${repairedManifest.patches.length === 1 ? '' : 'es'}. Review recovered metadata before release.`,
243
- });
244
- }
245
- catch (error) {
246
- checks.push({
247
- name: 'Patch manifest consistency',
248
- passed: false,
249
- severity: 'error',
250
- message: toError(error).message,
251
- fix: 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.',
252
- });
253
- }
254
- }
255
- else {
256
- checks.push({
257
- name: 'Patch manifest consistency',
258
- passed: false,
259
- severity: 'error',
260
- message: manifestConsistencyIssues.map((issue) => issue.message).join(' '),
261
- fix: 'Run "fireforge doctor --repair-patches-manifest" to rebuild patches.json from patch files.',
262
- });
263
- }
264
- }
265
- else {
266
- checks.push({
267
- name: 'Patch manifest consistency',
268
- passed: true,
269
- severity: 'ok',
270
- message: 'OK',
271
- });
272
- }
273
- if (engineExists) {
274
- checks.push(await runCheck('Patch integrity', async () => {
275
- const issues = await validatePatchIntegrity(paths.patches, paths.engine);
276
- if (issues.length > 0) {
277
- const fileList = issues.map((issue) => issue.targetFile).filter(Boolean);
278
- throw new Error(`${issues.length} patch(es) are modification patches for non-existent files: ${fileList.join(', ')}`);
279
- }
280
- }, 'Re-export affected files with "fireforge export <paths...>" to create full-file patches'));
281
- }
282
- }
283
- const configsExist = await pathExists(paths.configs);
284
- checks.push(await runCheck('Configs directory exists', () => {
285
- if (!configsExist) {
286
- throw new Error('configs/ directory not found');
287
- }
288
- }, 'Run "fireforge setup" to create configs'));
289
- return checks;
290
- }
291
384
  /**
292
385
  * Runs the doctor command to diagnose issues.
293
386
  * @param projectRoot - Root directory of the project
294
387
  */
295
388
  export async function doctorCommand(projectRoot, options = {}) {
296
389
  intro('FireForge Doctor');
297
- const checks = [];
298
390
  const paths = getProjectPaths(projectRoot);
299
391
  const state = await loadState(projectRoot);
300
- let config;
301
- // Check 1: Git installed
302
- checks.push(await runCheck('Git installed', async () => {
303
- await ensureGit();
304
- }, 'Install git from https://git-scm.com/'));
305
- // Check 2: Python supported by mach
306
- checks.push(await runCheck('Python supported by mach', async () => {
307
- await ensurePython(paths.engine);
308
- }, 'Install a Python version supported by engine/mach, then re-run "fireforge doctor".'));
309
- // Check 3: fireforge.json exists
310
- checks.push(await runCheck('fireforge.json exists', async () => {
311
- if (!(await configExists(projectRoot))) {
312
- throw new Error('fireforge.json not found');
313
- }
314
- }, 'Run "fireforge setup" to create a project'));
315
- // Check 4: fireforge.json is valid
316
- checks.push(await runCheck('fireforge.json is valid', async () => {
317
- config = await loadConfig(projectRoot);
318
- }, 'Check fireforge.json for syntax errors or missing fields'));
319
- // Check 5: Engine directory exists
320
392
  const engineExists = await pathExists(paths.engine);
321
- checks.push(await runCheck('Engine directory exists', () => {
322
- if (!engineExists) {
323
- throw new Error('engine/ directory not found');
324
- }
325
- }, 'Run "fireforge download" to download Firefox source'));
326
- // Check: Pending Resolution
327
- if (state.pendingResolution) {
328
- checks.push({
329
- name: 'Pending Resolution',
330
- passed: false,
331
- severity: 'error',
332
- message: `You are currently resolving a conflict for patch ${state.pendingResolution.patchFilename}.`,
333
- fix: 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed.',
334
- });
393
+ const furnaceConfigExistsFlag = await checkFurnaceConfigExists(projectRoot);
394
+ const ctx = {
395
+ projectRoot,
396
+ paths,
397
+ state,
398
+ options,
399
+ engineExists,
400
+ config: undefined,
401
+ furnaceConfigExists: furnaceConfigExistsFlag,
402
+ furnaceConfig: undefined,
403
+ };
404
+ const checks = [];
405
+ for (const definition of DOCTOR_CHECKS) {
406
+ checks.push(...(await executeCheck(definition, ctx)));
335
407
  }
336
- // Engine checks (git repo, state consistency, working tree, branch, mach)
337
- checks.push(...(await collectEngineChecks(paths, state, engineExists)));
338
- checks.push(...(await collectProjectChecks(paths, engineExists, config?.firefox.version, options)));
339
- // Display results and return
340
408
  const exitCode = reportDoctorResults(checks);
341
409
  return { checks, exitCode };
342
410
  }
@@ -346,6 +414,7 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
346
414
  .command('doctor')
347
415
  .description('Diagnose project issues')
348
416
  .option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
417
+ .option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
349
418
  .action(withErrorHandling(async (options) => {
350
419
  const result = await doctorCommand(getProjectRoot(), options);
351
420
  if (result.exitCode !== 0) {