@hominis/fireforge 0.18.2 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -143,6 +143,9 @@ fireforge re-export --all --scan
143
143
  # Preview what an export would do without writing
144
144
  fireforge export browser/base/content/browser.js --dry-run
145
145
 
146
+ # Same preview surface for the aggregate path
147
+ fireforge export-all --name "all-changes" --category ui --dry-run
148
+
146
149
  # Insert a new patch at a specific position
147
150
  fireforge export browser/base/content/browser.js --order 3 --name "inserted" --category ui
148
151
  fireforge export browser/base/content/browser.js --before 005-ui-sidebar.patch --name "prelim"
@@ -158,7 +161,7 @@ fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
158
161
  fireforge re-export --all --scan --stamp
159
162
  ```
160
163
 
161
- `export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default.
164
+ `export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default. The flag covers cross-patch _modification_ overlap, where two patches both edit the same file. It does NOT bypass the new-file creation guard: two patches creating the same path on `/dev/null` cannot coexist in any apply order, so that case stays a hard refusal regardless of `--allow-overlap`.
162
165
 
163
166
  `re-export --scan` also prompts before broadening a patch with more than a handful of newly discovered files or with files spanning multiple directories. The gate keeps the common refresh case frictionless (small, same-directory additions) while catching the failure mode where `--scan` silently pulls an adjacent feature into the wrong patch. Non-interactive mode requires `--yes` to acknowledge a broad expansion; dry-run previews never require confirmation.
164
167
 
@@ -267,16 +270,16 @@ fireforge status --json # machine-readable classified output
267
270
 
268
271
  Then fix with the appropriate primitive:
269
272
 
270
- | Problem | Fix |
271
- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
272
- | Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
273
- | A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
274
- | Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
275
- | Ordinal gaps after deletes/splits | `fireforge patch compact` |
276
- | A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
277
- | Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
278
- | Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes |
279
- | Unmanaged changes you want to discard | `fireforge discard <file>` or `fireforge reset` |
273
+ | Problem | Fix |
274
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
275
+ | Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
276
+ | A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
277
+ | Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
278
+ | Ordinal gaps after deletes/splits | `fireforge patch compact` |
279
+ | A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
280
+ | Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
281
+ | Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes |
282
+ | Unmanaged changes you want to discard | `fireforge discard <file>` (also accepts a directory path to discard everything beneath it) or `fireforge reset` |
280
283
 
281
284
  Every destructive command defaults to an interactive confirmation with a change summary. `--dry-run` previews without writing; `--yes` skips the prompt for CI; `--force-unsafe` bypasses structural refusals when you have context the linter cannot see. Do not hand-edit `patches.json` as the file is owned by FireForge — `doctor --repair-patches-manifest` reconstructs missing metadata, and `fireforge re-export <filename> --description "<text>"` overwrites recovered entries with operator-supplied metadata through the tool. `fireforge verify` cross-checks every registration hunk in each patch body against the files the queue and engine supply, so a patch that registers a widget / locale without carrying its source surfaces as a `dangling-registration` error rather than slipping through as "Verify clean"; `fireforge export-all --exclude-furnace` refuses up-front when it would produce that shape.
282
285
 
@@ -466,7 +469,11 @@ fireforge package
466
469
  fireforge watch
467
470
 
468
471
  # Add a CSS design token (requires `fireforge furnace init` first; see the Furnace/Tokens section below)
469
- fireforge token add --category 'Colors General' -- --my-color 'light-dark(#fff, #000)'
472
+ # The `--` separator is required because the token name itself starts with `--`,
473
+ # which Commander would otherwise read as an option flag. Bare names without `--`
474
+ # are accepted directly and get the configured `tokenPrefix` prepended.
475
+ fireforge token add --category 'Colors — General' --mode static -- --my-color 'light-dark(#fff, #000)'
476
+ fireforge token add --category 'Colors — General' --mode static my-color '#fff' # bare-name form
470
477
  ```
471
478
 
