@hominis/fireforge 0.18.1 → 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.
Files changed (35) hide show
  1. package/README.md +54 -33
  2. package/dist/src/commands/discard.js +93 -1
  3. package/dist/src/commands/doctor.js +17 -4
  4. package/dist/src/commands/download.js +21 -0
  5. package/dist/src/commands/export-all.js +35 -6
  6. package/dist/src/commands/export-flow.d.ts +4 -0
  7. package/dist/src/commands/export-flow.js +8 -0
  8. package/dist/src/commands/export.js +26 -2
  9. package/dist/src/commands/furnace/remove.js +68 -0
  10. package/dist/src/commands/import.js +9 -1
  11. package/dist/src/commands/lint.js +56 -10
  12. package/dist/src/commands/patch/index.d.ts +5 -3
  13. package/dist/src/commands/patch/index.js +10 -4
  14. package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
  15. package/dist/src/commands/patch/lint-ignore.js +200 -0
  16. package/dist/src/commands/patch/tier.d.ts +34 -0
  17. package/dist/src/commands/patch/tier.js +134 -0
  18. package/dist/src/commands/re-export-files.js +88 -45
  19. package/dist/src/commands/re-export.js +49 -6
  20. package/dist/src/commands/status.js +27 -0
  21. package/dist/src/commands/test.js +20 -1
  22. package/dist/src/commands/token.js +1 -1
  23. package/dist/src/core/furnace-config.js +19 -0
  24. package/dist/src/core/git-diff.js +34 -2
  25. package/dist/src/core/license-headers.d.ts +8 -0
  26. package/dist/src/core/license-headers.js +15 -1
  27. package/dist/src/core/manifest-rules.js +9 -1
  28. package/dist/src/core/patch-export.d.ts +77 -2
  29. package/dist/src/core/patch-export.js +82 -3
  30. package/dist/src/core/patch-lint.js +86 -29
  31. package/dist/src/core/register-shared-css.js +8 -2
  32. package/dist/src/types/commands/index.d.ts +1 -1
  33. package/dist/src/types/commands/options.d.ts +67 -0
  34. package/dist/src/types/commands/patches.d.ts +6 -5
  35. package/package.json +1 -1
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
 
@@ -210,7 +213,11 @@ This re-exports the fixed patch and clears the conflict state. The command is de
210
213
 
211
214
  The optional `lintIgnore` field lists lint check IDs to suppress for that patch specifically. Useful for the class of patch that is advisory-noisy by nature — a cohesive branding bundle, a localised-resource pack, an auto-generated manifest — where `--skip-lint` is too blunt and a per-line marker cannot exist (the `.patch` body is regenerated on every export). Threaded through `export`, `re-export`, `re-export --files`, and `lint --per-patch`. Unknown check IDs are a no-op.
212
215
 
