@hominis/fireforge 0.10.1 → 0.11.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 (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 +6 -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
@@ -0,0 +1,422 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { applyAllComponents } from '../core/furnace-apply.js';
4
+ import { hasCustomEngineDrift, hasOverrideEngineDrift } from '../core/furnace-apply-helpers.js';
5
+ import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, } from '../core/furnace-config.js';
6
+ import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from '../core/furnace-constants.js';
7
+ import { runFurnaceMutation } from '../core/furnace-operation.js';
8
+ import { validateAllComponents } from '../core/furnace-validate.js';
9
+ import { toError } from '../utils/errors.js';
10
+ import { pathExists } from '../utils/fs.js';
11
+ import { failure, ok, warning } from './doctor.js';
12
+ const ENGINE_REPAIRABLE_OPERATIONS = [
13
+ 'preview-teardown',
14
+ 'apply-rollback',
15
+ 'deploy-rollback',
16
+ 'remove-rollback',
17
+ ];
18
+ function isEngineRepairableOperation(operation) {
19
+ return ENGINE_REPAIRABLE_OPERATIONS.includes(operation);
20
+ }
21
+ async function runRepairApply(projectRoot) {
22
+ return runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { skipPendingRepairCheck: true });
23
+ }
24
+ function countApplyFailures(applyResult) {
25
+ const appliedWithStepErrors = applyResult.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
26
+ return applyResult.errors.length + appliedWithStepErrors;
27
+ }
28
+ function firstApplyFailure(applyResult) {
29
+ return (applyResult.errors[0]?.error ??
30
+ applyResult.applied
31
+ .flatMap((entry) => entry.stepErrors ?? [])
32
+ .map((step) => `${step.step}: ${step.error}`)[0] ??
33
+ 'unknown error');
34
+ }
35
+ async function clearPendingRepairMarker(projectRoot) {
36
+ await updateFurnaceState(projectRoot, (current) => {
37
+ const next = { ...current };
38
+ delete next.pendingRepair;
39
+ return next;
40
+ });
41
+ }
42
+ /**
43
+ * Returns the subset of state-file checksum keys whose `type/name` prefix
44
+ * does not match any component in `furnace.json`. Keys are structured as
45
+ * `<type>/<name>/<file>` where type is one of `override`, `custom`, or
46
+ * `stock` and name is the tag name.
47
+ *
48
+ * Stock components are never checksummed by apply, so they never appear
49
+ * in the state file — any stock-prefixed entry is automatically stale.
50
+ */
51
+ function collectStaleChecksumKeys(appliedChecksums, config) {
52
+ const stale = [];
53
+ for (const key of Object.keys(appliedChecksums)) {
54
+ const segments = key.split('/');
55
+ if (segments.length < 2) {
56
+ stale.push(key);
57
+ continue;
58
+ }
59
+ const type = segments[0];
60
+ const name = segments[1];
61
+ if (type === undefined || name === undefined) {
62
+ stale.push(key);
63
+ continue;
64
+ }
65
+ if (type === 'override' && !(name in config.overrides)) {
66
+ stale.push(key);
67
+ }
68
+ else if (type === 'custom' && !(name in config.custom)) {
69
+ stale.push(key);
70
+ }
71
+ else if (type !== 'override' && type !== 'custom') {
72
+ stale.push(key);
73
+ }
74
+ }
75
+ return stale;
76
+ }
77
+ /**
78
+ * Walks every override and custom component in the furnace config and asks
79
+ * the drift oracles whether the engine still reflects the workspace source.
80
+ * Returns the flat list of drifted component names so the doctor check can
81
+ * decide whether a repair is needed.
82
+ *
83
+ * Components whose workspace directory is missing are treated as drifted:
84
+ * the only consistent recovery is a re-run of apply which will surface the
85
+ * missing-directory error through its own failure path.
86
+ */
87
+ async function collectFurnaceDrift(projectRoot, engineDir, config, ftlDir) {
88
+ const drifted = [];
89
+ const furnacePaths = getFurnacePaths(projectRoot);
90
+ for (const [name, overrideConfig] of Object.entries(config.overrides)) {
91
+ const componentDir = join(furnacePaths.overridesDir, name);
92
+ if (!(await pathExists(componentDir))) {
93
+ drifted.push(name);
94
+ continue;
95
+ }
96
+ try {
97
+ if (await hasOverrideEngineDrift(engineDir, componentDir, overrideConfig, ftlDir)) {
98
+ drifted.push(name);
99
+ }
100
+ }
101
+ catch {
102
+ // Drift check throws on unreadable paths; treat as drift so the
103
+ // operator is told to run apply rather than swallowing the error.
104
+ drifted.push(name);
105
+ }
106
+ }
107
+ for (const [name, customConfig] of Object.entries(config.custom)) {
108
+ const componentDir = join(furnacePaths.customDir, name);
109
+ if (!(await pathExists(componentDir))) {
110
+ drifted.push(name);
111
+ continue;
112
+ }
113
+ try {
114
+ if (await hasCustomEngineDrift(projectRoot, name, componentDir, customConfig, ftlDir)) {
115
+ drifted.push(name);
116
+ }
117
+ }
118
+ catch {
119
+ drifted.push(name);
120
+ }
121
+ }
122
+ return { drifted };
123
+ }
124
+ /**
125
+ * "Furnace configuration" check: load and parse `furnace.json`. Populates
126
+ * `ctx.furnaceConfig` for the downstream furnace checks so they do not
127
+ * re-parse the file.
128
+ */
129
+ const furnaceConfigurationCheck = {
130
+ name: 'Furnace configuration',
131
+ // Silently skip when the project is not using furnace. Plenty of
132
+ // projects are patch-only, and flagging the absence of furnace.json
133
+ // would make `doctor` warn on every such project.
134
+ skipIf: (ctx) => !ctx.furnaceConfigExists,
135
+ run: async (ctx) => {
136
+ try {
137
+ ctx.furnaceConfig = await loadFurnaceConfig(ctx.projectRoot);
138
+ return ok('Furnace configuration');
139
+ }
140
+ catch (err) {
141
+ return failure('Furnace configuration', `furnace.json is invalid: ${toError(err).message}`, 'Fix the errors reported above in furnace.json and re-run "fireforge doctor".');
142
+ }
143
+ },
144
+ };
145
+ /**
146
+ * "Furnace state consistency" check: detect checksums keyed by components
147
+ * that are no longer in `furnace.json`. Repair clears the stale entries.
148
+ */
149
+ const furnaceStateConsistencyCheck = {
150
+ name: 'Furnace state consistency',
151
+ dependsOn: ['Furnace configuration'],
152
+ skipIf: (ctx) => !ctx.furnaceConfigExists || !ctx.furnaceConfig,
153
+ run: async (ctx) => {
154
+ const config = ctx.furnaceConfig;
155
+ if (!config) {
156
+ return [];
157
+ }
158
+ const state = await loadFurnaceState(ctx.projectRoot);
159
+ if (!state.appliedChecksums && !state.engineChecksums) {
160
+ return ok('Furnace state consistency');
161
+ }
162
+ // A "stale" entry is a checksum keyed by a component that is no
163
+ // longer in furnace.json. These are harmless but misleading: status
164
+ // and drift oracles read state independently and a stale entry
165
+ // shows up as a ghost component in their reports.
166
+ const staleApplied = state.appliedChecksums
167
+ ? collectStaleChecksumKeys(state.appliedChecksums, config)
168
+ : [];
169
+ const staleEngine = state.engineChecksums
170
+ ? collectStaleChecksumKeys(state.engineChecksums, config)
171
+ : [];
172
+ const staleKeys = [...new Set([...staleApplied, ...staleEngine])];
173
+ if (staleKeys.length === 0) {
174
+ return ok('Furnace state consistency');
175
+ }
176
+ const ghostSet = new Set();
177
+ for (const key of staleKeys) {
178
+ // Keys look like "override/<name>/<file>" — the ghost component is
179
+ // the first two segments joined for display purposes.
180
+ const segments = key.split('/');
181
+ if (segments.length >= 2) {
182
+ ghostSet.add(`${segments[0]}/${segments[1]}`);
183
+ }
184
+ }
185
+ const ghostList = [...ghostSet].sort();
186
+ const message = `.fireforge/furnace-state.json records ${staleKeys.length} checksum entr${staleKeys.length === 1 ? 'y' : 'ies'} for component${ghostList.length === 1 ? '' : 's'} no longer in furnace.json (${ghostList.join(', ')}).`;
187
+ if (!ctx.options.repairFurnace) {
188
+ return warning('Furnace state consistency', message, 'Run "fireforge doctor --repair-furnace" to clear the stale entries.');
189
+ }
190
+ try {
191
+ await updateFurnaceState(ctx.projectRoot, (current) => {
192
+ const result = { ...current };
193
+ if (current.appliedChecksums) {
194
+ result.appliedChecksums = Object.fromEntries(Object.entries(current.appliedChecksums).filter(([key]) => !staleKeys.includes(key)));
195
+ }
196
+ if (current.engineChecksums) {
197
+ result.engineChecksums = Object.fromEntries(Object.entries(current.engineChecksums).filter(([key]) => !staleKeys.includes(key)));
198
+ }
199
+ return result;
200
+ });
201
+ return warning('Furnace state consistency', `Cleared ${staleKeys.length} stale checksum entr${staleKeys.length === 1 ? 'y' : 'ies'} from .fireforge/furnace-state.json (${ghostList.join(', ')}).`);
202
+ }
203
+ catch (err) {
204
+ return failure('Furnace state consistency', `Could not clear stale furnace-state.json entries: ${toError(err).message}`, 'Fix the underlying file I/O issue and retry the doctor command.');
205
+ }
206
+ },
207
+ };
208
+ /**
209
+ * "Furnace engine state" check: detect the `pendingRepair` marker set by
210
+ * a failed preview teardown AND any on-disk drift between the workspace
211
+ * and the engine. Repair runs `applyAllComponents` to reconcile and
212
+ * clears the marker on success.
213
+ */
214
+ const furnaceEngineStateCheck = {
215
+ name: 'Furnace engine state',
216
+ dependsOn: ['Furnace configuration'],
217
+ // Requires both a furnace project AND an engine checkout — the drift
218
+ // oracles resolve engine paths, so a missing engine dir would throw.
219
+ skipIf: (ctx) => !ctx.furnaceConfigExists || !ctx.furnaceConfig || !ctx.engineExists,
220
+ run: async (ctx) => {
221
+ const config = ctx.furnaceConfig;
222
+ if (!config) {
223
+ return [];
224
+ }
225
+ const state = await loadFurnaceState(ctx.projectRoot);
226
+ const pendingRepair = state.pendingRepair;
227
+ // Drift check: walks every override and custom component and asks
228
+ // the same oracle apply's skip logic uses. A drifted component means
229
+ // the engine no longer reflects what the state file claims was
230
+ // deployed, so an apply is needed to reconcile.
231
+ const driftReport = await collectFurnaceDrift(ctx.projectRoot, ctx.paths.engine, config, resolveFtlDir(config.ftlBasePath));
232
+ const driftedNames = driftReport.drifted;
233
+ if (!pendingRepair && driftedNames.length === 0) {
234
+ return ok('Furnace engine state');
235
+ }
236
+ const pendingMessage = pendingRepair
237
+ ? `Pending repair marker set by ${pendingRepair.operation} at ${pendingRepair.timestamp}: ${pendingRepair.reason}.`
238
+ : '';
239
+ const driftMessage = driftedNames.length > 0
240
+ ? `Engine is drifted for ${driftedNames.length} component${driftedNames.length === 1 ? '' : 's'} (${driftedNames.join(', ')}).`
241
+ : '';
242
+ const message = [pendingMessage, driftMessage].filter(Boolean).join(' ');
243
+ if (!ctx.options.repairFurnace) {
244
+ const guidance = pendingRepair && !isEngineRepairableOperation(pendingRepair.operation)
245
+ ? 'Resolve or remove the partial component authoring changes, then run "fireforge doctor --repair-furnace" to re-validate and clear the repair marker.'
246
+ : 'Run "fireforge doctor --repair-furnace" to re-run furnace apply and reconcile the engine.';
247
+ return failure('Furnace engine state', message, guidance);
248
+ }
249
+ if (pendingRepair && !isEngineRepairableOperation(pendingRepair.operation)) {
250
+ try {
251
+ const validationResults = await validateAllComponents(ctx.projectRoot);
252
+ const validationIssues = [...validationResults.values()].flat();
253
+ const validationErrors = validationIssues.filter((issue) => issue.severity === 'error');
254
+ const validationWarnings = validationIssues.filter((issue) => issue.severity === 'warning').length;
255
+ if (validationErrors.length > 0) {
256
+ const firstError = validationErrors[0];
257
+ const firstMessage = firstError
258
+ ? `${firstError.component} [${firstError.check}] ${firstError.message}`
259
+ : 'unknown validation error';
260
+ return failure('Furnace engine state', `Authoring rollback marker from ${pendingRepair.operation} is still unresolved: validation found ${validationErrors.length} error(s) (first: ${firstMessage}).`, 'Inspect furnace.json and the affected component files, finish or remove the partial authoring change, then retry "fireforge doctor --repair-furnace".');
261
+ }
262
+ let applyResult = null;
263
+ if (driftedNames.length > 0) {
264
+ applyResult = await runRepairApply(ctx.projectRoot);
265
+ const totalFailures = countApplyFailures(applyResult);
266
+ if (totalFailures > 0) {
267
+ return failure('Furnace engine state', `Repair attempted after ${pendingRepair.operation}, but apply reported ${totalFailures} failure${totalFailures === 1 ? '' : 's'} (first: ${firstApplyFailure(applyResult)}).`, 'Fix the underlying component issue, or remove the partial authoring change, and retry the doctor command.');
268
+ }
269
+ }
270
+ await clearPendingRepairMarker(ctx.projectRoot);
271
+ const summary = driftedNames.length > 0 && applyResult
272
+ ? `Reconciled engine drift after ${pendingRepair.operation} (${applyResult.applied.length} applied, ${applyResult.skipped.length} skipped) and cleared the repair marker.`
273
+ : `Cleared the ${pendingRepair.operation} repair marker after validation passed${validationWarnings > 0 ? ` (${validationWarnings} warning${validationWarnings === 1 ? '' : 's'} remain)` : ''}.`;
274
+ return warning('Furnace engine state', summary);
275
+ }
276
+ catch (err) {
277
+ return failure('Furnace engine state', `Repair failed: ${toError(err).message}`, 'Inspect the error above, fix the partial authoring state, and retry the doctor command.');
278
+ }
279
+ }
280
+ // Repair path: run apply to reconcile the engine with the workspace
281
+ // state, then clear the pendingRepair marker on success. We re-run
282
+ // apply even when only the marker is set — the marker exists
283
+ // specifically because the last mutation could not clean up, so the
284
+ // cheapest honest thing we can do is re-reconcile end-to-end.
285
+ try {
286
+ const applyResult = await runRepairApply(ctx.projectRoot);
287
+ const totalFailures = countApplyFailures(applyResult);
288
+ if (totalFailures > 0) {
289
+ return failure('Furnace engine state', `Repair attempted but apply reported ${totalFailures} failure${totalFailures === 1 ? '' : 's'} (first: ${firstApplyFailure(applyResult)}).`, 'Fix the underlying component issue and retry the doctor command.');
290
+ }
291
+ // Apply succeeded — clear the pendingRepair marker so subsequent
292
+ // doctor runs stop reporting the issue. updateFurnaceState merges
293
+ // its return value via `validateFurnaceState` which simply writes
294
+ // whatever object we return, so dropping the key from the spread
295
+ // copy is enough to persist the cleared marker.
296
+ if (pendingRepair) {
297
+ await clearPendingRepairMarker(ctx.projectRoot);
298
+ }
299
+ const summary = driftedNames.length > 0
300
+ ? `Reconciled ${applyResult.applied.length} component${applyResult.applied.length === 1 ? '' : 's'} (${driftedNames.join(', ')} re-applied).`
301
+ : `Reconciled via furnace apply (${applyResult.applied.length} applied, ${applyResult.skipped.length} skipped).`;
302
+ return warning('Furnace engine state', summary);
303
+ }
304
+ catch (err) {
305
+ return failure('Furnace engine state', `Repair failed: ${toError(err).message}`, 'Inspect the error above, fix the underlying issue, and retry the doctor command.');
306
+ }
307
+ },
308
+ };
309
+ /**
310
+ * Furnace operations read from a handful of Firefox-internal paths that are
311
+ * hardcoded in `furnace-constants.ts` and `furnace-scanner.ts`. If upstream
312
+ * renames or restructures any of them, furnace will silently fail instead
313
+ * of diagnosing the change. This check verifies each expected path exists
314
+ * so the operator gets a targeted "this path moved" message rather than a
315
+ * confusing downstream error.
316
+ */
317
+ const furnaceEnginePathsCheck = {
318
+ name: 'Furnace engine paths',
319
+ dependsOn: ['Furnace configuration'],
320
+ skipIf: (ctx) => !ctx.furnaceConfigExists || !ctx.furnaceConfig || !ctx.engineExists,
321
+ run: async (ctx) => {
322
+ const expectedPaths = [
323
+ CUSTOM_ELEMENTS_JS,
324
+ JAR_MN,
325
+ 'toolkit/content/widgets',
326
+ resolveFtlDir(ctx.furnaceConfig?.ftlBasePath),
327
+ 'browser/base/content/browser.xhtml',
328
+ ];
329
+ const missing = [];
330
+ for (const relative of expectedPaths) {
331
+ const absolute = join(ctx.paths.engine, relative);
332
+ if (!(await pathExists(absolute))) {
333
+ missing.push(relative);
334
+ }
335
+ }
336
+ if (missing.length === 0) {
337
+ return ok('Furnace engine paths');
338
+ }
339
+ return warning('Furnace engine paths', `${missing.length} expected engine path${missing.length === 1 ? '' : 's'} missing: ${missing.join(', ')}. Firefox may have restructured its source tree — furnace operations that depend on these paths will fail.`, 'Re-run "fireforge download" to update the engine. If the paths have genuinely moved, file an issue so Furnace can be updated.');
340
+ },
341
+ };
342
+ /**
343
+ * Furnace Storybook backend check: verifies that the engine contains the
344
+ * Storybook workspace required by `furnace preview`. Missing Storybook
345
+ * support is a warning, not a failure, since furnace works fine without
346
+ * preview — but operators should know upfront rather than discovering it
347
+ * mid-command.
348
+ */
349
+ const furnaceStorybookCheck = {
350
+ name: 'Furnace Storybook backend',
351
+ dependsOn: ['Furnace configuration'],
352
+ skipIf: (ctx) => !ctx.furnaceConfigExists || !ctx.furnaceConfig || !ctx.engineExists,
353
+ run: async (ctx) => {
354
+ const storybookRoot = join(ctx.paths.engine, 'browser', 'components', 'storybook');
355
+ if (await pathExists(storybookRoot)) {
356
+ return ok('Furnace Storybook backend');
357
+ }
358
+ return warning('Furnace Storybook backend', 'browser/components/storybook not found in the engine. "fireforge furnace preview" will not work.', 'Re-run "fireforge download" to get a complete engine checkout. If you do not need Storybook preview, this warning can be ignored.');
359
+ },
360
+ };
361
+ /**
362
+ * "Furnace component validation" check: runs the full validation suite
363
+ * across all override and custom components. Surfaces structural,
364
+ * accessibility, compatibility, and registration issues that would
365
+ * otherwise go unnoticed until `furnace validate` is run explicitly.
366
+ */
367
+ const furnaceComponentValidationCheck = {
368
+ name: 'Furnace component validation',
369
+ dependsOn: ['Furnace configuration'],
370
+ // Requires a furnace project, a valid config, and an engine checkout.
371
+ // Skip when there are no override/custom components to validate.
372
+ skipIf: (ctx) => {
373
+ if (!ctx.furnaceConfigExists || !ctx.furnaceConfig || !ctx.engineExists)
374
+ return true;
375
+ const config = ctx.furnaceConfig;
376
+ return Object.keys(config.overrides).length === 0 && Object.keys(config.custom).length === 0;
377
+ },
378
+ run: async (ctx) => {
379
+ const config = ctx.furnaceConfig;
380
+ if (!config) {
381
+ return [];
382
+ }
383
+ try {
384
+ const results = await validateAllComponents(ctx.projectRoot);
385
+ const allIssues = [...results.values()].flat();
386
+ const errors = allIssues.filter((issue) => issue.severity === 'error');
387
+ const warnings = allIssues.filter((issue) => issue.severity === 'warning');
388
+ if (errors.length === 0 && warnings.length === 0) {
389
+ return ok('Furnace component validation');
390
+ }
391
+ const summary = `${errors.length} error${errors.length === 1 ? '' : 's'}, ` +
392
+ `${warnings.length} warning${warnings.length === 1 ? '' : 's'} ` +
393
+ `across ${results.size} component${results.size === 1 ? '' : 's'}`;
394
+ if (errors.length > 0) {
395
+ const first = errors[0];
396
+ const detail = first
397
+ ? ` (first: ${first.component} [${first.check}] ${first.message})`
398
+ : '';
399
+ return failure('Furnace component validation', `${summary}${detail}`, 'Run "fireforge furnace validate" for the full report, then fix the errors.');
400
+ }
401
+ return warning('Furnace component validation', summary, 'Run "fireforge furnace validate" for details.');
402
+ }
403
+ catch (err) {
404
+ return failure('Furnace component validation', `Validation failed: ${toError(err).message}`, 'Run "fireforge furnace validate" directly to diagnose.');
405
+ }
406
+ },
407
+ };
408
+ /**
409
+ * The ordered furnace check group. Exported as an array so `doctor.ts`
410
+ * can splice it into the main registry at the right position. The order
411
+ * here matters: `Furnace configuration` must run before the consumers
412
+ * that read `ctx.furnaceConfig`.
413
+ */
414
+ export const FURNACE_DOCTOR_CHECKS = [
415
+ furnaceConfigurationCheck,
416
+ furnaceStateConsistencyCheck,
417
+ furnaceEnginePathsCheck,
418
+ furnaceStorybookCheck,
419
+ furnaceEngineStateCheck,
420
+ furnaceComponentValidationCheck,
421
+ ];
422
+ //# sourceMappingURL=doctor-furnace.js.map
@@ -1,6 +1,121 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
3
  import type { DoctorCheck, DoctorOptions } from '../types/commands/index.js';
