@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.
- package/CHANGELOG.md +93 -1
- package/README.md +125 -238
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +5 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- 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.
|