213
- The optional `tier` field (only `"branding"` recognised) forces the branding threshold tier for the `large-patch-lines` rule regardless of what `filesAffected` looks like. The automatic branding-tier detection already fires when every file is under `browser/branding/` plus a narrow allowlist of branding-registration siblings (`browser/moz.configure`, `browser/confvars.sh`) covering the canonical Firefox fork shape. Declare `tier: "branding"` only when the patch legitimately also touches a non-allowlisted sibling the auto-detector cannot reach (a fork-specific theme override under `browser/themes/<name>/`, a vendor-specific icon resource, etc.). Precedence is `test > branding > general`: a patch of all-tests always gets the more permissive test-tier thresholds even if it declares `tier: "branding"`. Unknown tier values are rejected at load time rather than silently stripped, so a typo surfaces as a loader error. Prefer `tier` over `lintIgnore: ["large-patch-lines"]` when the patch is legitimately branding-shaped `tier` keeps the rule running at the correct thresholds (so the warning still surfaces if the patch crosses them); `lintIgnore` drops the rule entirely.
216
+ Settable through the CLI in two places. `fireforge export --lint-ignore <check-id>` (repeatable) writes the field on creation; `fireforge re-export <name> --lint-ignore <check-id>` (repeatable, append/union semantics, de-duplicated) adds entries to an existing patch on the next refresh. For metadata-only edits that should **not** regenerate the `.patch` body including the inverse `--remove` and `--clear` modes that re-export's append-only flag cannot express use `fireforge patch lint-ignore <name> --add <id> | --remove <id> | --clear`.
217
+
218
+ The optional `tier` field (only `"branding"` recognised) forces the branding threshold tier for the `large-patch-files` and `large-patch-lines` rules regardless of what `filesAffected` looks like. The automatic branding-tier detection already fires when every file is under `browser/branding/` plus a narrow allowlist of branding-registration siblings (`browser/moz.configure`, `browser/confvars.sh`) — covering the canonical Firefox fork shape. Declare `tier: "branding"` only when the patch legitimately also touches a non-allowlisted sibling the auto-detector cannot reach (a fork-specific theme override under `browser/themes/<name>/`, a vendor-specific icon resource, etc.). Precedence is `test > branding > general`: a patch of all-tests always gets the more permissive test-tier thresholds even if it declares `tier: "branding"`. Unknown tier values are rejected at load time rather than silently stripped, so a typo surfaces as a loader error. Prefer `tier` over `lintIgnore: ["large-patch-lines"]` when the patch is legitimately branding-shaped — `tier` keeps the rule running at the correct thresholds (so the warning still surfaces if the patch crosses them); `lintIgnore` drops the rule entirely.
219
+
220
+ Settable via `fireforge export --tier branding` on creation, `fireforge re-export <name> --tier branding` on refresh, and `fireforge patch tier <name> --tier branding | --clear` for a metadata-only edit that does not rewrite the `.patch` body. The CLI rejects values other than `branding` up-front (matching the validator's strictness), and `re-export --tier` / `--lint-ignore` refuse `--all` because mass tier/ignore edits across a heterogeneous queue are virtually always footguns.
214
221
 
215
222
  If the manifest drifts after an interrupted export or manual edits, `fireforge import` will stop rather then silently applying a stale stack. Use `fireforge doctor --repair-patches-manifest` to rebuild it from disk. Because the rebuild is deterministic, the result will always be consistent with what is actually on the filesystem.
216
223
 
@@ -223,24 +230,24 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
223
230
 
224
231
  By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed — with tool-managed branding paths (`browser/branding/<binaryName>/`) excluded. A fresh-setup workspace carries a large generated branding diff that operators did not author directly, and letting it through tripped the patch-size and license-header rules on content that matches the `branding` bucket in `fireforge status`. When the exclusion fires the command prints a one-line note naming the excluded count so the filter is visible. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further — explicit-path mode does lint branding files (the operator's explicit request wins over the branding exclusion); the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
225
232
 
226
- | Check | Scope | Severity |
227
- | ------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------ |
228
- | `missing-license-header` | New files (JS/CSS/FTL) | error |
229
- | `relative-import` | JS/MJS files | error |
230
- | `token-prefix-violation` | CSS files (with furnace) | error |
231
- | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
232
- | `duplicate-new-file-creation` | Same path created by multiple patches | error |
233
- | `forward-import` | Patch imports from a later-patch file | error |
234
- | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
235
- | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
236
- | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
237
- | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
238
- | `missing-modification-comment` | Modified upstream JS/MJS | warning |
239
- | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
240
- | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
241
- | `observer-topic-naming` | Observer topics with binaryName | warning |
242
- | `large-patch-files` | Patches affecting >5 files | warning |
243
- | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 3000/8000/20000 branding) | notice / warning / error |
233
+ | Check | Scope | Severity |
234
+ | ------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------ |
235
+ | `missing-license-header` | New files (JS/CSS/FTL) | error |
236
+ | `relative-import` | JS/MJS files | error |
237
+ | `token-prefix-violation` | CSS files (with furnace) | error |
238
+ | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
239
+ | `duplicate-new-file-creation` | Same path created by multiple patches | error |
240
+ | `forward-import` | Patch imports from a later-patch file | error |
241
+ | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
242
+ | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
243
+ | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
244
+ | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
245
+ | `missing-modification-comment` | Modified upstream JS/MJS | warning |
246
+ | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
247
+ | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
248
+ | `observer-topic-naming` | Observer topics with binaryName | warning |
249
+ | `large-patch-files` | Patches affecting many files (tiered: >5 general, >5 test, >60 branding) | warning |
250
+ | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 8000/18000/30000 branding) | notice / warning / error |
244
251
 