472
479
  Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`, and derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
@@ -11,6 +11,82 @@ import { toError } from '../utils/errors.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, intro, isCancel, outro, spinner, warn } from '../utils/logger.js';
13
13
  import { pickDefined } from '../utils/options.js';
14
+ /**
15
+ * Discards every status entry whose path lives under `dirPath`. Used by
16
+ * `discardCommand` as a directory-recursion fallback when the operator
17
+ * passed a directory path that contains modified or untracked entries
18
+ * but is not itself a status entry.
19
+ *
20
+ * Mirrors the single-file path's confirmation, dry-run, and Furnace-aware
21
+ * warning behaviour so the contract stays consistent. Each per-entry
22
+ * discard runs sequentially under its own try/catch so a failure on one
23
+ * file is reported but does not block the remaining files in the batch.
24
+ */
25
+ async function discardDirectoryEntries(projectRoot, engineDir, dirPath, entries, options) {
26
+ if (!options.yes && !options.dryRun) {
27
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
28
+ if (!isInteractive) {
29
+ throw new InvalidArgumentError('Interactive confirmation not available. Use --yes flag to discard without confirmation.', 'Use: fireforge discard <directory> --yes');
30
+ }
31
+ const confirmed = await confirm({
32
+ message: `Discard changes to ${entries.length} file${entries.length === 1 ? '' : 's'} under ${dirPath}/?`,
33
+ initialValue: false,
34
+ });
35
+ if (isCancel(confirmed) || !confirmed) {
36
+ outro('Discard cancelled');
37
+ return;
38
+ }
39
+ }
40
+ if (options.dryRun) {
41
+ info(`Would discard changes to ${entries.length} file(s) under ${dirPath}/:`);
42
+ for (const entry of entries) {
43
+ const target = entry.originalPath && entry.originalPath !== entry.file
44
+ ? `${entry.originalPath} -> ${entry.file}`
45
+ : entry.file;
46
+ info(` ${target}`);
47
+ }
48
+ outro('Dry run complete — no changes made');
49
+ return;
50
+ }
51
+ const s = spinner(`Discarding ${entries.length} file(s) under ${dirPath}/...`);
52
+ let succeeded = 0;
53
+ const failures = [];
54
+ try {
55
+ for (const entry of entries) {
56
+ try {
57
+ await discardStatusEntry(engineDir, entry);
58
+ succeeded += 1;
59
+ }
60
+ catch (error) {
61
+ failures.push(`${entry.file}: ${toError(error).message}`);
62
+ }
63
+ }
64
+ s.stop(`Discarded ${succeeded} of ${entries.length} file(s) under ${dirPath}/${failures.length > 0 ? ` (${failures.length} failed)` : ''}`);
65
+ for (const failure of failures) {
66
+ warn(` ${failure}`);
67
+ }
68
+ try {
69
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
70
+ const dirIsFurnace = [...furnacePrefixes].some((prefix) => `${dirPath}/`.startsWith(prefix) || prefix.startsWith(`${dirPath}/`));
71
+ if (dirIsFurnace) {
72
+ warn('These paths are managed by Furnace. Run "fireforge furnace apply" to redeploy components if needed.');
73
+ }
74
+ }
75
+ catch {
76
+ // Furnace config may not exist — skip silently
77
+ }
78
+ if (failures.length > 0) {
79
+ throw new GeneralError(`Failed to discard ${failures.length} file(s) under ${dirPath}/. See warnings above.`);
80
+ }
81
+ outro(`${succeeded} file(s) restored to original state`);
82
+ }
83
+ catch (error) {
84
+ if (!(error instanceof GeneralError)) {
85
+ s.error('Discard failed');
86
+ }
87
+ throw error;
88
+ }
89
+ }
14
90
  /**
15
91
  * Runs the discard command to revert changes to a specific file.
16
92
  * @param projectRoot - Root directory of the project
@@ -31,7 +107,23 @@ export async function discardCommand(projectRoot, file, options = {}) {
31
107
  // Check if the file has changes
32
108
  const statusEntries = await expandUntrackedDirectoryEntries(paths.engine, await getWorkingTreeStatus(paths.engine));
33
109
  const statusEntry = statusEntries.find((entry) => entry.file === file || entry.originalPath === file);
110
+ // Directory recursion fallback: when the explicit path does not match a
111
+ // single status entry but DOES correspond to one or more entries below
112
+ // it, treat the input as a directory and discard everything inside.
113
+ // 2026-04-25 eval Finding 20: `discard browser/components/storybook/
114
+ // stories/furnace --yes` failed with "no changes to discard" even
115
+ // though `status --unmanaged` listed 23 files under that directory —
116
+ // operators were forced to discard each file individually or fall
117
+ // back to non-FireForge cleanup commands. Match against the
118
+ // directory-with-trailing-slash form so a path like `foo/bar` doesn't
119
+ // accidentally match `foo/bar2/file`.
34
120
  if (!statusEntry) {
121
+ const dirPrefix = file.endsWith('/') ? file : `${file}/`;
122
+ const dirEntries = statusEntries.filter((entry) => entry.file.startsWith(dirPrefix) || entry.originalPath?.startsWith(dirPrefix));
123
+ if (dirEntries.length > 0) {
124
+ await discardDirectoryEntries(projectRoot, paths.engine, file, dirEntries, options);
125
+ return;
126
+ }
35
127
  throw new GeneralError(`File "${file}" has no changes to discard.`);
36
128
  }
