@hominis/fireforge 0.15.9 → 0.16.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 (59) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +6 -2
  3. package/dist/src/cli.d.ts +4 -1
  4. package/dist/src/cli.js +6 -3
  5. package/dist/src/commands/config.js +16 -5
  6. package/dist/src/commands/download.js +31 -4
  7. package/dist/src/commands/export-all.js +96 -9
  8. package/dist/src/commands/export.js +10 -1
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
  11. package/dist/src/commands/furnace/create.js +21 -3
  12. package/dist/src/commands/furnace/diff.js +22 -2
  13. package/dist/src/commands/furnace/index.js +1 -0
  14. package/dist/src/commands/furnace/init.js +76 -2
  15. package/dist/src/commands/furnace/override.js +35 -12
  16. package/dist/src/commands/furnace/preview.js +46 -1
  17. package/dist/src/commands/furnace/rename.js +14 -3
  18. package/dist/src/commands/lint.js +26 -2
  19. package/dist/src/commands/package.js +16 -5
  20. package/dist/src/commands/re-export.js +25 -0
  21. package/dist/src/commands/rebase/patch-loop.js +19 -0
  22. package/dist/src/commands/register.js +2 -18
  23. package/dist/src/commands/run.js +23 -2
  24. package/dist/src/commands/status.js +42 -8
  25. package/dist/src/commands/test.js +6 -24
  26. package/dist/src/commands/token.js +14 -1
  27. package/dist/src/commands/watch.js +14 -2
  28. package/dist/src/commands/wire.js +35 -9
  29. package/dist/src/core/branding.d.ts +23 -0
  30. package/dist/src/core/branding.js +39 -0
  31. package/dist/src/core/browser-wire.js +68 -23
  32. package/dist/src/core/build-baseline.d.ts +14 -0
  33. package/dist/src/core/build-baseline.js +61 -1
  34. package/dist/src/core/config-mutate.d.ts +1 -1
  35. package/dist/src/core/config.d.ts +17 -0
  36. package/dist/src/core/config.js +35 -0
  37. package/dist/src/core/firefox.d.ts +16 -2
  38. package/dist/src/core/firefox.js +7 -2
  39. package/dist/src/core/furnace-config.d.ts +23 -0
  40. package/dist/src/core/furnace-config.js +38 -0
  41. package/dist/src/core/mach-build-artifacts.d.ts +41 -0
  42. package/dist/src/core/mach-build-artifacts.js +70 -0
  43. package/dist/src/core/mach-error-hints.js +38 -0
  44. package/dist/src/core/mach-mozconfig.d.ts +25 -0
  45. package/dist/src/core/mach-mozconfig.js +66 -0
  46. package/dist/src/core/mach.d.ts +12 -1
  47. package/dist/src/core/mach.js +14 -1
  48. package/dist/src/core/manifest-rules.js +22 -1
  49. package/dist/src/core/patch-lint.js +43 -20
  50. package/dist/src/core/test-stale-check.js +46 -1
  51. package/dist/src/core/token-manager.js +57 -4
  52. package/dist/src/core/token-scaffold.d.ts +36 -0
  53. package/dist/src/core/token-scaffold.js +74 -0
  54. package/dist/src/types/commands/options.d.ts +10 -0
  55. package/dist/src/utils/fs.d.ts +12 -0
  56. package/dist/src/utils/fs.js +12 -0
  57. package/dist/src/utils/paths.d.ts +19 -0
  58. package/dist/src/utils/paths.js +33 -0
  59. package/package.json +1 -1
