@hominis/fireforge 0.13.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +20 -1
  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-templates.d.ts +26 -0
  13. package/dist/src/commands/furnace/create-templates.js +86 -0
  14. package/dist/src/commands/furnace/create.js +77 -103
  15. package/dist/src/commands/furnace/deploy.js +20 -5
  16. package/dist/src/commands/furnace/diff.js +3 -1
  17. package/dist/src/commands/furnace/init.js +25 -7
  18. package/dist/src/commands/furnace/list.js +15 -7
  19. package/dist/src/commands/furnace/override.js +47 -15
  20. package/dist/src/commands/furnace/remove.js +68 -20
  21. package/dist/src/commands/furnace/rename.js +31 -3
  22. package/dist/src/commands/furnace/scan.js +8 -0
  23. package/dist/src/commands/furnace/validate.js +70 -7
  24. package/dist/src/commands/import.js +65 -11
  25. package/dist/src/commands/re-export.js +11 -4
  26. package/dist/src/commands/rebase/abort.js +26 -14
  27. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  28. package/dist/src/commands/rebase/confirm.js +2 -2
  29. package/dist/src/commands/rebase/continue.js +39 -15
  30. package/dist/src/commands/rebase/index.js +2 -1
  31. package/dist/src/commands/rebase/patch-loop.js +90 -33
  32. package/dist/src/commands/register.js +13 -0
  33. package/dist/src/commands/resolve.js +31 -10
  34. package/dist/src/commands/run.js +9 -44
  35. package/dist/src/commands/setup-support.js +25 -7
  36. package/dist/src/commands/status.js +59 -8
  37. package/dist/src/commands/test.js +33 -7
  38. package/dist/src/commands/token.js +11 -1
  39. package/dist/src/commands/watch.js +51 -1
  40. package/dist/src/commands/wire.js +23 -0
  41. package/dist/src/core/config-paths.d.ts +2 -2
  42. package/dist/src/core/config-paths.js +2 -0
  43. package/dist/src/core/config-validate.js +47 -1
  44. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  45. package/dist/src/core/furnace-apply-ftl.js +102 -0
  46. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  47. package/dist/src/core/furnace-apply-helpers.js +16 -12
  48. package/dist/src/core/furnace-apply.js +7 -4
  49. package/dist/src/core/furnace-config-tokens.d.ts +11 -0
  50. package/dist/src/core/furnace-config-tokens.js +28 -0
  51. package/dist/src/core/furnace-config.d.ts +6 -0
  52. package/dist/src/core/furnace-config.js +8 -1
  53. package/dist/src/core/furnace-constants.d.ts +20 -0
  54. package/dist/src/core/furnace-constants.js +32 -0
  55. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  56. package/dist/src/core/furnace-registration-ast.js +58 -25
  57. package/dist/src/core/furnace-registration.d.ts +28 -1
  58. package/dist/src/core/furnace-registration.js +98 -1
  59. package/dist/src/core/furnace-staleness.d.ts +17 -0
  60. package/dist/src/core/furnace-staleness.js +58 -0
  61. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  62. package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
  63. package/dist/src/core/furnace-validate-helpers.js +81 -0
  64. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  65. package/dist/src/core/furnace-validate-registration.js +34 -9
  66. package/dist/src/core/furnace-validate.js +2 -2
  67. package/dist/src/core/marionette-preflight.d.ts +39 -0
  68. package/dist/src/core/marionette-preflight.js +210 -0
  69. package/dist/src/core/signal-critical.d.ts +49 -0
  70. package/dist/src/core/signal-critical.js +80 -0
  71. package/dist/src/errors/download.d.ts +1 -1
  72. package/dist/src/errors/download.js +6 -3
  73. package/dist/src/types/commands/options.d.ts +6 -0
  74. package/dist/src/types/config.d.ts +7 -0
  75. package/dist/src/types/furnace.d.ts +8 -0
  76. package/dist/src/utils/process.d.ts +15 -2
  77. package/dist/src/utils/process.js +73 -0
  78. package/package.json +1 -1
