@hominis/fireforge 0.13.1 → 0.14.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 +65 -0
- package/README.md +12 -8
- package/dist/bin/fireforge.js +19 -5
- package/dist/src/commands/config.js +7 -1
- package/dist/src/commands/discard.js +6 -1
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +6 -1
- package/dist/src/commands/download.js +106 -7
- package/dist/src/commands/export-shared.js +7 -0
- package/dist/src/commands/export.js +5 -0
- package/dist/src/commands/furnace/apply.js +147 -47
- package/dist/src/commands/furnace/create.js +13 -2
- package/dist/src/commands/furnace/deploy.js +17 -2
- package/dist/src/commands/furnace/diff.js +3 -1
- package/dist/src/commands/furnace/init.js +25 -7
- package/dist/src/commands/furnace/list.js +15 -7
- package/dist/src/commands/furnace/override.js +47 -15
- package/dist/src/commands/furnace/remove.js +68 -20
- package/dist/src/commands/furnace/rename.js +31 -3
- package/dist/src/commands/furnace/scan.js +8 -0
- package/dist/src/commands/furnace/validate.js +70 -7
- package/dist/src/commands/import.js +65 -11
- package/dist/src/commands/patch/compact.d.ts +25 -0
- package/dist/src/commands/patch/compact.js +132 -0
- package/dist/src/commands/patch/index.d.ts +1 -0
- package/dist/src/commands/patch/index.js +4 -1
- package/dist/src/commands/patch/reorder.d.ts +5 -1
- package/dist/src/commands/patch/reorder.js +4 -2
- package/dist/src/commands/re-export.js +11 -4
- package/dist/src/commands/rebase/abort.js +26 -14
- package/dist/src/commands/rebase/confirm.d.ts +15 -2
- package/dist/src/commands/rebase/confirm.js +2 -2
- package/dist/src/commands/rebase/continue.js +39 -15
- package/dist/src/commands/rebase/index.js +2 -1
- package/dist/src/commands/rebase/patch-loop.js +90 -33
- package/dist/src/commands/register.js +13 -0
- package/dist/src/commands/resolve.js +31 -10
- package/dist/src/commands/run.js +9 -44
- package/dist/src/commands/setup-support.js +25 -7
- package/dist/src/commands/status.js +59 -8
- package/dist/src/commands/test.js +13 -7
- package/dist/src/commands/token.js +11 -1
- package/dist/src/commands/watch.js +51 -1
- package/dist/src/commands/wire.js +23 -0
- package/dist/src/core/config-validate.js +15 -1
- package/dist/src/core/furnace-registration.d.ts +1 -1
- package/dist/src/core/furnace-registration.js +2 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -0
- package/dist/src/core/license-headers.d.ts +15 -0
- package/dist/src/core/license-headers.js +28 -0
- package/dist/src/core/manifest-rules.js +24 -3
- package/dist/src/core/patch-lint.d.ts +11 -0
- package/dist/src/core/patch-lint.js +30 -3
- package/dist/src/core/signal-critical.d.ts +49 -0
- package/dist/src/core/signal-critical.js +80 -0
- package/dist/src/errors/download.d.ts +1 -1
- package/dist/src/errors/download.js +6 -3
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +9 -0
- package/package.json +1 -1
|
@@ -135,13 +135,16 @@ async function restoreOverrideEngineFiles(engineDir, overrideDir, overrideConfig
|
|
|
135
135
|
* the failure.
|
|
136
136
|
*/
|
|
137
137
|
async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
138
|
+
const partialFailures = [];
|
|
138
139
|
let forgeConfig;
|
|
139
140
|
try {
|
|
140
141
|
forgeConfig = await loadConfig(projectRoot);
|
|
141
142
|
}
|
|
142
143
|
catch (error) {
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
const msg = `Could not load config for test cleanup — ${toError(error).message}. Remove test files manually if needed.`;
|
|
145
|
+
warn(msg);
|
|
146
|
+
partialFailures.push(msg);
|
|
147
|
+
return { partialFailures };
|
|
145
148
|
}
|
|
146
149
|
const paths = getProjectPaths(projectRoot);
|
|
147
150
|
const binaryName = forgeConfig.binaryName;
|
|
@@ -153,7 +156,7 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
153
156
|
const testFileName = `browser_${binaryName}_${underscored}.js`;
|
|
154
157
|
const testDir = join(paths.engine, 'browser/base/content/test', binaryName);
|
|
155
158
|
if (!(await pathExists(testDir)))
|
|
156
|
-
return;
|
|
159
|
+
return { partialFailures };
|
|
157
160
|
// Step 1: Delete the test file itself
|
|
158
161
|
try {
|
|
159
162
|
const testFilePath = join(testDir, testFileName);
|
|
@@ -164,7 +167,9 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
catch (error) {
|
|
167
|
-
|
|
170
|
+
const msg = `Could not delete test file ${testFileName} — ${toError(error).message}. Remove it manually if needed.`;
|
|
171
|
+
warn(msg);
|
|
172
|
+
partialFailures.push(msg);
|
|
168
173
|
}
|
|
169
174
|
// Step 2: Remove the test entry from browser.toml
|
|
170
175
|
try {
|
|
@@ -180,7 +185,9 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
catch (error) {
|
|
183
|
-
|
|
188
|
+
const msg = `Could not update browser.toml — ${toError(error).message}. Remove the test entry manually if needed.`;
|
|
189
|
+
warn(msg);
|
|
190
|
+
partialFailures.push(msg);
|
|
184
191
|
}
|
|
185
192
|
// Step 3: Clean up empty test directory and deregister from moz.build
|
|
186
193
|
try {
|
|
@@ -198,8 +205,11 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
198
205
|
}
|
|
199
206
|
}
|
|
200
207
|
catch (error) {
|
|
201
|
-
|
|
208
|
+
const msg = `Could not clean up test directory — ${toError(error).message}. Remove it manually if needed.`;
|
|
209
|
+
warn(msg);
|
|
210
|
+
partialFailures.push(msg);
|
|
202
211
|
}
|
|
212
|
+
return { partialFailures };
|
|
203
213
|
}
|
|
204
214
|
function dropChecksumsByPrefix(state, prefix) {
|
|
205
215
|
const result = { ...state };
|
|
@@ -211,6 +221,38 @@ function dropChecksumsByPrefix(state, prefix) {
|
|
|
211
221
|
}
|
|
212
222
|
return result;
|
|
213
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Confirms the remove operation interactively when TTY is available, or
|
|
226
|
+
* enforces the `--yes` contract in non-interactive mode. Returns `false`
|
|
227
|
+
* when the user cancelled and the caller should exit silently.
|
|
228
|
+
*/
|
|
229
|
+
async function confirmFurnaceRemove(name, type, options, isInteractive) {
|
|
230
|
+
if (!isInteractive && !options.yes) {
|
|
231
|
+
throw new FurnaceError(`Cannot remove "${name}" in non-interactive mode without --yes flag.`, name);
|
|
232
|
+
}
|
|
233
|
+
if (!options.yes && isInteractive) {
|
|
234
|
+
const confirmed = await confirm({
|
|
235
|
+
message: `Remove ${type} component "${name}"?`,
|
|
236
|
+
});
|
|
237
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
238
|
+
cancel('Remove cancelled');
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Enforces the engine-as-git precondition for both override and custom
|
|
246
|
+
* removals. Runs BEFORE the lock is acquired or a journal is registered so
|
|
247
|
+
* the failure path does not involve any rollback infrastructure.
|
|
248
|
+
*/
|
|
249
|
+
async function requireGitEngineForRemove(type, name, engineDir) {
|
|
250
|
+
if (type !== 'override' && type !== 'custom')
|
|
251
|
+
return;
|
|
252
|
+
if (!(await isGitRepository(engineDir))) {
|
|
253
|
+
throw new FurnaceError(`Cannot remove ${type} component "${name}": engine is not a git repository. Run "fireforge download" to initialise it.`, name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
214
256
|
/**
|
|
215
257
|
* Runs the furnace remove command to remove a component from the workspace.
|
|
216
258
|
* @param projectRoot - Root directory of the project
|
|
@@ -229,19 +271,8 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
229
271
|
if (!type) {
|
|
230
272
|
throw new FurnaceError(`Component "${name}" not found in furnace.json. Run "fireforge furnace list" to see registered components.`, name);
|
|
231
273
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
throw new FurnaceError(`Cannot remove "${name}" in non-interactive mode without --yes flag.`, name);
|
|
235
|
-
}
|
|
236
|
-
// Confirm removal (skip if --yes)
|
|
237
|
-
if (!options.yes && isInteractive) {
|
|
238
|
-
const confirmed = await confirm({
|
|
239
|
-
message: `Remove ${type} component "${name}"?`,
|
|
240
|
-
});
|
|
241
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
242
|
-
cancel('Remove cancelled');
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
274
|
+
if (!(await confirmFurnaceRemove(name, type, options, isInteractive))) {
|
|
275
|
+
return;
|
|
245
276
|
}
|
|
246
277
|
// Begin transactional mutation: every file deleted or rewritten is first
|
|
247
278
|
// snapshotted in a rollback journal so any failure mid-removal restores the
|
|
@@ -249,6 +280,7 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
249
280
|
// the furnace-wide lock and is registered with the global SIGINT/SIGTERM
|
|
250
281
|
// rollback pathway.
|
|
251
282
|
const paths = getProjectPaths(projectRoot);
|
|
283
|
+
await requireGitEngineForRemove(type, name, paths.engine);
|
|
252
284
|
await runFurnaceMutation(projectRoot, 'remove-rollback', async (ctx) => {
|
|
253
285
|
const journal = createRollbackJournal();
|
|
254
286
|
ctx.registerJournal(journal);
|
|
@@ -279,6 +311,12 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
279
311
|
}
|
|
280
312
|
else if (type === 'custom') {
|
|
281
313
|
const customConfig = config.custom[name];
|
|
314
|
+
// Custom-component removal mutates engine files (jar.mn,
|
|
315
|
+
// customElements.js, deployed widgets, optional .ftl) and the
|
|
316
|
+
// rollback journal is the only safety net for those edits while
|
|
317
|
+
// the command runs. The git-as-engine precondition is enforced
|
|
318
|
+
// before the lock is acquired (see furnaceRemoveCommand above)
|
|
319
|
+
// so if we reach this point, the engine is a git repository.
|
|
282
320
|
if (customConfig?.register) {
|
|
283
321
|
// customElements.js is the only file removeCustomElementRegistration touches.
|
|
284
322
|
await snapshotFile(journal, join(paths.engine, 'toolkit/content/customElements.js'));
|
|
@@ -317,8 +355,10 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
317
355
|
}
|
|
318
356
|
}
|
|
319
357
|
}
|
|
358
|
+
let testCleanupFailures = [];
|
|
320
359
|
if (type === 'custom') {
|
|
321
|
-
await cleanupCustomTestFiles(name, projectRoot, journal);
|
|
360
|
+
const result = await cleanupCustomTestFiles(name, projectRoot, journal);
|
|
361
|
+
testCleanupFailures = result.partialFailures;
|
|
322
362
|
}
|
|
323
363
|
// Remove entry from furnace.json
|
|
324
364
|
if (type === 'stock') {
|
|
@@ -337,6 +377,14 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
337
377
|
// entire remove operation is a single atomic unit.
|
|
338
378
|
await snapshotFile(journal, furnacePaths.furnaceState);
|
|
339
379
|
await updateFurnaceState(projectRoot, (state) => dropChecksumsByPrefix(state, `${type}/${name}/`));
|
|
380
|
+
// Test-cleanup failures are warn-and-continue by design (test files
|
|
381
|
+
// are secondary artefacts), but the caller deserves a single summary
|
|
382
|
+
// line pointing at the residue so they don't have to re-scan earlier
|
|
383
|
+
// warn output to realise the removal was partial.
|
|
384
|
+
if (testCleanupFailures.length > 0) {
|
|
385
|
+
warn(`Component "${name}" removed with ${testCleanupFailures.length} test-cleanup warning(s) above. ` +
|
|
386
|
+
`The component is deregistered, but test files may linger in the engine — review and delete manually if needed.`);
|
|
387
|
+
}
|
|
340
388
|
}
|
|
341
389
|
catch (error) {
|
|
342
390
|
try {
|
|
@@ -14,6 +14,26 @@ import { FurnaceError } from '../../errors/furnace.js';
|
|
|
14
14
|
import { toError } from '../../utils/errors.js';
|
|
15
15
|
import { copyFile, ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
|
|
16
16
|
import { info, intro, note, outro, warn } from '../../utils/logger.js';
|
|
17
|
+
/** Escapes regex metacharacters so a user-supplied name is literal inside a RegExp. */
|
|
18
|
+
function escapeRegex(input) {
|
|
19
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Applies the component rename to a filename. Only replaces the leading
|
|
23
|
+
* component name when it is followed by `.` (extension) or equals the
|
|
24
|
+
* filename exactly; every other filename is returned unchanged so stray
|
|
25
|
+
* assets, editor backups, or files whose name coincidentally contains the
|
|
26
|
+
* old component name in the middle or at the end are not accidentally
|
|
27
|
+
* renamed.
|
|
28
|
+
*/
|
|
29
|
+
function renameComponentFileName(fileName, oldName, newName) {
|
|
30
|
+
if (fileName === oldName)
|
|
31
|
+
return newName;
|
|
32
|
+
if (fileName.startsWith(oldName + '.')) {
|
|
33
|
+
return newName + fileName.slice(oldName.length);
|
|
34
|
+
}
|
|
35
|
+
return fileName;
|
|
36
|
+
}
|
|
17
37
|
function updateConfigForCustomRename(config, oldName, newName) {
|
|
18
38
|
const oldConfig = config.custom[oldName];
|
|
19
39
|
if (!oldConfig)
|
|
@@ -122,7 +142,15 @@ async function performRenameMutations(args) {
|
|
|
122
142
|
if (!entry.isFile())
|
|
123
143
|
continue;
|
|
124
144
|
const oldFileName = entry.name;
|
|
125
|
-
|
|
145
|
+
// Rename only when the filename starts with the component name — the
|
|
146
|
+
// scaffolding convention for both create and override is `${name}.ext`.
|
|
147
|
+
// A plain `replace(oldName, newName)` produced wrong results when the
|
|
148
|
+
// old name occurred more than once (e.g. `foo-foo.mjs` renamed `foo` →
|
|
149
|
+
// `bar` became `bar-foo.mjs` instead of `bar-bar.mjs`) and also when
|
|
150
|
+
// the old name appeared inside a file that was not the component
|
|
151
|
+
// scaffold itself (e.g. a sibling helper). Unrelated files (stray
|
|
152
|
+
// assets, editor backups) are copied verbatim.
|
|
153
|
+
const newFileName = renameComponentFileName(oldFileName, oldName, newName);
|
|
126
154
|
const oldPath = join(oldDir, oldFileName);
|
|
127
155
|
const newPath = join(newDir, newFileName);
|
|
128
156
|
if (isComponentSourceFile(oldFileName)) {
|
|
@@ -130,8 +158,8 @@ async function performRenameMutations(args) {
|
|
|
130
158
|
// Use word-boundary-aware patterns so substrings in other
|
|
131
159
|
// identifiers (e.g. "moz-panel" inside "moz-panel-group") are
|
|
132
160
|
// not replaced.
|
|
133
|
-
const tagPattern = new RegExp(`(?<![\\w-])${oldName
|
|
134
|
-
const classPattern = new RegExp(`\\b${oldClassName}\\b`, 'g');
|
|
161
|
+
const tagPattern = new RegExp(`(?<![\\w-])${escapeRegex(oldName)}(?![\\w-])`, 'g');
|
|
162
|
+
const classPattern = new RegExp(`\\b${escapeRegex(oldClassName)}\\b`, 'g');
|
|
135
163
|
content = content.replace(tagPattern, newName);
|
|
136
164
|
content = content.replace(classPattern, newClassName);
|
|
137
165
|
await writeText(newPath, content);
|
|
@@ -58,6 +58,14 @@ async function promptAddComponents(components, tracked, projectRoot) {
|
|
|
58
58
|
await snapshotFile(journal, furnacePaths.furnaceConfig);
|
|
59
59
|
try {
|
|
60
60
|
const config = await ensureFurnaceConfig(projectRoot);
|
|
61
|
+
// Defensive: `selected` is already filtered to exclude components
|
|
62
|
+
// currently in config.stock (see untrackedComponents above). This
|
|
63
|
+
// re-filter catches the edge case where the config on disk changed
|
|
64
|
+
// between the scan's read and the write (concurrent scan / manual
|
|
65
|
+
// edit). Without it a duplicate scan would introduce duplicate
|
|
66
|
+
// entries into stock; writeFurnaceConfig's validator would then
|
|
67
|
+
// reject the write, but the error would be less actionable than
|
|
68
|
+
// silently de-duplicating here.
|
|
61
69
|
const toAdd = selected.filter((s) => !config.stock.includes(s));
|
|
62
70
|
config.stock.push(...toAdd);
|
|
63
71
|
await writeFurnaceConfig(projectRoot, config);
|
|
@@ -43,9 +43,17 @@ async function autoFixIssues(projectRoot, issues) {
|
|
|
43
43
|
// Fix jar.mn entries
|
|
44
44
|
for (const [componentName, files] of jarMnFixesByComponent) {
|
|
45
45
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// addJarMnEntries is idempotent and reports how many entries it
|
|
47
|
+
// actually wrote. Only count + log the files that were added so the
|
|
48
|
+
// reported "fixed" number matches the on-disk change.
|
|
49
|
+
const added = await addJarMnEntries(engineDir, componentName, files);
|
|
50
|
+
fixed += added;
|
|
51
|
+
if (added > 0) {
|
|
52
|
+
info(`Fixed: added ${files.join(', ')} to jar.mn for ${componentName}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
info(`No-op: jar.mn entries for ${componentName} were already present`);
|
|
56
|
+
}
|
|
49
57
|
}
|
|
50
58
|
catch (err) {
|
|
51
59
|
warn(`Could not fix jar.mn for ${componentName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -162,13 +170,30 @@ export async function furnaceValidateCommand(projectRoot, name, options = {}) {
|
|
|
162
170
|
}
|
|
163
171
|
}
|
|
164
172
|
}
|
|
165
|
-
// Auto-fix fixable issues when --fix is passed
|
|
173
|
+
// Auto-fix fixable issues when --fix is passed. The auto-fix counter
|
|
174
|
+
// returned by `autoFixIssues` only counts function calls that did not
|
|
175
|
+
// throw — a write that succeeded but did not actually resolve the issue
|
|
176
|
+
// (e.g. addJarMnEntries appended to a file mach later ignores) would
|
|
177
|
+
// still bump the count. Re-validate the affected components and compute
|
|
178
|
+
// the *actual* drop in fixable issues so the reported number is honest.
|
|
166
179
|
if (options.fix && allIssues.length > 0) {
|
|
167
180
|
const fixableIssues = allIssues.filter((issue) => FIXABLE_CHECKS.has(issue.check));
|
|
168
181
|
if (fixableIssues.length > 0) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
await autoFixIssues(projectRoot, fixableIssues);
|
|
183
|
+
const reValidated = await reValidateComponents(projectRoot, config, furnacePaths, new Set(fixableIssues.map((issue) => issue.component)));
|
|
184
|
+
const fixableBefore = fixableIssues.length;
|
|
185
|
+
const fixableAfter = reValidated.issues.filter((issue) => FIXABLE_CHECKS.has(issue.check)).length;
|
|
186
|
+
const actuallyFixed = Math.max(0, fixableBefore - fixableAfter);
|
|
187
|
+
// Replace the pre-fix issue totals with the post-fix view so the
|
|
188
|
+
// summary reflects current reality. Issues that auto-fix could not
|
|
189
|
+
// address still count toward totalErrors / totalWarnings.
|
|
190
|
+
totalErrors = reValidated.totalErrors;
|
|
191
|
+
totalWarnings = reValidated.totalWarnings;
|
|
192
|
+
if (actuallyFixed > 0) {
|
|
193
|
+
info(`\nAuto-fixed ${actuallyFixed} issue(s).`);
|
|
194
|
+
}
|
|
195
|
+
if (fixableAfter > 0) {
|
|
196
|
+
warn(`${fixableAfter} fixable issue(s) remain after auto-fix — investigate manually.`);
|
|
172
197
|
}
|
|
173
198
|
}
|
|
174
199
|
else {
|
|
@@ -184,4 +209,42 @@ export async function furnaceValidateCommand(projectRoot, name, options = {}) {
|
|
|
184
209
|
}
|
|
185
210
|
outro('Validation passed');
|
|
186
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Re-validates a specific set of components after an auto-fix pass and
|
|
214
|
+
* returns the post-fix issue list with the recomputed error / warning
|
|
215
|
+
* totals. Used by the `--fix` path to honestly report what auto-fix
|
|
216
|
+
* actually accomplished.
|
|
217
|
+
*/
|
|
218
|
+
async function reValidateComponents(projectRoot, config, furnacePaths, componentNames) {
|
|
219
|
+
const issues = [];
|
|
220
|
+
let totalErrors = 0;
|
|
221
|
+
let totalWarnings = 0;
|
|
222
|
+
for (const componentName of componentNames) {
|
|
223
|
+
let type;
|
|
224
|
+
let componentDir;
|
|
225
|
+
if (componentName in config.overrides) {
|
|
226
|
+
type = 'override';
|
|
227
|
+
componentDir = join(furnacePaths.overridesDir, componentName);
|
|
228
|
+
}
|
|
229
|
+
else if (componentName in config.custom) {
|
|
230
|
+
type = 'custom';
|
|
231
|
+
componentDir = join(furnacePaths.customDir, componentName);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// Stock or removed components are not local-validated; skip silently.
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!(await pathExists(componentDir)))
|
|
238
|
+
continue;
|
|
239
|
+
const componentIssues = await validateComponent(componentDir, componentName, type, config, projectRoot);
|
|
240
|
+
issues.push(...componentIssues);
|
|
241
|
+
for (const issue of componentIssues) {
|
|
242
|
+
if (issue.severity === 'error')
|
|
243
|
+
totalErrors += 1;
|
|
244
|
+
else
|
|
245
|
+
totalWarnings += 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { issues, totalErrors, totalWarnings };
|
|
249
|
+
}
|
|
187
250
|
//# sourceMappingURL=validate.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { confirm } from '@clack/prompts';
|
|
4
|
-
import { getProjectPaths, loadConfig, loadState,
|
|
4
|
+
import { getProjectPaths, loadConfig, loadState, updateState } from '../core/config.js';
|
|
5
5
|
import { getHead } from '../core/git.js';
|
|
6
6
|
import { getDirtyFiles } from '../core/git-status.js';
|
|
7
7
|
import { applyPatchesWithContinue, computePatchedContent, countPatches, discoverPatches, extractAffectedFiles, PatchError, } from '../core/patch-apply.js';
|
|
@@ -11,6 +11,19 @@ import { toError } from '../utils/errors.js';
|
|
|
11
11
|
import { pathExists, readText } from '../utils/fs.js';
|
|
12
12
|
import { error, info, intro, isCancel, outro, spinner, success, verbose, warn, } from '../utils/logger.js';
|
|
13
13
|
import { pickDefined } from '../utils/options.js';
|
|
14
|
+
/**
|
|
15
|
+
* Errno codes for filesystem-level failures against the working file.
|
|
16
|
+
* These are safe to fall through as "unmanaged" because they describe the
|
|
17
|
+
* *state of the engine directory*, not the integrity of the patch stack.
|
|
18
|
+
* Manifest / patch-parse / PatchError failures do NOT match this set and
|
|
19
|
+
* are re-thrown so the root cause surfaces instead of being silently
|
|
20
|
+
* reclassified as a spurious dirty file.
|
|
21
|
+
*/
|
|
22
|
+
const SAFE_IO_FALLBACK_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'EBUSY']);
|
|
23
|
+
function isSafeIoFallback(error) {
|
|
24
|
+
const code = error?.code;
|
|
25
|
+
return typeof code === 'string' && SAFE_IO_FALLBACK_CODES.has(code);
|
|
26
|
+
}
|
|
14
27
|
async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
|
|
15
28
|
const classifications = await Promise.all(dirtyFiles.map(async (file) => {
|
|
16
29
|
try {
|
|
@@ -22,7 +35,19 @@ async function getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles) {
|
|
|
22
35
|
return actual === expected ? null : file;
|
|
23
36
|
}
|
|
24
37
|
catch (error) {
|
|
25
|
-
|
|
38
|
+
// PatchError, manifest corruption, and patch-parse failures are
|
|
39
|
+
// *structural* problems with the patch stack — masking them as
|
|
40
|
+
// "unmanaged dirty file" would let the user `--force` past a real
|
|
41
|
+
// root cause (e.g. "patch 003 missing from manifest") and compound
|
|
42
|
+
// the corruption. Only swallow the pure-IO fallback cases where
|
|
43
|
+
// the working file itself can't be read.
|
|
44
|
+
if (error instanceof PatchError) {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
if (!isSafeIoFallback(error)) {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
verbose(`Treating ${file} as unmanaged because patched-content classification failed with IO error: ${toError(error).message}`);
|
|
26
51
|
return file;
|
|
27
52
|
}
|
|
28
53
|
}));
|
|
@@ -64,14 +89,22 @@ async function checkUncommittedPatchFiles(engineDir, patchesDir, forceImport) {
|
|
|
64
89
|
}
|
|
65
90
|
}
|
|
66
91
|
}
|
|
67
|
-
async function handlePatchFailures(summary,
|
|
92
|
+
async function handlePatchFailures(summary, projectRoot) {
|
|
68
93
|
const firstFailed = summary.failed[0];
|
|
69
94
|
if (firstFailed) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
// Transactional update rather than `loadState` + mutate + `saveState`. The
|
|
96
|
+
// caller captures `state` at the start of the import run, and the run can
|
|
97
|
+
// span a long window (drift-check prompt, patch apply loop). A concurrent
|
|
98
|
+
// command (`fireforge download`, `rebase`, another state mutation) writing
|
|
99
|
+
// unrelated fields during that window would be silently clobbered when the
|
|
100
|
+
// stale state object was written back.
|
|
101
|
+
await updateState(projectRoot, (current) => ({
|
|
102
|
+
...current,
|
|
103
|
+
pendingResolution: {
|
|
104
|
+
patchFilename: firstFailed.patch.filename,
|
|
105
|
+
originalError: firstFailed.error ?? 'Unknown error',
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
75
108
|
}
|
|
76
109
|
for (const result of summary.failed) {
|
|
77
110
|
error(`\nFailed: ${result.patch.filename}`);
|
|
@@ -187,14 +220,35 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
187
220
|
}
|
|
188
221
|
}
|
|
189
222
|
}
|
|
190
|
-
// Validate patch integrity (detect orphaned modification patches)
|
|
223
|
+
// Validate patch integrity (detect orphaned modification patches). Warn
|
|
224
|
+
// and prompt the operator to confirm before proceeding — the legacy
|
|
225
|
+
// warn-and-continue behaviour hid the real root cause because import
|
|
226
|
+
// would later fail during patch application with a secondary, unrelated
|
|
227
|
+
// error that made diagnosis harder.
|
|
191
228
|
const integrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
|
|
192
229
|
if (integrityIssues.length > 0) {
|
|
193
230
|
warn('\nPatch integrity issues detected:');
|
|
194
231
|
for (const issue of integrityIssues) {
|
|
195
232
|
warn(` ${issue.filename}: ${issue.message}`);
|
|
196
233
|
}
|
|
197
|
-
info('Run "fireforge doctor" for more details
|
|
234
|
+
info('Run "fireforge doctor" for more details.');
|
|
235
|
+
if (forceImport) {
|
|
236
|
+
warn('Continuing because --force was provided. Integrity issues were not resolved.\n');
|
|
237
|
+
}
|
|
238
|
+
else if (!process.stdin.isTTY) {
|
|
239
|
+
throw new GeneralError(`Refusing to import while ${integrityIssues.length} patch integrity issue(s) are unresolved. ` +
|
|
240
|
+
`Fix the issues reported above (see "fireforge doctor") or re-run with --force to continue anyway.`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const shouldContinue = await confirm({
|
|
244
|
+
message: 'Patch integrity issues detected. Continuing may fail with cascading errors during patch application. Continue anyway?',
|
|
245
|
+
initialValue: false,
|
|
246
|
+
});
|
|
247
|
+
if (isCancel(shouldContinue) || !shouldContinue) {
|
|
248
|
+
outro('Import cancelled — fix the integrity issues and re-run');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
198
252
|
}
|
|
199
253
|
// Dry-run: list patches that would be applied and exit
|
|
200
254
|
if (isDryRun) {
|
|
@@ -226,7 +280,7 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
226
280
|
// Handle failures
|
|
227
281
|
if (summary.failed.length > 0) {
|
|
228
282
|
s.error(`${summary.failed.length} patch(es) failed`);
|
|
229
|
-
await handlePatchFailures(summary,
|
|
283
|
+
await handlePatchFailures(summary, projectRoot);
|
|
230
284
|
}
|
|
231
285
|
// Count auto-resolved patches
|
|
232
286
|
const autoResolved = summary.succeeded.filter((r) => r.autoResolved);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fireforge patch compact` — closes ordinal gaps in the patch queue.
|
|
3
|
+
*
|
|
4
|
+
* After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
|
|
5
|
+
* This command renumbers all patches to sequential ordinals (1, 2, 3, …)
|
|
6
|
+
* in a single atomic operation, preserving relative order.
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import type { CommandContext } from '../../types/cli.js';
|
|
10
|
+
import type { PatchCompactOptions } from '../../types/commands/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* Runs the `patch compact` command: renumbers all patches to close ordinal
|
|
13
|
+
* gaps in a single atomic operation.
|
|
14
|
+
*
|
|
15
|
+
* @param projectRoot - Project root directory
|
|
16
|
+
* @param options - Command options
|
|
17
|
+
*/
|
|
18
|
+
export declare function patchCompactCommand(projectRoot: string, options?: PatchCompactOptions): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Registers the `patch compact` subcommand on the `patch` parent.
|
|
21
|
+
*
|
|
22
|
+
* @param parent - Parent Commander command
|
|
23
|
+
* @param context - Shared CLI registration context
|
|
24
|
+
*/
|
|
25
|
+
export declare function registerPatchCompact(parent: Command, context: CommandContext): void;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch compact` — closes ordinal gaps in the patch queue.
|
|
4
|
+
*
|
|
5
|
+
* After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
|
|
6
|
+
* This command renumbers all patches to sequential ordinals (1, 2, 3, …)
|
|
7
|
+
* in a single atomic operation, preserving relative order.
|
|
8
|
+
*/
|
|
9
|
+
import { getProjectPaths } from '../../core/config.js';
|
|
10
|
+
import { appendHistory, confirmDestructive } from '../../core/destructive.js';
|
|
11
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
12
|
+
import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
|
|
13
|
+
import { GeneralError } from '../../errors/base.js';
|
|
14
|
+
import { toError } from '../../utils/errors.js';
|
|
15
|
+
import { pathExists } from '../../utils/fs.js';
|
|
16
|
+
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
17
|
+
import { pickDefined } from '../../utils/options.js';
|
|
18
|
+
import { rebuildFilenameForOrder } from './reorder.js';
|
|
19
|
+
/**
|
|
20
|
+
* Computes a rename map that assigns sequential ordinals (1, 2, 3, …)
|
|
21
|
+
* to all patches, sorted by their current order.
|
|
22
|
+
*/
|
|
23
|
+
function computeCompactRenameMap(patches) {
|
|
24
|
+
const sorted = [...patches].sort((a, b) => a.order - b.order);
|
|
25
|
+
const renames = new Map();
|
|
26
|
+
for (const [i, patch] of sorted.entries()) {
|
|
27
|
+
const newOrder = i + 1;
|
|
28
|
+
if (patch.order !== newOrder) {
|
|
29
|
+
renames.set(patch.filename, {
|
|
30
|
+
newOrder,
|
|
31
|
+
newFilename: rebuildFilenameForOrder(patch, newOrder),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return renames;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Runs the `patch compact` command: renumbers all patches to close ordinal
|
|
39
|
+
* gaps in a single atomic operation.
|
|
40
|
+
*
|
|
41
|
+
* @param projectRoot - Project root directory
|
|
42
|
+
* @param options - Command options
|
|
43
|
+
*/
|
|
44
|
+
export async function patchCompactCommand(projectRoot, options = {}) {
|
|
45
|
+
intro(options.dryRun ? 'FireForge patch compact (dry run)' : 'FireForge patch compact');
|
|
46
|
+
const paths = getProjectPaths(projectRoot);
|
|
47
|
+
if (!(await pathExists(paths.patches))) {
|
|
48
|
+
throw new GeneralError('Patches directory not found.');
|
|
49
|
+
}
|
|
50
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
51
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
52
|
+
throw new GeneralError('No patches in manifest.');
|
|
53
|
+
}
|
|
54
|
+
const renameMap = computeCompactRenameMap(manifest.patches);
|
|
55
|
+
if (renameMap.size === 0) {
|
|
56
|
+
info('Patch queue is already compact. Nothing to do.');
|
|
57
|
+
outro('Compact complete (no-op)');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const sorted = [...renameMap.entries()].sort((a, b) => a[1].newOrder - b[1].newOrder);
|
|
61
|
+
const summary = [`${renameMap.size} patch(es) would be renumbered:`];
|
|
62
|
+
for (const [oldFilename, entry] of sorted) {
|
|
63
|
+
summary.push(` ${oldFilename} → ${entry.newFilename} (order ${entry.newOrder})`);
|
|
64
|
+
}
|
|
65
|
+
const decision = await confirmDestructive({
|
|
66
|
+
operation: 'patch-compact',
|
|
67
|
+
title: `Compact ${manifest.patches.length} patches (${renameMap.size} rename(s))`,
|
|
68
|
+
summary,
|
|
69
|
+
yes: options.yes === true,
|
|
70
|
+
dryRun: options.dryRun === true,
|
|
71
|
+
});
|
|
72
|
+
if (decision === 'dry-run') {
|
|
73
|
+
outro('Dry run complete — no changes made');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (decision === 'cancelled') {
|
|
77
|
+
outro('Compact cancelled');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
await withPatchDirectoryLock(paths.patches, async () => {
|
|
81
|
+
const currentManifest = await loadPatchesManifest(paths.patches);
|
|
82
|
+
if (!currentManifest) {
|
|
83
|
+
throw new GeneralError('Manifest disappeared while waiting for lock.');
|
|
84
|
+
}
|
|
85
|
+
const currentRenameMap = computeCompactRenameMap(currentManifest.patches);
|
|
86
|
+
if (currentRenameMap.size === 0) {
|
|
87
|
+
info('Patch queue was compacted by another process. Nothing to do.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await renumberPatchesInManifest(paths.patches, currentRenameMap);
|
|
91
|
+
const historyEntry = {
|
|
92
|
+
operation: 'patch-compact',
|
|
93
|
+
args: {
|
|
94
|
+
renames: [...currentRenameMap.entries()]
|
|
95
|
+
.sort((a, b) => a[1].newOrder - b[1].newOrder)
|
|
96
|
+
.map(([from, entry]) => ({
|
|
97
|
+
from,
|
|
98
|
+
to: entry.newFilename,
|
|
99
|
+
order: entry.newOrder,
|
|
100
|
+
})),
|
|
101
|
+
},
|
|
102
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
103
|
+
result: 'ok',
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
await appendHistory(paths.patches, historyEntry);
|
|
107
|
+
}
|
|
108
|
+
catch (historyError) {
|
|
109
|
+
warn(`History log append failed after patch compact committed: ${toError(historyError).message}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
info(`Compacted ${renameMap.size} patch(es).`);
|
|
113
|
+
outro('Compact complete');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Registers the `patch compact` subcommand on the `patch` parent.
|
|
117
|
+
*
|
|
118
|
+
* @param parent - Parent Commander command
|
|
119
|
+
* @param context - Shared CLI registration context
|
|
120
|
+
*/
|
|
121
|
+
export function registerPatchCompact(parent, context) {
|
|
122
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
123
|
+
parent
|
|
124
|
+
.command('compact')
|
|
125
|
+
.description('Close ordinal gaps in the patch queue (renumber sequentially)')
|
|
126
|
+
.option('--dry-run', 'Show what would happen without writing')
|
|
127
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
128
|
+
.action(withErrorHandling(async (options) => {
|
|
129
|
+
await patchCompactCommand(getProjectRoot(), pickDefined(options));
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=compact.js.map
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Command } from 'commander';
|
|
8
8
|
import type { CommandContext } from '../../types/cli.js';
|
|
9
|
+
export { patchCompactCommand } from './compact.js';
|
|
9
10
|
export { patchDeleteCommand } from './delete.js';
|
|
10
11
|
export { patchReorderCommand } from './reorder.js';
|
|
11
12
|
/**
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* command list. Queue-level verbs like `lint`, `export`, `verify`, and
|
|
6
6
|
* `status` stay flat.
|
|
7
7
|
*/
|
|
8
|
+
import { registerPatchCompact } from './compact.js';
|
|
8
9
|
import { registerPatchDelete } from './delete.js';
|
|
9
10
|
import { registerPatchReorder } from './reorder.js';
|
|
11
|
+
export { patchCompactCommand } from './compact.js';
|
|
10
12
|
export { patchDeleteCommand } from './delete.js';
|
|
11
13
|
export { patchReorderCommand } from './reorder.js';
|
|
12
14
|
/**
|
|
@@ -18,7 +20,8 @@ export { patchReorderCommand } from './reorder.js';
|
|
|
18
20
|
export function registerPatch(program, context) {
|
|
19
21
|
const patch = program
|
|
20
22
|
.command('patch')
|
|
21
|
-
.description('Manage individual patches in the queue (delete, reorder)');
|
|
23
|
+
.description('Manage individual patches in the queue (compact, delete, reorder)');
|
|
24
|
+
registerPatchCompact(patch, context);
|
|
22
25
|
registerPatchDelete(patch, context);
|
|
23
26
|
registerPatchReorder(patch, context);
|
|
24
27
|
}
|