245
252
  **JSDoc validation** uses AST-based analysis (Acorn) to validate exported APIs in patch-owned `.sys.mjs` files. A file is "patch-owned" if it was newly created by the current diff or by an existing patch in the queue. Functions must document every `@param` (names must match) and include `@returns` when the function returns a value. Exported constants and classes require a JSDoc block.
246
253
 
@@ -263,16 +270,16 @@ fireforge status --json # machine-readable classified output
263
270
 
264
271
  Then fix with the appropriate primitive:
265
272
 
266
- | Problem | Fix |
267
- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
268
- | Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
269
- | A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
270
- | Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
271
- | Ordinal gaps after deletes/splits | `fireforge patch compact` |
272
- | A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
273
- | Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
274
- | Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes |
275
- | 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` |
276
283
 
277
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.
278
285
 
@@ -436,11 +443,21 @@ fireforge patch reorder 003-ui-sidebar.patch --before 001-branding-logo.patch
436
443
 
437
444
  # Close ordinal gaps after deletes or splits (e.g. 1, 3, 7 → 1, 2, 3)
438
445
  fireforge patch compact
446
+
447
+ # Set or clear the threshold-tier override on a single patch
448
+ # (no .patch body rewrite — manifest entry only)
449
+ fireforge patch tier 001-branding-assets.patch --tier branding
450
+ fireforge patch tier 001-branding-assets.patch --clear
451
+
452
+ # Edit the lintIgnore list on a single patch (one mode per invocation)
453
+ fireforge patch lint-ignore 001-branding-assets.patch --add large-patch-lines --add large-patch-files
454
+ fireforge patch lint-ignore 001-branding-assets.patch --remove large-patch-lines
455
+ fireforge patch lint-ignore 001-branding-assets.patch --clear
439
456
  ```
440
457
 
441
- `delete` and `reorder` accept three identifier forms for their target: the ordinal number (`fireforge patch reorder 3 --to 1`), the full filename (`003-ui-sidebar-tweaks.patch`), or the manifest `name` handle the patch was exported with (`ui-sidebar-tweaks`). Anchors passed to `--before` / `--after` accept the same three forms.
458
+ All `patch <verb>` subcommands accept three identifier forms for their target: the ordinal number (`fireforge patch reorder 3 --to 1`), the full filename (`003-ui-sidebar-tweaks.patch`), or the manifest `name` handle the patch was exported with (`ui-sidebar-tweaks`). Anchors passed to `--before` / `--after` accept the same three forms. All subcommands support `--dry-run` and `--yes`.
442
459
 
443
- All subcommands support `--dry-run` and `--yes`.
460
+ `patch tier` and `patch lint-ignore` are metadata-only edits: they update `patches/patches.json` under the patch directory lock and never rewrite the `.patch` body. Reach for them when an operator-visible advisory (e.g. `large-patch-files` firing on a 56-file fresh-fork branding bundle) needs the threshold-tier override or a per-patch lint suppression but the patch body is already correct — running `re-export` for that case wastes an engine read + diff regeneration. `patch lint-ignore` modes (`--add` / `--remove` / `--clear`) are mutually exclusive; `patch tier` rejects `--tier` and `--clear` together. The history log records each invocation under `operation: "patch-tier"` / `"patch-lint-ignore"` for audit.
444
461
 
445
462
  ### Additional workflow commands
446
463
 
@@ -452,7 +469,11 @@ fireforge package
452
469
  fireforge watch
453
470
 
454
471
  # Add a CSS design token (requires `fireforge furnace init` first; see the Furnace/Tokens section below)