37
129
  if (!options.yes && !options.dryRun) {
@@ -91,7 +183,7 @@ export async function discardCommand(projectRoot, file, options = {}) {
91
183
  export function registerDiscard(program, { getProjectRoot, withErrorHandling }) {
92
184
  program
93
185
  .command('discard <file>')
94
- .description('Discard changes to a specific file (deletes untracked files)')
186
+ .description('Discard changes to a specific file (deletes untracked files). Pass a directory path to discard every modified or untracked file beneath it; the operation walks the status output and reverts each match individually.')
95
187
  .option('--dry-run', 'Show what would be discarded without doing it')
96
188
  .option('-y, --yes', 'Skip confirmation prompt')
97
189
  .action(withErrorHandling(async (file, options) => {
@@ -9,7 +9,7 @@ import { ExitCode } from '../errors/codes.js';
9
9
  import { toError } from '../utils/errors.js';
10
10
  import { pathExists } from '../utils/fs.js';
11
11
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
12
- import { executableExists } from '../utils/process.js';
12
+ import { findExecutable } from '../utils/process.js';
13
13
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
14
14
  import { inspectEngineWorkingTree } from './doctor-working-tree.js';
15
15
  /**
@@ -255,9 +255,22 @@ const DOCTOR_CHECKS = [
255
255
  // failure site.
256
256
  name: 'Watchman available',
257
257
  run: async () => {
258
- const present = await executableExists('watchman');
259
- if (present)
260
- return ok('Watchman available');
258
+ // Resolve the absolute path so the OK row names what doctor actually
259
+ // found. The 2026-04-25 eval flagged a confusing case where the
260
+ // operator's interactive shell returned no result for `which
261
+ // watchman` but doctor still printed "OK" — the cause was a
262
+ // PATH-export discrepancy between the shell and the spawned
263
+ // subprocess, and surfacing the resolved path makes the discrepancy
264
+ // visible without users having to re-run with a verbose flag.
265
+ const path = await findExecutable('watchman');
266
+ if (path) {
267
+ return {
268
+ name: 'Watchman available',
269
+ passed: true,
270
+ severity: 'ok',
271
+ message: `OK (${path})`,
272
+ };
273
+ }
261
274
  return warning('Watchman available', 'watchman is not installed or not on PATH. "fireforge watch" requires it.', 'Install watchman (brew install watchman / dnf install watchman / https://facebook.github.io/watchman/), then re-run doctor.');
262
275
  },
263
276
  },
@@ -69,6 +69,25 @@ async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
69
69
  }
70
70
  return { hadQueue: true, restored: toClean.length, preserved: preserved.length };
71
71
  }
72
+ /**
73
+ * Prints a one-line nudge pointing at `fireforge import` when the project
74
+ * carries a non-empty patch queue but the just-downloaded engine has not
75
+ * yet had any patches applied. The post-download spinner closes with
76
+ * "Patch-touched files already match baseline" because a fresh tree IS at
77
+ * baseline, but the 2026-04-25 eval saw operators read that as "patches
78
+ * are restored" and skip the import step. The note is suppressed when
79
+ * patches/ is missing or the manifest is empty so unconfigured projects
80
+ * stay quiet.
81
+ */
82
+ async function noteUnappliedPatches(patchesDir) {
83
+ if (!(await pathExists(patchesDir)))
84
+ return;
85
+ const manifest = await loadPatchesManifest(patchesDir);
86
+ if (!manifest || manifest.patches.length === 0)
87
+ return;
88
+ const n = manifest.patches.length;
89
+ info(`Note: ${n} patch${n === 1 ? '' : 'es'} in patches/ have not been applied to this fresh engine. Run "fireforge import" to apply them.`);
90
+ }
72
91
  /**
73
92
  * Stops `restoreSpinner` with a message that reflects what actually
74
93
  * happened. Three branches: empty queue → explicit no-op; queue present but
@@ -151,6 +170,7 @@ export async function downloadCommand(projectRoot, options) {
151
170
  downloadedVersion: version,
152
171
  baseCommit,
153
172
  });
173
+ await noteUnappliedPatches(paths.patches);
154
174
  outro(`Firefox ${version} is ready! (resumed from partial init)`);
155
175
  return;
156
176
  }
@@ -299,6 +319,7 @@ export async function downloadCommand(projectRoot, options) {
299
319
  downloadedVersion: version,
300
320
  baseCommit,
301
321
  });
322
+ await noteUnappliedPatches(paths.patches);
302
323
  outro(`Firefox ${version} is ready!`);
303
324
  }
304
325
  /** Registers the download command on the CLI program. */
@@ -15,6 +15,7 @@ import { ensureDir, pathExists } from '../utils/fs.js';
15
15
  import { info, intro, outro, spinner } from '../utils/logger.js';
16
16
  import { pickDefined } from '../utils/options.js';
17
17
  import { PATCH_CATEGORIES } from '../utils/validation.js';
18
+ import { renderDryRunPreview } from './export-flow.js';
18
19
  import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
19
20
  async function checkBrandingManagedFiles(paths, config) {
20
21
  const changedFiles = await getWorkingTreeStatus(paths.engine);
@@ -158,7 +159,8 @@ async function checkDuplicateNewFileCreations(paths, diff) {
158
159
  .join('\n');
159
160
  throw new GeneralError('Export-all refuses to capture new-file creations that are already claimed by existing patches.\n\n' +
160
161
  `Conflicting creations:\n${conflictList}\n\n` +
161
- 'Only one patch may create a given path. Run "fireforge export <path> [...]" with an explicit file list that omits the already-claimed path(s), or resolve the conflict via "fireforge patch delete" / "fireforge re-export --files" before retrying export-all.');
162
+ 'Only one patch may create a given path two creation hunks on /dev/null cannot coexist in any apply order, so this case is structurally unrecoverable rather than verify-failing. The --allow-overlap escape hatch covers cross-patch MODIFICATION overlap (which yields a queue that fails verify but still applies); it deliberately does NOT cover this case. ' +
163
+ 'Run "fireforge export <path> [...]" with an explicit file list that omits the already-claimed path(s), or resolve the conflict via "fireforge patch delete" / "fireforge re-export --files" before retrying export-all.');
162
164
  }
163
165
  /**
164
166
  * Runs the export-all command to export all changes as a patch.
@@ -166,7 +168,8 @@ async function checkDuplicateNewFileCreations(paths, diff) {
166
168
  * @param options - Export options
167
169
  */
168
170
  export async function exportAllCommand(projectRoot, options = {}) {
169
- intro('FireForge Export All');
171
+ const isDryRun = options.dryRun === true;
172
+ intro(isDryRun ? 'FireForge Export All (dry run)' : 'FireForge Export All');
170
173
  const paths = getProjectPaths(projectRoot);
171
174
  // Check if engine exists
172
175
  if (!(await pathExists(paths.engine))) {
@@ -236,13 +239,38 @@ export async function exportAllCommand(projectRoot, options = {}) {
236
239
  if (!metadata)
237
240
  return;
238
241
  const { patchName, selectedCategory, description } = metadata;
239
- // Ensure patches directory exists
240
- await ensureDir(paths.patches);
241
- const s = spinner('Exporting all changes...');
242
+ // Ensure patches directory exists. Skip during a dry-run so the command
243
+ // is purely read-only — `--dry-run` callers should be safe to invoke
244
+ // against a project that has never exported a patch without leaving the
245
+ // empty `patches/` directory behind.
246
+ if (!isDryRun) {
247
+ await ensureDir(paths.patches);
248
+ }
249
+ const s = spinner(isDryRun ? 'Planning export-all...' : 'Exporting all changes...');
242
250
  try {
243
251
  // Extract affected files from diff
244
252
  const filesAffected = extractAffectedFiles(diff);
245
253
  await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
254
+ // Dry-run: enumerate filename, metadata, and supersede coverage without
255
+ // writing. Mirrors `fireforge export --dry-run` so the same preview
256
+ // surface is available for both targeted and aggregate exports. Runs
257
+ // AFTER lint so the operator sees the same lint output they would on
258
+ // a real run; runs BEFORE the supersede confirmation prompt because
259
+ // confirming a dry-run is meaningless.
260
+ if (isDryRun) {
261
+ s.stop('Plan ready');
262
+ await renderDryRunPreview({
263
+ patchesDir: paths.patches,
264
+ category: selectedCategory,
265
+ name: patchName,
266
+ description,
267
+ filesAffected,
268
+ sourceEsrVersion: config.firefox.version,
269
+ explicitSupersede: options.supersede === true,
270
+ });
271
+ outro('Dry run complete — no changes made');
272
+ return;
273
+ }
246
274
  // Check how many existing patches would be superseded
247
275
  const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
248
276
  if (!shouldProceed)
@@ -299,7 +327,8 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
299
327
  .option('--supersede', 'Allow superseding multiple existing patches')
300
328
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
301
329
  .option('--exclude-furnace', 'Export the non-Furnace subset of the aggregate diff instead of refusing when Furnace-managed files are modified. Furnace-managed files are still deployed by "fireforge furnace apply"; this flag only changes whether export-all aborts or filters in their presence.')
302
- .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify)')
330
+ .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify). Does not bypass the new-file creation guard — two patches creating the same path is structurally unrecoverable, so that case still refuses regardless of this flag.')
331
+ .option('--dry-run', 'Print the export-all plan (filename, metadata, files affected, supersede preview) without writing anything to patches/. Lint still runs so the operator sees the same lint output a real run would produce.')
303
332
  .action(withErrorHandling(async (options) => {
304
333
  const { category, ...rest } = options;
305
334
  await exportAllCommand(getProjectRoot(), {
@@ -212,6 +212,64 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
212
212
  }
213
213
  return { partialFailures };
214
214
  }
215
+ /**
216
+ * Removes the MochiKit test scaffold a `furnace create --with-tests
217
+ * --test-style mochikit` produced for the component (matches the rename
218
+ * counterpart in `rename.ts`). The test file is `test_<name>.html` under
219
+ * `engine/toolkit/content/tests/widgets/` and the registration is the
220
+ * `["test_<name>.html"]` entry in the same directory's `chrome.toml`.
221
+ *
222
+ * 2026-04-25 eval Finding 13: the prior cleanup only handled the
223
+ * browser-chrome mochitest layout under `browser/base/content/test/
224
+ * <binary>/`, which left mochikit-style scaffolds and their toml entries
225
+ * orphaned after `furnace remove`. The post-rename name passed in here
226
+ * is the canonical one written to disk by deploy/rename, so the file
227
+ * basenames match without needing to re-derive from the old name.
228
+ *
229
+ * Best-effort: each step warns on failure rather than throwing so the
230
+ * rest of the remove transaction proceeds. The journal still snapshots
231
+ * touched files so the outer rollback can restore them on a later
232
+ * failure in the same operation.
233
+ */
234
+ async function cleanupCustomMochikitTestFiles(name, projectRoot, journal) {
235
+ const partialFailures = [];
236
+ const paths = getProjectPaths(projectRoot);
237
+ const widgetsTestDir = join(paths.engine, 'toolkit/content/tests/widgets');
238
+ if (!(await pathExists(widgetsTestDir))) {
239
+ return { partialFailures };
240
+ }
241
+ const testFileName = `test_${name}.html`;
242
+ const testFilePath = join(widgetsTestDir, testFileName);
243
+ try {
244
+ if (await pathExists(testFilePath)) {
245
+ await snapshotFile(journal, testFilePath);
246
+ await unlink(testFilePath);
247
+ info(`Deleted mochikit test file: toolkit/content/tests/widgets/${testFileName}`);
248
+ }
249
+ }
250
+ catch (error) {
251
+ const msg = `Could not delete mochikit test file ${testFileName} — ${toError(error).message}. Remove it manually if needed.`;
252
+ warn(msg);
253
+ partialFailures.push(msg);
254
+ }
255
+ const chromeTomlPath = join(widgetsTestDir, 'chrome.toml');
256
+ try {
257
+ if (await pathExists(chromeTomlPath)) {
258
+ const toml = await readText(chromeTomlPath);
259
+ const headerLine = `["${testFileName}"]`;
260
+ if (toml.includes(headerLine)) {
261
+ await snapshotFile(journal, chromeTomlPath);
262
+ await writeText(chromeTomlPath, removeTomlSection(toml, testFileName));
263
+ }
264
+ }
265
+ }
266
+ catch (error) {
267
+ const msg = `Could not update widgets chrome.toml — ${toError(error).message}. Remove the test entry manually if needed.`;
268
+ warn(msg);
269
+ partialFailures.push(msg);
270
+ }
271
+ return { partialFailures };
272
+ }
215
273
  /**
216
274
  * Removes generated xpcshell test scaffolds associated with a custom
217
275
  * component. 2026-04-24 eval Finding 5: `furnace remove` handled
@@ -433,6 +491,16 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
433
491
  // versions.
434
492
  const xpcshellResult = await cleanupCustomXpcshellTestFiles(name, projectRoot, journal);
435
493
  testCleanupFailures.push(...xpcshellResult.partialFailures);
494
+ // 2026-04-25 eval Finding 13: mochikit-style scaffolds
495
+ // (`--test-style mochikit`) live under
496
+ // `engine/toolkit/content/tests/widgets/` with `chrome.toml`
497
+ // entries — neither the browser-chrome path nor the xpcshell
498
+ // path touches them. Without this pass, a `furnace create
499
+ // --with-tests --test-style mochikit` followed by `furnace
500
+ // remove` left the test file and its toml entry referencing a
501
+ // component that no longer exists.
502
+ const mochikitResult = await cleanupCustomMochikitTestFiles(name, projectRoot, journal);
503
+ testCleanupFailures.push(...mochikitResult.partialFailures);
436
504
  }
437
505
  // Remove entry from furnace.json
438
506
  if (type === 'stock') {
@@ -74,7 +74,15 @@ async function checkUncommittedPatchFiles(engineDir, patchesDir, forceImport) {
74
74
  if (dirtyFiles.length > 0) {
75
75
  const unmanagedDirtyFiles = await getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles);
76
76
  if (unmanagedDirtyFiles.length === 0) {
77
- info('Patch-backed materialized files already match the stored patch stack.');
77
+ // Common path here: operator just ran `fireforge resolve` to
78
+ // regenerate a patch from manual conflict edits, so the engine
79
+ // already carries the patch's effects. The import below will
80
+ // still re-apply each patch (a no-op for files whose contents
81
+ // already match), so phrase the line as "no resync needed"
82
+ // rather than "patches already applied" — the latter contradicts
83
+ // the "Applied N patch(es)" summary `applyPatchesWithContinue`
84
+ // prints next, which the 2026-04-25 eval flagged as ambiguous.
85
+ info('Patch-touched files already match the stored patch stack — no engine resync needed before re-applying.');
78
86
  }
79
87
  else if (!forceImport) {
80
88
  warn('Uncommitted changes detected in files that patches will modify:');
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { isBrandingManagedPath } from '../core/branding.js';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
7
  import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
7
8
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
9
  import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
@@ -35,7 +36,7 @@ import { stripEnginePrefix } from '../utils/paths.js';
35
36
  * previous behaviour: passing a branding file explicitly still lints
36
37
  * it, so operators who need to audit branding content can do so.
37
38
  */
38
- async function resolveLintDiff(engineDir, files, binaryName) {
39
+ async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
39
40
  if (files.length > 0) {
40
41
  const collectedFiles = new Set();
41
42
  let fileStatuses;
@@ -115,16 +116,31 @@ async function resolveLintDiff(engineDir, files, binaryName) {
115
116
  const expanded = await expandUntrackedDirectoryEntries(engineDir, rawStatus);
116
117
  const allPaths = [...new Set(expanded.map((entry) => entry.file))];
117
118
  const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
118
- const excludedCount = allPaths.length - nonBrandingPaths.length;
119
- if (excludedCount > 0) {
120
- info(`Excluded ${excludedCount} tool-managed branding file${excludedCount === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
119
+ const brandingExcluded = allPaths.length - nonBrandingPaths.length;
120
+ // Drop Furnace-managed paths the same way branding is dropped: their
121
+ // contents are tool output (overrides, custom widgets, preview-
122
+ // generated stories) that the operator did not author and never
123
+ // intended to land on the patch queue. Without this carve-out, a
124
+ // post-`furnace preview` aggregate `lint` failed with one
125
+ // `missing-license-header` error per generated story file (eval
126
+ // Finding 19) — each story is intentionally header-less because it's
127
+ // re-generated from component metadata on every preview run.
128
+ const filteredPaths = furnacePrefixes
129
+ ? nonBrandingPaths.filter((path) => ![...furnacePrefixes].some((p) => path.startsWith(p)))
130
+ : nonBrandingPaths;
131
+ const furnaceExcluded = nonBrandingPaths.length - filteredPaths.length;
132
+ if (brandingExcluded > 0) {
133
+ info(`Excluded ${brandingExcluded} tool-managed branding file${brandingExcluded === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
121
134
  }
122
- if (nonBrandingPaths.length === 0) {
123
- info('No non-branding changes to lint.');
135
+ if (furnaceExcluded > 0) {
136
+ info(`Excluded ${furnaceExcluded} Furnace-managed file${furnaceExcluded === 1 ? '' : 's'} from lint (deployed components and preview-generated stories). Pass the path explicitly to include them.`);
137
+ }
138
+ if (filteredPaths.length === 0) {
139
+ info('No non-branding, non-Furnace changes to lint.');
124
140
  outro('Nothing to lint');
125
141
  return null;
126
142
  }
127
- const diff = await getDiffForFilesAgainstHead(engineDir, nonBrandingPaths.sort());
143
+ const diff = await getDiffForFilesAgainstHead(engineDir, filteredPaths.sort());
128
144
  if (!diff.trim()) {
129
145
  info('No diff content to lint.');
130
146
  outro('Nothing to lint');
@@ -185,7 +201,13 @@ export async function lintCommand(projectRoot, files, options = {}) {
185
201
  // the diff was resolved; hoisting it is cheap and keeps the two
186
202
  // call sites close together.
187
203
  const config = await loadConfig(projectRoot);
188
- const diff = await resolveLintDiff(paths.engine, files, config.binaryName);
204
+ // Pull the Furnace-managed prefix set up-front so aggregate lint can
205
+ // mirror the branding exclusion for Furnace material — without it,
206
+ // preview-generated stories under `browser/components/storybook/
207
+ // stories/furnace/` show up as license-header errors on every
208
+ // post-preview lint run.
209
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
210
+ const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
189
211
  if (diff === null)
190
212
  return;
191
213
  const filesAffected = extractAffectedFiles(diff);
@@ -284,7 +306,23 @@ export async function lintCommand(projectRoot, files, options = {}) {
284
306
  : '';
285
307
  throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
286
308
  }
287
- outro('Lint passed with warnings');
309
+ // Notices are advisory and don't count as warnings — emitting "passed
310
+ // with warnings" when only notices fired contradicts the preceding
311
+ // `0 warning(s)` summary line and reads as a regression. Distinguish
312
+ // the three pass states explicitly. Errors suppressed by
313
+ // --only-introduced still warrant the "with warnings" outro — they
314
+ // print as ERROR rows but no longer fail the run, which is the same
315
+ // contract the operator gets from a real warning.
316
+ const suppressedErrors = options.onlyIntroduced && errors.length > 0;
317
+ if (warnings.length > 0 || suppressedErrors) {
318
+ outro('Lint passed with warnings');
319
+ }
320
+ else if (notices.length > 0) {
321
+ outro('Lint passed with notices');
322
+ }
323
+ else {
324
+ outro('Lint passed');
325
+ }
288
326
  }
289
327
  /**
290
328
  * Lints each patch in the queue as its own isolated diff, honouring
@@ -357,7 +395,15 @@ async function lintPerPatch(projectRoot, paths) {
357
395
  outro('Lint failed');
358
396
  throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
359
397
  }
360
- outro('Lint passed with warnings');
398
+ if (warnings.length > 0) {
399
+ outro('Lint passed with warnings');
400
+ }
401
+ else if (notices.length > 0) {
402
+ outro('Lint passed with notices');
403
+ }
404
+ else {
405
+ outro('Lint passed');
406
+ }
361
407
  }
362
408
  /** Registers the lint command on the CLI program. */
363
409
  export function registerLint(program, { getProjectRoot, withErrorHandling }) {
@@ -260,6 +260,14 @@ async function assertEngineHasBaselineCommit(engineDir, options) {
260
260
  warn(guidance);
261
261
  outro('Engine baseline missing — re-run download --force');
262
262
  }
263
+ if (options.json) {
264
+ // Mirror `--json`'s contract: errors must be machine-parseable too.
265
+ // Without this branch the human guidance above is suppressed but the
266
+ // throw still falls through to the styled error renderer in
267
+ // withErrorHandling, leaving JSON consumers with non-JSON output on
268
+ // exactly the failure mode they care about catching.
269
+ process.stdout.write(JSON.stringify({ error: guidance, code: 'engine-baseline-missing' }) + '\n');
270
+ }
263
271
  throw new GeneralError(guidance);
264
272
  }
265
273
  }
@@ -278,6 +286,19 @@ export async function statusCommand(projectRoot, options = {}) {
278
286
  }
279
287
  const paths = getProjectPaths(projectRoot);
280
288
  const config = await loadConfig(projectRoot);
289
+ // `--json` mode contracts to machine-parseable output on every code path,
290
+ // including failure modes. Before this guard, errors raised below
291
+ // ("Firefox source not found", "engine is not a git repository") flowed
292
+ // through the normal styled error renderer in `withErrorHandling`, so
293
+ // scripts piping `status --json | jq` broke precisely when the engine was
294
+ // missing. Surface a structured `{ "error": ..., "code": ... }` payload
295
+ // and exit non-zero via GeneralError so the exit code still reflects the
296
+ // failure but stdout remains valid JSON. The same guard runs for
297
+ // ownership mode below because that path also throws on missing engine.
298
+ const emitJsonError = (code, message) => {
299
+ process.stdout.write(JSON.stringify({ error: message, code }) + '\n');
300
+ throw new GeneralError(message);
301
+ };
281
302
  // Ownership mode is a flat file→patch table; sources are the manifest's
282
303
  // filesAffected, any worktree drift, and the cross-patch
283
304
  // duplicate-new-file-creation map produced by walking each patch
@@ -326,10 +347,16 @@ export async function statusCommand(projectRoot, options = {}) {
326
347
  }
327
348
  // Check if engine exists
328
349
  if (!(await pathExists(paths.engine))) {
350
+ if (options.json) {
351
+ emitJsonError('engine-missing', 'Firefox source not found. Run "fireforge download" first.');
352
+ }
329
353
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
330
354
  }
331
355
  // Check if it's a git repository
332
356
  if (!(await isGitRepository(paths.engine))) {
357
+ if (options.json) {
358
+ emitJsonError('engine-not-git', 'Engine directory is not a git repository. Run "fireforge download" to initialize.');
359
+ }
333
360
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
334
361
  }
335
362
  await assertEngineHasBaselineCommit(paths.engine, options);
@@ -2,7 +2,7 @@
2
2
  import { join } from 'node:path';
3
3
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
- import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
5
+ import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
6
6
  import { assertMarionettePortAvailable } from '../core/marionette-port.js';
7
7
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
@@ -193,6 +193,25 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
193
193
  // probe have access to `binaryName` (the port probe uses it to
194
194
  // recognise a fork-branded browser holding the Marionette port).
195
195
  const projectConfig = await loadConfig(projectRoot);
196
+ // `hasBuildArtifacts` only confirms `obj-*/dist/` exists; a partial
197
+ // build (linker failed, packaging step interrupted, etc.) can satisfy
198
+ // that check without ever writing the launchable binary the marionette
199
+ // preflight needs to spawn. `fireforge run` already uses
200
+ // `hasRunnableBundle` to fail fast with a precise message; mirror that
201
+ // here so `test --doctor` against an incomplete build surfaces the
202
+ // missing-bundle path instead of a cryptic `Browser process exited
203
+ // during spawn (exit code 1, signal none). stderr tail: (empty)`.
204
+ if (buildCheck.objDir) {
205
+ const bundleCheck = await hasRunnableBundle(paths.engine, projectConfig.binaryName, buildCheck.objDir);
206
+ if (!bundleCheck.runnable) {
207
+ const expectedSuffix = bundleCheck.expectedPath
208
+ ? ` (expected at engine/${bundleCheck.expectedPath})`
209
+ : '';
210
+ throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
211
+ 'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
212
+ 'Run "fireforge build" again and let it finish before retrying "fireforge test".');
213
+ }
214
+ }
196
215
  // Run incremental build if requested
197
216
  if (options.build) {
198
217
  await prepareBuildEnvironment(projectRoot, paths, projectConfig);
@@ -120,7 +120,7 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
120
120
  });
121
121
  token
122
122
  .command('add <token-name> <value>')
123
- .description('Add a design token to CSS and documentation')
123
+ .description('Add a design token to CSS and documentation. The token name is a positional argument, but most tokens start with `--` (CSS custom property syntax), which Commander reads as an option flag. Use the standard `--` separator to mark the end of options before the token name, e.g. `fireforge token add --mode static --category Colors -- --my-token "#fff"`. Bare names without `--` are accepted directly and prefixed using the configured Furnace `tokenPrefix`.')
124
124
  .requiredOption('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing")')
125
125
  .addOption(
126
126
  // Use Commander's .choices() so invalid --mode values are rejected with
@@ -541,6 +541,20 @@ export async function updateFurnaceState(root, updates) {
541
541
  await writeJson(paths.furnaceState, validateFurnaceState(nextState));
542
542
  });
543
543
  }
544
+ /**
545
+ * Engine-relative path of the directory `furnace preview` writes its
546
+ * generated Storybook story files into. Treated as Furnace-managed so
547
+ * `status` does not flag them as unmanaged and `lint` does not fail on
548
+ * their (intentionally bare) license headers.
549
+ *
550
+ * 2026-04-25 eval Finding 19: a successful `furnace preview` run synced
551
+ * 23 stories under this prefix; afterwards `status` showed all 23 as
552
+ * untracked unmanaged changes and aggregate `lint` failed with 23
553
+ * `missing-license-header` errors. The files are tool output — operators
554
+ * are not expected to commit or hand-edit them — so the right shape is
555
+ * to bucket them with the rest of Furnace's managed material.
556
+ */
557
+ const FURNACE_STORYBOOK_STORIES_PREFIX = 'browser/components/storybook/stories/furnace/';
544
558
  /**
545
559
  * Collects engine-relative path prefixes that are managed by the Furnace
546
560
  * component system (overrides, custom components, and their Fluent l10n
@@ -571,6 +585,11 @@ export async function collectFurnaceManagedPrefixes(root) {
571
585
  prefixes.add(ftlDir.endsWith('/') ? ftlDir : ftlDir + '/');
572
586
  }
573
587
  }
588
+ // Always include the preview-generated stories prefix when furnace is
589
+ // initialised. The directory may not exist yet (no preview ever ran),
590
+ // but classifying it as furnace-managed is safe even when empty —
591
+ // status simply has nothing to bucket.
592
+ prefixes.add(FURNACE_STORYBOOK_STORIES_PREFIX);
574
593
  return prefixes;
575
594
  }
576
595
  //# sourceMappingURL=furnace-config.js.map
@@ -21,6 +21,14 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
21
21
  * Returns true if `content` starts with any known license header for the
22
22
  * given comment style.
23
23
  *
24
+ * For `js` files, MPL-2.0 is also accepted in the upstream Mozilla block-
25
+ * comment form (`/* ... *\/`) used by the Firefox source tree, not just the
26
+ * `// ` line-comment form `getLicenseHeader` emits. Without that, a new JS
27
+ * file copied from upstream Firefox (or written to match the surrounding
28
+ * code's convention) hit `missing-license-header` even with a verbatim
29
+ * standard MPL header — operators were forced to `--skip-lint` over a real
30
+ * false positive.
31
+ *
24
32
  * @param content - File content to check
25
33
  * @param style - Comment syntax of the file
26
34
  */
@@ -57,12 +57,26 @@ export function getLicenseHeader(license, style) {
57
57
  * Returns true if `content` starts with any known license header for the
58
58
  * given comment style.
59
59
  *
60
+ * For `js` files, MPL-2.0 is also accepted in the upstream Mozilla block-
61
+ * comment form (`/* ... *\/`) used by the Firefox source tree, not just the
62
+ * `// ` line-comment form `getLicenseHeader` emits. Without that, a new JS
63
+ * file copied from upstream Firefox (or written to match the surrounding
64
+ * code's convention) hit `missing-license-header` even with a verbatim
65
+ * standard MPL header — operators were forced to `--skip-lint` over a real
66
+ * false positive.
67
+ *
60
68
  * @param content - File content to check
61
69
  * @param style - Comment syntax of the file
62
70
  */
63
71
  export function hasAnyLicenseHeader(content, style) {
64
72
  const licenses = Object.keys(HEADER_LINES);
65
- return licenses.some((license) => content.startsWith(getLicenseHeader(license, style)));
73
+ if (licenses.some((license) => content.startsWith(getLicenseHeader(license, style)))) {
74
+ return true;
75
+ }
76
+ if (style === 'js' && content.startsWith(getLicenseHeader('MPL-2.0', 'css'))) {
77
+ return true;
78
+ }
79
+ return false;
66
80
  }
67
81
  /**
68
82
  * Returns true if `content` starts with any known license header in any
@@ -65,7 +65,15 @@ async function isSharedCSSRegistered(engineDir, fileName) {
65
65
  }
66
66
  const name = basename(fileName, '.css');
67
67
  const content = await readText(manifestPath);
68
- return content.includes(`skin/classic/browser/${name}.css`);
68
+ // `register` writes the canonical `skin/classic/browser/<name>.css` form;
69
+ // `furnace chrome-doc create` writes a `content/browser/<name>.css` entry
70
+ // because the CSS is loaded by a chrome document via a `chrome://browser/
71
+ // content/<name>.css` URI. Match either prefix so paths registered by the
72
+ // chrome-doc scaffolder are not flagged as "potentially unregistered" by
73
+ // `status` and so a re-run of `register` against the same file recognises
74
+ // the existing entry instead of proposing a duplicate.
75
+ return (content.includes(`skin/classic/browser/${name}.css`) ||
76
+ content.includes(`content/browser/${name}.css`));
69
77
  }
70
78
  async function isBrowserContentRegistered(engineDir, fileName) {
71
79
  const manifestPath = join(engineDir, 'browser/base/jar.mn');
@@ -359,6 +359,14 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
359
359
  continue;
360
360
  if (file.startsWith('browser/branding/') && hasAnyLicenseHeader(content, style))
361
361
  continue;
362
+ // Accept the MPL-2.0 block-comment form (`/* ... */` with leading `*`)
363
+ // for any JS file — that is the canonical upstream Firefox header
364
+ // shape, and `hasAnyLicenseHeader` above also recognises it. The
365
+ // explicit guard mirrors the branding carve-out so operators using the
366
+ // standard Mozilla header on a JS file (e.g. one copied from upstream
367
+ // browser/base/content) do not need `--skip-lint` to land it.
368
+ if (license === 'MPL-2.0' && hasAnyLicenseHeader(content, style))
369
+ continue;
362
370
  issues.push({
363
371
  file,
364
372
  check: 'missing-license-header',
@@ -98,8 +98,14 @@ export async function registerSharedCSS(engineDir, fileName, after, dryRun = fal
98
98
  const name = basename(fileName, '.css');
99
99
  const entry = ` skin/classic/browser/${name}.css (../shared/${name}.css)`.replace(/\\/g, '/');
100
100
  const content = await readText(manifestPath);
101
- // Idempotency check
102
- if (content.includes(`skin/classic/browser/${name}.css`)) {
101
+ // Idempotency check. `furnace chrome-doc create` writes its CSS as a
102
+ // `content/browser/<name>.css` entry rather than the canonical
103
+ // `skin/classic/browser/<name>.css` form `register` produces; recognise
104
+ // both shapes so a follow-up `register` invocation against an
105
+ // already-chrome-doc-registered file reports `skipped` instead of
106
+ // appending a duplicate `skin/classic/browser/...` row.
107
+ if (content.includes(`skin/classic/browser/${name}.css`) ||
108
+ content.includes(`content/browser/${name}.css`)) {
103
109
  return { manifest, entry, skipped: true };
104
110
  }
105
111
  const { value } = withParserFallback(() => registerSharedCSSTokenized(content, name, entry, after), () => legacyRegisterSharedCSS(content, name, entry, after), manifest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",