@hominis/fireforge 0.16.3 → 0.17.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 +39 -1
- package/README.md +11 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/download.js +44 -13
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/create.js +2 -0
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +40 -16
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +23 -3
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +56 -10
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +59 -6
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-destroy.js +18 -5
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- package/dist/src/core/wire-init.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Eval-driven hardening
|
|
6
|
+
|
|
7
|
+
- **`fireforge config` — serialised writes behind a sidecar lock.** The 2026-04-21 eval reproduced silent data loss by running two `fireforge config` invocations in parallel against the same `fireforge.json`: both commands exited `0`, but only one key survived. Atomic-rename writes (`writeJson`'s temp file + rename) prevented torn files but not lost updates: each writer read the pre-state, mutated its own copy, and the second rename clobbered the first writer's change. A new `withConfigFileLock(projectRoot, operation)` helper in `src/core/config.ts` wraps the read-modify-write cycle in `src/commands/config.ts:configCommand` — both the strict-validated and `--force` write branches now take the lock. Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: atomic rename means readers always see either the pre- or post-state, so only writers need to be serialised. Stale-lock recovery reuses the existing PID-alive probe from `withFileLock` so a crashed `fireforge config` does not wedge the next command.
|
|
8
|
+
- **`fireforge token add --mode override` — dark values land inside the nested `:root { }`.** The previous `insertDarkModeOverride` in `src/core/token-manager.ts` found the outer `@media (prefers-color-scheme: dark) { }` block's closing `}` and spliced the dark-value declaration before that line — which is _after_ the nested `:root { }` had already closed, producing a declaration outside any rule block. The generated tokens CSS was syntactically malformed after every legitimate `token add --mode override` call, and `token coverage` no longer recognised the added tokens. The fix walks the comment-stripped line array to find the `:root {` opener _inside_ the `@media` block, then depth-counts to its own closing `}`, and inserts there. A fallback path (warn + synthesise a fresh nested `:root` before the outer close) handles malformed scaffolds where the nested `:root` was removed, so we never silently drop a dark value or emit a top-level declaration. The test coverage in `src/core/__tests__/token-manager.test.ts` now pins the invariant that the dark entry's line index must be _less_ than the inner `:root`'s closing `}`, so a future refactor cannot regress to the outer-block landing.
|
|
9
|
+
- **`fireforge furnace rename` — cleans up deployed widgets, renames mochikit test + chrome.toml.** The previous `renameTestFiles` helper in `src/commands/furnace/rename.ts` handled the `browser/base/content/test/<binaryName>/` browser-chrome layout but not the `toolkit/content/tests/widgets/` mochikit layout, and `performRenameMutations` made no attempt to clear `engine/<oldTargetPath>/` after deployment. The 2026-04-21 eval renamed `ff-chip-row` → `ff-chip-stack` and ended up with `engine/toolkit/content/widgets/ff-chip-row/` still deployed, `test_ff-chip-row.html` still importing `chrome://global/content/elements/ff-chip-row.mjs`, and the `chrome.toml` entry still naming the old file. Two new helpers pick up the slack: `removeStaleDeployedComponentDir` snapshots + removes `engine/<oldTargetPath>/` so the next `furnace apply` is the sole writer of the new deployment; `renameMochikitTestFiles` snapshots the old scaffold, rewrites the chrome URI + class-test identifiers to the new name, and updates the widgets `chrome.toml` entry. Both run under the same rollback journal as the rest of the rename, so a later failure restores every touched path.
|
|
10
|
+
- **`fireforge furnace create --with-tests` — scaffolded mochikit test runs to completion.** `generateMochikitTestContent` in `src/commands/furnace/create-templates.ts` previously emitted `SimpleTest.waitForExplicitFinish()` alongside an `add_task(...)` and no explicit `SimpleTest.finish()`. The test harness waits forever: `waitForExplicitFinish()` tells the harness not to finish on script end, and the `add_task`-managed finish never fires because the scaffold has no explicit `finish()` body. The 2026-04-21 eval's `fireforge test --headless toolkit/content/tests/widgets/test_ff-chip-row.html` hung until the operator SIGINT'd it. The fix removes the `waitForExplicitFinish()` call — `add_task` already calls `SimpleTest.finish()` when every queued task resolves, matching the convention upstream widget tests (`toolkit/content/tests/widgets/test_moz-button.html` and siblings) use. A regression test in `src/commands/furnace/__tests__/create-mochikit.test.ts` pins the contract that the generated content must not contain `waitForExplicitFinish`.
|
|
11
|
+
- **`fireforge furnace chrome-doc create --with-tests` — packaging test probes the correct packaged CSS path.** `generateChromeDocPackagingTest` in `src/commands/furnace/chrome-doc-tests.ts` probed `<AppDir>/chrome/browser/skin/classic/browser/<name>-chrome.css`, but the `jar.inc.mn` entry in `chrome-doc-templates.ts:chromeDocJarIncMnCssEntry` registers the file at `content/browser/<name>-chrome.css` — so the packaged location is `.../chrome/browser/content/browser/<name>-chrome.css`, not the skin layout. The 2026-04-21 eval's `fireforge test --build` against a scaffolded chrome-doc failed with a spurious "missing" assertion even though the file was correctly packaged. Both the primary probe and the macOS `.app`-bundle fallback now name the `content/browser/` layout, and a negative-match test guards against anyone pinning it back to `skin/classic/browser/` in a future refactor.
|
|
12
|
+
- **`fireforge status --json` — cross-patch ownership conflicts surface as `conflict` with `claimedBy`.** `classifyFiles` in `src/commands/status.ts` tracked patch ownership as a `Set<string>` and collapsed multi-owner paths into the single-owner branch, where the content-compare then routed them into `unmanaged` when the engine content didn't match any single patch's expected result. The 2026-04-21 eval's `status --json` run reported `"classification": "unmanaged"` on two files that `status --ownership` correctly labelled `CONFLICT` — scripts built on the JSON view mis-diagnosed the drift and could have taken the wrong corrective action. The classifier now builds a `Map<string, string[]>` (filename → claiming patch filenames), emits `classification: "conflict"` for entries claimed by two or more patches, and attaches `claimedBy: string[]` to those entries so machine consumers can read the ownership set directly. The human default-mode output now surfaces a `Cross-patch ownership conflicts` section at the top pointing at `status --ownership` and `re-export --files` for recovery. Single-claim entries stay byte-identical to the pre-0.16.0 JSON shape (no unconditional `claimedBy` field) so parsers unaware of the new classification continue to work.
|
|
13
|
+
- **`fireforge furnace init --ftl-base-path` — rejects file-shaped values up-front.** `validateFtlBasePath` in `src/commands/furnace/init.ts` only enforced syntactic safety (no absolute paths, no `..`, no null bytes). A plausible-but-invalid value like `browser/forgefresh.ftl` passed the gate, and the next localized `furnace create` scaffolded a component whose generated `.mjs` referenced `insertFTLIfNeeded("<name>.ftl")` while `furnace.json` never got the component entry — the scaffold was orphaned, every follow-up command failed with "not found in furnace.json", and the 2026-04-21 eval recorded this as Finding #5 (apparent non-registration) whose real root cause was Finding #6 (bad `ftlBasePath`). The validator now refuses any value whose basename carries `.ftl`, `.properties`, or `.dtd` with a message that names the file and points at a locale directory (`toolkit/locales/en-US/toolkit/global` or `browser/locales/en-US/browser`). When the engine directory is present on disk, it additionally probes whether the resolved path is a directory and warns (non-blocking) if the path does not yet exist — a fresh project that has not `fireforge download`-ed yet is legitimate.
|
|
14
|
+
- **`fireforge furnace init` — defaults `tokenPrefix` from `fireforge.json`'s `binaryName`.** `createDefaultFurnaceConfig` previously accepted no arguments and omitted `tokenPrefix` entirely, so every fresh project that ran `furnace init` → `token add` → `token coverage` got `0 tokens` and "all unknown" reports until the operator discovered the missing key and hand-edited `furnace.json`. The helper now accepts `{ binaryName }` and, when passed, seeds `tokenPrefix: \`--${binaryName}-\``so the coverage scan has a prefix to key off immediately.`furnaceInitCommand`best-effort-loads`fireforge.json`and threads the binaryName through — a project that initialises Furnace before`fireforge setup`completes still gets a valid prefix-less default (and`token coverage` continues to warn when invoked without a prefix set).
|
|
15
|
+
- **`fireforge build` + `fireforge build --ui` — per-project build lock prevents overlap.** A second `fireforge build --ui` launched while a full `fireforge build` was still running against the same engine tree raced the `obj-*` directory and failed immediately with `No rule to make target 'XUL'` — mach's downstream consequence of an incomplete backend, not a clue that the overlap was the root cause. A new `withBuildLock(projectRoot, operation)` in `src/core/mach.ts` backs onto `withFileLock` at `.fireforge-build.lock` beside the project root; `buildCommand` in `src/commands/build.ts` wraps both `build()` and `buildUI()` call paths in the lock. The refusal message names the holder PID and points at the stale-lock recovery path. Timeouts are bumped to 24h (a slow full build legitimately exceeds the default 30s) but the lock releases on process exit or via PID-alive stale recovery so a crashed build cannot permanently wedge the next invocation. A dedicated integration test in `src/core/__tests__/build-lock.integration.test.ts` pins the serialisation, throw-propagation, and stale-recovery contracts.
|
|
16
|
+
- **`fireforge wire --dry-run` — insertion-point probe runs in both modes.** The dry-run branch previously skipped the `pathExists(join(paths.engine, domTargetPath))` check that the real-run branch ran, and even with existence confirmed the real run could still throw `Could not find insertion point in chrome document` from deep inside `addDomFragment` when the resolved chrome doc offered neither `#include browser-sets.inc` nor `<html:body>`. The 2026-04-21 eval's `wire ... --dom ... --dry-run` previewed a plausible plan targeting `tokenHostDocuments[0]`, then the same command without `--dry-run` failed against a `furnace chrome-doc create`-scaffolded document that lacked both anchors. A new `probeDomFragmentInsertionPoint` helper in `src/core/wire-dom-fragment.ts` reads the chrome doc and runs the same tokenised + legacy insertion-point scan the real run uses; `wireCommand` now calls it in both modes and surfaces the `Could not find insertion point` error before printing the plan. The dry-run preview now refuses the same cases the real run would refuse, before any operator commits to executing.
|
|
17
|
+
- **`fireforge resolve` — `--yes` escape hatch + clearer two-step messaging.** The command refused any non-interactive invocation even after a CI-assisted manual merge was complete, because the TTY guard ran unconditionally — scripted recovery flows could complete the merge but could not then record the refreshed patch body. A new `--yes` / `-y` flag in `src/commands/resolve.ts` skips the interactive `confirm(...)` prompt and passes through in non-interactive mode; the unconditional TTY refusal fires only when the flag is absent. The command description changes from `Update a broken patch with manual fixes and continue` to `Update a broken patch with manual fixes (then run "fireforge import" to resume the queue)`, and the post-success info line now names the second-step command explicitly — the old copy implied a one-step flow where operators sometimes believed resolve continued the queue itself. Help snapshot under `src/__tests__/__snapshots__/help.test.ts.snap` is refreshed to match; resolve tests cover both the `--yes`-in-non-TTY path and the continuation messaging.
|
|
18
|
+
- **`fireforge test` — marionette port probe catches stale browsers before mach launches.** An interrupted `fireforge test --headless` run can leave a `<binaryName> -marionette` child listening on port `2828` with parent PID `1`. The next `fireforge test` run — potentially in a sibling FireForge project — fails immediately with a mach Marionette bind error that points nowhere near the real cause, and the generic "delete obj-\* and rebuild" guidance wastes operator time. A new `probeMarionettePort` / `assertMarionettePortAvailable` pair in `src/core/marionette-port.ts` runs `lsof -i tcp:2828 -sTCP:LISTEN` (POSIX) or `Get-NetTCPConnection` (Windows) before every test launch; when the holder's basename or command line identifies a Firefox-family browser (including `binaryName` from `fireforge.json` for branded forks), the probe raises a targeted `GeneralError` naming the PID and the exact `kill` command. Unrelated listeners produce a softer "this is not a FireForge-launched browser" error so the operator can tell the two cases apart. The probe is best-effort: missing `lsof`/PowerShell falls back to `{ inUse: false }` rather than failing the test run itself.
|
|
19
|
+
- **`fireforge lint` (default) — aggregate-mode skips tool-managed branding.** `resolveLintDiff` in `src/commands/lint.ts` passed the full `getAllDiff(engineDir)` to `lintExportedPatch` when no file list was supplied, so a fresh-setup workspace with 63 modified branding files fired `large-patch-lines`, `large-patch-files`, and `missing-license-header` on tool-managed content the operator never authored. The 2026-04-21 eval's first `fireforge lint` on a newly-built `fresh/` tree failed with blocking patch-lint errors despite the project being minimal-customisation. The aggregate-mode branch now loads `fireforge.json`'s `binaryName`, partitions the dirty tree with `isBrandingManagedPath`, and passes only the non-branding paths into `getDiffForFilesAgainstHead`. The operator sees a one-line `info()` naming the excluded count so the exclusion is visible, not silent. Explicit-path mode (`fireforge lint <path>`) preserves the previous behaviour — passing a branding path explicitly still lints it, so operators who need to audit generated branding content can do so.
|
|
20
|
+
- **`fireforge doctor --repair-patches-manifest` — names every reconstructed entry.** `rebuildPatchesManifest` previously returned only the rebuilt manifest, silently overwriting `description` / `createdAt` with generic fallback values when the existing entry was missing. FireForge patch files do not carry header metadata that could carry human-written descriptions forward, so full fidelity is impossible — but at least visibility is. The helper now returns `{ manifest, recoveredFilenames }` in `src/core/patch-manifest-consistency.ts`; the doctor repair path prints a per-filename `warn(...)` telling the operator exactly which manifest entry was reconstructed from generic defaults and suggesting they edit `patches.json` if they have the original description backed up. The summary row count now reports both the total patches rebuilt and the subset that needed reconstruction.
|
|
21
|
+
|
|
3
22
|
## 0.16.0
|
|
4
23
|
|
|
5
|
-
###
|
|
24
|
+
### UX, correctness, and consistency
|
|
25
|
+
|
|
26
|
+
- **`fireforge patch` / `fireforge token` — exit 0 with help (parent-command contract).** `fireforge furnace` exited 0 and printed its status message when run with no subcommand; `fireforge patch` and `fireforge token` silently inherited commander's default help-then-exit-1 path. Scripts probing the CLI surface therefore saw an inconsistent exit contract for three parent commands that do the same job (group related subcommands). Both `patch` and `token` now install a default `.action()` that prints their own help via `outputHelp()` and returns successfully — exit 0, same output shape, no destructive or stateful side effect. A new drift test in `src/commands/__tests__/manifest.test.ts` asserts every group-style parent has a default action installed so a future parent cannot regress back to the exit-1 contract silently.
|
|
27
|
+
- **`fireforge furnace status` — tips now prefix with `fireforge`.** The trailing `info()` line at the end of `furnace status` said `run \`furnace status <name>\``/`furnace --help`, which only worked if the operator happened to have a separate `furnace` binary on PATH. Copy-pasting the suggestion out of FireForge's own output produced a shell error on every fresh project. The message now names the real invocation (`fireforge furnace status <name>`, `fireforge furnace --help`), so the suggested commands are directly runnable.
|
|
28
|
+
- **`fireforge download` — de-duplicated git-init progress in non-TTY logs.** The resume and init `onProgress` callbacks called both `spinner.message(msg)` and `step(msg)` in non-TTY mode, producing two copies of every git-init progress line in CI logs because `src/utils/logger.ts`'s non-TTY spinner fallback already calls `p.log.step(msg)` from `message()`. The explicit `step()` sibling call is removed on both paths, so each progress message appears exactly once regardless of TTY mode. The tests in `download.test.ts` / `download.integration.test.ts` now assert the spinner-handle contract directly instead of the removed `step()` signal.
|
|
29
|
+
- **`fireforge download` — honest closing message on an empty patch queue.** `download` always stopped the restore spinner with `Patch-touched files restored`, even when the project had never exported a patch — a misleading claim of work on a fresh workspace. `cleanPatchTouchedFiles` now returns a structured `{ hadQueue, restored, preserved }` result, and a new `closeRestoreSpinner` helper picks one of three stop messages: `No patches in queue — nothing to restore` (empty queue), `Patch-touched files already match baseline` (queue present, nothing dirty), or the original `Patch-touched files restored` (work actually happened).
|
|
30
|
+
- **`fireforge build` — gecko-profiler bindgen hint now surfaces on Darwin 25.** The `_CharT` / `basic_string___self_view` hint had been registered in `mach-error-hints.ts` since 0.16.0 but never fired against the eval's Darwin 25 build log. Root cause: `build()` and `buildUI()` in `src/core/mach.ts` fed only `result.stderr` to `surfaceMachErrorHints`, but mach's timestamp-prefixing wrapper streamed the `rustc error[E0425]` lines through stdout. The hint surfacer now scans `${result.stderr}\n${result.stdout}` so the existing `_CharT` pattern matches regardless of which stream mach chose. A new regression test in `mach.test.ts` pins the stdout-only failure mode so a future refactor cannot accidentally narrow the capture again.
|
|
31
|
+
- **`fireforge build` — new hint clarifying the post-failure `Configure complete!` epilogue.** When `mach build` fails, mach's own shutdown pipeline runs a `Config object not found by mach. / Configure complete! / Be sure to run |mach build|...` block on the way out. That block is plain upstream mach output, printed after the non-zero exit code has already been established, but it looks deceptively like a success banner. A new `MACH_ERROR_HINTS` entry matches the exact `Config object not found by mach.\s*Configure complete!` signature and surfaces `Ignore the trailing "Config object not found by mach. / Configure complete!" block — that is mach's post-failure configure summary printed after the build already failed, not a sign the build succeeded.` The pattern is narrow enough that a real post-`mach configure` success (which legitimately prints "Configure complete!" alone) does not trigger it.
|
|
32
|
+
- **`fireforge wire --dry-run` — init/destroy validation now matches the real run.** Pre-0.16 the `validateWireName(expression, …)` check only ran inside `addInitToBrowserInit` / `addDestroyToBrowserInit` (the real-execution path), so `fireforge wire eval-startup --init 'void 0' --dry-run` succeeded and rendered a plausible preview — then the same arguments without `--dry-run` failed with `Invalid init expression "void 0": must contain only letters, digits, hyphens, underscores, dots, and $ signs`. Validation is now hoisted into `wireCommand` before the dry-run/real branch, so both paths enforce the identical regex. The library-level call inside addInit/addDestroy remains as defence-in-depth for programmatic callers that bypass the CLI entry point.
|
|
33
|
+
- **`fireforge wire` — coerces bare property chains into function calls.** The init/destroy validator accepted both `Foo.bar` and `Foo.bar()` shapes, but the emitted code template interpolated the expression verbatim — so `fireforge wire eval-startup --init EvalStartup.init` wrote `EvalStartup.init;` (a plain property reference) into `browser-init.js`, silently producing a lifecycle hookup that never invoked the hook. A new `coerceToCall(expression)` helper in `src/core/wire-utils.ts` appends `()` when the expression lacks trailing parens, idempotent when they are already present. Both the AST (`addInitAST`/`addDestroyAST`) and legacy fallback (`legacyAddInit`/`legacyAddDestroy`) code paths route the expression through the coercer so they agree on the emitted block shape. The idempotency regex in `addInitToBrowserInit`/`addDestroyToBrowserInit` also matches against the coerced form, so re-running `wire` with the bare form is correctly a no-op against a file that already contains the coerced call. The dry-run preview (`printWireDryRun`) applies the same coercion so the preview and the real run match.
|
|
34
|
+
- **`fireforge furnace create` — defensive read-back after writing `furnace.json`.** The eval observed a run where `furnace create eval-card --with-tests --localized --allow-prefix-mismatch` reported success and wrote component files under `components/custom/eval-card/`, but `furnace.json` ended up with `"custom": {}` and every subsequent command (`status`, `apply`, `rename`, `remove`) on `eval-card` failed with "not found in furnace.json". Local reproduction with the same source and same arguments writes the entry correctly and the unit-test coverage is green, so the bug could not be pinpointed from source alone. The defensive fix is a read-back verification in `performCreateMutations`: after `writeFurnaceConfig(…, config)` we call `loadFurnaceConfig(projectRoot)` and assert `componentName in persisted.custom`. If the entry is missing, the command throws a `FurnaceError` pointing to the prefix rule and `--allow-prefix-mismatch`, the rollback journal restores the pre-command state, and the operator sees the failure instead of a phantom success. The mock in `furnace-create.test.ts` was updated to reflect `writeFurnaceConfig` into subsequent `loadFurnaceConfig` reads so the unit-test path exercises the same round-trip.
|
|
35
|
+
- **`fireforge token coverage` — scans deployed Furnace custom-component CSS.** Coverage discovery was git-status-based: it read `getStatusWithCodes` and filtered to `.css` files. A `moz-eval-card.css` deployed into `engine/toolkit/content/widgets/moz-eval-card/` by `furnace deploy` never appeared in the scan if it was already tracked in the engine's own git repository (i.e. not dirty in status). The command now loads `furnace.json` (when present) and walks every entry in `config.custom`, probing `${targetPath}/${componentName}.css` under the engine directory. Files that exist on disk are merged with the git-status set and de-duplicated; the tokens CSS itself is still excluded. Projects without `furnace.json` keep the old git-status-only path unchanged. A new test case in `token-coverage.test.ts` pins the augmented discovery against the eval's exact scenario.
|
|
36
|
+
- **`fireforge furnace preview` — backend-artifact failure gets its own diagnosis.** When `mach storybook` failed deep inside `config.status` / `chrome-map.json` because the Firefox build backend was incomplete, the pre-0.16 heuristic's second clause matched the literal string `backend` — which does not appear in the error output — so the generic message sent the operator back to `fireforge furnace preview --install` (the wrong recovery path) after the install had already succeeded. `buildStorybookFailureMessage` is now exported and covered by a dedicated test file (`preview-failure-message.test.ts`). It first checks a narrow set of backend-artifact patterns (`chrome-map.json`, `config.status`, `obj-*/dist/bin/.lldbinit`) against a file-not-found signal; on match it produces a distinct message telling the operator to rerun `fireforge build` and wait for it to finish. The generic "missing Storybook dependencies" and fallthrough branches are preserved for the cases they actually describe.
|
|
37
|
+
- **`fireforge export` / `fireforge export-all` — new `--allow-overlap` gate against silent cross-patch ownership.** Pre-0.16 `export` only caught FULL-coverage supersedes via `findAllPatchesForFiles`. A second export targeting a shared file like `browser/themes/shared/jar.inc.mn` happily created a queue where two patches both listed the same file in `filesAffected`, and `fireforge verify` then immediately failed with "cross-patch filesAffected conflicts". A new `findPartialOwnershipOverlap` helper in `export-shared.ts` walks the manifest and maps each overlapping file to its claiming patches, excluding any patches that the caller already intends to fully supersede. The new `guardOwnershipOverlap` routes the result through a non-interactive refusal or an interactive prompt: without `--allow-overlap`, the command refuses in non-interactive mode and asks for acknowledgement in interactive mode. The refusal message points at `fireforge re-export --files <paths> <patch>` as the right primitive for repartitioning ownership, which replaces the (much more dangerous) manual `patches.json` editing the eval's operator resorted to. Both `export` and `export-all` now expose a matching `--allow-overlap` flag.
|
|
38
|
+
- **`fireforge re-export --scan` — broad expansions require explicit acknowledgement.** Pre-0.16 `--scan` blindly merged every modified or untracked file in a patch's parent directories into its `filesAffected`. The eval scenario: two small patches in adjacent-but-unrelated features shared a directory tree, and a single `re-export --all --scan` silently absorbed the entire neighbour feature (xhtml + xpcshell tests + theme CSS) into the wrong patch. The 0.16.0 gate: when `--scan` would add more than three files, or files spanning more than one directory, the command calls `confirmBroadScanAdditions` — dry-run proceeds silently (previewing is the whole point), `--yes` proceeds silently (the explicit opt-in), interactive mode prompts for confirmation, and non-interactive mode without `--yes` refuses with the expansion summary. Small same-directory additions (the common refresh case) stay frictionless.
|
|
39
|
+
- **`fireforge test --doctor` — closes the intro frame with an explicit outro.** Running `test --doctor` on its own showed `● Running marionette preflight...` and then exited silently with code 0 in the eval's non-TTY capture — the `Marionette preflight: PASS (…)` line that `reportMarionettePreflight` emitted via `info()` failed to render inside the unclosed clack intro frame. The doctor-only success branch now calls `outro(\`Marionette preflight: PASS (${durationMs}ms)\`)`before returning, which closes the tree and gives scripts a deterministic "done" marker to parse. The failing branch already throws through`GeneralError`, which is routed via the standard error pipeline and does not need the same treatment.
|
|
40
|
+
- **`fireforge run --smoke-exit` — allowlist summary shows both error-class and total counts.** The previous summary only reported `Allowlisted hits: N`, where `N` was incremented only when a line matched both `SMOKE_ERROR_PATTERNS` AND the caller-supplied allowlist. An operator whose `--console-allow RSLoader:` pattern visibly matched `console.warn: RSLoader: …` lines still saw `Allowlisted hits: 0`, because `console.warn:` is not a smoke-error class — the allowlist was never consulted for those lines. The summary now distinguishes two counters: `Allowlisted error hits (suppressed): N` (the exit-contract number: errors the allowlist kept out of the findings list) and `Allowlisted lines total: M` (the mental-model number: every console line that matched the allowlist, regardless of error class). The exit contract itself is unchanged — unallowed errors still fail the smoke window.
|
|
41
|
+
- **`fireforge resolve` — `filesAffected` is always recomputed from the new diff body.** Pre-0.16 the resolve command updated `patches.json.filesAffected` only when `activeFiles.length < existingFiles.length` (files deleted from disk). If the user's manual fix eliminated every hunk for a specific file while the file itself still existed on disk, the metadata kept claiming it — and the next `fireforge import` immediately failed the patch-manifest consistency check with "patches.json declares [...] but the patch file targets [...]". The command now threads the generated `diffContent` through `extractAffectedFiles` (the same helper `export` and the consistency checker use) and unconditionally passes the result as `filesAffected`. Resolve and consistency-check therefore always agree on the set of targeted files. A new round-trip test in `resolve.test.ts` pins the scenario where the diff shrinks but every file still exists on disk.
|
|
42
|
+
|
|
43
|
+
### Security
|
|
6
44
|
|
|
7
45
|
- **Release workflow — shell injection via `${{ inputs.version }}` interpolation.** `.github/workflows/release.yml` previously interpolated `${{ inputs.version }}` directly into `npm version "${{ inputs.version }}" --no-git-tag-version`, so anyone with `actions:write` could trigger `workflow_dispatch` with a crafted version string (e.g. `1.0.0"; <command> #`) and execute arbitrary shell inside the job. The release job carries `contents: write`, `id-token: write`, and the `npm` trusted-publishing environment, so that shell would have had an open door to the publish credentials. The fix routes `${{ inputs.version }}` through an `env: INPUT_VERSION:` block on the `Bump version` step and references `"$INPUT_VERSION"` inside the `run:` script, so GitHub Actions substitutes the value into the process environment rather than into the shell source. The `Tag and push` step gets the same treatment for `${{ steps.version.outputs.version }}` — defense-in-depth, since `npm version`'s semver check already filters that interpolation, but the pattern is consistent and cheap.
|
|
8
46
|
- **`fireforge config` — prototype pollution via sentinel key segments.** `mutateConfig` in `src/core/config-mutate.ts` walks a dot-separated key through `getOrCreateChildRecord(parent, segment)` with no filter on `__proto__`, `constructor`, or `prototype`. `fireforge config __proto__.polluted 1 --force` therefore reached `parent["__proto__"]` and wrote a plain property onto `Object.prototype`, polluting every object in the Node process for the rest of the run. `--force` was the motivating pathway (the strict path guard rejects unknown top-level keys for non-sentinels), but the raw sink was also publicly re-exported from `src/core/config.ts`, widening the blast radius to any future caller. The fix rejects sentinel segments up-front in `mutateConfig` with a `ConfigError` before any clone or mutation — a single guard at the entry point covers both the descent loop and the final leaf assignment, and surfaces to the CLI as a normal "invalid key" failure rather than a crash. `readJson`'s existing reviver already strips the sentinels from loads, so input configs can't arrive pre-polluted.
|
package/README.md
CHANGED
|
@@ -154,6 +154,10 @@ fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
|
|
|
154
154
|
fireforge re-export --all --scan --stamp
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
`export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default.
|
|
158
|
+
|
|
159
|
+
`re-export --scan` also prompts before broadening a patch with more than a handful of newly discovered files or with files spanning multiple directories. The gate keeps the common refresh case frictionless (small, same-directory additions) while catching the failure mode where `--scan` silently pulls an adjacent feature into the wrong patch. Non-interactive mode requires `--yes` to acknowledge a broad expansion; dry-run previews never require confirmation.
|
|
160
|
+
|
|
157
161
|
### Rebasing on top of a new Firefox version
|
|
158
162
|
|
|
159
163
|
1. Update `firefox.version` in `fireforge.json`
|
|
@@ -170,7 +174,7 @@ When `fireforge import` fails on a patch, fix the `.rej` files in `engine/`, the
|
|
|
170
174
|
fireforge resolve
|
|
171
175
|
```
|
|
172
176
|
|
|
173
|
-
This re-exports the fixed patch and
|
|
177
|
+
This re-exports the fixed patch and clears the conflict state. The command is deliberately a single-patch refresh — to continue applying the remainder of the queue, run `fireforge import` afterwards. For scripted or CI-driven recovery, pass `--yes` (or `-y`) to skip the interactive "are you done?" prompt; the flag is the explicit opt-in for non-interactive use once the manual merge is complete.
|
|
174
178
|
|
|
175
179
|
<details>
|
|
176
180
|
<summary>Patch manifest format</summary>
|
|
@@ -207,7 +211,7 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
|
|
|
207
211
|
|
|
208
212
|
`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.
|
|
209
213
|
|
|
210
|
-
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.
|
|
214
|
+
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.
|
|
211
215
|
|
|
212
216
|
| Check | Scope | Severity |
|
|
213
217
|
| ------------------------------ | ------------------------------------------------------------------------- | ------------------------ |
|
|
@@ -405,6 +409,8 @@ fireforge config firefox.version 145.0.0esr
|
|
|
405
409
|
fireforge config customKey "value" --force
|
|
406
410
|
```
|
|
407
411
|
|
|
412
|
+
Writes are serialised behind a sidecar lock — two concurrent `fireforge config` invocations against the same `fireforge.json` (for example, parallel automation steps) queue instead of racing the read-modify-write. The lock is released automatically on process exit; stale locks from a crashed earlier command are reclaimed on the next invocation via the PID-alive probe.
|
|
413
|
+
|
|
408
414
|
### Patch queue management
|
|
409
415
|
|
|
410
416
|
```bash
|
|
@@ -436,7 +442,7 @@ fireforge watch
|
|
|
436
442
|
fireforge token add --category 'Colors — General' -- --my-color 'light-dark(#fff, #000)'
|
|
437
443
|
```
|
|
438
444
|
|
|
439
|
-
Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint
|
|
445
|
+
Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`, and derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
|
|
440
446
|
|
|
441
447
|
### Diff-scoped lint (`lint --since`)
|
|
442
448
|
|
|
@@ -592,6 +598,8 @@ Exit codes are wired distinct from `BUILD_ERROR`:
|
|
|
592
598
|
|
|
593
599
|
POSIX only — process-group semantics do not map cleanly onto Windows. A smoke window shorter than 30 s warns up-front because cold-start time alone can consume that budget on a debug build; `--capture-console <file>` mirrors the captured stream so post-exit inspection has the raw log without re-running.
|
|
594
600
|
|
|
601
|
+
The summary block reports two allowlist counters so operators can tell whether a pattern actually matched anything: `Allowlisted error hits (suppressed)` is the exit-contract number (errors that would have failed the window but were dropped by the allowlist), and `Allowlisted lines total` is the mental-model number (every console line that matched the allowlist, regardless of whether it was an error-class line). A non-zero `total` with a zero `suppressed` count means the allowlist patterns matched benign info/warn lines that never counted toward the exit contract to begin with.
|
|
602
|
+
|
|
595
603
|
### Furnace `--shared-ftl` for feature-scoped Fluent bundles
|
|
596
604
|
|
|
597
605
|
A feature with multiple components (e.g. an eight-component dock) typically wants one shared `.ftl` per feature rather than eight per-component stubs. `furnace create <tag> --localized --shared-ftl <chrome-uri>` participates in an existing feature-scoped bundle:
|
|
@@ -5,7 +5,7 @@ import { auditBuildArtifacts } from '../core/build-audit.js';
|
|
|
5
5
|
import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
|
|
6
6
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
7
7
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
8
|
-
import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, runMach, } from '../core/mach.js';
|
|
8
|
+
import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, runMach, withBuildLock, } from '../core/mach.js';
|
|
9
9
|
import { GeneralError } from '../errors/base.js';
|
|
10
10
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
@@ -129,12 +129,21 @@ export async function buildCommand(projectRoot, options) {
|
|
|
129
129
|
const startTime = Date.now();
|
|
130
130
|
let exitCode;
|
|
131
131
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
// Hold the per-project build lock across the mach invocation so two
|
|
133
|
+
// overlapping `fireforge build` / `fireforge build --ui` commands
|
|
134
|
+
// against the same engine tree serialise instead of racing through
|
|
135
|
+
// the same obj-*. 2026-04-21 eval: a `build --ui` launched during
|
|
136
|
+
// an in-progress full build hit `No rule to make target 'XUL'` in
|
|
137
|
+
// mach, which is the downstream consequence of an incomplete
|
|
138
|
+
// backend — not a clue that a concurrent build was the cause. The
|
|
139
|
+
// lock turns the second invocation's failure into an explicit
|
|
140
|
+
// refusal naming the holder PID.
|
|
141
|
+
exitCode = await withBuildLock(projectRoot, async () => {
|
|
142
|
+
if (options.ui) {
|
|
143
|
+
return buildUI(paths.engine);
|
|
144
|
+
}
|
|
145
|
+
return build(paths.engine, jobs);
|
|
146
|
+
});
|
|
138
147
|
}
|
|
139
148
|
catch (error) {
|
|
140
149
|
throw new BuildError('Build process failed to start', options.ui ? 'mach build faster' : 'mach build', error instanceof Error ? error : undefined);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { configExists, loadConfig, loadRawConfigDocument, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, writeConfig, writeConfigDocument, } from '../core/config.js';
|
|
1
|
+
import { configExists, loadConfig, loadRawConfigDocument, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, withConfigFileLock, writeConfig, writeConfigDocument, } from '../core/config.js';
|
|
2
2
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
3
3
|
import { toError } from '../utils/errors.js';
|
|
4
4
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
@@ -112,25 +112,37 @@ export async function configCommand(projectRoot, key, value, options = {}) {
|
|
|
112
112
|
const parsedValue = parseValue(value, key);
|
|
113
113
|
const keyIsKnown = SUPPORTED_CONFIG_PATHS.includes(key);
|
|
114
114
|
try {
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
115
|
+
// Serialise the read → mutate → write round-trip behind the sidecar
|
|
116
|
+
// config lock so two concurrent `fireforge config` invocations can't
|
|
117
|
+
// each read the pre-state, mutate their own copy, and clobber each
|
|
118
|
+
// other on write. Before the lock, the 2026-04-21 eval reproduced
|
|
119
|
+
// silent data loss with two parallel `fireforge config <key>
|
|
120
|
+
// <value>` commands writing different keys: both exited 0, one key
|
|
121
|
+
// survived, the other vanished. Atomic file writes (temp + rename)
|
|
122
|
+
// were never enough on their own — the lost update happens before
|
|
123
|
+
// the rename, inside the read-modify step. Readers stay lock-free
|
|
124
|
+
// (see `withConfigFileLock` docstring).
|
|
125
|
+
await withConfigFileLock(projectRoot, async () => {
|
|
126
|
+
// `--force` is intended as an escape hatch for *unknown* keys; it
|
|
127
|
+
// should not also let the user write a structurally invalid value
|
|
128
|
+
// for a *known* key. Apply strict validation whenever the key is
|
|
129
|
+
// listed in SUPPORTED_CONFIG_PATHS, regardless of --force, and only
|
|
130
|
+
// skip validation for genuinely unknown key paths.
|
|
131
|
+
if (options.force && !keyIsKnown) {
|
|
132
|
+
// Seed mutation from the raw on-disk document so previously-forced
|
|
133
|
+
// keys (which `validateConfig` would strip) survive the round-trip.
|
|
134
|
+
// Without this, writing a second --force key would silently drop
|
|
135
|
+
// every earlier forced key from fireforge.json.
|
|
136
|
+
const rawConfig = await loadRawConfigDocument(projectRoot);
|
|
137
|
+
const updatedConfig = mutateConfig(rawConfig, key, parsedValue, true);
|
|
138
|
+
await writeConfigDocument(projectRoot, updatedConfig);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const config = await loadConfig(projectRoot);
|
|
142
|
+
const updatedConfig = mutateConfig(config, key, parsedValue);
|
|
143
|
+
await writeConfig(projectRoot, updatedConfig);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
134
146
|
}
|
|
135
147
|
catch (error) {
|
|
136
148
|
throw new InvalidArgumentError(`Invalid value for "${key}": ${toError(error).message}`, key);
|
|
@@ -314,7 +314,20 @@ const DOCTOR_CHECKS = [
|
|
|
314
314
|
}
|
|
315
315
|
try {
|
|
316
316
|
const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version);
|
|
317
|
-
|
|
317
|
+
// 2026-04-21 eval (Finding #17): the repair path silently
|
|
318
|
+
// overwrote useful human-written descriptions on recovered
|
|
319
|
+
// entries, leaving the queue less trustworthy as an audit
|
|
320
|
+
// trail. The rebuilder now returns the list of filenames
|
|
321
|
+
// whose metadata was entirely invented, and we name them
|
|
322
|
+
// explicitly here so the operator knows exactly which
|
|
323
|
+
// patches to review. Names that DID have a preserved entry
|
|
324
|
+
// (only `filesAffected` / ordering drifted) are not flagged.
|
|
325
|
+
if (repaired.recoveredFilenames.length > 0) {
|
|
326
|
+
for (const filename of repaired.recoveredFilenames) {
|
|
327
|
+
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. Edit patches.json to restore the original description if you have it backed up.`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
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.`);
|
|
318
331
|
}
|
|
319
332
|
catch (err) {
|
|
320
333
|
return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
|
|
@@ -10,7 +10,7 @@ import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
|
10
10
|
import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
12
12
|
import { checkDiskSpace, ensureDir, pathExists, removeDir } from '../utils/fs.js';
|
|
13
|
-
import { info, intro, outro, spinner,
|
|
13
|
+
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
14
14
|
import { pickDefined } from '../utils/options.js';
|
|
15
15
|
/**
|
|
16
16
|
* Collects the set of patch-touched files from the manifest.
|
|
@@ -39,11 +39,13 @@ async function getPatchTouchedFiles(patchesDir) {
|
|
|
39
39
|
*/
|
|
40
40
|
async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
41
41
|
const patchFiles = await getPatchTouchedFiles(patchesDir);
|
|
42
|
-
if (patchFiles.size === 0)
|
|
43
|
-
return;
|
|
42
|
+
if (patchFiles.size === 0) {
|
|
43
|
+
return { hadQueue: false, restored: 0, preserved: 0 };
|
|
44
|
+
}
|
|
44
45
|
const dirtyFiles = await getDirtyFiles(engineDir, [...patchFiles]);
|
|
45
|
-
if (dirtyFiles.length === 0)
|
|
46
|
-
return;
|
|
46
|
+
if (dirtyFiles.length === 0) {
|
|
47
|
+
return { hadQueue: true, restored: 0, preserved: 0 };
|
|
48
|
+
}
|
|
47
49
|
const toClean = preExistingDirty
|
|
48
50
|
? dirtyFiles.filter((f) => !preExistingDirty.has(f))
|
|
49
51
|
: dirtyFiles;
|
|
@@ -65,6 +67,29 @@ async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
|
65
67
|
warn(` ${file}`);
|
|
66
68
|
}
|
|
67
69
|
}
|
|
70
|
+
return { hadQueue: true, restored: toClean.length, preserved: preserved.length };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Stops `restoreSpinner` with a message that reflects what actually
|
|
74
|
+
* happened. Three branches: empty queue → explicit no-op; queue present but
|
|
75
|
+
* nothing dirty → "already clean"; queue with dirty files → the usual
|
|
76
|
+
* "Patch-touched files restored" success line.
|
|
77
|
+
*
|
|
78
|
+
* Before 0.16.0 the spinner always closed with "Patch-touched files
|
|
79
|
+
* restored", so a fresh project with zero patches saw a claim of restore
|
|
80
|
+
* work that had not happened — misleading and easy to mistake for a
|
|
81
|
+
* silent retry.
|
|
82
|
+
*/
|
|
83
|
+
function closeRestoreSpinner(restoreSpinner, result) {
|
|
84
|
+
if (!result.hadQueue) {
|
|
85
|
+
restoreSpinner.stop('No patches in queue — nothing to restore');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (result.restored === 0 && result.preserved === 0) {
|
|
89
|
+
restoreSpinner.stop('Patch-touched files already match baseline');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
restoreSpinner.stop('Patch-touched files restored');
|
|
68
93
|
}
|
|
69
94
|
/**
|
|
70
95
|
* Runs the download command.
|
|
@@ -100,11 +125,16 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
100
125
|
const resumeSpinner = spinner('Resuming git repository initialization...');
|
|
101
126
|
try {
|
|
102
127
|
await resumeRepository(paths.engine, {
|
|
128
|
+
// The non-TTY spinner fallback in `src/utils/logger.ts`
|
|
129
|
+
// already calls `p.log.step(msg)` from `message()`, so
|
|
130
|
+
// forwarding the progress message is the single authority
|
|
131
|
+
// in both TTY and non-TTY modes. Before 0.16.0 this
|
|
132
|
+
// callback also invoked `step(message)` explicitly when
|
|
133
|
+
// stdio was not a TTY, which printed the same step line
|
|
134
|
+
// twice in CI logs (once from the fallback, once from
|
|
135
|
+
// the explicit call).
|
|
103
136
|
onProgress: (message) => {
|
|
104
137
|
resumeSpinner.message(message);
|
|
105
|
-
if (!(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
106
|
-
step(message);
|
|
107
|
-
}
|
|
108
138
|
},
|
|
109
139
|
});
|
|
110
140
|
const baseCommit = await getHead(paths.engine);
|
|
@@ -223,11 +253,12 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
223
253
|
let baseCommit;
|
|
224
254
|
try {
|
|
225
255
|
await initRepository(paths.engine, 'firefox', {
|
|
256
|
+
// Same one-authority rule as the resume path above: the non-TTY
|
|
257
|
+
// spinner fallback already emits `step(msg)` internally, so
|
|
258
|
+
// calling `step()` in addition to `.message()` duplicated every
|
|
259
|
+
// git-init progress line in CI logs.
|
|
226
260
|
onProgress: (message) => {
|
|
227
261
|
gitSpinner.message(message);
|
|
228
|
-
if (!(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
229
|
-
step(message);
|
|
230
|
-
}
|
|
231
262
|
},
|
|
232
263
|
});
|
|
233
264
|
baseCommit = await getHead(paths.engine);
|
|
@@ -257,8 +288,8 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
257
288
|
// reporting success against a dirty engine.
|
|
258
289
|
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
259
290
|
try {
|
|
260
|
-
await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
261
|
-
restoreSpinner
|
|
291
|
+
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
292
|
+
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
262
293
|
}
|
|
263
294
|
catch (error) {
|
|
264
295
|
restoreSpinner.error('Failed to restore patch-touched files');
|
|
@@ -7,14 +7,14 @@ import { hasChanges, isGitRepository } from '../core/git.js';
|
|
|
7
7
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
8
|
import { getWorkingTreeStatus } from '../core/git-status.js';
|
|
9
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
10
|
-
import { commitExportedPatch } from '../core/patch-export.js';
|
|
10
|
+
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
11
11
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
|
|
12
12
|
import { GeneralError } from '../errors/base.js';
|
|
13
13
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
14
14
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
15
15
|
import { pickDefined } from '../utils/options.js';
|
|
16
16
|
import { PATCH_CATEGORIES } from '../utils/validation.js';
|
|
17
|
-
import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
17
|
+
import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
18
18
|
async function checkBrandingManagedFiles(paths, config) {
|
|
19
19
|
const changedFiles = await getWorkingTreeStatus(paths.engine);
|
|
20
20
|
const brandingManagedFiles = changedFiles
|
|
@@ -177,6 +177,22 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
177
177
|
const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
|
|
178
178
|
if (!shouldProceed)
|
|
179
179
|
return;
|
|
180
|
+
// Overlap gate — see the matching comment in `export.ts`. The same
|
|
181
|
+
// cross-patch ownership problem applies to `export-all` because a
|
|
182
|
+
// mixed aggregate diff often touches shared files like manifest
|
|
183
|
+
// fragments that other patches already claim.
|
|
184
|
+
const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
|
|
185
|
+
const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
|
|
186
|
+
const shouldProceedPastOverlap = await guardOwnershipOverlap({
|
|
187
|
+
patchesDir: paths.patches,
|
|
188
|
+
filesAffected,
|
|
189
|
+
supersedingFilenames,
|
|
190
|
+
allowOverlap: options.allowOverlap === true,
|
|
191
|
+
isInteractive,
|
|
192
|
+
s,
|
|
193
|
+
});
|
|
194
|
+
if (!shouldProceedPastOverlap)
|
|
195
|
+
return;
|
|
180
196
|
// Get Firefox version for metadata
|
|
181
197
|
const { patchFilename, superseded } = await commitExportedPatch({
|
|
182
198
|
patchesDir: paths.patches,
|
|
@@ -213,6 +229,7 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
|
|
|
213
229
|
.option('--supersede', 'Allow superseding multiple existing patches')
|
|
214
230
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
215
231
|
.option('--exclude-furnace', 'Export the non-Furnace subset of the aggregate diff instead of refusing when Furnace-managed files are modified. Furnace-managed files are still deployed by "fireforge furnace apply"; this flag only changes whether export-all aborts or filters in their presence.')
|
|
232
|
+
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify)')
|
|
216
233
|
.action(withErrorHandling(async (options) => {
|
|
217
234
|
const { category, ...rest } = options;
|
|
218
235
|
await exportAllCommand(getProjectRoot(), {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PatchesManifest } from '../types/commands/index.js';
|
|
1
2
|
import type { ExportOptions, PatchCategory } from '../types/commands/index.js';
|
|
2
3
|
import type { FireForgeConfig } from '../types/config.js';
|
|
3
4
|
import type { SpinnerHandle } from '../utils/logger.js';
|
|
@@ -52,3 +53,38 @@ export declare function confirmSupersedePatches(patchesDir: string, filesAffecte
|
|
|
52
53
|
* @returns true if files were modified on disk (caller must regenerate diff)
|
|
53
54
|
*/
|
|
54
55
|
export declare function autoFixLicenseHeaders(engineDir: string, diffContent: string, config: FireForgeConfig, isInteractive: boolean): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Maps every file in `filesAffected` to the existing patches that already
|
|
58
|
+
* claim ownership of it, excluding the caller's own patch (when `newFilename`
|
|
59
|
+
* is provided) and any patches that the caller intends to fully supersede.
|
|
60
|
+
*
|
|
61
|
+
* Returns an empty map when no overlap exists. Used by the overlap gate in
|
|
62
|
+
* `export` and `export-all` to refuse a default-mode export that would
|
|
63
|
+
* silently create cross-patch ownership conflicts — the same class of
|
|
64
|
+
* conflict `verify` immediately fails with.
|
|
65
|
+
*/
|
|
66
|
+
export declare function findPartialOwnershipOverlap(manifest: PatchesManifest, filesAffected: string[], excludeFilenames: ReadonlySet<string>): Map<string, string[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Gate that refuses the default export path when the new patch would
|
|
69
|
+
* silently claim files that are already tracked by other non-superseded
|
|
70
|
+
* patches. `findAllPatchesForFiles` already catches the full-coverage
|
|
71
|
+
* supersede case — this helper fills the gap for partial overlap, which
|
|
72
|
+
* was the eval finding #12 scenario (two patches both claiming
|
|
73
|
+
* `browser/themes/shared/jar.inc.mn` after a second export with
|
|
74
|
+
* `--before`).
|
|
75
|
+
*
|
|
76
|
+
* Proceeds silently when there is no overlap, or when the caller passed
|
|
77
|
+
* `--allow-overlap`. In interactive mode the caller is prompted to
|
|
78
|
+
* acknowledge the overlap (the proper fix path is `re-export --files` to
|
|
79
|
+
* repartition ownership, so the prompt surfaces that pointer). In
|
|
80
|
+
* non-interactive mode the function throws — better to fail fast than
|
|
81
|
+
* let the queue fall out of sync with verify.
|
|
82
|
+
*/
|
|
83
|
+
export declare function guardOwnershipOverlap(args: {
|
|
84
|
+
patchesDir: string;
|
|
85
|
+
filesAffected: string[];
|
|
86
|
+
supersedingFilenames: ReadonlySet<string>;
|
|
87
|
+
allowOverlap: boolean;
|
|
88
|
+
isInteractive: boolean;
|
|
89
|
+
s: SpinnerHandle;
|
|
90
|
+
}): Promise<boolean>;
|
|
@@ -4,6 +4,7 @@ import { confirm, select, text } from '@clack/prompts';
|
|
|
4
4
|
import { addLicenseHeaderToFile, getLicenseHeader } from '../core/license-headers.js';
|
|
5
5
|
import { findAllPatchesForFiles } from '../core/patch-export.js';
|
|
6
6
|
import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, } from '../core/patch-lint.js';
|
|
7
|
+
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
7
8
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
8
9
|
import { pathExists, readText } from '../utils/fs.js';
|
|
9
10
|
import { cancel, info, isCancel, warn } from '../utils/logger.js';
|
|
@@ -222,4 +223,79 @@ export async function autoFixLicenseHeaders(engineDir, diffContent, config, isIn
|
|
|
222
223
|
}
|
|
223
224
|
return true;
|
|
224
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Maps every file in `filesAffected` to the existing patches that already
|
|
228
|
+
* claim ownership of it, excluding the caller's own patch (when `newFilename`
|
|
229
|
+
* is provided) and any patches that the caller intends to fully supersede.
|
|
230
|
+
*
|
|
231
|
+
* Returns an empty map when no overlap exists. Used by the overlap gate in
|
|
232
|
+
* `export` and `export-all` to refuse a default-mode export that would
|
|
233
|
+
* silently create cross-patch ownership conflicts — the same class of
|
|
234
|
+
* conflict `verify` immediately fails with.
|
|
235
|
+
*/
|
|
236
|
+
export function findPartialOwnershipOverlap(manifest, filesAffected, excludeFilenames) {
|
|
237
|
+
const overlap = new Map();
|
|
238
|
+
const targetSet = new Set(filesAffected);
|
|
239
|
+
for (const patch of manifest.patches) {
|
|
240
|
+
if (excludeFilenames.has(patch.filename))
|
|
241
|
+
continue;
|
|
242
|
+
for (const file of patch.filesAffected) {
|
|
243
|
+
if (!targetSet.has(file))
|
|
244
|
+
continue;
|
|
245
|
+
const owners = overlap.get(file) ?? [];
|
|
246
|
+
owners.push(patch.filename);
|
|
247
|
+
overlap.set(file, owners);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return overlap;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Gate that refuses the default export path when the new patch would
|
|
254
|
+
* silently claim files that are already tracked by other non-superseded
|
|
255
|
+
* patches. `findAllPatchesForFiles` already catches the full-coverage
|
|
256
|
+
* supersede case — this helper fills the gap for partial overlap, which
|
|
257
|
+
* was the eval finding #12 scenario (two patches both claiming
|
|
258
|
+
* `browser/themes/shared/jar.inc.mn` after a second export with
|
|
259
|
+
* `--before`).
|
|
260
|
+
*
|
|
261
|
+
* Proceeds silently when there is no overlap, or when the caller passed
|
|
262
|
+
* `--allow-overlap`. In interactive mode the caller is prompted to
|
|
263
|
+
* acknowledge the overlap (the proper fix path is `re-export --files` to
|
|
264
|
+
* repartition ownership, so the prompt surfaces that pointer). In
|
|
265
|
+
* non-interactive mode the function throws — better to fail fast than
|
|
266
|
+
* let the queue fall out of sync with verify.
|
|
267
|
+
*/
|
|
268
|
+
export async function guardOwnershipOverlap(args) {
|
|
269
|
+
const { patchesDir, filesAffected, supersedingFilenames, allowOverlap, isInteractive, s } = args;
|
|
270
|
+
if (allowOverlap)
|
|
271
|
+
return true;
|
|
272
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
273
|
+
if (!manifest)
|
|
274
|
+
return true;
|
|
275
|
+
const overlap = findPartialOwnershipOverlap(manifest, filesAffected, supersedingFilenames);
|
|
276
|
+
if (overlap.size === 0)
|
|
277
|
+
return true;
|
|
278
|
+
s.stop();
|
|
279
|
+
const entries = [...overlap.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
280
|
+
warn(`This export would create cross-patch ownership overlap on ${String(entries.length)} file${entries.length === 1 ? '' : 's'}:`);
|
|
281
|
+
for (const [file, owners] of entries) {
|
|
282
|
+
warn(` - ${file} already claimed by: ${owners.join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
warn('The queue would fail `fireforge verify` immediately after this export. ' +
|
|
285
|
+
'To repartition ownership safely, run `fireforge re-export --files <paths> <existing-patch>` ' +
|
|
286
|
+
'on the overlapping patches first, then re-run the export.');
|
|
287
|
+
if (!isInteractive) {
|
|
288
|
+
throw new GeneralError('Refusing to export a queue with cross-patch ownership overlap in non-interactive mode. ' +
|
|
289
|
+
'Pass --allow-overlap to acknowledge the conflict, or repartition ownership via `fireforge re-export --files`.');
|
|
290
|
+
}
|
|
291
|
+
const confirmed = await confirm({
|
|
292
|
+
message: 'Proceed with overlapping ownership? This will leave the queue in a verify-failing state.',
|
|
293
|
+
initialValue: false,
|
|
294
|
+
});
|
|
295
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
296
|
+
cancel('Export cancelled');
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
225
301
|
//# sourceMappingURL=export-shared.js.map
|