@@ -51,7 +51,9 @@ async function diffOverride(name, projectRoot, config) {
51
51
  const state = await loadState(projectRoot);
52
52
  const baseCommit = overrideConfig.baseCommit ?? state.baseCommit;
53
53
  if (!baseCommit) {
54
- throw new FurnaceError('Cannot diff: baseCommit not found. Re-run "fireforge download" to establish a baseline.', name);
54
+ throw new FurnaceError(`Cannot diff "${name}": baseCommit not recorded for this override. ` +
55
+ `Run "fireforge furnace refresh --reset-base ${name}" to stamp the current engine HEAD as the baseline, ` +
56
+ `or re-run "fireforge download" to re-establish a project-wide baseline.`, name);
55
57
  }
56
58
  const entries = await readdir(overrideDir, { withFileTypes: true });
57
59
  let hasDifferences = false;
@@ -1,8 +1,30 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { isAbsolute, normalize } from 'node:path';
2
3
  import { text } from '@clack/prompts';
3
4
  import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
4
5
  import { FurnaceError } from '../../errors/furnace.js';
5
6
  import { cancel, info, intro, isCancel, note, outro, success } from '../../utils/logger.js';
7
+ /**
8
+ * Validates an FTL base path before writing it to furnace.json. Rejects
9
+ * absolute paths, null bytes, and any normalised segment starting with
10
+ * `..` — the previous `includes('..')` substring check caught the common
11
+ * case but missed `./../../` and absolute paths that are arguably worse.
12
+ */
13
+ function validateFtlBasePath(value) {
14
+ if (value.length === 0) {
15
+ throw new FurnaceError('ftlBasePath must not be empty.');
16
+ }
17
+ if (value.includes('\0')) {
18
+ throw new FurnaceError('ftlBasePath must not contain null bytes.');
19
+ }
20
+ if (isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)) {
21
+ throw new FurnaceError(`ftlBasePath "${value}" must be a relative path inside the engine checkout, not absolute.`);
22
+ }
23
+ const normalized = normalize(value.replace(/\\/g, '/'));
24
+ if (normalized === '..' || normalized.startsWith('../')) {
25
+ throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
26
+ }
27
+ }
6
28
  /**
7
29
  * Runs the furnace init command to create a default furnace.json with
8
30
  * user-specified settings.
@@ -15,7 +37,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
15
37
  throw new FurnaceError('furnace.json already exists. Use --force to overwrite it.');
16
38
  }
17
39
  const config = createDefaultFurnaceConfig();
18
- const isInteractive = process.stdin.isTTY;
40
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
19
41
  // Resolve componentPrefix
20
42
  if (options.prefix !== undefined) {
21
43
  config.componentPrefix = options.prefix;
@@ -38,9 +60,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
38
60
  }
39
61
  // Resolve ftlBasePath
40
62
  if (options.ftlBasePath !== undefined) {
41
- if (options.ftlBasePath.includes('..')) {
42
- throw new FurnaceError('ftlBasePath must not contain ".." (path traversal)');
43
- }
63
+ validateFtlBasePath(options.ftlBasePath);
44
64
  config.ftlBasePath = options.ftlBasePath;
45
65
  }
46
66
  else if (isInteractive) {
@@ -54,9 +74,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
54
74
  }
55
75
  const ftlValue = ftlResult.trim();
56
76
  if (ftlValue) {
57
- if (ftlValue.includes('..')) {
58
- throw new FurnaceError('ftlBasePath must not contain ".." (path traversal)');
59
- }
77
+ validateFtlBasePath(ftlValue);
60
78
  config.ftlBasePath = ftlValue;
61
79
  }
62
80
  }
@@ -9,15 +9,23 @@ import { formatErrorText, formatSuccessText, info, intro, note, outro, } from '.
9
9
  * its workspace checksums have changed since the last apply.
10
10
  */
