@hominis/fireforge 0.18.0 → 0.18.2
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 +18 -2
- package/README.md +55 -34
- package/dist/src/commands/doctor.js +13 -1
- package/dist/src/commands/export-all.js +63 -1
- package/dist/src/commands/export-flow.d.ts +4 -0
- package/dist/src/commands/export-flow.js +8 -0
- package/dist/src/commands/export.js +26 -2
- package/dist/src/commands/furnace/create-xpcshell.js +4 -2
- package/dist/src/commands/furnace/preview.js +38 -0
- package/dist/src/commands/furnace/remove.js +67 -1
- package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
- package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
- package/dist/src/commands/furnace/rename.js +9 -0
- package/dist/src/commands/patch/index.d.ts +5 -3
- package/dist/src/commands/patch/index.js +10 -4
- package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
- package/dist/src/commands/patch/lint-ignore.js +200 -0
- package/dist/src/commands/patch/tier.d.ts +34 -0
- package/dist/src/commands/patch/tier.js +134 -0
- package/dist/src/commands/re-export-files.js +88 -45
- package/dist/src/commands/re-export.js +49 -6
- package/dist/src/commands/rebase/index.js +19 -1
- package/dist/src/commands/status.js +44 -5
- package/dist/src/commands/test.js +27 -16
- package/dist/src/commands/verify.js +81 -6
- package/dist/src/commands/watch.js +43 -7
- package/dist/src/core/furnace-constants.d.ts +14 -0
- package/dist/src/core/furnace-constants.js +16 -0
- package/dist/src/core/furnace-validate.js +67 -1
- package/dist/src/core/git-base.d.ts +27 -2
- package/dist/src/core/git-base.js +41 -3
- package/dist/src/core/git-diff.js +34 -2
- package/dist/src/core/git.js +53 -14
- package/dist/src/core/mach.d.ts +14 -2
- package/dist/src/core/mach.js +12 -2
- package/dist/src/core/marionette-preflight.d.ts +16 -0
- package/dist/src/core/marionette-preflight.js +19 -0
- package/dist/src/core/patch-export.d.ts +77 -2
- package/dist/src/core/patch-export.js +82 -3
- package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
- package/dist/src/core/patch-lint-diff-tag.js +25 -0
- package/dist/src/core/patch-lint.js +82 -32
- package/dist/src/core/patch-registration-refs.d.ts +42 -0
- package/dist/src/core/patch-registration-refs.js +117 -0
- package/dist/src/core/xpcshell-appdir.d.ts +19 -5
- package/dist/src/core/xpcshell-appdir.js +46 -20
- package/dist/src/errors/git.d.ts +20 -0
- package/dist/src/errors/git.js +39 -0
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +67 -0
- package/dist/src/types/commands/patches.d.ts +6 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
- **`fireforge test --doctor` — triple-path PASS footer survives non-TTY clack rendering.** The eval's `test --doctor` captures on both fresh and hominis projects showed `● Running marionette preflight...` and then exited 0 with nothing else — the 0.17.0 `outro(\`Marionette preflight: PASS (...)\`)`was dropped by clack somewhere in the non-TTY render pipeline (exact root cause could not be isolated from captures alone). Rather than chase the rendering bug, the fix triple-writes the result:`success(\`Marionette preflight: PASS (...)\`)`, then `outro('Test completed')`closes the intro frame, then`process.stdout.write(\`${summary}\n\`)`guarantees the line reaches stdout regardless of clack's buffering. A test in`test.test.ts` asserts all three emissions.
|
|
21
21
|
- **`fireforge test` — fork-owned module import failures beat the branding stale-build diagnosis.** The eval's hominis xpcshell test failed loading `resource:///modules/hominis/HominisStore.sys.mjs`; FireForge reported "stale build artifacts, run fireforge build --ui" because the harness teardown also printed a branding warning that matched the narrower stale-build pattern. Re-running the build did nothing — the real failure was that the new module wasn't registered in `browser/modules/hominis/moz.build`. `handleNonZeroTestExit` now checks for `Failed to load resource:///modules/<binaryName>/` BEFORE the branding stale-build branch and surfaces a fork-module-specific diagnostic pointing at `browser/modules/<binary>/moz.build` + `fireforge register`, so the operator sees the correct recovery path on the first try. Existing branding-stale diagnosis is untouched.
|
|
22
22
|
- **`fireforge token coverage` — `--moz-*` platform variables allowlisted by default; untracked CSS directories now scanned.** Two independent token-coverage fixes:
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
- **Platform allowlist** — `src/core/token-coverage.ts` seeds a default `platformPrefixes: ['--moz-']` and treats matches as `allowlisted` rather than `unknown`. A CSS-only Furnace override of `moz-button` with one fork token previously reported 1% coverage because the copied upstream baseline referenced 84 `--moz-*` platform vars; coverage now reports 100% on the same diff, and the one unknown was correctly declared by the fork. Forks can override through a new optional `platformPrefixes?: string[]` field on `furnace.json` — extending the list (e.g. `['--moz-', '--in-content-']`) for forks with additional platform prefixes, or setting an explicit empty array to restore the pre-0.18 strict contract. The field is added to the public `FurnaceConfig` type.
|
|
24
|
+
- **Untracked-directory expansion** — `src/commands/token-coverage.ts` now reads `getWorkingTreeStatus` + `expandUntrackedDirectoryEntries` instead of `getStatusWithCodes` so CSS files inside a `?? dir/` untracked directory (the common shape for a freshly-imported patch stack adding a new theme tree) show up in the scan. The eval's hominis run saw `?? browser/branding/hominis/content/aboutDialog.css` and `?? browser/themes/shared/hominis-tokens.css` collapse to directory entries that didn't end in `.css`; coverage reported "No modified CSS files". Both CSS files are now scanned.
|
|
25
25
|
- **`fireforge patch reorder` + `fireforge patch delete` — accept manifest `name` handle in addition to filename and ordinal.** `resolvePatchIdentifier` in `src/core/patch-manifest-resolve.ts` previously rejected the `name` field even though CLI help says `<name>` and the manifest stores a `name` on every entry. Operators had to copy the full filename from `patches.json` before every queue mutation. The resolver now matches, in order: ordinal number → exact filename → filename-with-`.patch`-appended → `name` field. Filename lookup still wins when a manifest happens to have a `name` identical to a different filename — legacy scripts passing filenames keep working. Error messages on both commands now list filenames AND their `name` counterparts so operators see both forms as valid identifiers.
|
|
26
26
|
- **`fireforge patch reorder` — postcondition assert on each Phase 2 rename.** The eval reported that a two-patch swap updated `patches.json` but left the files at their pre-rename names, and `fireforge verify` then failed ENOENT opening the manifest-renamed file. The code path in `renumberPatchesInManifest` (two-phase staging: stage → final + manifest write) appears correct against both the integration test in `patch-mutations.integration.test.ts` and the shipped 0.17.0 dist — but the eval's operation log shows the manifest was updated while the rename was missing, so a filesystem-specific failure mode exists that the existing integration test does not exercise. Defense-in-depth lands a postcondition `pathExists(newFilename)` assert after every `rename()` in Phase 2, so any silent rename failure aborts the reorder before the manifest write and triggers the Phase 2 rollback (which itself reverses completed final-name moves and unwinds the staging). A second integration test reproduces the exact eval scenario (`001-infra` + `002-ui` swap via `--to 1 --yes`) and asserts both filenames exist on disk after completion.
|
|
27
27
|
- **`fireforge register --dry-run` — honours the rule-level idempotency decision.** `registerFile` in `src/core/manifest-rules.ts` returned `{ skipped: true }` when the rule's `isRegistered(...)` check found the file already present in its manifest, but `registerCommand` in `src/commands/register.ts` ignored the flag on the dry-run branch and always printed `[dry-run] Would register <path>`. The next real invocation correctly reported `Already registered: <path>`. Automation that consumed the dry-run as a plan mis-predicted unnecessary work. Dry-run now prints `[dry-run] Already registered: <path> in <manifest>` when `result.skipped` is true — mirroring the real-run copy — and otherwise prints the registration plan as before.
|
|
@@ -30,9 +30,25 @@
|
|
|
30
30
|
- **`fireforge register` — empty single-line `EXTRA_JS_MODULES += []` lists expand before insertion.** `tokenizeMozBuildList` in `src/core/manifest-tokenizers.ts` tracked opening `[` and closing `]` on separate lines. A freshly-scaffolded `browser/modules/<fork>/moz.build` that wrote `EXTRA_JS_MODULES += []` on one line returned `null` from the tokenizer because the scanner never saw a line STARTING with `]`, and `registerFireForgeModule` failed with `Could not find EXTRA_JS_MODULES in moz.build` — blocking the documented `browser/modules/<fork>/*.sys.mjs` workflow at the first module. The tokenizer now detects the single-line empty-list form `...+= []` at open time, rewrites `lines[i]` to `...+= [` and splices in a `]` at `i + 1`, and emits matched `list-open` / `list-close` tokens for the canonical multi-line shape. Downstream `findAlphabeticalMozBuildPosition` and the register helpers then place the new entry inside the brackets as normal.
|
|
31
31
|
- **`fireforge wire --dry-run` — notice when the subscript file is absent.** The dry-run branch in `src/commands/wire.ts` deliberately skips the subscript-file existence check to support the "wire first, create the file after" scaffolding workflow. Pre-0.18 that deliberate skip produced a plausible plan even when the subscript was genuinely missing, and the real `wire` invocation then refused with `Subscript file not found`. The skip stays (the workflow is legitimate) but dry-run now emits `Note: <subscriptDir>/<name>.js does not exist yet — the real wire command will require it before writing.` so the operator sees the absence in the preview and can create the file before re-running without `--dry-run`.
|
|
32
32
|
- **`fireforge status --unmanaged` + `fireforge register` — browser-chrome test files route to browser.toml, not jar.mn.** The browser-content pattern in `src/core/manifest-rules.ts` matched every `browser/base/content/**/*.{js,mjs,xhtml,css}` path, including test implementation files at `browser/base/content/test/<dir>/browser_*.js`. `status --unmanaged` therefore proposed `fireforge register browser/base/content/test/forgeqa/browser_forgeqa_qa_browser.js` which, if run, would cluttered jar.mn with a test-file chrome URI (the right place for those files is the sibling `browser.toml`). The pattern now excludes the `test/` subtree via a negative lookahead, so these paths fall through to `getUnregistrableAdvice`, which already returns the correct browser.toml-centric guidance for individual test files. `status`, `register`, and `register --dry-run` all now agree that test JS belongs in the test manifest.
|
|
33
|
+
- **`fireforge export-all --exclude-furnace` — refuses patches that would register a furnace component without carrying its source files; `fireforge verify` now flags dangling registrations that slipped through earlier versions.** Finding 1 (High). The evaluator's fresh-project smoke sequence (`furnace init` → `token add` → `furnace override moz-button -t css-only` → `furnace create moz-qa-panel --localized --with-tests --test-style mochikit` → `furnace deploy` → `export-all --exclude-furnace`) produced a patch whose `toolkit/content/customElements.js`, `toolkit/content/jar.mn`, and `toolkit/locales/jar.mn` hunks registered `moz-qa-panel` even though the widget sources were filtered out. `fireforge verify` reported "Verify clean" for the broken queue. The fix lands in two layers: a new `collectPatchRegistrationReferences` scanner in `src/core/patch-registration-refs.ts` extracts component-shaped registration references from any patch body, and `verifyCommand` in `src/commands/verify.ts` now cross-checks each extracted reference against both the aggregate `filesAffected` coverage and the engine working tree — any miss fires a `dangling-registration` error naming the specific patch + target path. `export-all --exclude-furnace` in `src/commands/export-all.ts` calls the same scanner before writing and refuses with a clear message when the hunks would register a furnace-managed component not included in the patch. Tests in `patch-registration-refs.test.ts` + `verify.integration.test.ts` pin both halves.
|
|
34
|
+
- **`fireforge status --unmanaged` — tolerates a missing parent moz.build instead of exiting non-zero.** Finding 2 (Medium). `printUnregisteredWarnings` in `src/commands/status.ts` awaited `Promise.all` on `isFileRegistered` checks, and the manifest-rules layer threw `GeneralError("Manifest not found: ...")` synchronously when the parent `moz.build` was absent — exactly the state an operator hits after scaffolding a new engine module and before running `register`. Status is a read-only reporter; the non-zero exit broke `status --unmanaged` as a discovery tool. The fix wraps each `isFileRegistered` call in a narrowly scoped try/catch, buckets missing-manifest cases into a distinct "registration manifest does not exist yet" warning list, and lets the command exit cleanly. Other error shapes still propagate. A regression test in `status.test.ts` pins the new contract.
|
|
35
|
+
- **`fireforge lint --since HEAD --only-introduced` — aggregate patch-size findings tag as `[introduced]` when the diff set is non-empty.** Finding 4 (Low). The `large-patch-files` and `large-patch-lines` rules emit findings with the synthetic `file: '(patch)'` placeholder, which never matched any entry in the `diffFiles` set. `tagLintIssues` in `src/core/patch-lint-diff-tag.ts` therefore tagged them `[cumulative]` even when the aggregate was entirely the operator's own diff — reading as "pre-existing drift" to an operator asking "what did this task introduce?" The tagger now recognises the synthetic placeholder (exported as `AGGREGATE_PATCH_FILE`) and promotes it to `introduced` whenever the diff set is non-empty. Cumulative semantics are preserved for the empty-diff case. Tests in `patch-lint-diff-tag.test.ts` pin both branches.
|
|
36
|
+
- **`fireforge furnace remove` / `rename` / `validate` — xpcshell scaffolds are cleaned up, renamed, and flagged as orphans.** Finding 5 (Medium). `furnace create --with-tests --xpcshell` scaffolds at `browser/base/content/test/<binary>-xpcshell/<name>/`, but the pre-0.18.1 `furnace remove` only touched the sibling browser-mochitest tree and `furnace rename` never reached the scaffold at all. Operators who ran create → rename → remove were left with an orphan scaffold whose filenames still referenced the original pre-rename component. The path template is now centralised in `xpcshellTestParentDir` in `src/core/furnace-constants.ts`; `create-xpcshell.ts` consumes it, a new `cleanupCustomXpcshellTestFiles` helper in `src/commands/furnace/remove.ts` removes the scaffold under the journal contract, a new `renameXpcshellTestFiles` helper in `src/commands/furnace/rename-xpcshell.ts` (extracted to keep `rename.ts` under the per-file LOC budget) updates the directory, test filename, TOML section header, and word-boundary tag references in the test body, and `findOrphanXpcshellScaffolds` in `src/core/furnace-validate.ts` walks the parent directory reporting any entry whose name is not in furnace.json as an `orphan-xpcshell-scaffold` error. The validator degrades silently on projects that never used xpcshell scaffolding. Test coverage lands in `furnace-validate-xpcshell-orphan.test.ts`.
|
|
37
|
+
- **`fireforge doctor --repair-patches-manifest` — recovery guidance no longer contradicts the docs by telling operators to hand-edit patches.json.** Finding 6 (Low). The per-filename warning emitted on recovered manifest entries ended with "Edit patches.json to restore the original description if you have it backed up." — which directly contradicts the README and Hominis docs that treat the manifest as FireForge-owned. The reworded warning now points at `fireforge re-export <filename> --description "<your description>"` (or the equivalent `fireforge export` invocation) to overwrite the reconstructed metadata through the tool, and explicitly warns against hand-editing. Test coverage in `doctor.test.ts` pins both the presence of the new guidance and the absence of the old "Edit patches.json" string.
|
|
38
|
+
- **`fireforge test --doctor` — PASS/FAIL line written directly to stdout, bypassing any clack flush races.** Finding 7 (Medium). The 0.18.0 "belt-and-suspenders" approach still reproducibly dropped the PASS footer under non-TTY capture — the eval log showed only the intro and `Running marionette preflight...` banner before exit. A new `formatMarionettePreflightLine` helper in `src/core/marionette-preflight.ts` returns the raw banner as a plain string, and `testCommand` in `src/commands/test.ts` writes both the "Running marionette preflight..." intro and the final PASS/FAIL summary via `process.stdout.write` as the authoritative emissions so non-TTY captures always see the summary. The clack `success()` + `outro()` calls are retained for TTY framing. Test coverage in `test.test.ts` now spies on `process.stdout.write` and asserts both the intro and PASS lines are emitted.
|
|
39
|
+
- **`fireforge test` — xpcshell appdir probe prefers the macOS `.app/Contents/Resources/<value>` layout on Darwin.** Finding 8 (High). `resolveAbsoluteAppPath` in `src/core/xpcshell-appdir.ts` probed `dist/bin/<value>` first on every platform. On macOS `dist/bin` is a symlink to `<App>.app/Contents/MacOS/`, so `dist/bin/browser` resolved to the _binaries_ directory rather than the Resources tree where `resource:///modules/` is rooted — the auto-injection logged success, but the injected path did not match where modules actually live. The probe now branches on `process.platform`: on Darwin it prefers `<App>.app/Contents/Resources/<value>` first and falls back to `dist/bin/<value>`; other platforms keep the historical order. The operator-facing `buildXpcshellAppdirMessage` in `src/commands/test.ts` also gains a macOS-specific note recommending the `<appname>-appdir = "browser"` xpcshell.toml migration, which is the most reliable fix on rebranded macOS builds. Test coverage in `xpcshell-appdir.test.ts` now exercises both probe orders via `process.platform` branches.
|
|
40
|
+
- **`fireforge download` — git-indexing timeouts raise a typed `GitIndexingTimeoutError` with env-var recovery guidance, and the monolithic → chunked transition is visible in non-TTY logs.** Finding 10 (High). On a loaded filesystem the fresh Firefox source indexing legitimately exceeded the 10-minute monolithic `git add -A` budget; the chunked fallback then hit its own `AbortSignal.timeout` and surfaced a generic `AbortError: The operation was aborted` with no recovery direction. `src/core/git-base.ts` now exposes `FIREFORGE_GIT_ADD_TIMEOUT_MS` and `FIREFORGE_GIT_ADD_CHUNK_TIMEOUT_MS` environment variables that override the monolithic (default 10 min) and chunked (default grew to 30 min) timeouts respectively; the chunked pass's timeout is wrapped into a typed `GitIndexingTimeoutError` in `src/errors/git.ts` that names the elapsed budget, points at the environment variable override, and explains the `fireforge download --force` resume path. The fallback-transition progress banner now names the specific timeout so operators watching a non-TTY log see exactly when the monolithic attempt lost. Test coverage in `git-performance.test.ts` pins both the new error type and the refreshed transition banner.
|
|
41
|
+
- **`fireforge rebase --dry-run` — refuses when the engine has no baseline commit.** Finding 11 (Medium). A partially-initialised engine (e.g. the aftermath of an aborted `download --force`) has `.git/` in place but no valid HEAD; the real `rebase --yes` immediately failed with `fatal: ambiguous argument 'HEAD'` even though dry-run had previously reported "Dry run complete" suggesting the rebase was ready to run. `handleFreshStart` in `src/commands/rebase/index.ts` now calls `getHead(paths.engine)` and, on `isMissingHeadError`, throws a clear `GeneralError` pointing at `fireforge download --force`. The check runs ahead of the dry-run early-exit so dry-run and real-run preconditions stay in sync. Test coverage in `rebase.test.ts` pins both the dry-run and real-run refusal paths.
|
|
42
|
+
- **`fireforge watch` — resolved watchman directory is prepended to the mach subprocess PATH.** Finding 12 (Medium). `fireforge watch` located watchman via the parent shell PATH but the `mach watch` subprocess inherited the Node parent's PATH, which on macOS frequently omits `/opt/homebrew/bin`. `mach watch` then failed at the `watch-project` subscription step with a confusing `FasterBuildException: timed out`. `watch.ts` now resolves watchman's absolute path via `findExecutable`, prepends the containing directory to the subprocess PATH, and threads the composed env through a new optional `options.env` parameter on `watchWithOutput` in `src/core/mach.ts`. The watch-failure diagnostic also gains a line naming the resolved watchman path so operators can distinguish "FireForge didn't find watchman" from "FireForge found watchman but mach still failed to reach it." Test coverage in `watch.test.ts` pins the env plumbing and the de-duplication branch.
|
|
43
|
+
- **`fireforge furnace preview` — first-run banner frames the unavoidable npm noise from `mach storybook`.** Finding 13 (Low). `mach storybook` internally drives a ~1000-package `npm install` when the Storybook workspace's `node_modules/` is absent and emits `npm error code ELSPROBLEMS` + `UNMET DEPENDENCY` lines verbatim. Operators on a fresh Firefox checkout read the npm block as a fatal error even though the command recovers and Storybook starts. `furnacePreviewCommand` in `src/commands/furnace/preview.ts` now checks for the absent `node_modules` before spawning mach and emits a clear pre-banner explaining the npm output is an expected one-time first-run cost; an explicit "Storybook stopped cleanly." line fires on clean exits so the npm noise is visually terminated. The `--install` path skips the banner because `mach storybook upgrade` already runs its install before `mach storybook` launches. Test coverage in `furnace-preview.test.ts` pins both the presence of the banner when node_modules is absent and its absence when node_modules is already populated.
|
|
33
44
|
|
|
34
45
|
### Internal
|
|
35
46
|
|
|
47
|
+
- **Module extractions preserve the per-file LOC budget.** `doctor-working-tree.ts` hosts `inspectEngineWorkingTree` (the ownership-aware working-tree check), `doctor-furnace-manifest-sync.ts` hosts the orphan override / custom detection + repair, `patch-registration-refs.ts` hosts the dangling-registration scanner shared between `verify` and `export-all`, and `rename-xpcshell.ts` hosts the xpcshell scaffold rename path. All spliced into their respective orchestrators from the hosting file, keeping `doctor.ts`, `doctor-furnace.ts`, `rename.ts`, and `preview.ts` under the 500-line `max-lines` rule. The drift test in `manifest.test.ts` classifies each new file as a helper so the top-level command drift check stays clean.
|
|
48
|
+
- **Expanded test coverage.** Every eval3 fix ships with at least one regression test exercising the failing path: `patch-registration-refs.test.ts` + `verify.integration.test.ts` (Finding 1), `status.test.ts` (Finding 2), `patch-lint-diff-tag.test.ts` (Finding 4), `furnace-validate-xpcshell-orphan.test.ts` (Finding 5), `doctor.test.ts` (Finding 6), `test.test.ts` (Finding 7), `xpcshell-appdir.test.ts` (Finding 8), `git-performance.test.ts` (Finding 10), `rebase.test.ts` (Finding 11), `watch.test.ts` (Finding 12), `furnace-preview.test.ts` (Finding 13).
|
|
49
|
+
|
|
50
|
+
### Internal (0.18.0 original)
|
|
51
|
+
|
|
36
52
|
- **Two small module extractions preserve the per-file LOC budget.** `doctor-working-tree.ts` hosts `inspectEngineWorkingTree` (the ownership-aware working-tree check), and `doctor-furnace-manifest-sync.ts` hosts the orphan override / custom detection + repair. Both are spliced into their respective registries from the hosting file, keeping `doctor.ts` and `doctor-furnace.ts` under the 500-line `max-lines` rule. The drift test in `manifest.test.ts` is extended to classify both new files as helpers so the top-level command drift check stays clean.
|
|
37
53
|
- **Expanded test coverage — 34 new assertions.** Every fix above ships with at least one new test exercising the regression path: `src/core/__tests__/git-diff.test.ts` gains a `?? dir/` expansion case; `src/commands/__tests__/doctor.test.ts` gains patch-backed / conflict / orphan-override / manifest-sync-repair cases; `furnace-remove.test.ts` + `furnace-rename.test.ts` pin the locale jar.mn re-wire / removal; `furnace-override.test.ts` pins the concurrent-writer read-modify-write; `build.test.ts` pins the stale-configure annotation; `test.test.ts` pins the fork-module diagnostic + triple-path PASS footer; `token-coverage.test.ts` pins the `--moz-*` allowlist + untracked-dir expansion; `patch-manifest-helpers.test.ts` pins name-field resolution; `patch-mutations.integration.test.ts` pins the two-patch swap postcondition; `register.test.ts` pins dry-run idempotency; `wire-init.test.ts` pins the marker; `manifest-tokenizers.test.ts` + `register-module.test.ts` pin the empty-list expansion; `wire.test.ts` pins the dry-run missing-subscript notice; `manifest-register.test.ts` pins the test-file pattern exclusion.
|
|
38
54
|
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
|
|
|
36
36
|
- **Python 3** (required by Firefox's `mach` build system).
|
|
37
37
|
- **Git**
|
|
38
38
|
- Platform build tools: Xcode on macOS, `build-essential` on Linux, Visual Studio Build Tools on Windows.
|
|
39
|
-
- **Watchman** (optional, only required by `fireforge watch`). Install via `brew install watchman` (macOS), `dnf install watchman` (Fedora), or follow the upstream [Meta docs](https://facebook.github.io/watchman/). `fireforge doctor` surfaces a warning row when it is not on `PATH` so the dependency is visible during the usual onboarding sweep rather than at the watch-mode failure site.
|
|
39
|
+
- **Watchman** (optional, only required by `fireforge watch`). Install via `brew install watchman` (macOS), `dnf install watchman` (Fedora), or follow the upstream [Meta docs](https://facebook.github.io/watchman/). `fireforge doctor` surfaces a warning row when it is not on `PATH` so the dependency is visible during the usual onboarding sweep rather than at the watch-mode failure site. `fireforge watch` resolves watchman's absolute path via `which` / `where` and prepends its directory to the subprocess `PATH` it hands mach, so a homebrew-installed watchman at `/opt/homebrew/bin/watchman` (absent from the Node subprocess's default `PATH` on macOS) is still visible to `mach watch` without the operator having to re-export `PATH` manually.
|
|
40
40
|
|
|
41
41
|
### Setup
|
|
42
42
|
|
|
@@ -83,6 +83,10 @@ npx fireforge download --force
|
|
|
83
83
|
npx fireforge rebase
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
+
`fireforge download` indexes the extracted Firefox source into a fresh git repository — a one-time 1–3 minute pass on a cold SSD, longer on slow or loaded disks. The monolithic `git add -A` is capped at 10 minutes by default and falls back to a per-directory chunked pass (30 minutes per chunk) when the cap hits. If indexing still times out, the command now raises `GitIndexingTimeoutError` with recovery guidance: extend the cap via `FIREFORGE_GIT_ADD_TIMEOUT_MS` (monolithic) and/or `FIREFORGE_GIT_ADD_CHUNK_TIMEOUT_MS` (chunked) in milliseconds, e.g. `FIREFORGE_GIT_ADD_TIMEOUT_MS=1800000 fireforge download --force` for a 30-minute monolithic budget, then re-run `fireforge download --force` — the resume path picks up from the partial git state so the repeat is not wasted work.
|
|
87
|
+
|
|
88
|
+
`fireforge rebase --dry-run` refuses when the engine has no baseline commit yet (e.g. the aftermath of an aborted `download --force`), so dry-run and real-run preconditions stay in sync.
|
|
89
|
+
|
|
86
90
|
## Patch Workflow
|
|
87
91
|
|
|
88
92
|
Patches live in `patches/`, applied by numeric filename prefix and tracked in `patches/patches.json`:
|
|
@@ -206,7 +210,11 @@ This re-exports the fixed patch and clears the conflict state. The command is de
|
|
|
206
210
|
|
|
207
211
|
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.
|
|
208
212
|
|
|
209
|
-
|
|
213
|
+
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`.
|
|
214
|
+
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
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.
|
|
210
218
|
|
|
211
219
|
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.
|
|
212
220
|
|
|
@@ -219,24 +227,24 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
|
|
|
219
227
|
|
|
220
228
|
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.
|
|
221
229
|
|
|
222
|
-
| Check | Scope
|
|
223
|
-
| ------------------------------ |
|
|
224
|
-
| `missing-license-header` | New files (JS/CSS/FTL)
|
|
225
|
-
| `relative-import` | JS/MJS files
|
|
226
|
-
| `token-prefix-violation` | CSS files (with furnace)
|
|
227
|
-
| `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`)
|
|
228
|
-
| `duplicate-new-file-creation` | Same path created by multiple patches
|
|
229
|
-
| `forward-import` | Patch imports from a later-patch file
|
|
230
|
-
| `missing-jsdoc` | Exports in patch-owned `.sys.mjs`
|
|
231
|
-
| `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs`
|
|
232
|
-
| `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs`
|
|
233
|
-
| `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in)
|
|
234
|
-
| `missing-modification-comment` | Modified upstream JS/MJS
|
|
235
|
-
| `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL)
|
|
236
|
-
| `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test)
|
|
237
|
-
| `observer-topic-naming` | Observer topics with binaryName
|
|
238
|
-
| `large-patch-files` | Patches affecting >5
|
|
239
|
-
| `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test,
|
|
230
|
+
| Check | Scope | Severity |
|
|
231
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------ |
|
|
232
|
+
| `missing-license-header` | New files (JS/CSS/FTL) | error |
|
|
233
|
+
| `relative-import` | JS/MJS files | error |
|
|
234
|
+
| `token-prefix-violation` | CSS files (with furnace) | error |
|
|
235
|
+
| `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
|
|
236
|
+
| `duplicate-new-file-creation` | Same path created by multiple patches | error |
|
|
237
|
+
| `forward-import` | Patch imports from a later-patch file | error |
|
|
238
|
+
| `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
|
|
239
|
+
| `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
|
|
240
|
+
| `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
|
|
241
|
+
| `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
|
|
242
|
+
| `missing-modification-comment` | Modified upstream JS/MJS | warning |
|
|
243
|
+
| `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
|
|
244
|
+
| `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
|
|
245
|
+
| `observer-topic-naming` | Observer topics with binaryName | warning |
|
|
246
|
+
| `large-patch-files` | Patches affecting many files (tiered: >5 general, >5 test, >60 branding) | warning |
|
|
247
|
+
| `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 8000/18000/30000 branding) | notice / warning / error |
|
|
240
248
|
|
|
241
249
|
**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.
|
|
242
250
|
|
|
@@ -259,17 +267,18 @@ fireforge status --json # machine-readable classified output
|
|
|
259
267
|
|
|
260
268
|
Then fix with the appropriate primitive:
|
|
261
269
|
|
|
262
|
-
| Problem | Fix
|
|
263
|
-
| ---------------------------------------------- |
|
|
264
|
-
| Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files`
|
|
265
|
-
| A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>`
|
|
266
|
-
| Wrong patch ordering | `fireforge patch reorder <patch> --to <N>`
|
|
267
|
-
| Ordinal gaps after deletes/splits | `fireforge patch compact`
|
|
268
|
-
| A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>`
|
|
269
|
-
| Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest`
|
|
270
|
-
|
|
|
270
|
+
| Problem | Fix |
|
|
271
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
272
|
+
| Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
|
|
273
|
+
| A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
|
|
274
|
+
| Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
|
|
275
|
+
| Ordinal gaps after deletes/splits | `fireforge patch compact` |
|
|
276
|
+
| A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
|
|
277
|
+
| Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
|
|
278
|
+
| Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes |
|
|
279
|
+
| Unmanaged changes you want to discard | `fireforge discard <file>` or `fireforge reset` |
|
|
271
280
|
|
|
272
|
-
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.
|
|
281
|
+
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.
|
|
273
282
|
|
|
274
283
|
## Wiring Custom Code
|
|
275
284
|
|
|
@@ -431,11 +440,21 @@ fireforge patch reorder 003-ui-sidebar.patch --before 001-branding-logo.patch
|
|
|
431
440
|
|
|
432
441
|
# Close ordinal gaps after deletes or splits (e.g. 1, 3, 7 → 1, 2, 3)
|
|
433
442
|
fireforge patch compact
|
|
443
|
+
|
|
444
|
+
# Set or clear the threshold-tier override on a single patch
|
|
445
|
+
# (no .patch body rewrite — manifest entry only)
|
|
446
|
+
fireforge patch tier 001-branding-assets.patch --tier branding
|
|
447
|
+
fireforge patch tier 001-branding-assets.patch --clear
|
|
448
|
+
|
|
449
|
+
# Edit the lintIgnore list on a single patch (one mode per invocation)
|
|
450
|
+
fireforge patch lint-ignore 001-branding-assets.patch --add large-patch-lines --add large-patch-files
|
|
451
|
+
fireforge patch lint-ignore 001-branding-assets.patch --remove large-patch-lines
|
|
452
|
+
fireforge patch lint-ignore 001-branding-assets.patch --clear
|
|
434
453
|
```
|
|
435
454
|
|
|
436
|
-
`
|
|
455
|
+
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`.
|
|
437
456
|
|
|
438
|
-
|
|
457
|
+
`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.
|
|
439
458
|
|
|
440
459
|
### Additional workflow commands
|
|
441
460
|
|
|
@@ -472,6 +491,8 @@ fireforge lint --since main --only-introduced
|
|
|
472
491
|
|
|
473
492
|
The failure message reports how many cumulative errors were suppressed by the flag so a branch that passed only because of the flag still tells the operator what was hidden. Without `--since`, `--only-introduced` is rejected up-front — there is no introduced-vs-cumulative distinction to scope to.
|
|
474
493
|
|
|
494
|
+
Aggregate patch-size findings (`large-patch-files`, `large-patch-lines`) describe the whole diff rather than a single file. Under `--since` + a non-empty diff they tag `[introduced]` (the aggregate IS the introduced work); with an empty diff they tag `[cumulative]` (the finding describes drift accumulated across earlier commits).
|
|
495
|
+
|
|
475
496
|
### Post-build audit and auto-configure
|
|
476
497
|
|
|
477
498
|
`fireforge build` is a transactional step: after a successful mach build it audits the dist bundle against engine-relative paths touched since the last successful build, and warns per file that is packageable-by-convention (`.js`/`.mjs`/`.css`/`.ftl`/`.xhtml`/`app/profile/…`) but has no matching artifact or whose dist mtime is older than the source. Ends every build with a `Packaged: N updated, M stale, K missing, S skipped` summary. The audit is warn-only — it never fails a build that mach reported green.
|
|
@@ -589,9 +610,9 @@ The two flags can be combined — `--with-tests --xpcshell` writes both harnesse
|
|
|
589
610
|
|
|
590
611
|
### xpcshell appdir auto-injection on rebranded forks
|
|
591
612
|
|
|
592
|
-
`fireforge test` auto-resolves and injects `--app-path=<absolute>` into the underlying `mach test` invocation when the nearest `xpcshell.toml` sets `firefox-appdir = "browser"` and the active build's `appname` is anything other than `firefox`. Without this, every `resource:///modules/<name>.sys.mjs` import inside the harness throws `Failed to load resource:///modules/…` 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 on rebranded forks, `appPath` falls back to `xrePath`, and `resource:///` resolves one level above the real app root. The resolver walks each test path to its nearest manifest, reads `mozinfo.json` for the active appname, prefers any `<appname>-appdir` already in the manifest, and otherwise probes `<objDir>/dist/
|
|
613
|
+
`fireforge test` auto-resolves and injects `--app-path=<absolute>` into the underlying `mach test` invocation when the nearest `xpcshell.toml` sets `firefox-appdir = "browser"` and the active build's `appname` is anything other than `firefox`. Without this, every `resource:///modules/<name>.sys.mjs` import inside the harness throws `Failed to load resource:///modules/…` 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 on rebranded forks, `appPath` falls back to `xrePath`, and `resource:///` resolves one level above the real app root. The resolver walks each test path to its nearest manifest, reads `mozinfo.json` for the active appname, prefers any `<appname>-appdir` already in the manifest, and otherwise probes the platform-appropriate layout for the absolute target: on macOS, `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` is preferred over `<objDir>/dist/bin/<value>` (the `dist/bin` symlink on Darwin points at the binaries directory, not the Resources tree where modules live); on other platforms `dist/bin/<value>` is preferred and the macOS bundle layout is the fallback. Operator overrides via `--mach-arg=--app-path=…` always win and skip the resolver silently. Mismatches across multiple test paths and unresolvable manifest values surface as warnings rather than guesses, so triage reaches the underlying cause.
|
|
593
614
|
|
|
594
|
-
The durable fix is to add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the manifest — the harness then reads the appname-keyed value directly without auto-injection. The xpcshell appdir hint that fires when the symptom persists despite injection lists this option first.
|
|
615
|
+
The durable fix is to add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the manifest — the harness then reads the appname-keyed value directly without auto-injection. This is the most reliable fix on rebranded macOS builds where mach's xpcshell runner binds `-a` to the `.app/Contents/Resources` default and may not honour `--app-path` overrides. The xpcshell appdir hint that fires when the symptom persists despite injection lists this option first.
|
|
595
616
|
|
|
596
617
|
### Smoke-run mode (`fireforge run --smoke-exit`)
|
|
597
618
|
|
|
@@ -322,7 +322,19 @@ const DOCTOR_CHECKS = [
|
|
|
322
322
|
// (only `filesAffected` / ordering drifted) are not flagged.
|
|
323
323
|
if (repaired.recoveredFilenames.length > 0) {
|
|
324
324
|
for (const filename of repaired.recoveredFilenames) {
|
|
325
|
-
|
|
325
|
+
// 2026-04-24 eval Finding 6: the repair path used to tell the
|
|
326
|
+
// operator to hand-edit patches.json, which contradicts the
|
|
327
|
+
// README + Hominis docs that treat the manifest as
|
|
328
|
+
// FireForge-owned. Point at the existing `re-export` /
|
|
329
|
+
// `export` workflow instead so the fix stays inside the tool:
|
|
330
|
+
// re-exporting the same files with an explicit `--description`
|
|
331
|
+
// overwrites the recovered entry with operator-supplied
|
|
332
|
+
// metadata and supersedes the mtime-based createdAt stamp.
|
|
333
|
+
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. ` +
|
|
334
|
+
'Re-export the affected files with `fireforge re-export <filename> --description "<your description>"` ' +
|
|
335
|
+
'(or `fireforge export <paths...> --name <name> --category <category> --description "<your description>"`) ' +
|
|
336
|
+
'to overwrite the reconstructed metadata, or accept the generic description if the original text is not recoverable. ' +
|
|
337
|
+
'Avoid hand-editing patches.json — FireForge owns that file and will regenerate it on the next manifest consistency pass.');
|
|
326
338
|
}
|
|
327
339
|
}
|
|
328
340
|
return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import { Option } from 'commander';
|
|
3
3
|
import { isBrandingManagedPath } from '../core/branding.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
|
-
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
5
|
+
import { collectFurnaceManagedPrefixes, furnaceConfigExists, loadFurnaceConfig, } from '../core/furnace-config.js';
|
|
6
6
|
import { hasChanges, isGitRepository } from '../core/git.js';
|
|
7
7
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
8
|
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
9
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
10
10
|
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
11
11
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
|
|
12
|
+
import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
|
|
12
13
|
import { GeneralError } from '../errors/base.js';
|
|
13
14
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
14
15
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
@@ -62,6 +63,62 @@ async function resolveFurnaceExclusionPolicy(paths, projectRoot, excludeFurnace)
|
|
|
62
63
|
'Review them with "fireforge status" or "fireforge furnace status", ' +
|
|
63
64
|
'or pass --exclude-furnace to export the non-Furnace subset of the diff.');
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Refuses the export when the resulting patch would register furnace
|
|
68
|
+
* component source files it does not itself carry. 2026-04-24 eval
|
|
69
|
+
* Finding 1: operators running `export-all --exclude-furnace` after
|
|
70
|
+
* `furnace create --localized --with-tests` ended up with patches that
|
|
71
|
+
* added `toolkit/content/widgets/moz-qa-panel/*` via jar.mn /
|
|
72
|
+
* customElements.js / locale jar.mn but excluded the component source
|
|
73
|
+
* files themselves. The resulting patch queue was structurally broken
|
|
74
|
+
* and `fireforge verify` stayed silent. We now detect the condition
|
|
75
|
+
* pre-write and ask the operator to either include the component
|
|
76
|
+
* sources (skip `--exclude-furnace`) or revert the furnace changes
|
|
77
|
+
* before exporting.
|
|
78
|
+
*
|
|
79
|
+
* The check runs against the synthesised patch body before
|
|
80
|
+
* `commitExportedPatch` writes anything, so no broken patch is left on
|
|
81
|
+
* disk when the refusal fires.
|
|
82
|
+
*/
|
|
83
|
+
async function checkDanglingFurnaceRegistrations(projectRoot, diff, furnaceExcluded) {
|
|
84
|
+
if (furnaceExcluded.size === 0)
|
|
85
|
+
return;
|
|
86
|
+
if (!(await furnaceConfigExists(projectRoot)))
|
|
87
|
+
return;
|
|
88
|
+
const refs = collectPatchRegistrationReferences(diff);
|
|
89
|
+
if (refs.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
const config = await loadFurnaceConfig(projectRoot);
|
|
92
|
+
// Build the set of furnace-managed component names so we can tell
|
|
93
|
+
// "registers moz-qa-panel (furnace-managed)" apart from "registers
|
|
94
|
+
// moz-button (an upstream widget this patch legitimately touches)".
|
|
95
|
+
const furnaceComponentNames = new Set([
|
|
96
|
+
...Object.keys(config.custom),
|
|
97
|
+
...Object.keys(config.overrides),
|
|
98
|
+
...config.stock,
|
|
99
|
+
]);
|
|
100
|
+
const dangling = [];
|
|
101
|
+
for (const ref of refs) {
|
|
102
|
+
if (!furnaceExcluded.has(ref.targetPath))
|
|
103
|
+
continue;
|
|
104
|
+
const tagMatch = /toolkit\/content\/widgets\/([a-z][a-z0-9-]*)\//.exec(ref.targetPath);
|
|
105
|
+
const ftlMatch = /toolkit\/locales\/en-US\/toolkit\/global\/([a-z][a-z0-9-]*)\.ftl$/.exec(ref.targetPath);
|
|
106
|
+
const component = tagMatch?.[1] ?? ftlMatch?.[1];
|
|
107
|
+
if (!component || !furnaceComponentNames.has(component))
|
|
108
|
+
continue;
|
|
109
|
+
dangling.push({ component, targetPath: ref.targetPath, source: ref.source });
|
|
110
|
+
}
|
|
111
|
+
if (dangling.length === 0)
|
|
112
|
+
return;
|
|
113
|
+
const summary = dangling
|
|
114
|
+
.map((d) => ` • ${d.component} — registered via ${d.source} → ${d.targetPath}`)
|
|
115
|
+
.join('\n');
|
|
116
|
+
throw new GeneralError('Export-all --exclude-furnace would produce a patch that registers furnace-managed components without including their source files.\n\n' +
|
|
117
|
+
`Dangling registrations:\n${summary}\n\n` +
|
|
118
|
+
'To proceed, either:\n' +
|
|
119
|
+
' 1. Drop the --exclude-furnace flag so the source files are captured alongside the registration edits.\n' +
|
|
120
|
+
' 2. Revert the registration hunks (or the whole furnace workflow) before re-running export-all — registrations belong with their components, and splitting them across separate patches is what "verify" catches post-hoc as a dangling-registration error.');
|
|
121
|
+
}
|
|
65
122
|
/**
|
|
66
123
|
* Refuses the export when the aggregate diff would create (new-file-mode) a
|
|
67
124
|
* path that some existing patch in the queue already creates. `verify`
|
|
@@ -163,6 +220,11 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
163
220
|
// the aggregate would newly create, so it runs here instead of alongside
|
|
164
221
|
// the branding / furnace guards that operate on the raw status list.
|
|
165
222
|
await checkDuplicateNewFileCreations(paths, diff);
|
|
223
|
+
// Dangling-furnace-registration preflight (Finding 1). Runs after the
|
|
224
|
+
// diff is assembled so we can inspect the exact hunks the operator is
|
|
225
|
+
// about to land; runs BEFORE any write so a refusal leaves the
|
|
226
|
+
// patches directory untouched.
|
|
227
|
+
await checkDanglingFurnaceRegistrations(projectRoot, diff, furnaceExcluded);
|
|
166
228
|
// Check for non-interactive mode
|
|
167
229
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
168
230
|
// Auto-fix missing license headers on new files (interactive only)
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* per-file LOC budget and the scaffolder is unit-testable in isolation.
|
|
6
6
|
*/
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import { xpcshellTestParentDir } from '../../core/furnace-constants.js';
|
|
8
9
|
import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
|
|
9
10
|
import { getLicenseHeader } from '../../core/license-headers.js';
|
|
10
11
|
import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
|
|
@@ -26,8 +27,9 @@ import { generateXpcshellManifestContent, generateXpcshellTestContent, xpcshellT
|
|
|
26
27
|
* auto-insertion that guessed wrong would be worse than a note.
|
|
27
28
|
*/
|
|
28
29
|
export async function scaffoldXpcshellTestFiles(componentName, license, forgeConfig, paths, journal) {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
30
|
+
const parentRelDir = xpcshellTestParentDir(forgeConfig.binaryName);
|
|
31
|
+
const parentDirName = parentRelDir.split('/').slice(-1)[0] ?? `${forgeConfig.binaryName}-xpcshell`;
|
|
32
|
+
const testDir = join(paths.engine, parentRelDir, componentName);
|
|
31
33
|
if (journal && !(await pathExists(testDir))) {
|
|
32
34
|
recordCreatedDir(journal, testDir);
|
|
33
35
|
}
|
|
@@ -163,6 +163,38 @@ async function assertPreviewPrerequisites(engineDir) {
|
|
|
163
163
|
'Run "fireforge bootstrap" (or the underlying `mach bootstrap` in the engine) to populate the toolchain config, then rerun "fireforge furnace preview".');
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Emits a framing banner when the Storybook workspace has not yet had
|
|
168
|
+
* its npm dependencies installed. `mach storybook` will drive the
|
|
169
|
+
* install internally and print ELSPROBLEMS / UNMET DEPENDENCY lines
|
|
170
|
+
* verbatim; without this banner operators reliably read the npm output
|
|
171
|
+
* as a failure (2026-04-24 eval Finding 13).
|
|
172
|
+
*
|
|
173
|
+
* Skipped when `--install` was explicitly requested — that path already
|
|
174
|
+
* runs `mach storybook upgrade` before the preview launches, so the npm
|
|
175
|
+
* output for the subsequent `mach storybook` invocation is a no-op.
|
|
176
|
+
*/
|
|
177
|
+
async function announceStorybookFirstRunIfNeeded(engineDir, installRequested) {
|
|
178
|
+
if (installRequested)
|
|
179
|
+
return;
|
|
180
|
+
const storybookNodeModules = join(engineDir, 'browser', 'components', 'storybook', 'node_modules');
|
|
181
|
+
const storybookDepsMissing = !(await pathExists(storybookNodeModules));
|
|
182
|
+
if (!storybookDepsMissing)
|
|
183
|
+
return;
|
|
184
|
+
info('Storybook workspace dependencies are not yet installed. The next step will install ~1000 npm packages via `mach storybook`; expect npm error-style output below. This is a one-time first-run cost — Storybook will start once the install finishes.');
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Surfaces an explicit success banner after a clean mach-storybook
|
|
188
|
+
* exit so the operator's scrollback visually terminates the npm noise
|
|
189
|
+
* from the first-run install. Only fires on expected exit codes — non-
|
|
190
|
+
* zero cases fall through to the existing
|
|
191
|
+
* `buildStorybookFailureMessage` classification.
|
|
192
|
+
*/
|
|
193
|
+
function announceStorybookCleanExitIfApplicable(exitCode) {
|
|
194
|
+
if (exitCode === 0 || exitCode === 130 || exitCode === 143) {
|
|
195
|
+
info('Storybook stopped cleanly.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
166
198
|
/**
|
|
167
199
|
* Runs the furnace preview command to start Storybook for component preview.
|
|
168
200
|
* @param projectRoot - Root directory of the project
|
|
@@ -269,10 +301,16 @@ export async function furnacePreviewCommand(projectRoot, options = {}) {
|
|
|
269
301
|
}
|
|
270
302
|
installSpinner.stop('Storybook dependencies reinstalled');
|
|
271
303
|
}
|
|
304
|
+
// 2026-04-24 eval Finding 13: frame the npm noise that `mach
|
|
305
|
+
// storybook` emits on first-run as expected progress rather than a
|
|
306
|
+
// failure. The banner-before / banner-after helpers are extracted
|
|
307
|
+
// so the command body stays under the per-function LOC budget.
|
|
308
|
+
await announceStorybookFirstRunIfNeeded(paths.engine, options.install ?? false);
|
|
272
309
|
// Start Storybook
|
|
273
310
|
info('Starting Storybook...');
|
|
274
311
|
info('Press Ctrl+C to stop\n');
|
|
275
312
|
previewResult = await runMachCapture(['storybook'], paths.engine);
|
|
313
|
+
announceStorybookCleanExitIfApplicable(previewResult.exitCode);
|
|
276
314
|
}
|
|
277
315
|
catch (error) {
|
|
278
316
|
primaryError = error;
|