4
+ import type { FireForgeConfig, FireForgeState, ProjectPaths } from '../types/config.js';
5
+ import type { FurnaceConfig } from '../types/furnace.js';
6
+ /**
7
+ * Shared state available to every doctor check during a single run.
8
+ *
9
+ * The context is populated lazily by the doctor runner. Individual checks
10
+ * can record side-observations (e.g. the parsed `fireforge.json`) into the
11
+ * context for later checks to consume without re-parsing.
12
+ *
13
+ * Exported so sibling modules (e.g. `doctor-furnace.ts`) can declare
14
+ * `DoctorCheckDefinition` entries against the same shared context.
15
+ */
16
+ export interface DoctorCheckContext {
17
+ projectRoot: string;
18
+ paths: ProjectPaths;
19
+ state: FireForgeState;
20
+ options: DoctorOptions;
21
+ /**
22
+ * Whether the engine/ directory exists on disk. Populated before checks
23
+ * run so downstream checks can skip git/mach inspections cheaply.
24
+ */
25
+ engineExists: boolean;
26
+ /**
27
+ * The loaded project config, set by the "fireforge.json is valid" check
28
+ * when it succeeds. Undefined before that check runs and whenever loading
29
+ * failed.
30
+ */
31
+ config: FireForgeConfig | undefined;
32
+ /**
33
+ * Whether `furnace.json` exists on disk. Populated before checks run so
34
+ * the furnace group can skipIf cheaply when the subsystem is not in use.
35
+ * A missing furnace.json is not an error — plenty of projects never touch
36
+ * the subsystem — so the doctor stays silent rather than failing.
37
+ */
38
+ furnaceConfigExists: boolean;
39
+ /**
40
+ * The parsed furnace config, set by the "Furnace configuration" check
41
+ * when it succeeds. Later furnace checks read from this so they do not
42
+ * re-parse the file; undefined when the config could not be loaded.
43
+ */
44
+ furnaceConfig: FurnaceConfig | undefined;
45
+ }
46
+ /**
47
+ * Result a check may return. A single object is the common case; an array
48
+ * lets a single check emit multiple related rows (e.g. the engine branch
49
+ * check which may report on branch + detached state together).
50
+ */
51
+ export type CheckResult = DoctorCheck | DoctorCheck[];
52
+ /**
53
+ * Declarative definition of a single doctor check.
54
+ *
55
+ * Every check opts into the shared execution/reporting pipeline by
56
+ * implementing only its inspection logic in `run`. Cross-cutting concerns
57
+ * (result aggregation, summary, exit codes) live in the runner instead of
58
+ * being duplicated at each call site.
59
+ *
60
+ * Exported so sibling modules (e.g. `doctor-furnace.ts`) can declare
61
+ * new checks without re-deriving the shape.
62
+ */
63
+ export interface DoctorCheckDefinition {
64
+ /**
65
+ * Human-readable name surfaced in the check report (e.g. "Git installed").
66
+ * Not required to be unique, but tests assert on it.
67
+ */
68
+ name: string;
69
+ /**
70
+ * When `true`, the check is silently skipped. Used for checks that only
71
+ * apply when the engine is present, or only when specific state flags
72
+ * are set. Skipped checks contribute nothing to the final report.
73
+ */
74
+ skipIf?: (ctx: DoctorCheckContext) => boolean;
75
+ /**
76
+ * Names of checks that must appear earlier in the registry and run before
77
+ * this check. Enforced at startup via {@link validateCheckDependencies} so
78
+ * accidental reorders surface immediately instead of producing subtle
79
+ * context-population bugs at runtime.
80
+ */
81
+ dependsOn?: readonly string[];
82
+ /**
83
+ * Runs the inspection. Throwing is shorthand for "this check failed with
84
+ * severity 'error'" — the runner converts the exception message into a
85
+ * DoctorCheck. Returning a DoctorCheck (or array) lets the check control
86
+ * severity, warnings, and fix hints directly.
87
+ */
88
+ run: (ctx: DoctorCheckContext) => CheckResult | Promise<CheckResult>;
89
+ /**
90
+ * Optional recovery hint attached to the auto-generated failure result
91
+ * when `run` throws. Ignored when `run` returns a DoctorCheck explicitly.
92
+ */
93
+ fix?: string;
94
+ }
95
+ /**
96
+ * Builds a DoctorCheck object representing a successful "OK" check.
97
+ * Exported for sibling check modules that declare `DoctorCheckDefinition`
98
+ * entries out-of-file (e.g. `doctor-furnace.ts`).
99
+ */
100
+ export declare function ok(name: string): DoctorCheck;
101
+ /**
102
+ * Builds a DoctorCheck object representing a warning result.
103
+ * Exported for sibling check modules — see {@link ok}.
104
+ */
105
+ export declare function warning(name: string, message: string, fix?: string): DoctorCheck;
106
+ /**
107
+ * Builds a DoctorCheck object representing a failure result.
108
+ * Exported for sibling check modules — see {@link ok}.
109
+ */
110
+ export declare function failure(name: string, message: string, fix?: string): DoctorCheck;
111
+ /**
112
+ * Ordered list of the doctor check names, exported for tests. Pinning
113
+ * the order here is intentional: any reorder that breaks the
114
+ * context-population dependency chain (see {@link DOCTOR_CHECKS}) must
115
+ * also update this list, which gives us a single place to notice and
116
+ * think through the consequences.
117
+ */
118
+ export declare const DOCTOR_CHECK_ORDER: readonly string[];
4
119
  /**
5
120
  * Result of the doctor command, carrying the exit code so the caller
6
121
  * (or test) can inspect it without relying on process.exitCode.