@@ -100,6 +100,14 @@ async function diffOverride(name, projectRoot, config) {
100
100
  /**
101
101
  * Diffs a custom component's workspace files against the engine-deployed copy.
102
102
  * Shows what would change (or has changed) on the next `furnace apply`.
103
+ *
104
+ * `.ftl` files deploy to `engine/<ftlDir>/<name>.ftl` via `applyCustomFtlFile`
105
+ * — NOT to `customConfig.targetPath` — so the deployment-target lookup has
106
+ * to branch on extension. Before this branch existed, a component's
107
+ * localization file always reported "not yet deployed to engine (new
108
+ * file)" after a successful apply/deploy because diff was looking for it
109
+ * under the component's `targetPath` while apply had written it into the
110
+ * locale tree.
103
111
  */
104
112
  async function diffCustom(name, projectRoot, config) {
105
113
  const customConfig = config.custom[name];
@@ -108,6 +116,7 @@ async function diffCustom(name, projectRoot, config) {
108
116
  }
109
117
  const paths = getProjectPaths(projectRoot);
110
118
  const furnacePaths = getFurnacePaths(projectRoot);
119
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
111
120
  const customDir = join(furnacePaths.customDir, name);
112
121
  if (!(await pathExists(customDir))) {
113
122
  throw new FurnaceError(`Custom component directory not found: components/custom/${name}`, name);
@@ -121,8 +130,19 @@ async function diffCustom(name, projectRoot, config) {
121
130
  if (!isComponentSourceFile(entry.name))
122
131
  continue;
123
132
  const workspacePath = join(customDir, entry.name);
124
- const deployedPath = join(engineDir, entry.name);
125
133
  const workspaceContent = await readText(workspacePath);
134
+ // `.ftl` files deploy to the locale tree, not the component's
135
+ // targetPath; mirror `applyCustomFtlFile`'s target computation so the
136
+ // diff header and the existence probe name the same path apply
137
+ // writes to. Any change here must stay in lock-step with
138
+ // `src/core/furnace-apply-ftl.ts`.
139
+ const isFtl = entry.name.endsWith('.ftl');
140
+ const deployedPath = isFtl
141
+ ? join(paths.engine, ftlDir, entry.name)
142
+ : join(engineDir, entry.name);
143
+ const deployedDisplayPath = isFtl
144
+ ? `engine/${ftlDir}/${entry.name}`
145
+ : `engine/${customConfig.targetPath}/${entry.name}`;
126
146
  if (!(await pathExists(deployedPath))) {
127
147
  info(`${entry.name}: not yet deployed to engine (new file)`);
128
148
  hasDifferences = true;
@@ -133,7 +153,7 @@ async function diffCustom(name, projectRoot, config) {
133
153
  continue;
134
154
  }
135
155
  hasDifferences = true;
136
- info(`--- engine/${customConfig.targetPath}/${entry.name}`);
156
+ info(`--- ${deployedDisplayPath}`);
137
157
  info(`+++ components/custom/${name}/${entry.name}`);
138
158
  for (const line of formatUnifiedDiff(deployedContent, workspaceContent)) {
139
159
  info(line);
@@ -83,6 +83,7 @@ function registerFurnaceInfoCommands(furnace, context) {
83
83
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
84
84
  .option('--shared-ftl <path>', 'Participate in an existing feature-scoped .ftl at this path (e.g. "browser/hominis-dock.ftl"); skips the per-component .ftl scaffold (implies --localized)')
85
85
  .option('--dry-run', 'Show the planned file set and furnace.json changes without writing')
86
+ .option('--allow-prefix-mismatch', 'Create the component even when its name does not start with the configured `componentPrefix` in furnace.json. Without this flag the command refuses to write anything on a prefix mismatch.')
86
87
  .action(withErrorHandling(async (name, options) => {
87
88
  await furnaceCreateCommand(getProjectRoot(), name, options);
88
89
  }));
@@ -1,9 +1,15 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { isAbsolute, normalize } from 'node:path';
2
+ import { dirname, isAbsolute, join, normalize } from 'node:path';
3
3
  import { text } from '@clack/prompts';
4
+ import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
4
5
  import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
+ import { DEFAULT_LICENSE } from '../../core/license-headers.js';
7
+ import { getTokensCssPath } from '../../core/token-manager.js';
8
+ import { generateDefaultTokensCss } from '../../core/token-scaffold.js';
5
9
  import { FurnaceError } from '../../errors/furnace.js';
6
- import { cancel, info, intro, isCancel, note, outro, success } from '../../utils/logger.js';
10
+ import { toError } from '../../utils/errors.js';
11
+ import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
12
+ import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
7
13
  /**
8
14
  * Validates an FTL base path before writing it to furnace.json. Rejects
9
15
  * absolute paths, null bytes, and any normalised segment starting with
@@ -80,10 +86,14 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
80
86
  }
81
87
  await writeFurnaceConfig(projectRoot, config);
82
88
  success('Created furnace.json');
89
+ const scaffoldResult = await scaffoldTokensCss(projectRoot);
83
90
  const lines = [`Component prefix: ${config.componentPrefix}`];
84
91
  if (config.ftlBasePath) {
85
92
  lines.push(`FTL base path: ${config.ftlBasePath}`);
86
93
  }
94
+ if (scaffoldResult.tokensCssPath) {
95
+ lines.push(`Tokens CSS: ${scaffoldResult.tokensCssPath}`);
96
+ }
87
97
  note(lines.join('\n'), 'Configuration');
88
98
  info('Next steps:\n' +
89
99
  ' fireforge furnace scan — discover engine components\n' +
@@ -91,4 +101,68 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
91
101
  ' fireforge furnace override — fork an existing component');
92
102
  outro('Init complete');
93
103
  }
104
+ /**
105
+ * Scaffolds the default tokens CSS file under the engine and registers
106
+ * its path in `fireforge.json`'s `patchLint.rawColorAllowlist`. Both
107
+ * operations are skipped silently when the engine directory does not
108
+ * yet exist (a fresh project that hasn't `fireforge download`ed yet);
109
+ * the scaffold is re-driven on the next `furnace init --force`.
110
+ *
111
+ * Returns the scaffolded path when the file was actually created, so
112
+ * the init command can surface it in the summary note.
113
+ */
114
+ async function scaffoldTokensCss(projectRoot) {
115
+ const paths = getProjectPaths(projectRoot);
116
+ if (!(await pathExists(paths.engine))) {
117
+ info('Skipping tokens CSS scaffold: engine/ not found. Run "fireforge download" followed by "fireforge furnace init --force" to scaffold it.');
118
+ return {};
119
+ }
120
+ let forgeConfig;
121
+ try {
122
+ forgeConfig = await loadConfig(projectRoot);
123
+ }
124
+ catch (error) {
125
+ warn(`Skipping tokens CSS scaffold: fireforge.json could not be loaded (${toError(error).message}). Re-run "fireforge furnace init --force" after fixing the config.`);
126
+ return {};
127
+ }
128
+ const tokensCssPath = getTokensCssPath(forgeConfig.binaryName);
129
+ const tokensCssAbsPath = join(paths.engine, tokensCssPath);
130
+ if (!(await pathExists(tokensCssAbsPath))) {
131
+ try {
132
+ await ensureDir(dirname(tokensCssAbsPath));
133
+ await writeText(tokensCssAbsPath, generateDefaultTokensCss(forgeConfig.binaryName, forgeConfig.license ?? DEFAULT_LICENSE));
134
+ success(`Scaffolded tokens CSS at engine/${tokensCssPath}`);
135
+ }
136
+ catch (error) {
137
+ warn(`Could not scaffold tokens CSS at engine/${tokensCssPath}: ${toError(error).message}. Create the file manually before running "fireforge token add".`);
138
+ return {};
139
+ }
140
+ }
141
+ else {
142
+ info(`Tokens CSS already present at engine/${tokensCssPath}; leaving it untouched.`);
143
+ }
144
+ // Registering the tokens file in `patchLint.rawColorAllowlist` is the
145
+ // complement to the scaffold itself: the file exists specifically to
146
+ // carry raw color literals, and without the allowlist entry the very
147
+ // first `fireforge lint` run against a post-`token add` workspace
148
+ // fails on raw-color-value issues for tokens the operator just
149
+ // created. The add is idempotent, so re-running `furnace init --force`
150
+ // does not duplicate the entry.
151
+ try {
152
+ const existingAllowlist = forgeConfig.patchLint?.rawColorAllowlist ?? [];
153
+ if (!existingAllowlist.includes(tokensCssPath)) {
154
+ const updatedConfig = mutateConfig(forgeConfig, 'patchLint.rawColorAllowlist', [
155
+ ...existingAllowlist,
156
+ tokensCssPath,
157
+ ]);
158
+ await writeConfig(projectRoot, updatedConfig);
159
+ info(`Added ${tokensCssPath} to patchLint.rawColorAllowlist`);
160
+ }
161
+ }
162
+ catch (error) {
163
+ warn(`Could not register tokens CSS in patchLint.rawColorAllowlist: ${toError(error).message}. ` +
164
+ `Add "${tokensCssPath}" manually under patchLint.rawColorAllowlist in fireforge.json if lint flags its contents.`);
165
+ }
166
+ return { tokensCssPath };
167
+ }
94
168
  //# sourceMappingURL=init.js.map
@@ -139,23 +139,37 @@ async function performOverrideMutations(args) {
139
139
  });
140
140
  }
141
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.
142
+ * Throws if `componentName` is already classified as something `override`
143
+ * cannot coexist with. A stock-bucket entry is NOT a hard conflict — the
144
+ * whole point of `override` is to fork a component out of the stock bucket
145
+ * into the overrides bucket, and requiring manual `furnace.json` surgery
146
+ * first was a pure footgun. `promoteStockToOverrideIfNeeded` handles the
147
+ * transition in-memory; this guard only rejects the other two cases where
148
+ * a rename actually contradicts existing state.
147
149
  */
148
150
  function assertNoComponentCollision(config, componentName) {
149
151
  if (componentName in config.overrides) {
150
152
  throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
151
153
  }
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
154
  if (componentName in config.custom) {
156
155
  throw new FurnaceError(`"${componentName}" is already registered as a custom component. Custom components cannot also be overrides.`, componentName);
157
156
  }
158
157
  }
158
+ /**
159
+ * When the operator overrides a component that `furnace scan` previously
160
+ * classified as stock, splice the name out of `config.stock` in-memory so
161
+ * the subsequent `writeFurnaceConfig` inside the mutation phase persists
162
+ * the stock → override promotion atomically alongside the new override
163
+ * entry. Returns true when a promotion happened so the caller can emit a
164
+ * one-line note; false when the component was not stock.
165
+ */
166
+ function promoteStockToOverrideIfNeeded(config, componentName) {
167
+ const index = config.stock.indexOf(componentName);
168
+ if (index === -1)
169
+ return false;
170
+ config.stock.splice(index, 1);
171
+ return true;
172
+ }
159
173
  /**
160
174
  * Runs the furnace override command to fork an existing engine component.
161
175
  * @param projectRoot - Root directory of the project
@@ -213,6 +227,10 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
213
227
  componentName = selected;
214
228
  }
215
229
  assertNoComponentCollision(config, componentName);
230
+ const promotedFromStock = promoteStockToOverrideIfNeeded(config, componentName);
231
+ if (promotedFromStock) {
232
+ info(`Promoting "${componentName}" from stock to override.`);
233
+ }
216
234
  // Validate the component exists in engine
217
235
  const details = await getComponentDetails(paths.engine, componentName, ftlDir);
218
236
  if (!details) {
@@ -323,13 +341,18 @@ export async function furnaceBatchOverrideCommand(projectRoot, names, options =
323
341
  const forgeConfig = await loadConfig(projectRoot);
324
342
  const state = await loadState(projectRoot);
325
343
  // 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.
344
+ // bucket in furnace.json. A stock-bucket entry is promoted in-memory
345
+ // here (see `promoteStockToOverrideIfNeeded`) rather than rejected
346
+ // the operator's intent is to fork that specific stock component. The
347
+ // collision guard still rejects name conflicts that would double-
348
+ // classify a tag in a way `writeFurnaceConfig` cannot safely produce
349
+ // (two overrides, or an override + custom).
330
350
  const uniqueNames = [...new Set(names)];
331
351
  for (const name of uniqueNames) {
332
352
  assertNoComponentCollision(config, name);
353
+ if (promoteStockToOverrideIfNeeded(config, name)) {
354
+ info(`Promoting "${name}" from stock to override.`);
355
+ }
333
356
  }
334
357
  const succeeded = [];
335
358
  const failed = [];
@@ -6,7 +6,7 @@ import { furnaceConfigExists, loadFurnaceConfig, updateFurnaceState, } from '../
6
6
  import { runFurnaceMutation } from '../../core/furnace-operation.js';
7
7
  import { restoreRollbackJournal } from '../../core/furnace-rollback.js';
8
8
  import { cleanStories, syncStories } from '../../core/furnace-stories.js';
9
- import { runMach, runMachCapture } from '../../core/mach.js';
9
+ import { hasBuildArtifacts, runMach, runMachCapture } from '../../core/mach.js';
10
10
  import { FurnaceError } from '../../errors/furnace.js';
11
11
  import { toError } from '../../utils/errors.js';
12
12
  import { pathExists } from '../../utils/fs.js';
@@ -89,6 +89,48 @@ function buildStorybookFailureMessage(output, installRequested) {
89
89
  return ('Storybook failed to start. Check the output above for the specific Firefox-side error.\n\n' +
90
90
  installHint);
91
91
  }
92
+ /**
93
+ * Preflights the Firefox build + toolchain prerequisites `mach storybook`
94
+ * quietly assumes. Pre-0.16.0 the preview staged components and launched
95
+ * a ~1000-package `mach storybook upgrade` npm install before the
96
+ * backend surfaced a "missing chrome-map.json" / Cargo-config failure;
97
+ * the preflight below refuses fast and leaves the workspace untouched.
98
+ *
99
+ * Extracted from `furnacePreviewCommand` so the main function stays
100
+ * under the per-function LOC budget as the preflight list grows.
101
+ *
102
+ * @param engineDir - Resolved engine directory
103
+ * @throws FurnaceError when the Firefox build hasn't produced dist/, or
104
+ * when `.cargo/config.toml` is absent
105
+ */
106
+ async function assertPreviewPrerequisites(engineDir) {
107
+ const buildCheck = await hasBuildArtifacts(engineDir);
108
+ if (!buildCheck.exists) {
109
+ throw new FurnaceError('Furnace preview requires a completed Firefox build. ' +
110
+ '`mach storybook` consumes `obj-*/dist/chrome-map.json` and the packaged chrome resources under `dist/`, neither of which is present before `fireforge build` completes.\n\n' +
111
+ 'Run "fireforge build" and wait for it to finish, then rerun "fireforge furnace preview". ' +
112
+ 'This preflight avoids a multi-minute `mach storybook upgrade` npm install on an engine that cannot start Storybook anyway.');
113
+ }
114
+ // Accept either `.cargo/config.toml` (post-configure) or
115
+ // `.cargo/config.toml.in` (post-bootstrap template, consumed at
116
+ // `mach configure` time). Pre-0.16.0 the preflight insisted on the
117
+ // plain file, but `fireforge bootstrap` alone produces only `.in` —
118
+ // operators who followed the remediation instruction ("run bootstrap
119
+ // then rerun preview") hit the same refusal on the retry. Either name
120
+ // is sufficient to prove the Rust toolchain is registered; the stronger
121
+ // `hasBuildArtifacts` check above already guards against a completely
122
+ // un-configured tree, so relaxing this to an OR-check does not weaken
123
+ // the signal we care about.
124
+ const cargoConfigPath = join(engineDir, '.cargo', 'config.toml');
125
+ const cargoConfigInPath = join(engineDir, '.cargo', 'config.toml.in');
126
+ const cargoConfigPresent = (await pathExists(cargoConfigPath)) || (await pathExists(cargoConfigInPath));
127
+ if (!cargoConfigPresent) {
128
+ throw new FurnaceError("Furnace preview requires the engine's Rust toolchain to be bootstrapped. " +
129
+ 'Neither `.cargo/config.toml` nor `.cargo/config.toml.in` exists under the engine directory — ' +
130
+ '`mach storybook` fails deep inside the Storybook backend compile without either of them.\n\n' +
131
+ 'Run "fireforge bootstrap" (or the underlying `mach bootstrap` in the engine) to populate the toolchain config, then rerun "fireforge furnace preview".');
132
+ }
133
+ }
92
134
  /**
93
135
  * Runs the furnace preview command to start Storybook for component preview.
94
136
  * @param projectRoot - Root directory of the project
@@ -119,6 +161,9 @@ export async function furnacePreviewCommand(projectRoot, options = {}) {
119
161
  if (!(await pathExists(storybookRoot))) {
120
162
  throw new FurnaceError('This Firefox checkout does not contain browser/components/storybook. Furnace preview requires the upstream Storybook workspace to exist before stories can be synced.');
121
163
  }
164
+ // Build + toolchain preflight (Finding #9). Extracted into a helper so
165
+ // the function below stays under the per-function LOC budget.
166
+ await assertPreviewPrerequisites(paths.engine);
122
167
  let previewResult;
123
168
  // True once we are about to (or have) written to engine/.../stories/furnace.
124
169
  // Intentionally set BEFORE `syncStories` is awaited so a mid-sync failure
@@ -304,14 +304,25 @@ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
304
304
  throw new FurnaceError(`A component named "${newName}" already exists in furnace.json.`, newName);
305
305
  }
306
306
  const componentType = isCustom ? 'custom' : 'override';
307
+ // `componentType` is the furnace-state key (singular: `custom` /
308
+ // `override`); the on-disk directory label differs — custom components
309
+ // live under `components/custom/` (singular) while overrides live under
310
+ // `components/overrides/` (plural). Before 0.16.0, every rename
311
+ // user-facing message appended an `s` to `componentType`, which
312
+ // produced the wrong label `components/customs/` for custom components
313
+ // and was technically correct for overrides only by coincidence.
314
+ // `componentDirLabel` centralises the singular/plural pick so every
315
+ // operator-facing string names the directory that actually exists on
316
+ // disk.
317
+ const componentDirLabel = isCustom ? 'custom' : 'overrides';
307
318
  const baseDir = isCustom ? furnacePaths.customDir : furnacePaths.overridesDir;
308
319
  const oldDir = join(baseDir, oldName);
309
320
  const newDir = join(baseDir, newName);
310
321
  if (!(await pathExists(oldDir))) {
311
- throw new FurnaceError(`Component directory not found: components/${componentType}s/${oldName}`, oldName);
322
+ throw new FurnaceError(`Component directory not found: components/${componentDirLabel}/${oldName}`, oldName);
312
323
  }
313
324
  if (await pathExists(newDir)) {
314
- throw new FurnaceError(`Target directory already exists: components/${componentType}s/${newName}`, newName);
325
+ throw new FurnaceError(`Target directory already exists: components/${componentDirLabel}/${newName}`, newName);
315
326
  }
316
327
  await performRenameMutations({
317
328
  projectRoot,
@@ -326,7 +337,7 @@ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
326
337
  engineDir: paths.engine,
327
338
  });
328
339
  note(`Component renamed: ${oldName} → ${newName}\n\n` +
329
- `Directory: components/${componentType}s/${newName}/\n\n` +
340
+ `Directory: components/${componentDirLabel}/${newName}/\n\n` +
330
341
  'Next steps:\n' +
331
342
  ' 1. Review the renamed files for any remaining references\n' +
332
343
  ' 2. Run "fireforge furnace validate" to verify\n' +
@@ -12,6 +12,7 @@ import { loadPatchesManifest } from '../core/patch-manifest.js';
12
12
  import { GeneralError } from '../errors/base.js';
13
13
  import { pathExists } from '../utils/fs.js';
14
14
  import { info, intro, outro, success, warn } from '../utils/logger.js';
15
+ import { stripEnginePrefix } from '../utils/paths.js';
15
16
  /**
16
17
  * Resolves the diff the lint command should run against. Returns `null` when
17
18
  * there is nothing to lint (e.g. no matching files, clean tree, or empty
@@ -27,7 +28,15 @@ async function resolveLintDiff(engineDir, files) {
27
28
  const collectedFiles = new Set();
28
29
  let fileStatuses;
29
30
  let untrackedFiles;
30
- for (const inputPath of files) {
31
+ // Strip a leading `engine/` segment up-front so the rest of the lookup
32
+ // pipeline (directory stat, modified-files-in-dir, status probe) all
33
+ // see the engine-relative form. Without this, passing
34
+ // `engine/browser/base/content/foo.js` fell through to "No modified
35
+ // files found in the specified paths." because git sees every path
36
+ // relative to engine/. The same normalization runs in `register`,
37
+ // `test`, and `export` via `stripEnginePrefix`.
38
+ const normalizedFiles = files.map((inputPath) => stripEnginePrefix(inputPath));
39
+ for (const inputPath of normalizedFiles) {
31
40
  const fullInputPath = join(engineDir, inputPath);
32
41
  let isDirectory = false;
33
42
  try {
@@ -145,10 +154,25 @@ export async function lintCommand(projectRoot, files, options = {}) {
145
154
  // really an artefact of aggregation. Surface a one-line note pointing at
146
155
  // `--per-patch` so the operator knows the per-patch scope exists before
147
156
  // they read the error message as "my queue is broken".
157
+ //
158
+ // In aggregate mode over a multi-patch queue we also downgrade the two
159
+ // size rules from `error` to `warning`. Before this downgrade, a
160
+ // fresh-imported patch stack of 20+ patches hard-failed `fireforge lint`
161
+ // on lines-per-aggregate counts that are mathematically impossible to
162
+ // satisfy without splitting patches that were already split — the
163
+ // actionable unit is the individual patch, and `--per-patch` is the
164
+ // mode that matches. Per-patch mode keeps errors as errors (see
165
+ // `lintPerPatch` below).
148
166
  const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
149
167
  if (aggregateHintApplicable &&
150
168
  issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
151
- info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode.');
169
+ info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode and are reported as warnings rather than errors here.');
170
+ for (const issue of issues) {
171
+ if ((issue.check === 'large-patch-lines' || issue.check === 'large-patch-files') &&
172
+ issue.severity === 'error') {
173
+ issue.severity = 'warning';
174
+ }
175
+ }
152
176
  }
153
177
  if (issues.length === 0) {
154
178
  success('No lint issues found.');
@@ -1,7 +1,8 @@
1
1
  import { validateBrandOverride } from '../core/brand-validation.js';
2
2
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
3
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
- import { buildArtifactMismatchMessage, hasBuildArtifacts, machPackage } from '../core/mach.js';
4
+ import { buildArtifactMismatchMessage, hasBuildArtifacts, machPackageCapture, } from '../core/mach.js';
5
+ import { explainMachError } from '../core/mach-error-hints.js';
5
6
  import { GeneralError } from '../errors/base.js';
6
7
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
7
8
  import { pathExists } from '../utils/fs.js';
@@ -49,9 +50,16 @@ export async function packageCommand(projectRoot, options) {
49
50
  info('Creating distribution package...');
50
51
  info('This may take a while.\n');
51
52
  const startTime = Date.now();
52
- let exitCode;
53
+ let result;
53
54
  try {
54
- exitCode = await machPackage(paths.engine);
55
+ // `machPackageCapture` streams output live AND captures the tail for
56
+ // post-run diagnostics. Previously `machPackage` inherited stdio
57
+ // only, so a targeted hint translator could not see the failure text.
58
+ // The captured stderr is fed through `explainMachError` below so
59
+ // recognised failure modes (notably the `packager.py` NoneType trip
60
+ // the evaluator hit on `hominis/`) get an actionable hint prepended
61
+ // to the raw mach output the operator already saw.
62
+ result = await machPackageCapture(paths.engine);
55
63
  }
56
64
  catch (error) {
57
65
  throw new BuildError('Package process failed to start', 'mach package', error instanceof Error ? error : undefined);
@@ -60,9 +68,12 @@ export async function packageCommand(projectRoot, options) {
60
68
  const minutes = Math.floor(duration / 60000);
61
69
  const seconds = Math.floor((duration % 60000) / 1000);
62
70
  const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
63
- if (exitCode !== 0) {
71
+ if (result.exitCode !== 0) {
64
72
  error(`Packaging failed after ${timeStr}`);
65
- throw new BuildError(`Packaging failed with exit code ${exitCode}`, 'mach package');
73
+ const combinedOutput = `${result.stdout}\n${result.stderr}`;
74
+ const hints = explainMachError(combinedOutput);
75
+ const hintBlock = hints.length > 0 ? `\n\nHint:\n${hints.map((h) => ` ${h}`).join('\n')}` : '';
76
+ throw new BuildError(`Packaging failed with exit code ${result.exitCode}.${hintBlock}`, 'mach package');
66
77
  }
67
78
  info(`\nPackage created in obj-*/dist/`);
68
79
  outro(`Packaging completed in ${timeStr}!`);
@@ -59,6 +59,31 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
59
59
  if (options.scan) {
60
60
  currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
61
61
  }
62
+ else if (options.files === undefined) {
63
+ // Finding #16: when neither `--scan` nor `--files` is set and some
64
+ // of the manifest's claimed files no longer exist on disk, the
65
+ // re-export silently writes a refreshed body whose filesAffected
66
+ // still names the vanished paths. That is the documented contract,
67
+ // but it is also a footgun — a later `verify` then fails on
68
+ // manifest-consistency with no obvious trigger. Emit one advisory
69
+ // warning up-front when we can detect the drift cheaply, so the
70
+ // operator has a chance to re-run with `--scan` or `--files`
71
+ // before the stale filesAffected lands in patches.json.
72
+ const missingFiles = [];
73
+ for (const file of currentFilesAffected) {
74
+ if (!(await pathExists(join(paths.engine, file)))) {
75
+ missingFiles.push(file);
76
+ }
77
+ }
78
+ if (missingFiles.length > 0) {
79
+ warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
80
+ `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
81
+ `filesAffected unchanged and the missing entries will be preserved — ` +
82
+ `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
83
+ ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
84
+ `or pass --files <paths> to set the list explicitly.`);
85
+ }
86
+ }
62
87
  // --- Explicit file-subset path ---
63
88
  // When --files is given, the target filesAffected is authoritative — drop
64
89
  // anything not in the list, add anything new. This is the surgical repair
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { join } from 'node:path';
6
6
  import { updateState } from '../../core/config.js';
7
+ import { stampFurnaceOverrideBaseVersions } from '../../core/furnace-config.js';
7
8
  import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
8
9
  import { applyPatchWithFuzz } from '../../core/patch-apply-fuzz.js';
9
10
  import { updatePatch } from '../../core/patch-export.js';
@@ -126,6 +127,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
126
127
  if (appliedFilenames.length > 0) {
127
128
  await stampPatchVersions(paths.patches, appliedFilenames, session.toVersion);
128
129
  }
130
+ // Stamp every Furnace override's `baseVersion` to match the rebased
131
+ // Firefox version. Before this stamp, a successful ESR bump left
132
+ // overrides in a doctor-failing drift state (each override still
133
+ // claimed the pre-rebase ESR as its baseline) and every subsequent
134
+ // `fireforge doctor` failed `Furnace component validation`. The
135
+ // stamp is unconditional per the helper's contract: rebase already
136
+ // succeeded on the patch side, so the operator is committing to the
137
+ // new ESR baseline; per-component health checking stays with
138
+ // `fireforge furnace validate` / `doctor --repair-furnace`.
139
+ try {
140
+ const overridesStamped = await stampFurnaceOverrideBaseVersions(projectRoot, session.toVersion);
141
+ if (overridesStamped > 0) {
142
+ info(`Stamped ${overridesStamped} Furnace override baseVersion(s) to ${session.toVersion}.`);
143
+ }
144
+ }
145
+ catch (furnaceStampError) {
146
+ warn(`Could not stamp Furnace override baseVersion(s) to ${session.toVersion}: ${toError(furnaceStampError).message}. Update baseVersion in furnace.json by hand or run "fireforge furnace refresh" if validate reports drift.`);
147
+ }
129
148
  // Print summary and clean up
130
149
  printSummary(session);
131
150
  await clearRebaseSession(projectRoot);
@@ -6,23 +6,7 @@ import { InvalidArgumentError } from '../errors/base.js';
6
6
  import { pathExists } from '../utils/fs.js';
7
7
  import { info, intro, outro, success, warn } from '../utils/logger.js';
8
8
  import { pickDefined } from '../utils/options.js';
9
- /**
10
- * Strips a leading `engine/` segment (either separator flavour) from a
11
- * user-supplied path so operators can pass either a repo-root-relative
12
- * path (`engine/browser/base/content/foo.xhtml`) or an engine-relative
13
- * path (`browser/base/content/foo.xhtml`). The engine-relative form is
14
- * what the manifest writers expect; without this normalisation, the
15
- * former failed with a misleading "File not found in engine" pointing
16
- * at a doubled path like `engine/engine/browser/...` that operators
17
- * had no way to spot from the error message alone.
18
- */
19
- function normalizeEngineRelativePath(filePath) {
20
- if (filePath.startsWith('engine/'))
21
- return filePath.slice('engine/'.length);
22
- if (filePath.startsWith('engine\\'))
23
- return filePath.slice('engine\\'.length);
24
- return filePath;
25
- }
9
+ import { stripEnginePrefix } from '../utils/paths.js';
26
10
  /**
27
11
  * Registers a file in the appropriate build manifest.
28
12
  *
@@ -50,7 +34,7 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
50
34
  // the former from the output of tab completion or `git status`, and
51
35
  // the mismatch used to produce a "File not found" error that named
52
36
  // the original path with no hint that dropping `engine/` would fix it.
53
- const engineRelativePath = normalizeEngineRelativePath(filePath);
37
+ const engineRelativePath = stripEnginePrefix(filePath);
54
38
  // Verify the file exists in engine/ (skip for dry-run)
55
39
  if (!options.dryRun) {
56
40
  const paths = getProjectPaths(projectRoot);
@@ -2,9 +2,9 @@
2
2
  import { createWriteStream } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { getProjectPaths } from '../core/config.js';
5
+ import { getProjectPaths, loadConfig } from '../core/config.js';
6
6
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
7
- import { buildArtifactMismatchMessage, hasBuildArtifacts, run, runMachSmoke, } from '../core/mach.js';
7
+ import { buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke, } from '../core/mach.js';
8
8
  import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
9
9
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
10
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
@@ -94,6 +94,27 @@ export async function runCommand(projectRoot, options = {}) {
94
94
  throw new GeneralError(`Run requires a completed build. ${detail}\n\n` +
95
95
  "Run 'fireforge build' first, then rerun 'fireforge run'.");
96
96
  }
97
+ // `hasBuildArtifacts` only checks for an `obj-*/dist/` directory; a
98
+ // build that configured but hasn't yet produced the launchable binary
99
+ // (common in a long real Firefox compile that the operator stopped
100
+ // and restarted) passes that check, and `mach run` then fails on the
101
+ // missing binary path. `hasRunnableBundle` narrows the probe to the
102
+ // actual executable so `fireforge run` refuses with a targeted
103
+ // message before handing control to mach. `fireforge watch` stays
104
+ // permissive and instead surfaces the same information as a banner
105
+ // suffix; watch is supposed to drive rebuilds of partially-built
106
+ // trees, so blocking there would defeat the feature.
107
+ if (buildCheck.objDir) {
108
+ const config = await loadConfig(projectRoot);
109
+ const bundleCheck = await hasRunnableBundle(paths.engine, config.binaryName, buildCheck.objDir);
110
+ if (!bundleCheck.runnable) {
111
+ const expected = bundleCheck.expectedPath ?? `dist/bin/${config.binaryName}`;
112
+ throw new GeneralError(`Run requires a completed build that produced the launchable bundle. ` +
113
+ `Build artifacts exist in ${buildCheck.objDir}/ but the expected binary at ${expected} is missing — ` +
114
+ `the build may have aborted or is still in progress.\n\n` +
115
+ "Run 'fireforge build' and wait for it to finish before retrying 'fireforge run'.");
116
+ }
117
+ }
97
118
  // Warn if Furnace components changed since the last apply
98
119
  await warnIfFurnaceStale(projectRoot);
99
120
  // Clean stale profile state to prevent silent startup failures