455
- 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
456
477
  ```
457
478
 
458
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(), {
@@ -82,6 +82,10 @@ export interface DryRunPreviewInput {
82
82
  filesAffected: string[];
83
83
  sourceEsrVersion: string;
84
84
  explicitSupersede: boolean;
85
+ /** Optional `PatchMetadata.tier` opt-in carried from the CLI. */
86
+ tier?: 'branding';
87
+ /** Optional `PatchMetadata.lintIgnore` carried from the CLI. */
88
+ lintIgnore?: string[];
85
89
  }
86
90
  /**
87
91
  * Renders the plain (non-placement) dry-run preview: calls planExport,
@@ -314,12 +314,20 @@ export async function renderDryRunPreview(input) {
314
314
  description: input.description,
315
315
  filesAffected: input.filesAffected,
316
316
  sourceEsrVersion: input.sourceEsrVersion,
317
+ ...(input.tier !== undefined ? { tier: input.tier } : {}),
318
+ ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
317
319
  });
318
320
  info(`\n[dry-run] Would write: patches/${plan.patchFilename}`);
319
321
  info(` category: ${plan.metadata.category}`);
320
322
  info(` order: ${plan.metadata.order}`);
321
323
  info(` description: ${plan.metadata.description || '(none)'}`);
322
324
  info(` filesAffected (${plan.metadata.filesAffected.length}): ${plan.metadata.filesAffected.join(', ')}`);
325
+ if (plan.metadata.tier !== undefined) {
326
+ info(` tier: ${plan.metadata.tier}`);
327
+ }
328
+ if (plan.metadata.lintIgnore !== undefined && plan.metadata.lintIgnore.length > 0) {
329
+ info(` lintIgnore: ${plan.metadata.lintIgnore.join(', ')}`);
330
+ }
323
331
  if (supersedeDetails.length > 0) {
324
332
  info(`\n[dry-run] Would supersede ${supersedeDetails.length} existing patch(es):`);
325
333
  for (const detail of supersedeDetails) {
@@ -172,7 +172,15 @@ export async function exportCommand(projectRoot, files, options) {
172
172
  try {
173
173
  // Extract affected files from diff
174
174
  const filesAffected = extractAffectedFiles(diff);
175
- await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
175
+ // Apply the just-set --tier and --lint-ignore on the lint pass so the
176
+ // operator's intent takes effect on this invocation, not only on the
177
+ // next one. Without this, a fresh export with `--tier branding` would
178
+ // still hit general thresholds because the lint runs before the
179
+ // metadata is committed.
180
+ const exportIgnoreChecks = options.lintIgnore && options.lintIgnore.length > 0
181
+ ? new Set(options.lintIgnore)
182
+ : undefined;
183
+ await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint, undefined, exportIgnoreChecks, options.tier);
176
184
  // Resolve placement (if any flag was given). Placement is mutually
177
185
  // exclusive with supersede — the semantics overlap confusingly.
178
186
  let placementPlan = null;
@@ -225,6 +233,10 @@ export async function exportCommand(projectRoot, files, options) {
225
233
  filesAffected,
226
234
  sourceEsrVersion: config.firefox.version,
227
235
  explicitSupersede: options.supersede === true,
236
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
237
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
238
+ ? { lintIgnore: options.lintIgnore }
239
+ : {}),
228
240
  });
229
241
  outro('Dry run complete — no changes made');
230
242
  return;
@@ -243,6 +255,10 @@ export async function exportCommand(projectRoot, files, options) {
243
255
  createdAt: new Date().toISOString(),
244
256
  sourceEsrVersion: config.firefox.version,
245
257
  filesAffected,
258
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
259
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
260
+ ? { lintIgnore: options.lintIgnore }
261
+ : {}),
246
262
  };
247
263
  const committedPlan = await commitPlacementExport({
248
264
  patchesDir: paths.patches,
@@ -314,6 +330,10 @@ export async function exportCommand(projectRoot, files, options) {
314
330
  diff,
315
331
  filesAffected,
316
332
  sourceEsrVersion: config.firefox.version,
333
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
334
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
335
+ ? { lintIgnore: options.lintIgnore }
336
+ : {}),
317
337
  });
318
338
  for (const oldPatch of superseded) {
319
339
  info(`Superseded: ${oldPatch.filename}`);
@@ -348,11 +368,15 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
348
368
  .option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
349
369
  .option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
350
370
  .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
371
+ .addOption(new Option('--tier <tier>', 'Force a tier override on the new patch (only "branding" recognised)').choices(['branding']))
372
+ .option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], [])
351
373
  .action(withErrorHandling(async (paths, options) => {
352
- const { category, ...rest } = options;
374
+ const { category, tier, lintIgnore, ...rest } = options;
353
375
  await exportCommand(getProjectRoot(), paths, {
354
376
  ...pickDefined(rest),
355
377
  ...(category !== undefined ? { category: category } : {}),
378
+ ...(tier !== undefined ? { tier: tier } : {}),
379
+ ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
356
380
  });
357
381
  }));
358
382
  }
@@ -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') {