11
11
  async function getHealthIndicator(componentDir, type, name, appliedChecksums) {
12
- if (!(await pathExists(componentDir))) {
13
- return formatErrorText('missing');
12
+ try {
13
+ if (!(await pathExists(componentDir))) {
14
+ return formatErrorText('missing');
15
+ }
16
+ const previous = extractComponentChecksums(appliedChecksums, type, name);
17
+ if (Object.keys(previous).length === 0) {
18
+ return formatErrorText('not applied');
19
+ }
20
+ const changed = await hasComponentChanged(componentDir, previous);
21
+ return changed ? formatErrorText('modified') : formatSuccessText('clean');
14
22
  }
15
- const previous = extractComponentChecksums(appliedChecksums, type, name);
16
- if (Object.keys(previous).length === 0) {
17
- return formatErrorText('not applied');
23
+ catch {
24
+ // A race with `furnace remove`, filesystem permission change, or a
25
+ // transient IO failure must not crash the entire `list -v` output —
26
+ // render a degraded state so the rest of the table still shows.
27
+ return formatErrorText('unavailable');
18
28
  }
19
- const changed = await hasComponentChanged(componentDir, previous);
20
- return changed ? formatErrorText('modified') : formatSuccessText('clean');
21
29
  }
22
30
  /**
23
31
  * Runs the furnace list command to display all registered components.
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { readdir } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { dirname, join } from 'node:path';
4
4
  import { select, text } from '@clack/prompts';
5
5
  import { getProjectPaths, loadConfig, loadState } from '../../core/config.js';
6
6
  import { createDefaultFurnaceConfig, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
@@ -31,6 +31,24 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
31
31
  await ensureDir(destDir);
32
32
  const entries = await readdir(srcDir, { withFileTypes: true });
33
33
  const copiedFiles = [];
34
+ // Snapshot-then-copy helper: ensures the destination's parent dir exists
35
+ // before snapshot + copy, and surfaces the failing filename on error so
36
+ // partial-state rollback has the context needed to report cleanly.
37
+ const snapshotAndCopy = async (from, dest, displayName) => {
38
+ await ensureDir(dirname(dest));
39
+ try {
40
+ await snapshotFile(journal, dest);
41
+ }
42
+ catch (error) {
43
+ throw new FurnaceError(`Failed to snapshot "${displayName}" before override: ${toError(error).message}`, componentName);
44
+ }
45
+ try {
46
+ await copyFile(from, dest);
47
+ }
48
+ catch (error) {
49
+ throw new FurnaceError(`Failed to copy "${displayName}" into the override: ${toError(error).message}`, componentName);
50
+ }
51
+ };
34
52
  for (const entry of entries) {
35
53
  if (!entry.isFile())
36
54
  continue;
@@ -38,8 +56,7 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
38
56
  // Only copy .css files
39
57
  if (entry.name.endsWith('.css')) {
40
58
  const dest = join(destDir, entry.name);
41
- await snapshotFile(journal, dest);
42
- await copyFile(join(srcDir, entry.name), dest);
59
+ await snapshotAndCopy(join(srcDir, entry.name), dest, entry.name);
43
60
  copiedFiles.push(entry.name);
44
61
  }
45
62
  }
@@ -47,8 +64,7 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
47
64
  // Full override: copy .mjs and .css files
48
65
  if (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')) {
49
66
  const dest = join(destDir, entry.name);
50
- await snapshotFile(journal, dest);
51
- await copyFile(join(srcDir, entry.name), dest);
67
+ await snapshotAndCopy(join(srcDir, entry.name), dest, entry.name);
52
68
  copiedFiles.push(entry.name);
53
69
  }
54
70
  }
@@ -57,8 +73,7 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
57
73
  const ftlName = `${componentName}.ftl`;
58
74
  const ftlSrc = join(engineDir, ftlDir, ftlName);
59
75
  const dest = join(destDir, ftlName);
60
- await snapshotFile(journal, dest);
61
- await copyFile(ftlSrc, dest);
76
+ await snapshotAndCopy(ftlSrc, dest, ftlName);
62
77
  copiedFiles.push(ftlName);
63
78
  }
64
79
  return copiedFiles;
@@ -123,6 +138,24 @@ async function performOverrideMutations(args) {
123
138
  }
124
139
  });
125
140
  }
141
+ /**
142
+ * Throws if `componentName` is already classified anywhere in the furnace
143
+ * config. Without this guard, `writeFurnaceConfig` would happily produce a
144
+ * file where the same tag appears under multiple categories (stock +
145
+ * override, custom + override) and later commands would no longer be able
146
+ * to reason about that component cleanly.
147
+ */
148
+ function assertNoComponentCollision(config, componentName) {
149
+ if (componentName in config.overrides) {
150
+ throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
151
+ }
152
+ if (config.stock.includes(componentName)) {
153
+ throw new FurnaceError(`"${componentName}" is already registered as a stock component. Remove it from config.stock before creating an override.`, componentName);
154
+ }
155
+ if (componentName in config.custom) {
156
+ throw new FurnaceError(`"${componentName}" is already registered as a custom component. Custom components cannot also be overrides.`, componentName);
157
+ }
158
+ }
126
159
  /**
127
160
  * Runs the furnace override command to fork an existing engine component.
128
161
  * @param projectRoot - Root directory of the project
@@ -179,10 +212,7 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
179
212
  }
180
213
  componentName = selected;
181
214
  }
182
- // Check for existing override
183
- if (componentName in config.overrides) {
184
- throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
185
- }
215
+ assertNoComponentCollision(config, componentName);
186
216
  // Validate the component exists in engine
187
217
  const details = await getComponentDetails(paths.engine, componentName, ftlDir);
188
218
  if (!details) {
@@ -292,12 +322,14 @@ export async function furnaceBatchOverrideCommand(projectRoot, names, options =
292
322
  const ftlDir = resolveFtlDir(config.ftlBasePath);
293
323
  const forgeConfig = await loadConfig(projectRoot);
294
324
  const state = await loadState(projectRoot);
295
- // Check for duplicates and pre-existing overrides
325
+ // Check for duplicates and pre-existing classifications across every
326
+ // bucket in furnace.json. Missing these collisions silently double-
327
+ // classifies a tag (e.g. both stock and override) and leaves the
328
+ // workspace in a state that later `furnace status`/`apply` cannot
329
+ // reason about cleanly.
296
330
  const uniqueNames = [...new Set(names)];
297
331
  for (const name of uniqueNames) {
298
- if (name in config.overrides) {
299
- throw new FurnaceError(`An override for "${name}" already exists in furnace.json`, name);
300
- }
332
+ assertNoComponentCollision(config, name);
301
333
  }
302
334
  const succeeded = [];
303
335
  const failed = [];
@@ -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