@hominis/fireforge 0.15.8 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +16 -3
- package/dist/src/cli.d.ts +4 -1
- package/dist/src/cli.js +6 -3
- package/dist/src/commands/download.js +9 -0
- package/dist/src/commands/export-all.js +46 -0
- package/dist/src/commands/export-shared.d.ts +6 -1
- package/dist/src/commands/export-shared.js +7 -2
- package/dist/src/commands/export.js +10 -1
- package/dist/src/commands/furnace/diff.js +22 -2
- package/dist/src/commands/furnace/override.js +35 -12
- package/dist/src/commands/furnace/preview.js +33 -1
- package/dist/src/commands/furnace/rename.js +14 -3
- package/dist/src/commands/lint.d.ts +20 -0
- package/dist/src/commands/lint.js +167 -45
- package/dist/src/commands/package.js +16 -5
- package/dist/src/commands/re-export-files.js +6 -2
- package/dist/src/commands/re-export.js +62 -4
- package/dist/src/commands/register.js +2 -18
- package/dist/src/commands/run.js +23 -2
- package/dist/src/commands/status.js +25 -3
- package/dist/src/commands/test.js +6 -24
- package/dist/src/commands/token.js +14 -1
- package/dist/src/commands/watch.js +14 -2
- package/dist/src/core/branding.d.ts +23 -0
- package/dist/src/core/branding.js +39 -0
- package/dist/src/core/browser-wire.js +68 -23
- package/dist/src/core/mach-build-artifacts.d.ts +41 -0
- package/dist/src/core/mach-build-artifacts.js +70 -0
- package/dist/src/core/mach-error-hints.js +15 -0
- package/dist/src/core/mach-mozconfig.d.ts +25 -0
- package/dist/src/core/mach-mozconfig.js +66 -0
- package/dist/src/core/mach.d.ts +12 -1
- package/dist/src/core/mach.js +14 -1
- package/dist/src/core/manifest-rules.js +22 -1
- package/dist/src/core/patch-lint.d.ts +6 -1
- package/dist/src/core/patch-lint.js +14 -1
- package/dist/src/types/commands/options.d.ts +10 -0
- package/dist/src/types/commands/patches.d.ts +22 -0
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +12 -0
- package/dist/src/utils/paths.d.ts +19 -0
- package/dist/src/utils/paths.js +33 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Wire — transactional rollback
|
|
6
|
+
|
|
7
|
+
- `fireforge wire` now snapshots every file the mutation sequence may touch (`browser/base/content/browser-main.js`, conditionally `browser/base/content/browser-init.js`, the chrome document the `#include` lands in, and `browser/base/jar.mn`) before any write, and restores them when any step fails. The evaluator hit the motivating case on `hominis/`: a `wire mock-wire --init … --destroy … --dom …` run threw `Could not find insertion point in chrome document` AFTER `browser-main.js`, `browser-init.js`, and `browser/base/jar.mn` had already been mutated — the operator had to hand-revert the partial mutation. The journal plumbing reuses `createRollbackJournal` / `snapshotFile` / `restoreRollbackJournal` from Furnace's rollback module; a rollback that itself fails surfaces both the original wire failure and the rollback diagnosis in a single `GeneralError` with `review "git status" under engine/` guidance so the operator knows the engine may need manual attention.
|
|
8
|
+
- The snapshot set is conditional on which option set the run actually uses — no unused snapshot cost on a `wire` invocation that doesn't pass `--init`/`--destroy` or `--dom`. Real-fs integration tests in `browser-wire-rollback.integration.test.ts` pin both the failure-rollback contract and the successful-run pass-through so a future refactor can't regress either branch silently.
|
|
9
|
+
|
|
10
|
+
### Branding/mozconfig preflight
|
|
11
|
+
|
|
12
|
+
- New `assertBrandingMozconfigAgreement` fires at the end of `generateMozconfig` and refuses to hand off to `mach` when the just-written `engine/mozconfig` sets `--with-branding=browser/branding/<X>` while FireForge's branding tree lives at `browser/branding/<Y>`. The new `BrandingMozconfigMismatchError` enumerates three reasons — `mozconfig-missing-branding`, `name-mismatch`, `branding-dir-missing` — each with an actionable recovery line: edit `configs/common.mozconfig` to use `${binaryName}`, or align `fireforge.json`'s `binaryName` with the baked-in value. Motivating case: the evaluator's real `fresh/` tree produced a branding scaffold at `browser/branding/freshforge/` while the rendered mozconfig still pointed `mach` at `browser/branding/freshtest/moz.build`, and every first build failed deep inside moz.build resolution with a confusing "path does not exist" message. The preflight turns that into a single-line refusal before `mach` runs.
|
|
13
|
+
- The extractor (`extractWithBrandingPath`) matches `/^\s*(?:ac_add_options\s+)?--with-branding\s*=\s*(\S+)/m` so both the bare form and the `ac_add_options`-prefixed on-disk convention are recognised, picks the last match (matches `mach`'s last-write-wins semantics for overlapping `ac_add_options` calls), and normalises backslash separators before comparing.
|
|
14
|
+
- The preflight only fires on values that would actually fail under `mach`; it does not prescribe a single shape for `configs/*.mozconfig`. A follow-up (tracked for 0.17.0) will flip `setup-support.ts` to keep templates unsubstituted and substitute at `generateMozconfig` time, removing the drift vector entirely. The 0.16.0 fix is preflight-only because existing projects already have post-substitution configs; reshaping those requires a migration we intend to ship with 0.17.
|
|
15
|
+
|
|
16
|
+
### Export-all — duplicate new-file-creation guard
|
|
17
|
+
|
|
18
|
+
- `fireforge export-all` now refuses before writing when the aggregate diff would newly-create (`new file mode`) a path some other patch in the queue already creates. Motivating case on `fresh/lab/`: exporting patch 1 with `browser/modules/labforge/Hello.sys.mjs` as a new file, then running `export-all --name bye-module --category infra` without scoping the change set, produced a second patch that also claimed the same path — `verify` then failed on `files-affected-mismatch`, a cross-patch `filesAffected` conflict, AND a `duplicate-new-file-creation` error all at once, and the fix required either `patch delete` or hand-edited `re-export --files`. The new guard slots in right after the existing branding and furnace refusals at `src/commands/export-all.ts:checkDuplicateNewFileCreations`; it lists every conflicting path and every existing owner, and points the operator at `fireforge export <path>` with explicit file scoping as the clean recovery.
|
|
19
|
+
- The guard reuses `detectNewFilesInDiff` + `collectNewFileCreatorsByPath` (already used by `verify` and `status --ownership`), so the pre-export check and the post-hoc detection report exactly the same conflict set. No change to `export` itself — single-path exports stay the surgical primitive for this case.
|
|
20
|
+
|
|
21
|
+
### Path normalization parity (`lint` / `export`)
|
|
22
|
+
|
|
23
|
+
- `fireforge lint <paths...>` and `fireforge export <paths...>` now accept both repo-root-relative forms (`engine/browser/base/content/foo.js`) and engine-relative forms (`browser/base/content/foo.js`), matching the normalization already implemented by `register` and `test`. Motivating cases on both `fresh/` and `fresh/lab/`: pasting `engine/browser/base/content/fresh-extra-a.js` into `fireforge export` produced `File "engine/..." has no changes to export.` and the same input to `fireforge lint` produced `No modified files found in the specified paths.` — both because the status lookup sees paths relative to `engine/` and the explicit prefix double-rooted the candidate. Re-running with the engine-relative form succeeded. The normalization is now shared: `stripEnginePrefix` in `src/utils/paths.ts` is the single source of truth, and `register`/`test` delegate to it so every command that takes an engine-relative path treats `Engine/`, `engine\\`, and leading whitespace identically.
|
|
24
|
+
|
|
25
|
+
### Furnace override — stock auto-promotion
|
|
26
|
+
|
|
27
|
+
- `fireforge furnace override <name>` no longer rejects the component when `name` is already present in `config.stock`. Motivating case on `hominis/`: every stock-discovered widget (populated by `furnace scan`) forced the operator to hand-edit `furnace.json` before the override could be created, with the error `"<name>" is already registered as a stock component. Remove it from config.stock before creating an override.` That's busywork — the whole point of `override` is to fork a stock component. The new contract splices the name out of `config.stock` in-memory and lets the existing mutation-phase `writeFurnaceConfig` persist the promotion atomically alongside the new override entry, under the same rollback journal. The collision check for `config.overrides[name]` and `config.custom[name]` stays — those are real conflicts. Promotion emits a one-line `Promoting "<name>" from stock to override.` so the operator sees that the stock entry is gone.
|
|
28
|
+
- The batch variant `furnaceBatchOverrideCommand` applies the same promotion per-name; a batch that overrides both `moz-button` and `moz-card` demotes both from `stock` in the same `writeFurnaceConfig` write.
|
|
29
|
+
|
|
30
|
+
### Furnace diff — FTL deployment path parity
|
|
31
|
+
|
|
32
|
+
- `fireforge furnace diff <name>` now resolves `.ftl` entries through the configured `ftlDir` instead of the component's `targetPath`, matching the path `furnace apply`'s `applyCustomFtlFile` writes to. Before the fix, a custom component with a `.ftl` always reported "`<name>.ftl`: not yet deployed to engine (new file)" after a clean apply, because `diff` probed `engine/<customConfig.targetPath>/<name>.ftl` while the deployed file was at `engine/<ftlDir>/<name>.ftl`. The diff header also now names the locale path (`--- engine/<ftlDir>/<name>.ftl`) so the rendered diff anchors at the same target on both sides. Motivating case on `hominis/`: `furnace diff moz-lab-pill` after a successful apply/deploy reported the `.ftl` as new even though `engine/toolkit/locales/en-US/toolkit/global/moz-lab-pill.ftl` was present on disk and `furnace status moz-lab-pill` reported clean — `diff`, `status`, and `validate` now agree on deployed state.
|
|
33
|
+
|
|
34
|
+
### Furnace rename — path-label fix
|
|
35
|
+
|
|
36
|
+
- `fireforge furnace rename <old> <new>` for a custom component now reports `components/custom/<new>/` instead of `components/customs/<new>/` in the guidance that follows a successful rename (and in the "directory not found" / "target directory already exists" error messages). The actual filesystem operations always used `furnacePaths.customDir` / `furnacePaths.overridesDir` correctly — this was a cosmetic mis-pluralisation produced by appending `s` to the `custom` / `override` furnace-state key — but operators who copied the path from the message to a `cd` or `ls` invocation hit "no such file or directory". Override renames continue to name `components/overrides/<new>/` (which was already correct by coincidence with the plural on-disk dir). A `componentDirLabel` helper centralises the singular/plural pick so a future refactor cannot re-introduce the drift.
|
|
37
|
+
|
|
38
|
+
### Furnace preview — build + toolchain preflight
|
|
39
|
+
|
|
40
|
+
- `fireforge furnace preview` now refuses before staging components or running the multi-minute `mach storybook upgrade` npm install when either (a) no `obj-*/dist/` tree exists, or (b) `.cargo/config.toml` is absent under the engine directory. Motivating case on `fresh/`: on an otherwise-unbuilt engine, preview staged workspace components into `engine/`, npm-installed 1023 packages into the upstream Storybook workspace, and only then failed deep in `mach storybook` with errors about `.cargo/config.toml` and `chrome-map.json` — both artefacts `fireforge build` / `fireforge bootstrap` would have produced. The new preflight catches the dominant failure mode (no `dist/`) fast via the existing `hasBuildArtifacts` helper and points the operator at `fireforge build`; the secondary check fires on a bootstrap-incomplete tree and points at `fireforge bootstrap`. Extracted into `assertPreviewPrerequisites` so `furnacePreviewCommand` stays under the per-function LOC budget as the preflight list grows.
|
|
41
|
+
|
|
42
|
+
### Register — `.inc.xhtml` fragments routed through `getUnregistrableAdvice`
|
|
43
|
+
|
|
44
|
+
- The browser-content registration pattern in `src/core/manifest-rules.ts` now excludes `.inc.xhtml` fragments under `browser/base/content/`. Motivating case on `fresh/`: wiring a `browser/base/content/fresh-fragment.inc.xhtml` into `browser.xhtml` with `fireforge wire ... --dom …` then running `fireforge status` flagged the fragment as `Potentially unregistered`, and `fireforge register <fragment>.inc.xhtml --dry-run` proposed a bogus `browser/base/jar.mn` entry. `.inc.xhtml` files are deliberately consumed via `#include` from a registered chrome document; they don't need a separate chrome URI entry. The narrowed regex now routes these paths through `getUnregistrableAdvice`, which emits specific guidance: "`.inc.xhtml` fragments are consumed via `#include` from a registered chrome document — run `fireforge wire <name> --dom <path>`, or add the `#include` directive manually in the top-level chrome document." Plain `.xhtml` files under `browser/base/content/` still match the registrable pattern.
|
|
45
|
+
|
|
46
|
+
### Stackless precondition errors
|
|
47
|
+
|
|
48
|
+
- `getProjectRoot` now throws a typed `ConfigNotFoundError` (exit code `CONFIG_ERROR` / 2) instead of a plain `Error` when no `fireforge.json` exists in any ancestor of the current working directory. Motivating case on `fresh/` before setup: every precondition-checking command (`doctor`, `status`, `download`, `import`, `build`, `run`, `test`, `lint`, `verify`) printed `Unexpected error: Could not find fireforge.json...` followed by a full JS stack trace — a routine user mistake surfaced as what looked like an internal crash. `withErrorHandling` now routes the `FireForgeError` subclass through `logError(error.userMessage)` and exits with the CONFIG_ERROR code; the stack-dump fallback stays in place for genuinely unexpected errors. The user-facing message is the already-defined `ConfigNotFoundError` copy — `This directory does not appear to be a FireForge project. Navigate to your project root directory, or run "fireforge setup" to initialize a new project.`
|
|
49
|
+
|
|
50
|
+
### Packager NoneType hint
|
|
51
|
+
|
|
52
|
+
- `fireforge package` now captures `mach package`'s streamed output (via the new `machPackageCapture` helper that layers over `runMachCapture`) and feeds the stderr tail through `explainMachError` on non-zero exit. A new hint pattern matches the `AttributeError: 'NoneType' object has no attribute 'open'` / `packager.py` co-occurrence (either ordering) and surfaces an actionable guidance line: "This usually means the packager was handed an incomplete `obj-*/dist/` tree — e.g. running `fireforge package` before a full `fireforge build` (not --ui) completed. Re-run `fireforge build` to completion … before rerunning `fireforge package`." Motivating case on a real `hominis/` tree that reached `mach package` but had not completed a full build: the raw mach traceback was surfaced but wrapped only in "Packaging failed with exit code 1"; operators had to read the Python traceback to learn that the `obj-*/dist/` tree was incomplete. The hint now lands in the thrown `BuildError` directly after the generic exit-code line, so the operator reads the recovery instruction before scrolling up through the traceback.
|
|
53
|
+
- The legacy `machPackage` helper stays exported for callers that only need the exit code; the new variant is additive. A negative-match test pins the pattern to the specific `packager.py` + `.open` pairing so unrelated `NoneType` errors elsewhere in mach output don't falsely trigger the hint.
|
|
54
|
+
|
|
55
|
+
### Run + watch — bundle-readiness agreement
|
|
56
|
+
|
|
57
|
+
- `fireforge run` now probes for the launchable binary (`obj-*/dist/<App>.app/Contents/MacOS/<binary>` on macOS, `obj-*/dist/bin/<binary>` on Linux, `obj-*/dist/bin/<binary>.exe` on Windows) via the new `hasRunnableBundle` helper in `src/core/mach-build-artifacts.ts`. Motivating case on a mid-build `hominis/` tree: `fireforge run` failed inside `mach run` because `dist/Hominis.app/Contents/MacOS/hominis` didn't exist yet, while `fireforge watch` happily announced "Using build artifacts from obj-…/" and entered watch mode — the two commands disagreed about whether the obj dir was usable. `run` now refuses up-front with a targeted error naming the missing path ("the expected binary at `obj-debug/dist/MyBrowser.app/Contents/MacOS/mybrowser` is missing — the build may have aborted or is still in progress"). `watch` stays permissive (it exists to drive rebuilds of partially-built trees) but now reports the bundle state in its startup banner: `Using build artifacts from obj-…/ (bundle: runnable)` or `Using build artifacts from obj-…/ (bundle: pending — watch will rebuild)`, so the operator can see at a glance why `run` would refuse right now.
|
|
58
|
+
- `hasRunnableBundle` is exported from `mach.ts` alongside `hasBuildArtifacts`, degrades to `runnable: false` on readdir failure (no throw), and reports `expectedPath` even on the not-runnable branch so error copy can always name what to look for on disk. The three-platform probe is covered by explicit unit tests that mock `getPlatform`.
|
|
59
|
+
|
|
60
|
+
### Token add — Furnace-initialized precondition
|
|
61
|
+
|
|
62
|
+
- `fireforge token add` now refuses before any normalization when `furnace.json` is missing under the project root. Motivating case on `fresh/`: running `token add surface-test '#abcdef' --category 'Colors — Canvas' --mode static --dry-run` on a brand-new project surfaced a warning about the missing `furnace.json` and then hard-failed with `Token CSS file not found: browser/themes/shared/freshforge-tokens.css` — technically correct, but the missing tokens CSS is a downstream artefact of Furnace not being initialized. The new guard throws a targeted `FurnaceError`: `Token management requires Furnace. Run "fireforge furnace init" first, then rerun "fireforge token add …"`. Projects with an initialized Furnace hit the existing "tokens CSS file not found" path unchanged when they're missing the rendered tokens file specifically (e.g. after a manual deletion).
|
|
63
|
+
|
|
64
|
+
### Re-export — stale-manifest advisory without `--scan`
|
|
65
|
+
|
|
66
|
+
- `fireforge re-export <patch>` now warns at the start of a single-patch run when `--scan`/`--files` is not set AND one or more files in `patches.json`'s `filesAffected` no longer exist on disk. Motivating case on `fresh/lab/`: after discarding a file and re-exporting the patch without `--scan`, the refreshed patch body targeted fewer files than the manifest claimed, and `fireforge verify` then failed on manifest-consistency. The warning names the missing paths and spells out both recovery modes: re-run with `--scan` to reconcile `filesAffected` with the worktree, or `--files <paths>` to set the list explicitly. Behaviour is unchanged when `--scan` / `--files` is set (those modes already reconcile the manifest); this is advisory-only.
|
|
67
|
+
|
|
68
|
+
### Download — indexing banner
|
|
69
|
+
|
|
70
|
+
- `fireforge download` now emits a one-line `Indexing downloaded source into git (one-time; typically 1–3 minutes on a ~600 MB Firefox tree)...` before starting the git-init spinner. Motivating case on `fresh/`: after the 609 MB archive download completed, the `git add -A` pass ran silently for minutes, long enough that a CI log tail or non-TTY shell looked like the command had hung. The banner lands via `info` (not the interactive spinner) so non-TTY wrappers still see the heads-up in log scrollback.
|
|
71
|
+
|
|
72
|
+
### Status — atomic-temp-file filter
|
|
73
|
+
|
|
74
|
+
- `fireforge status` no longer surfaces FireForge's own in-flight atomic-write temp files in any output mode. Motivating case on `hominis/`: a `status --json` that coincided with a `brand.ftl` or `mozconfig` write briefly listed paths like `.brand.ftl.fireforge-tmp-12345-<uuid>` and `.mozconfig.fireforge-tmp-12345-<uuid>` alongside real changes. `src/utils/fs.ts` now exports `FIREFORGE_TMP_PATH_PATTERN`, a regex anchored on the exact shape `createAtomicTempPath` produces (`<dir>/.<filename>.fireforge-tmp-<pid>-<uuid>`), and `status.ts` filters every status entry through it after `expandDirectoryEntries` and before classification. All status modes — default, raw, unmanaged, ownership, json — apply the same filter, so a late `status` call during a large write produces the same output regardless of which view the operator chose. The pattern is tight enough to let an operator-named `.notes.fireforge-tmp-backup` (no PID+UUID continuation) pass through unfiltered.
|
|
75
|
+
|
|
3
76
|
## 0.15.0
|
|
4
77
|
|
|
78
|
+
### Re-export — opt-in `--stamp` and per-patch `lintIgnore`
|
|
79
|
+
|
|
80
|
+
- New `fireforge re-export --stamp` stamps `sourceEsrVersion` on every successfully re-exported patch to the current `firefox.version` from `fireforge.json`. Previously `re-export` only ever refreshed patch bodies and `filesAffected`; version stamping was exclusive to `rebase`'s `stampPatchVersions` call (plus `doctor --repair-patches-manifest`). An operator asked to "re-export targeting a new ESR" had no in-surface signal that the command could not deliver the version half of that request, and had to route through the full rebase flow (which requires a Firefox source re-download) purely to update a version string. `--stamp` closes that gap for the case where the re-export cleanly refreshes every selected patch — a partial run (any skipped or failed patch) refuses to stamp and the success line notes the refusal, so a torn "some bodies refreshed at old version, some at new" state is not representable. The command description and `--help` text now explicitly call out that `re-export` does NOT change `sourceEsrVersion` by default.
|
|
81
|
+
- New optional `lintIgnore: string[]` field on each patch entry in `patches.json` lists lint check IDs to suppress for that patch specifically. Surgical alternative to `--skip-lint` (which downgrades _every_ error to a warning) for the class of patch that is advisory-noisy by nature — cohesive branding bundles, localised-resource packs, auto-generated manifests — where a rule like `large-patch-lines` is not actionable. Threaded through `lintExportedPatch` as an optional `ignoreChecks` filter, honoured by `re-export`, `re-export --files`, and `lint --per-patch`. The file-level `fireforge-ignore:` comment markers for `raw-color-value` and `forward-import` are unchanged; `lintIgnore` fills the gap at the patch level where no per-line marker can exist (the `.patch` body is regenerated on every export). Unknown check IDs are a no-op so the metadata documents the _intent_ to suppress even if the rule is renamed later.
|
|
82
|
+
- Motivating case: re-exporting a 22-patch queue onto 140.9.0esr after `download --force` failed on `001-branding-branding-assets` with `ERROR [large-patch-lines] (patch): Patch is 15665 lines (hard limit: 3000)` — a 57-file branding bundle that genuinely cannot be split. The only escape was `--skip-lint` (downgrades 22 patches' worth of errors) or the full `rebase` flow (already-wasted Firefox download). With `lintIgnore: ["large-patch-lines", "large-patch-files"]` on that one patch, `re-export --all --scan --stamp` now completes in one call.
|
|
83
|
+
|
|
84
|
+
### Lint — `--per-patch` scope and aggregate-mode hint
|
|
85
|
+
|
|
86
|
+
- New `fireforge lint --per-patch` scopes the lint diff to each patch's own `filesAffected` in turn rather than the aggregate `git diff HEAD` across every applied patch. Motivating case: running `fireforge lint` (no args) after `fireforge import` / `fireforge rebase` has just applied a 22-patch queue produces an aggregate diff of every patch summed, which means the patch-size advisory rules (`large-patch-lines`, `large-patch-files`) fire against the sum — e.g. `Patch is 37529 lines`, `Patch affects 126 files` — with `Lint failed` and a non-zero exit code on a repo that is actually in a good state. The aggregate framing reads as a task-specific regression when it is really an artefact of aggregation. `--per-patch` restates the scope so each patch lints as its own isolated diff, honours the patch's own `lintIgnore` entries, and runs the cross-patch rules (`duplicate-new-file-creation`, `forward-import`) once over the whole queue so queue-level findings are not lost by the rescoping. Mutually exclusive with explicit file paths (the two scope contracts are different).
|
|
87
|
+
- Aggregate-mode runs that would otherwise surface a `large-patch-lines` / `large-patch-files` error against a multi-patch queue now print a one-line `NOTE: aggregate diff across all applied patches. Use 'fireforge lint --per-patch' to lint each patch individually; patch-size rules fire against the sum in aggregate mode.` ahead of the failure message, so the operator reaches the per-patch escape hatch without having to read the help text first. The note fires only when the patches directory has at least two entries AND the rule that fired is a patch-size rule — a single-patch queue or a non-size rule behaves identically to before.
|
|
88
|
+
- Per-patch output namespaces every issue with its owning patch filename (`ERROR [relative-import] 001-ui-test.patch :: browser/base/content/a.ts: …`) so triage can attribute findings without cross-referencing patches.json. The passing summary reports how many patches were actually linted (patches with no files on disk or an empty projected diff are silently skipped — they are not a finding).
|
|
89
|
+
|
|
5
90
|
### Test — xpcshell appdir auto-injection
|
|
6
91
|
|
|
7
92
|
- `fireforge test` now auto-injects `--app-path=<absolute>` into mach test invocations whose nearest `xpcshell.toml` sets `firefox-appdir = "browser"` on a rebranded fork (appname != `firefox`). Without this, every `resource:///modules/<name>.sys.mjs` import inside the harness throws because the upstream xpcshell harness reads the appdir override under the appname-keyed manifest field (`<appname>-appdir`) — the literal `firefox-appdir = "browser"` directive is silently ignored when `appname` is anything other than `firefox`, so `appPath` falls back to `xrePath` (one level above the real app root). The resolver lives in the new `src/core/xpcshell-appdir.ts`: it walks each test path to the nearest `xpcshell.toml`, reads `mozinfo.json` for the active appname, prefers any `<appname>-appdir` already in the manifest (so an operator who already migrated is not overridden), and otherwise probes `<objDir>/dist/bin/<value>` and `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` for the absolute target. Operator overrides via `--mach-arg=--app-path=…` always win and the resolver is skipped silently when one is detected. Mismatches across multiple test paths (different manifests resolving to different app dirs) and unresolvable manifest values (no candidate under `dist/`) are surfaced as warnings rather than guessed at, so triage can reach the underlying cause instead of debugging a wrong path.
|
package/README.md
CHANGED
|
@@ -64,8 +64,7 @@ npx fireforge export browser/base/content/browser.js --name "custom-toolbar" --c
|
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
3. Your patch is now in `patches/`.
|
|
67
|
-
|
|
68
|
-
# 4. Reset and import to verify everything applies cleanly:
|
|
67
|
+
4. Reset and import to verify everything applies cleanly:
|
|
69
68
|
|
|
70
69
|
```bash
|
|
71
70
|
npx fireforge reset --yes
|
|
@@ -116,6 +115,8 @@ fireforge import --force
|
|
|
116
115
|
|
|
117
116
|
### Exporting changes
|
|
118
117
|
|
|
118
|
+
`export`, `export-all`, `lint`, `register`, and `test` all accept either engine-relative paths (`browser/base/content/foo.js`) or repo-root-relative paths with a leading `engine/` segment (`engine/browser/base/content/foo.js`). The prefix is case-insensitive and tolerates leading whitespace; operators commonly paste the repo-rooted form from `git status` output or shell tab-completion.
|
|
119
|
+
|
|
119
120
|
```bash
|
|
120
121
|
# Single file
|
|
121
122
|
fireforge export browser/base/content/browser.js
|
|
@@ -139,6 +140,13 @@ fireforge export browser/base/content/browser.js --before 005-ui-sidebar.patch -
|
|
|
139
140
|
|
|
140
141
|
# Restrict a re-export to a specific file subset
|
|
141
142
|
fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
|
|
143
|
+
|
|
144
|
+
# Refresh every patch AND stamp sourceEsrVersion from fireforge.json onto each
|
|
145
|
+
# one. Only stamps when every selected patch refreshes cleanly — partial
|
|
146
|
+
# runs refuse to stamp. Use when you re-exported after a manual Firefox
|
|
147
|
+
# bump that did not go through `rebase`. By default `re-export` refreshes
|
|
148
|
+
# patch bodies and filesAffected but does NOT change sourceEsrVersion.
|
|
149
|
+
fireforge re-export --all --scan --stamp
|
|
142
150
|
```
|
|
143
151
|
|
|
144
152
|
### Rebasing on top of a new Firefox version
|
|
@@ -176,12 +184,15 @@ This re-exports the fixed patch and continues applying the remaining stack.
|
|
|
176
184
|
"description": "Replaces default Firefox branding with custom logo",
|
|
177
185
|
"createdAt": "2025-01-15T10:30:00Z",
|
|
178
186
|
"sourceEsrVersion": "140.9.0esr",
|
|
179
|
-
"filesAffected": ["browser/branding/official/logo.png"]
|
|
187
|
+
"filesAffected": ["browser/branding/official/logo.png"],
|
|
188
|
+
"lintIgnore": ["large-patch-lines", "large-patch-files"]
|
|
180
189
|
}
|
|
181
190
|
]
|
|
182
191
|
}
|
|
183
192
|
```
|
|
184
193
|
|
|
194
|
+
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.
|
|
195
|
+
|
|
185
196
|
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.
|
|
186
197
|
|
|
187
198
|
</details>
|
|
@@ -191,6 +202,8 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
|
|
|
191
202
|
|
|
192
203
|
`fireforge lint` runs automatically during export, export-all and re-export. Use `--skip-lint` to downgrade errors to warnings. Errors block the export; warnings are printed but do not block.
|
|
193
204
|
|
|
205
|
+
By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed. 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; the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
|
|
206
|
+
|
|
194
207
|
| Check | Scope | Severity |
|
|
195
208
|
| ------------------------------ | ------------------------------------------------------------------------- | ------------------------ |
|
|
196
209
|
| `missing-license-header` | New files (JS/CSS/FTL) | error |
|
package/dist/src/cli.d.ts
CHANGED
|
@@ -11,7 +11,10 @@ export declare function resetBrokenPipeHandlerForTests(): void;
|
|
|
11
11
|
/**
|
|
12
12
|
* Gets the project root directory.
|
|
13
13
|
* Walks up from the current working directory until a fireforge.json is found.
|
|
14
|
-
* Throws
|
|
14
|
+
* Throws a {@link ConfigNotFoundError} (code: CONFIG_ERROR) when no
|
|
15
|
+
* fireforge.json is found within the walk depth limit — the error is
|
|
16
|
+
* user-facing so `withErrorHandling` can print the guidance without
|
|
17
|
+
* the stack dump that a plain `Error` would trigger.
|
|
15
18
|
*/
|
|
16
19
|
export declare function getProjectRoot(): string;
|
|
17
20
|
/**
|
package/dist/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Command, Help } from 'commander';
|
|
|
5
5
|
import { COMMAND_MANIFEST } from './commands/manifest.js';
|
|
6
6
|
import { CancellationError, CommandError, FireForgeError } from './errors/base.js';
|
|
7
7
|
import { ExitCode } from './errors/codes.js';
|
|
8
|
+
import { ConfigNotFoundError } from './errors/config.js';
|
|
8
9
|
import { toError } from './utils/errors.js';
|
|
9
10
|
import { cancel, error as logError, setVerbose } from './utils/logger.js';
|
|
10
11
|
import { getPackageVersion } from './utils/package-root.js';
|
|
@@ -64,7 +65,10 @@ const MAX_PROJECT_ROOT_WALK_DEPTH = 50;
|
|
|
64
65
|
/**
|
|
65
66
|
* Gets the project root directory.
|
|
66
67
|
* Walks up from the current working directory until a fireforge.json is found.
|
|
67
|
-
* Throws
|
|
68
|
+
* Throws a {@link ConfigNotFoundError} (code: CONFIG_ERROR) when no
|
|
69
|
+
* fireforge.json is found within the walk depth limit — the error is
|
|
70
|
+
* user-facing so `withErrorHandling` can print the guidance without
|
|
71
|
+
* the stack dump that a plain `Error` would trigger.
|
|
68
72
|
*/
|
|
69
73
|
export function getProjectRoot() {
|
|
70
74
|
const start = resolve(process.cwd());
|
|
@@ -78,8 +82,7 @@ export function getProjectRoot() {
|
|
|
78
82
|
break;
|
|
79
83
|
current = parent;
|
|
80
84
|
}
|
|
81
|
-
throw new
|
|
82
|
-
'Are you inside a FireForge project?');
|
|
85
|
+
throw new ConfigNotFoundError('fireforge.json');
|
|
83
86
|
}
|
|
84
87
|
/**
|
|
85
88
|
* Wraps a command handler with error handling.
|
|
@@ -191,6 +191,15 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
191
191
|
s.error('Download failed');
|
|
192
192
|
throw error;
|
|
193
193
|
}
|
|
194
|
+
// Finding #17: the git indexing phase of `download` can block for
|
|
195
|
+
// minutes on a ~600 MB Firefox tree — the spinner updates less often
|
|
196
|
+
// than operators expect during the monolithic `git add -A` pass, and
|
|
197
|
+
// non-TTY shells see long stretches of silence. Emit a one-line
|
|
198
|
+
// heads-up banner BEFORE the spinner starts so even a log-scraping
|
|
199
|
+
// CI job notes the expected duration. The progress callbacks below
|
|
200
|
+
// still fire as usual; this is an additional up-front signal, not a
|
|
201
|
+
// replacement.
|
|
202
|
+
info('Indexing downloaded source into git (one-time; typically 1–3 minutes on a ~600 MB Firefox tree)...');
|
|
194
203
|
// Initialize git repository
|
|
195
204
|
const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
|
|
196
205
|
let baseCommit;
|
|
@@ -8,6 +8,7 @@ import { getAllDiff } from '../core/git-diff.js';
|
|
|
8
8
|
import { getWorkingTreeStatus } from '../core/git-status.js';
|
|
9
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
10
10
|
import { commitExportedPatch } from '../core/patch-export.js';
|
|
11
|
+
import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
|
|
11
12
|
import { GeneralError } from '../errors/base.js';
|
|
12
13
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
13
14
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
@@ -37,6 +38,47 @@ async function checkFurnaceManagedFiles(paths, projectRoot) {
|
|
|
37
38
|
'These files are deployed by "fireforge furnace apply" and should be managed through the Furnace workflow. Review them with "fireforge status" or "fireforge furnace status".');
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Refuses the export when the aggregate diff would create (new-file-mode) a
|
|
43
|
+
* path that some existing patch in the queue already creates. `verify`
|
|
44
|
+
* detects this post-hoc via `collectNewFileCreatorsByPath`, but by the time
|
|
45
|
+
* `verify` runs the operator has already landed a patch that irreversibly
|
|
46
|
+
* sits atop the queue — resolving the conflict from there requires a
|
|
47
|
+
* `patch delete` or hand-surgery on `re-export --files`. Catching it here,
|
|
48
|
+
* pre-write, keeps the queue clean and gives the operator a specific path
|
|
49
|
+
* to narrow with `export` + explicit file scoping (which lets them drop
|
|
50
|
+
* the already-claimed path without losing other edits).
|
|
51
|
+
*
|
|
52
|
+
* Slots in alongside the existing branding and furnace guards so the three
|
|
53
|
+
* "export-all refuses" branches remain the single, symmetric fence around
|
|
54
|
+
* unintended captures.
|
|
55
|
+
*/
|
|
56
|
+
async function checkDuplicateNewFileCreations(paths, diff) {
|
|
57
|
+
if (!(await pathExists(paths.patches)))
|
|
58
|
+
return;
|
|
59
|
+
const pendingNewFiles = detectNewFilesInDiff(diff);
|
|
60
|
+
if (pendingNewFiles.size === 0)
|
|
61
|
+
return;
|
|
62
|
+
const ctx = await buildPatchQueueContext(paths.patches);
|
|
63
|
+
if (ctx.entries.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
const creators = collectNewFileCreatorsByPath(ctx);
|
|
66
|
+
const conflicts = [];
|
|
67
|
+
for (const path of pendingNewFiles) {
|
|
68
|
+
const owners = creators.get(path);
|
|
69
|
+
if (owners && owners.length > 0) {
|
|
70
|
+
conflicts.push({ path, owners });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (conflicts.length === 0)
|
|
74
|
+
return;
|
|
75
|
+
const conflictList = conflicts
|
|
76
|
+
.map(({ path, owners }) => ` • ${path} — already created by ${owners.join(', ')}`)
|
|
77
|
+
.join('\n');
|
|
78
|
+
throw new GeneralError('Export-all refuses to capture new-file creations that are already claimed by existing patches.\n\n' +
|
|
79
|
+
`Conflicting creations:\n${conflictList}\n\n` +
|
|
80
|
+
'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.');
|
|
81
|
+
}
|
|
40
82
|
/**
|
|
41
83
|
* Runs the export-all command to export all changes as a patch.
|
|
42
84
|
* @param projectRoot - Root directory of the project
|
|
@@ -69,6 +111,10 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
69
111
|
outro('Nothing to export');
|
|
70
112
|
return;
|
|
71
113
|
}
|
|
114
|
+
// Duplicate-creation preflight needs the diff in hand to see which paths
|
|
115
|
+
// the aggregate would newly create, so it runs here instead of alongside
|
|
116
|
+
// the branding / furnace guards that operate on the raw status list.
|
|
117
|
+
await checkDuplicateNewFileCreations(paths, diff);
|
|
72
118
|
// Check for non-interactive mode
|
|
73
119
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
74
120
|
// Auto-fix missing license headers on new files (interactive only)
|
|
@@ -11,8 +11,13 @@ import type { SpinnerHandle } from '../utils/logger.js';
|
|
|
11
11
|
* @param config - Project configuration
|
|
12
12
|
* @param skipLint - If true, downgrade errors to warnings
|
|
13
13
|
* @param patchQueueCtx - Optional cross-patch context for ownership resolution
|
|
14
|
+
* @param ignoreChecks - Optional per-patch set of `check` IDs to suppress
|
|
15
|
+
* (threaded from `PatchMetadata.lintIgnore`). Surgical alternative to
|
|
16
|
+
* `--skip-lint` when exactly one advisory rule does not apply to a
|
|
17
|
+
* specific patch — e.g. `large-patch-lines` on a cohesive branding
|
|
18
|
+
* bundle that genuinely cannot be split.
|
|
14
19
|
*/
|
|
15
|
-
export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext): Promise<void>;
|
|
20
|
+
export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>): Promise<void>;
|
|
16
21
|
/**
|
|
17
22
|
* Resolves patch metadata interactively or from flags, with shared validation.
|
|
18
23
|
* @param options - Export command options
|
|
@@ -18,9 +18,14 @@ import { isValidPatchCategory, PATCH_CATEGORIES, validatePatchName } from '../ut
|
|
|
18
18
|
* @param config - Project configuration
|
|
19
19
|
* @param skipLint - If true, downgrade errors to warnings
|
|
20
20
|
* @param patchQueueCtx - Optional cross-patch context for ownership resolution
|
|
21
|
+
* @param ignoreChecks - Optional per-patch set of `check` IDs to suppress
|
|
22
|
+
* (threaded from `PatchMetadata.lintIgnore`). Surgical alternative to
|
|
23
|
+
* `--skip-lint` when exactly one advisory rule does not apply to a
|
|
24
|
+
* specific patch — e.g. `large-patch-lines` on a cohesive branding
|
|
25
|
+
* bundle that genuinely cannot be split.
|
|
21
26
|
*/
|
|
22
|
-
export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx) {
|
|
23
|
-
const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx);
|
|
27
|
+
export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx, ignoreChecks) {
|
|
28
|
+
const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks);
|
|
24
29
|
if (issues.length === 0)
|
|
25
30
|
return;
|
|
26
31
|
const errors = issues.filter((i) => i.severity === 'error');
|
|
@@ -16,6 +16,7 @@ import { toError } from '../utils/errors.js';
|
|
|
16
16
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
17
17
|
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
18
18
|
import { pickDefined } from '../utils/options.js';
|
|
19
|
+
import { stripEnginePrefix } from '../utils/paths.js';
|
|
19
20
|
import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
|
|
20
21
|
import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
|
|
21
22
|
import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
@@ -23,7 +24,15 @@ async function collectExportFiles(paths, files) {
|
|
|
23
24
|
const collectedFiles = new Set();
|
|
24
25
|
let fileStatuses;
|
|
25
26
|
let untrackedFiles;
|
|
26
|
-
|
|
27
|
+
// Accept both repo-root-relative (`engine/browser/...`) and engine-relative
|
|
28
|
+
// (`browser/...`) paths for every input, matching `register`/`test`/`lint`.
|
|
29
|
+
// Previously, an `engine/`-prefixed path fell through to
|
|
30
|
+
// `File "engine/..." has no changes to export.` because the status lookup
|
|
31
|
+
// sees paths relative to `paths.engine` and the explicit prefix double-
|
|
32
|
+
// rooted the candidate. `stripEnginePrefix` makes that user-facing form
|
|
33
|
+
// a no-op for the lookup pipeline.
|
|
34
|
+
for (const rawInputPath of files) {
|
|
35
|
+
const inputPath = stripEnginePrefix(rawInputPath);
|
|
27
36
|
const fullInputPath = join(paths.engine, inputPath);
|
|
28
37
|
let isDirectory = false;
|
|
29
38
|
try {
|
|
@@ -100,6 +100,14 @@ async function diffOverride(name, projectRoot, config) {
|
|
|
100
100
|
/**
|
|
101
101
|
* Diffs a custom component's workspace files against the engine-deployed copy.
|
|
102
102
|
* Shows what would change (or has changed) on the next `furnace apply`.
|
|
103
|
+
*
|
|
104
|
+
* `.ftl` files deploy to `engine/<ftlDir>/<name>.ftl` via `applyCustomFtlFile`
|
|
105
|
+
* — NOT to `customConfig.targetPath` — so the deployment-target lookup has
|
|
106
|
+
* to branch on extension. Before this branch existed, a component's
|
|
107
|
+
* localization file always reported "not yet deployed to engine (new
|
|
108
|
+
* file)" after a successful apply/deploy because diff was looking for it
|
|
109
|
+
* under the component's `targetPath` while apply had written it into the
|
|
110
|
+
* locale tree.
|
|
103
111
|
*/
|
|
104
112
|
async function diffCustom(name, projectRoot, config) {
|
|
105
113
|
const customConfig = config.custom[name];
|
|
@@ -108,6 +116,7 @@ async function diffCustom(name, projectRoot, config) {
|
|
|
108
116
|
}
|
|
109
117
|
const paths = getProjectPaths(projectRoot);
|
|
110
118
|
const furnacePaths = getFurnacePaths(projectRoot);
|
|
119
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
111
120
|
const customDir = join(furnacePaths.customDir, name);
|
|
112
121
|
if (!(await pathExists(customDir))) {
|
|
113
122
|
throw new FurnaceError(`Custom component directory not found: components/custom/${name}`, name);
|
|
@@ -121,8 +130,19 @@ async function diffCustom(name, projectRoot, config) {
|
|
|
121
130
|
if (!isComponentSourceFile(entry.name))
|
|
122
131
|
continue;
|
|
123
132
|
const workspacePath = join(customDir, entry.name);
|
|
124
|
-
const deployedPath = join(engineDir, entry.name);
|
|
125
133
|
const workspaceContent = await readText(workspacePath);
|
|
134
|
+
// `.ftl` files deploy to the locale tree, not the component's
|
|
135
|
+
// targetPath; mirror `applyCustomFtlFile`'s target computation so the
|
|
136
|
+
// diff header and the existence probe name the same path apply
|
|
137
|
+
// writes to. Any change here must stay in lock-step with
|
|
138
|
+
// `src/core/furnace-apply-ftl.ts`.
|
|
139
|
+
const isFtl = entry.name.endsWith('.ftl');
|
|
140
|
+
const deployedPath = isFtl
|
|
141
|
+
? join(paths.engine, ftlDir, entry.name)
|
|
142
|
+
: join(engineDir, entry.name);
|
|
143
|
+
const deployedDisplayPath = isFtl
|
|
144
|
+
? `engine/${ftlDir}/${entry.name}`
|
|
145
|
+
: `engine/${customConfig.targetPath}/${entry.name}`;
|
|
126
146
|
if (!(await pathExists(deployedPath))) {
|
|
127
147
|
info(`${entry.name}: not yet deployed to engine (new file)`);
|
|
128
148
|
hasDifferences = true;
|
|
@@ -133,7 +153,7 @@ async function diffCustom(name, projectRoot, config) {
|
|
|
133
153
|
continue;
|
|
134
154
|
}
|
|
135
155
|
hasDifferences = true;
|
|
136
|
-
info(`---
|
|
156
|
+
info(`--- ${deployedDisplayPath}`);
|
|
137
157
|
info(`+++ components/custom/${name}/${entry.name}`);
|
|
138
158
|
for (const line of formatUnifiedDiff(deployedContent, workspaceContent)) {
|
|
139
159
|
info(line);
|
|
@@ -139,23 +139,37 @@ async function performOverrideMutations(args) {
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
* Throws if `componentName` is already classified
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
142
|
+
* Throws if `componentName` is already classified as something `override`
|
|
143
|
+
* cannot coexist with. A stock-bucket entry is NOT a hard conflict — the
|
|
144
|
+
* whole point of `override` is to fork a component out of the stock bucket
|
|
145
|
+
* into the overrides bucket, and requiring manual `furnace.json` surgery
|
|
146
|
+
* first was a pure footgun. `promoteStockToOverrideIfNeeded` handles the
|
|
147
|
+
* transition in-memory; this guard only rejects the other two cases where
|
|
148
|
+
* a rename actually contradicts existing state.
|
|
147
149
|
*/
|
|
148
150
|
function assertNoComponentCollision(config, componentName) {
|
|
149
151
|
if (componentName in config.overrides) {
|
|
150
152
|
throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
|
|
151
153
|
}
|
|
152
|
-
if (config.stock.includes(componentName)) {
|
|
153
|
-
throw new FurnaceError(`"${componentName}" is already registered as a stock component. Remove it from config.stock before creating an override.`, componentName);
|
|
154
|
-
}
|
|
155
154
|
if (componentName in config.custom) {
|
|
156
155
|
throw new FurnaceError(`"${componentName}" is already registered as a custom component. Custom components cannot also be overrides.`, componentName);
|
|
157
156
|
}
|
|
158
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* When the operator overrides a component that `furnace scan` previously
|
|
160
|
+
* classified as stock, splice the name out of `config.stock` in-memory so
|
|
161
|
+
* the subsequent `writeFurnaceConfig` inside the mutation phase persists
|
|
162
|
+
* the stock → override promotion atomically alongside the new override
|
|
163
|
+
* entry. Returns true when a promotion happened so the caller can emit a
|
|
164
|
+
* one-line note; false when the component was not stock.
|
|
165
|
+
*/
|
|
166
|
+
function promoteStockToOverrideIfNeeded(config, componentName) {
|
|
167
|
+
const index = config.stock.indexOf(componentName);
|
|
168
|
+
if (index === -1)
|
|
169
|
+
return false;
|
|
170
|
+
config.stock.splice(index, 1);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
159
173
|
/**
|
|
160
174
|
* Runs the furnace override command to fork an existing engine component.
|
|
161
175
|
* @param projectRoot - Root directory of the project
|
|
@@ -213,6 +227,10 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
|
|
|
213
227
|
componentName = selected;
|
|
214
228
|
}
|
|
215
229
|
assertNoComponentCollision(config, componentName);
|
|
230
|
+
const promotedFromStock = promoteStockToOverrideIfNeeded(config, componentName);
|
|
231
|
+
if (promotedFromStock) {
|
|
232
|
+
info(`Promoting "${componentName}" from stock to override.`);
|
|
233
|
+
}
|
|
216
234
|
// Validate the component exists in engine
|
|
217
235
|
const details = await getComponentDetails(paths.engine, componentName, ftlDir);
|
|
218
236
|
if (!details) {
|
|
@@ -323,13 +341,18 @@ export async function furnaceBatchOverrideCommand(projectRoot, names, options =
|
|
|
323
341
|
const forgeConfig = await loadConfig(projectRoot);
|
|
324
342
|
const state = await loadState(projectRoot);
|
|
325
343
|
// Check for duplicates and pre-existing classifications across every
|
|
326
|
-
// bucket in furnace.json.
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
344
|
+
// bucket in furnace.json. A stock-bucket entry is promoted in-memory
|
|
345
|
+
// here (see `promoteStockToOverrideIfNeeded`) rather than rejected —
|
|
346
|
+
// the operator's intent is to fork that specific stock component. The
|
|
347
|
+
// collision guard still rejects name conflicts that would double-
|
|
348
|
+
// classify a tag in a way `writeFurnaceConfig` cannot safely produce
|
|
349
|
+
// (two overrides, or an override + custom).
|
|
330
350
|
const uniqueNames = [...new Set(names)];
|
|
331
351
|
for (const name of uniqueNames) {
|
|
332
352
|
assertNoComponentCollision(config, name);
|
|
353
|
+
if (promoteStockToOverrideIfNeeded(config, name)) {
|
|
354
|
+
info(`Promoting "${name}" from stock to override.`);
|
|
355
|
+
}
|
|
333
356
|
}
|
|
334
357
|
const succeeded = [];
|
|
335
358
|
const failed = [];
|
|
@@ -6,7 +6,7 @@ import { furnaceConfigExists, loadFurnaceConfig, updateFurnaceState, } from '../
|
|
|
6
6
|
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
7
7
|
import { restoreRollbackJournal } from '../../core/furnace-rollback.js';
|
|
8
8
|
import { cleanStories, syncStories } from '../../core/furnace-stories.js';
|
|
9
|
-
import { runMach, runMachCapture } from '../../core/mach.js';
|
|
9
|
+
import { hasBuildArtifacts, runMach, runMachCapture } from '../../core/mach.js';
|
|
10
10
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
11
11
|
import { toError } from '../../utils/errors.js';
|
|
12
12
|
import { pathExists } from '../../utils/fs.js';
|
|
@@ -89,6 +89,35 @@ function buildStorybookFailureMessage(output, installRequested) {
|
|
|
89
89
|
return ('Storybook failed to start. Check the output above for the specific Firefox-side error.\n\n' +
|
|
90
90
|
installHint);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Preflights the Firefox build + toolchain prerequisites `mach storybook`
|
|
94
|
+
* quietly assumes. Pre-0.16.0 the preview staged components and launched
|
|
95
|
+
* a ~1000-package `mach storybook upgrade` npm install before the
|
|
96
|
+
* backend surfaced a "missing chrome-map.json" / Cargo-config failure;
|
|
97
|
+
* the preflight below refuses fast and leaves the workspace untouched.
|
|
98
|
+
*
|
|
99
|
+
* Extracted from `furnacePreviewCommand` so the main function stays
|
|
100
|
+
* under the per-function LOC budget as the preflight list grows.
|
|
101
|
+
*
|
|
102
|
+
* @param engineDir - Resolved engine directory
|
|
103
|
+
* @throws FurnaceError when the Firefox build hasn't produced dist/, or
|
|
104
|
+
* when `.cargo/config.toml` is absent
|
|
105
|
+
*/
|
|
106
|
+
async function assertPreviewPrerequisites(engineDir) {
|
|
107
|
+
const buildCheck = await hasBuildArtifacts(engineDir);
|
|
108
|
+
if (!buildCheck.exists) {
|
|
109
|
+
throw new FurnaceError('Furnace preview requires a completed Firefox build. ' +
|
|
110
|
+
'`mach storybook` consumes `obj-*/dist/chrome-map.json` and the packaged chrome resources under `dist/`, neither of which is present before `fireforge build` completes.\n\n' +
|
|
111
|
+
'Run "fireforge build" and wait for it to finish, then rerun "fireforge furnace preview". ' +
|
|
112
|
+
'This preflight avoids a multi-minute `mach storybook upgrade` npm install on an engine that cannot start Storybook anyway.');
|
|
113
|
+
}
|
|
114
|
+
const cargoConfigPath = join(engineDir, '.cargo', 'config.toml');
|
|
115
|
+
if (!(await pathExists(cargoConfigPath))) {
|
|
116
|
+
throw new FurnaceError("Furnace preview requires the engine's Rust toolchain to be bootstrapped. " +
|
|
117
|
+
'`.cargo/config.toml` is missing under the engine directory — `mach storybook` fails deep inside the Storybook backend compile without it.\n\n' +
|
|
118
|
+
'Run "fireforge bootstrap" (or the underlying `mach bootstrap` in the engine) to populate the toolchain config, then rerun "fireforge furnace preview".');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
92
121
|
/**
|
|
93
122
|
* Runs the furnace preview command to start Storybook for component preview.
|
|
94
123
|
* @param projectRoot - Root directory of the project
|
|
@@ -119,6 +148,9 @@ export async function furnacePreviewCommand(projectRoot, options = {}) {
|
|
|
119
148
|
if (!(await pathExists(storybookRoot))) {
|
|
120
149
|
throw new FurnaceError('This Firefox checkout does not contain browser/components/storybook. Furnace preview requires the upstream Storybook workspace to exist before stories can be synced.');
|
|
121
150
|
}
|
|
151
|
+
// Build + toolchain preflight (Finding #9). Extracted into a helper so
|
|
152
|
+
// the function below stays under the per-function LOC budget.
|
|
153
|
+
await assertPreviewPrerequisites(paths.engine);
|
|
122
154
|
let previewResult;
|
|
123
155
|
// True once we are about to (or have) written to engine/.../stories/furnace.
|
|
124
156
|
// Intentionally set BEFORE `syncStories` is awaited so a mid-sync failure
|
|
@@ -304,14 +304,25 @@ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
|
|
|
304
304
|
throw new FurnaceError(`A component named "${newName}" already exists in furnace.json.`, newName);
|
|
305
305
|
}
|
|
306
306
|
const componentType = isCustom ? 'custom' : 'override';
|
|
307
|
+
// `componentType` is the furnace-state key (singular: `custom` /
|
|
308
|
+
// `override`); the on-disk directory label differs — custom components
|
|
309
|
+
// live under `components/custom/` (singular) while overrides live under
|
|
310
|
+
// `components/overrides/` (plural). Before 0.16.0, every rename
|
|
311
|
+
// user-facing message appended an `s` to `componentType`, which
|
|
312
|
+
// produced the wrong label `components/customs/` for custom components
|
|
313
|
+
// and was technically correct for overrides only by coincidence.
|
|
314
|
+
// `componentDirLabel` centralises the singular/plural pick so every
|
|
315
|
+
// operator-facing string names the directory that actually exists on
|
|
316
|
+
// disk.
|
|
317
|
+
const componentDirLabel = isCustom ? 'custom' : 'overrides';
|
|
307
318
|
const baseDir = isCustom ? furnacePaths.customDir : furnacePaths.overridesDir;
|
|
308
319
|
const oldDir = join(baseDir, oldName);
|
|
309
320
|
const newDir = join(baseDir, newName);
|
|
310
321
|
if (!(await pathExists(oldDir))) {
|
|
311
|
-
throw new FurnaceError(`Component directory not found: components/${
|
|
322
|
+
throw new FurnaceError(`Component directory not found: components/${componentDirLabel}/${oldName}`, oldName);
|
|
312
323
|
}
|
|
313
324
|
if (await pathExists(newDir)) {
|
|
314
|
-
throw new FurnaceError(`Target directory already exists: components/${
|
|
325
|
+
throw new FurnaceError(`Target directory already exists: components/${componentDirLabel}/${newName}`, newName);
|
|
315
326
|
}
|
|
316
327
|
await performRenameMutations({
|
|
317
328
|
projectRoot,
|
|
@@ -326,7 +337,7 @@ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
|
|
|
326
337
|
engineDir: paths.engine,
|
|
327
338
|
});
|
|
328
339
|
note(`Component renamed: ${oldName} → ${newName}\n\n` +
|
|
329
|
-
`Directory: components/${
|
|
340
|
+
`Directory: components/${componentDirLabel}/${newName}/\n\n` +
|
|
330
341
|
'Next steps:\n' +
|
|
331
342
|
' 1. Review the renamed files for any remaining references\n' +
|
|
332
343
|
' 2. Run "fireforge furnace validate" to verify\n' +
|