@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +12 -8
  3. package/dist/bin/fireforge.js +19 -5
  4. package/dist/src/commands/config.js +7 -1
  5. package/dist/src/commands/discard.js +6 -1
  6. package/dist/src/commands/doctor.d.ts +12 -0
  7. package/dist/src/commands/doctor.js +6 -1
  8. package/dist/src/commands/download.js +106 -7
  9. package/dist/src/commands/export-shared.js +7 -0
  10. package/dist/src/commands/export.js +5 -0
  11. package/dist/src/commands/furnace/apply.js +147 -47
  12. package/dist/src/commands/furnace/create.js +13 -2
  13. package/dist/src/commands/furnace/deploy.js +17 -2
  14. package/dist/src/commands/furnace/diff.js +3 -1
  15. package/dist/src/commands/furnace/init.js +25 -7
  16. package/dist/src/commands/furnace/list.js +15 -7
  17. package/dist/src/commands/furnace/override.js +47 -15
  18. package/dist/src/commands/furnace/remove.js +68 -20
  19. package/dist/src/commands/furnace/rename.js +31 -3
  20. package/dist/src/commands/furnace/scan.js +8 -0
  21. package/dist/src/commands/furnace/validate.js +70 -7
  22. package/dist/src/commands/import.js +65 -11
  23. package/dist/src/commands/patch/compact.d.ts +25 -0
  24. package/dist/src/commands/patch/compact.js +132 -0
  25. package/dist/src/commands/patch/index.d.ts +1 -0
  26. package/dist/src/commands/patch/index.js +4 -1
  27. package/dist/src/commands/patch/reorder.d.ts +5 -1
  28. package/dist/src/commands/patch/reorder.js +4 -2
  29. package/dist/src/commands/re-export.js +11 -4
  30. package/dist/src/commands/rebase/abort.js +26 -14
  31. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  32. package/dist/src/commands/rebase/confirm.js +2 -2
  33. package/dist/src/commands/rebase/continue.js +39 -15
  34. package/dist/src/commands/rebase/index.js +2 -1
  35. package/dist/src/commands/rebase/patch-loop.js +90 -33
  36. package/dist/src/commands/register.js +13 -0
  37. package/dist/src/commands/resolve.js +31 -10
  38. package/dist/src/commands/run.js +9 -44
  39. package/dist/src/commands/setup-support.js +25 -7
  40. package/dist/src/commands/status.js +59 -8
  41. package/dist/src/commands/test.js +13 -7
  42. package/dist/src/commands/token.js +11 -1
  43. package/dist/src/commands/watch.js +51 -1
  44. package/dist/src/commands/wire.js +23 -0
  45. package/dist/src/core/config-validate.js +15 -1
  46. package/dist/src/core/furnace-registration.d.ts +1 -1
  47. package/dist/src/core/furnace-registration.js +2 -1
  48. package/dist/src/core/furnace-staleness.d.ts +17 -0
  49. package/dist/src/core/furnace-staleness.js +58 -0
  50. package/dist/src/core/license-headers.d.ts +15 -0
  51. package/dist/src/core/license-headers.js +28 -0
  52. package/dist/src/core/manifest-rules.js +24 -3
  53. package/dist/src/core/patch-lint.d.ts +11 -0
  54. package/dist/src/core/patch-lint.js +30 -3
  55. package/dist/src/core/signal-critical.d.ts +49 -0
  56. package/dist/src/core/signal-critical.js +80 -0
  57. package/dist/src/errors/download.d.ts +1 -1
  58. package/dist/src/errors/download.js +6 -3
  59. package/dist/src/types/commands/index.d.ts +1 -1
  60. package/dist/src/types/commands/options.d.ts +9 -0
  61. 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
- warn(`Could not load config for test cleanup — ${toError(error).message}. Remove test files manually if needed.`);
144
- return;
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
- warn(`Could not delete test file ${testFileName} — ${toError(error).message}. Remove it manually if needed.`);
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
- warn(`Could not update browser.toml — ${toError(error).message}. Remove the test entry manually if needed.`);
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
- warn(`Could not clean up test directory — ${toError(error).message}. Remove it manually if needed.`);
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
- // Require --yes in non-interactive mode to prevent silent removals
233
- if (!isInteractive && !options.yes) {
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
- const newFileName = oldFileName.replace(oldName, newName);
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.replace(/-/g, '\\-')}(?![\\w-])`, 'g');
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
- await addJarMnEntries(engineDir, componentName, files);
47
- fixed += files.length;
48
- info(`Fixed: added ${files.join(', ')} to jar.mn for ${componentName}`);
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
- const fixedCount = await autoFixIssues(projectRoot, fixableIssues);
170
- if (fixedCount > 0) {
171
- info(`\nAuto-fixed ${fixedCount} issue(s). Re-run validate to confirm.`);
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, saveState } from '../core/config.js';
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
- verbose(`Treating ${file} as unmanaged because patched-content classification failed: ${toError(error).message}`);
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, state, projectRoot) {
92
+ async function handlePatchFailures(summary, projectRoot) {
68
93
  const firstFailed = summary.failed[0];
69
94
  if (firstFailed) {
70
- state.pendingResolution = {
71
- patchFilename: firstFailed.patch.filename,
72
- originalError: firstFailed.error ?? 'Unknown error',
73
- };
74
- await saveState(projectRoot, state);
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.\n');
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, state, projectRoot);
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
  }