@hominis/fireforge 0.37.0 → 0.39.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 +33 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +13 -8
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/discard.js +75 -50
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +6 -2
- package/dist/src/commands/export-flow.js +17 -11
- package/dist/src/commands/export-placement-gate.js +4 -6
- package/dist/src/commands/export-placement-policy.d.ts +11 -3
- package/dist/src/commands/export-placement-policy.js +32 -29
- package/dist/src/commands/export-shared.js +17 -2
- package/dist/src/commands/export.js +5 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/import.js +5 -0
- package/dist/src/commands/lint-per-patch.js +15 -5
- package/dist/src/commands/lint.js +1 -1
- package/dist/src/commands/patch/lint-ignore.js +20 -1
- package/dist/src/commands/patch/move-files-create.d.ts +19 -0
- package/dist/src/commands/patch/move-files-create.js +116 -0
- package/dist/src/commands/patch/move-files-into.d.ts +47 -0
- package/dist/src/commands/patch/move-files-into.js +245 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +74 -15
- package/dist/src/commands/patch/rename.js +4 -1
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/re-export-scan.d.ts +17 -0
- package/dist/src/commands/re-export-scan.js +51 -0
- package/dist/src/commands/re-export.js +17 -6
- package/dist/src/commands/reset.js +5 -0
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/status-output.d.ts +9 -0
- package/dist/src/commands/status-output.js +33 -1
- package/dist/src/commands/status.js +17 -3
- package/dist/src/commands/test-diagnose.d.ts +1 -1
- package/dist/src/commands/test-diagnose.js +9 -3
- package/dist/src/commands/test-modes.d.ts +21 -0
- package/dist/src/commands/test-modes.js +42 -0
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +8 -2
- package/dist/src/commands/test-stale-gate.d.ts +44 -0
- package/dist/src/commands/test-stale-gate.js +81 -0
- package/dist/src/commands/test.js +52 -81
- package/dist/src/core/build-audit.d.ts +12 -0
- package/dist/src/core/build-audit.js +14 -0
- package/dist/src/core/build-baseline-types.d.ts +41 -0
- package/dist/src/core/build-baseline.d.ts +6 -1
- package/dist/src/core/build-baseline.js +48 -7
- package/dist/src/core/build-prepare.d.ts +9 -0
- package/dist/src/core/build-prepare.js +24 -0
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +14 -1
- package/dist/src/core/discard-baseline.d.ts +60 -0
- package/dist/src/core/discard-baseline.js +232 -0
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -2
- package/dist/src/core/file-lock.d.ts +33 -0
- package/dist/src/core/file-lock.js +32 -6
- package/dist/src/core/firefox-archive.d.ts +2 -1
- package/dist/src/core/firefox-archive.js +32 -8
- package/dist/src/core/firefox.d.ts +8 -3
- package/dist/src/core/firefox.js +11 -6
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/license-headers.d.ts +24 -0
- package/dist/src/core/license-headers.js +103 -1
- package/dist/src/core/patch-export.d.ts +10 -1
- package/dist/src/core/patch-export.js +12 -2
- package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
- package/dist/src/core/patch-lint-checkjs.js +30 -14
- package/dist/src/core/patch-lint-cross.d.ts +9 -1
- package/dist/src/core/patch-lint-cross.js +69 -9
- package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
- package/dist/src/core/patch-lint-mozbuild.js +104 -0
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +29 -8
- package/dist/src/core/patch-policy.d.ts +14 -1
- package/dist/src/core/patch-policy.js +13 -2
- package/dist/src/core/status-classify.d.ts +11 -2
- package/dist/src/core/status-classify.js +49 -25
- package/dist/src/core/test-harness-crash.d.ts +10 -0
- package/dist/src/core/test-harness-crash.js +16 -0
- package/dist/src/core/test-stale-check.d.ts +56 -1
- package/dist/src/core/test-stale-check.js +114 -4
- package/dist/src/core/token-manager.js +39 -2
- package/dist/src/core/token-variant.d.ts +7 -0
- package/dist/src/core/token-variant.js +9 -0
- package/dist/src/core/typecheck-shim.d.ts +13 -0
- package/dist/src/core/typecheck-shim.js +17 -2
- package/dist/src/core/typecheck.js +14 -3
- package/dist/src/types/commands/options.d.ts +69 -3
- package/dist/src/types/config.d.ts +22 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.39.0
|
|
4
|
+
|
|
5
|
+
- **`fireforge discard` restores patch-claimed paths to their PATCH-APPLIED baseline instead of pristine upstream, and re-materializes patch-created files instead of deleting them** (FORGE F1, P0 data-loss bug — 15 `patch-backed`/`owned` engine files discarded with `--yes` came back as upstream Firefox artwork, `jar.mn` lost all 134 fork entries, and patch-created files like `hominis-start.html` were deleted outright, all under the message "File restored to original state"; recovery cost a full `reset` + 207-patch `import` + rebuild): discard now consults the manifest's ownership claims per status entry and restores claimed files by folding HEAD content through every affecting patch in order (`computePatchedContent`), leaving the post-`import` worktree convention intact (patch edits stay unstaged ` M`, patch creations stay untracked `??`); patch-deleted files are removed to match the baseline. The legacy semantics are an explicit opt-in via the new `--to-upstream` flag. Success messages name the baseline (`File restored to patch baseline (0001-ui-edit.patch)`, `File re-materialized from patch baseline (…)`, `File restored to pristine upstream (HEAD)`); directory mode applies the same rule per file and its outro buckets the outcomes (`3 file(s) restored: 1 to patch baseline, 1 re-materialized, 1 to upstream state`); dry-run lines annotate each path with its restore target. A corrupt manifest or an unreconstructible baseline REFUSES (naming `--to-upstream`) rather than silently reverting to HEAD; multi-owner (conflict) claims restore the cumulative baseline with a warning pointing at `status --ownership`. Pinned by `discard-patch-baseline.integration.test.ts` (the exact handoff shape: edit-patch + create-patch, dirty both, discard each, assert patch content and git state) and `discard-baseline.test.ts`.
|
|
6
|
+
- **New per-patch lint check `mozbuild-unsorted-list` catches unsorted `EXTRA_JS_MODULES` (and sibling strict-ordered moz.build lists) before any build is dispatched** (FORGE F2, feature — three friction-log occurrences where an unsorted insertion surfaced only as `mozbuild.util.UnsortedError … expected "X" but got "Y"` at configure time, each costing a full multi-minute build cycle): the check reads each patch-touched `moz.build` from the engine tree and reports the exact expected/got pair mozbuild would raise (`moz.build list EXTRA_JS_MODULES is not alphabetically sorted: expected "X" but got "Y" (mozbuild will fail at configure time with UnsortedError)`), using mozbuild's case-insensitive ordering. Covered variables: `EXTRA_COMPONENTS`, `EXTRA_JS_MODULES`, `EXTRA_PP_COMPONENTS`, `EXPORTS`, `PYTHON_UNITTEST_MANIFESTS`, `TESTING_JS_MODULES`, `XPIDL_SOURCES`, including dotted namespaces (`EXTRA_JS_MODULES.<subdir>`); `DIRS` is deliberately excluded (order-meaningful). Severity error; waivable per patch via `lintIgnore: ["mozbuild-unsorted-list"]`; participates in the per-patch lint cache like every other rule.
|
|
7
|
+
- **Forward-import detection now catches a bare getter-property line added to an existing `defineESModuleGetters` map, and every refusal names the exact `patch staged-dependency` invocation to paste** (FORGE F3, bug + message — a patch inserting `Foo: "resource://gre/modules/Foo.sys.mjs",` into a PRE-EXISTING getter object carried no `defineESModuleGetters(` opener in its added lines, so the export gate missed it and the refusal surfaced one consumer at a time downstream): added-lines-only content is additionally scanned for `Name: "resource://…"` property lines (resource/chrome/moz-src schemes, forward-importable extensions only; deduped against the existing extractors so a patch adding the whole call reports once; the `// fireforge-ignore: forward-import` marker and staged-dependency suppression apply unchanged). The `forward-import` message now embeds the exact per-owner remedy — `fireforge patch staged-dependency <patch> --add --file <site> --specifier "<url>" --creates <path> --owner <patch>` — with the fingerprint unchanged. The "reports only the first offender" half of the field report did NOT reproduce on 0.38.0 code: the rule already emits one issue per site and the export gate returns all details (pinned by a two-site test); the observed one-at-a-time behavior was an artifact of the getter shapes being missed.
|
|
8
|
+
- **`patch move-files <from> <to>` into an EXISTING patch is now a real transaction** (FORGE F4, feature — the preview-only flow printed two `re-export --files` commands in the WRONG order (grow first) and without the `--allow-shrink`/`--yes` flags the real execution then demanded, costing three refusals per move): the two-positional form now performs the shrink→grow as one operation under the patch-directory lock with rollback, mirroring `patch split`/`--create`: both bodies are regenerated from the worktree, per-patch linted with each patch's own `lintIgnore`/`tier`, cross-patch linted as a projection (only regressions the move itself introduces refuse; `--force-unsafe` overrides), staged-dependency owners re-point to the target, and forward edges the move introduces are auto-declared. `--dry-run` previews without writing; `-y/--yes` covers non-TTY. The equivalent manual `re-export` commands are still printed — now in EXECUTABLE order (shrink first, flags included) with a note that the shrink must land first.
|
|
9
|
+
- **`lint --per-patch --max-warnings 0` on the cache-hit path: claim REFUTED; two warm-run gaps hardened** (FORGE F5 — the reported 0-exit-despite-warnings was observed once on a 0.37-line host and does not reproduce on 0.38.0+: cached issues flow through the same `--max-warnings` comparison as fresh ones, pinned since 0.38.0 by the "cached warnings still fail per-patch lint" test; the cache key hashes every affected engine file plus the engine HEAD SHA, so an engine-side revert that would empty a patch's diff always misses the cache — now pinned explicitly). One REAL warm-run gap is fixed: an all-cache-hit run previously dropped run-level checkJs errors entirely (e.g. "typescript not installed", unreadable extra shim), letting a warm run report fewer errors than a cold one; global checkJs findings are now emitted whenever checkJs is configured, building the queue-wide program lazily on demand.
|
|
10
|
+
- **`patch lint-ignore --add` warns that the waiver is subject to the project's patch-policy review process** (FORGE F6, message-only — a size-only waiver turned per-patch lint green and then failed a downstream policy audit as `lint-ignore-unreviewed`, a discovery costing a full multi-minute gate run): every `--add` that lands a new check id (including `--dry-run`) prints: "This lint waiver is subject to the project's patch-policy review process. Downstream policy audits that mirror lintIgnore lists in a reviewed allow-map will refuse this patch until the map is updated to include the new ID(s)." No warning on `--remove`, `--clear`, or a no-op add. (Per the upstream adjudication, the audit demanding the patch's WHOLE lintIgnore list in the review map remains intentional; this is the write-time warning only.)
|
|
11
|
+
- **The mixed-harness refusal runs BEFORE the pre-test build** (FORGE F7, inefficiency — `fireforge test <xpcshell file> <browser file> --build` spent the full build before reporting "cannot run xpcshell and browser/mochitest paths in the same mach invocation"): harness classification is a pure path→manifest lookup and now runs immediately after path normalization, ahead of the `--build` dispatch and the stale/coverage gates; a mixed request refuses in milliseconds and `runProtectedMachBuild` is never invoked (pinned by test). Stale/coverage refusals still take precedence over missing-path errors.
|
|
12
|
+
- **`token add --mode override` also writes existing `:root[data-theme="dark"]` / `:root[data-theme="light"]` blocks** (FORGE F8, bug — an override add wrote `:root` and the `@media (prefers-color-scheme: dark)` block but not the explicit theme-attribute blocks, so on scaffolds requiring identical token sets across all themed lists every override add was a guaranteed half-finished edit failing the downstream invariants audit): when the tokens CSS carries top-level `:root[data-theme="dark"]`/`:root[data-theme="light"]` blocks, the dark value is mirrored into the dark block and the base value into the light block (idempotent per block; files without those blocks are unchanged), with an info line naming the blocks written. The report's claim that the FireForge-generated file header documents the CLI "cannot write" those blocks did not match this repo — no such text exists in FireForge's scaffold (it is consumer-authored); nothing to drop here.
|
|
13
|
+
- **A `.patch` extension on a patch-name argument no longer double-suffixes, and `patch rename` explains its no-ops** (FORGE F9, bug + partial refutation — `patch move-files <from> 348-ui-editor-panels.patch --create` produced `348-ui-editor-panels-patch.patch`, and the repair `rename --to 348-ui-editor-panels.patch` reported "Patch rename (no-op)"): `patchNameSlug` now strips one trailing `.patch` (case-insensitive) before sanitisation, converging `export --name`, `patch split --name`, `move-files --create`, and `rename --to` — so the double-suffix never forms and the repair rename is a REAL rename. A true no-op now says why (`nothing to change — filename already "…" (slug of "X" is "Y"), name already "…"`). The claimed positional `<from> <to>` usage string on `rename` was REFUTED: registration is `rename <name>` with a required `--to` flag on 0.38.0.
|
|
14
|
+
- **xpcshell-only runs skip the Marionette port preflight and the mochitest client flags** (FORGE F10, bug — `fireforge test` probed port 2828 even for xpcshell path sets, so a developer's interactive browser holding the port killed xpcshell runs, and `--marionette-port <n>` alone then failed the dispatch because the mochitest-only client flags were forwarded to mach; the only working incantation was the `--mach-arg --flavor=xpcshell` crutch): when manifest classification shows every requested path is xpcshell, the stale-port preflight is skipped (with an explanatory line when `--marionette-port`/`--kill-stale-marionette` were passed) and neither `--setpref=marionette.port` nor `--marionette=127.0.0.1:<n>` is forwarded — the crutch is unnecessary. Pathless runs and `--doctor` (whose probe launches a Marionette browser regardless) keep the preflight.
|
|
15
|
+
- **New read-only coverage reporter: `fireforge status --test-coverage`** (FORGE F11, feature — with concurrent sessions sharing one engine tree, the only way to learn the current recorded test-packaging coverage was to trip the out-of-coverage refusal on a real run): prints the last build baseline's coverage (`full`, or the scoped path list with the refusal implication), `builtAt`, `binaryName`, engine HEAD, and the new `recordedBy` field (`fireforge build`, `fireforge build --ui`, `fireforge test --build <paths>`) that 0.39.0 baselines now record — older baselines render `Recorded by: unknown` and `full (implicit — baseline predates coverage recording)`. Needs only the project root (no engine/git); mutually exclusive with the other status modes. Per the upstream adjudication, coverage REPLACES — no union model; this is strictly a reporter.
|
|
16
|
+
- **`typecheck` and the checkJs lint pass no longer silently suppress undefined free identifiers (TS2304/TS2552)** (FORGE F12, bug — a deployed module referencing `EditorState.create(...)` with no import typechecked as "0 errors across all three projects" because the codes were unconditionally suppressed as globals-shim noise; the failure surfaced as a runtime `ReferenceError` manifesting as a hung suite): both flows now report undefined identifiers at a configurable severity — default `warning` (visible without breaking gates; blocking under `--max-warnings 0`), `error` to make them hard failures, `off` to restore the historical suppression — via `typecheck.undefinedIdentifiers` and `patchLint.undefinedIdentifiers` in fireforge.json. Shim-covered Firefox globals stay clean; reported diagnostics append a hint naming the extra-shim and config remedies. The other module-resolution suppressions (TS2307/2306/2305/2792/2580/7016) are unchanged.
|
|
17
|
+
- **`reset` and `import` warn at completion when `components.conf` diverged from the last full build** (FORGE F13, message — after a `reset` + `import` revert train, the next gate run's unit sweep refused on the stale compiled StaticComponents table one full rebuild too late): both commands now run the existing static-components probe after a successful mutation and print: "components.conf changed relative to the last full \"fireforge build\": <paths>. The compiled StaticComponents table in obj-\* no longer matches the tree, so the next \"fireforge test\" will require a full \"fireforge build\" first — a scoped \"fireforge test --build\" cannot regenerate the compiled table." Advisory only (never throws, silent without a baseline); dry-run/cancelled/failed paths skip it.
|
|
18
|
+
- **The forward-import "Closest legal ordinal" hint is suppressed when no legal ordinal exists in the importing patch's category range** (FORGE F14, message bug — the hint suggested ordinal 388 for a dependency that no reorder could satisfy, sending the operator into a `patch reorder` the category-range policy then refused): when `patchPolicy` is configured and the computed ordinal falls outside the importing patch's category range, the sentence becomes "No legal ordinal in the <category> category range (<from-to>) lands after the creating patch(es); declaring the staged dependency is the recommended remedy." Fingerprints are unchanged across both variants; without a policy the hint prints as before.
|
|
19
|
+
- **`export` never offers to prepend the project license header onto a vendored third-party file** (FORGE F15, bug — the header auto-fix offered EUPL-1.2 headers for three byte-identity-pinned MIT xterm bundles; accepting (or `-y`) would have mislicensed upstream code): new-file candidates carrying a recognized third-party permissive banner (MIT, ISC, BSD-2/3-Clause, Apache-2.0 — by license text markers or a bare `SPDX-License-Identifier`) are excluded from the offer and the dry-run fix list and reported separately: "N new file(s) carry a third-party permissive license banner and were left untouched (vendored); if the missing-license-header lint check flags them, waive it with --lint-ignore missing-license-header". The lint check itself still fires — the reviewed waiver (expressible at creation since 0.38.0's `export --lint-ignore`) remains the sanctioned path.
|
|
20
|
+
- **Signal-shaped configure/build exits are labeled as external interruptions** (FORGE F16 — the reported `--headless`→configure exit-144 mechanism was REFUTED: `--headless` only ever lands in mach TEST args and `mach configure` receives no environment from the test path, so no plausible interaction exists): configure and build failures with exit codes in the 128+N signal convention now append e.g. "Exit 144 is signal-shaped (144 - 128 = 16, SIGURG on this host): the process was likely interrupted externally (OOM-killer, terminal disconnect, display/session teardown) rather than failing on its own, and the log may be truncated mid-step", using the host's real signal table — so the next occurrence is self-describing.
|
|
21
|
+
- **Headed macOS no-output timeouts hint at display sleep and `caffeinate`** (FORGE F17, message-only — a headed single-file run with no `caffeinate` wrapper timed out at 370 s before the first TEST-START; every subsequent `caffeinate -dimsu`-wrapped run passed): when a harness crash with the no-output-timeout shape came from a HEADED run on macOS, the failure message appends: "Hint: this was a HEADED run on macOS that died at the no-output timeout. A common cause is the display going to sleep mid-run, which freezes the headed browser before any test starts. Wrap headed runs in `caffeinate -dimsu <command>` to keep the display awake, or pass --headless." Auto-wrapping dispatches in `caffeinate` was evaluated and DEFERRED — the message half is the clearly-correct, cheap half.
|
|
22
|
+
|
|
23
|
+
## 0.38.0
|
|
24
|
+
|
|
25
|
+
- **`status --ownership` reports a furnace-deployed file whose owning patch body went stale as drift** (friction-log item 1, bug — after `furnace deploy` refreshed 12 deployed widget copies, the pre-export "consult ownership" step read clean while the composed gate caught the staleness later on the re-export path): the classifier no longer short-circuits every furnace-prefixed path into the `furnace` bucket — a furnace path claimed by exactly ONE patch now runs the same expected-content comparison as any patch-owned file (`computePatchedContent` vs the deployed copy, deletion and error branches included), classifying as `patch-owned-drift` on divergence and staying `furnace` on a match. Multi-owner and unowned furnace paths keep the old bucket (cross-patch conflicts are already surfaced independently), so the only behavioral change is that stale widget patches now show as drift in the table and in `--json` (machine consumers see those paths move from `"furnace"` to `"patch-owned-drift"`). An integration test pins the exact observed sequence: deploy, export the patch, edit the component source, deploy again, and `status --ownership` reports the owning patch drifted.
|
|
26
|
+
- **Scoped `test --build` and build-less runs refuse a `components.conf` changed since the last FULL build** (friction-log item 2, bug — a new about: module packaged fine under a scoped `test --build` but the child process resolved `NS_ERROR_MALFORMED_URI`, because `components.conf` entries bake into the compiled StaticComponents table that only a full build regenerates; the failure was silent and read as a test bug): the baseline gains `staticComponentsBaseline` (`engineHeadSha` + per-manifest content fingerprints), written fresh only by full-coverage builds and carried forward verbatim through scoped `test --build` writes — so the guard always anchors to the last full build, not the last scoped one. Any scoped `test --build` or non-`--build` run over a changed `components.conf` is refused up front with a message naming the manifest(s), explaining the compiled-table mechanism and the misleading error shape, and advising `fireforge build`; `--allow-stale-build` deliberately does NOT bypass it (it accepts stale packaged content, not a stale compiled registration), while the new `--allow-stale-components` downgrades the refusal to a warning for out-of-band rebuilds. Detection keys on the `components.conf` basename (`isXpcomManifestPath` is the single documented extension point; parsing `moz.build` `XPCOM_MANIFESTS` wiring is out of scope), pre-0.38.0 baselines lack the anchor and degrade to fresh, and probe failures never block a run.
|
|
27
|
+
- **Per-patch size lint agrees with `wc -l` and fires only ABOVE the soft limit** (friction-log item 3, lint bug — three independent recurrences: a file counted one line more than `wc -l` from patch-hunk trailing-newline accounting, and `file-too-large` fired at "750 lines (soft limit: 750)" while a wc-1499 patch reported 1500/1500, each costing 1–2 trim/re-export/lint cycles): line counting now drops the phantom element a trailing newline adds (`countContentLines` for on-disk content, the same correction inside `countNonBinaryDiffLines` for patch bodies, with binary-hunk subtraction kept consistent), and every tier of `file-too-large` and `large-patch-lines` compares strictly (`>`), matching the already-strict `large-patch-files`, so a file or patch sitting exactly AT a notice/soft/hard limit is clean. Boundary tests pin limit−1 / limit / limit+1 for both rules across the general, test, and branding tiers, plus the trailing-newline agreement cases.
|
|
28
|
+
- **Patch-lint accepts both established upstream MPL header wraps** (friction-log item 4, lint — derived files keeping the genuine upstream header of files like `ext-browser.js`, which wrap after "file," instead of the canonical break after "this", were flagged `missing EUPL-1.2 license header`): the upstream-MPL acceptance in `hasAnyLicenseHeader`/`hasUpstreamMplBlockHeader` keeps its exact-prefix fast path and adds a whitespace-normalized fallback — the leading comment span (line-comment run, first block comment, or hash run; anything else never matches) is collapsed to single spaces and must still begin with the full canonical MPL sentence including the URL — so any line-break position within the verbatim text passes while altered wording still fails. Both wraps are tested on JS and CSS comment styles, with and without leading editor directives.
|
|
29
|
+
- **The packaged-test coverage guard accepts same-manifest siblings instead of demanding the exact recorded path set** (friction-log item 5, test inefficiency — a run scoped to `…/hominis-webext/file_A.js` refused `file_B.js` of the SAME manifest, forcing a ~1–2 min repackage per alternation): coverage is now matched at manifest granularity — mozbuild manifests are per-directory and mach stages support-files per manifest, so a request whose manifest granule (a file's containing directory; a directory itself) equals a covered entry's granule is accepted, as are subset paths of a covered directory; genuinely uncovered manifests keep the existing refusal verbatim, and the recorded baseline format is unchanged so 0.37.0 and 0.38.0 baselines interoperate both ways. The entry's second suggestion — unioning covered sets across successive scoped builds to stop concurrent-session ping-pong — was evaluated and rejected as UNSOUND: every baseline write refreshes content fingerprints for ALL dirty packageable paths, so a union would whitewash an earlier scope's edited fixtures while `obj-*/_tests/` still holds that scope's stale staging — precisely the silent-hang class the guard exists to refuse. The verdict is recorded on `testPackagingCoverage`'s doc contract; coverage REPLACES.
|
|
30
|
+
- **Positional inserts refuse with ONE actionable error when they would renumber a reserved range, `patch move-files --create --order <n>` bootstraps a split in one step, and `re-export --scan` refuses forward-import adoptions** (friction-log item 6, patch-tooling ergonomics, three parts): (a) `patch split --before` / positional `export` on a dense queue that would shift patches through a `patchPolicy.reservedRanges` block no longer sprays per-patch reserved-range findings — the placement plan is gated up front (`assertPlacementAvoidsReservedRanges`, subsuming the old inside-range check) with a single error naming the range and computing the remedy: `pass --order <first free number below the reserved block>`, or explicit guidance when no free order exists. (b) `patch move-files` gains `--create --order <n>`: creating the target patch and moving the files is one transaction through the split pipeline (same placement gate, projected cross-patch lint, and staged-dependency owner repointing the manual shrink-then-export dance needed by hand), while the flag-less preview behavior is unchanged. (c) `re-export --scan`/`--scan-file`/`--scan-files` run the forward-import check per adoption candidate BEFORE adopting: a candidate importing a module created by a LATER patch is refused with the later patch and closest legal ordinal named, advising `export --order` / `patch split --order` / `patch staged-dependency --add`; declared staged dependencies and lint-ignores are honored because the gate runs the real cross-patch rule on a projected queue.
|
|
31
|
+
- **Engine-mutating commands can wait for the engine lock instead of failing fast** (friction-log item 7, missing-feature — two sessions on one engine tree had operators hand-rolling `for i in $(seq 1 60); do CMD; sleep 25; done` around "Another FireForge engine-mutating command is already running"): `test`, `build`, `export`, `re-export`, and the engine-locked furnace subcommands gain `--wait-lock [seconds]` (bare flag = 60, validated 1–3600), which polls the lock with exponential backoff (100 ms doubling to a 2 s cap) up to the bound, printing a progress line about every 5 s identifying the holder from the lock's owner metadata (`Waiting for the FireForge engine lock held by PID … (command=…, started=…) — 10s of up to 60s.`), then proceeds when the lock frees or fails with the unchanged standard message. Without the flag, behavior is byte-identical to the 1 s fail-fast; the lock-ownership invariants and their doc (`docs/lifecycle-invariants.md`) are updated to cover the bounded wait.
|
|
32
|
+
- **`furnace validate` credits the implicit wrapping `<label>` association** (friction-log item 8, false positive — `[unlabelled-form-input]` fired on the accessible `<label><input type="radio">…</label>` pattern although the control has an accessible name per the HTML spec): an input/select/textarea nested inside a `<label>` whose content includes real text (tags stripped; a `${…}` binding deliberately counts as text since it is legitimate dynamic label content) is now treated as labelled; a bare input, or one wrapped in an empty or tags-only `<label>`, still warns, and the explicit `for`/`aria-label`/`aria-labelledby` paths are unchanged.
|
|
33
|
+
- **`furnace create --shared-ftl <path>` was already shipped; its validation is now pinned by tests** (friction-log item 9, missing-feature — verified already implemented on current main: the flag sets `sharedFtl` in furnace.json, skips the component-local FTL scaffold, emits the `insertFTLIfNeeded` line for the given path verbatim, and rejects the contradictory `--no-localized` combination): 0.38.0 adds the previously missing direct unit tests for `validateSharedFtl` (trimming, empty/non-string rejection, unsafe-character rejection, localized requirement) and a command-level test pinning the exact CLI rejection wording.
|
|
34
|
+
- **`source set --candidate <buildN>` fetches release-candidate archives** (friction-log item 10, missing-feature — `resolveArchive` hardcoded `pub/firefox/releases/`, so a `candidates/<version>-candidates/buildN/` source, the normal shape for pre-release verification, forced hand-seeding `.fireforge/cache/` with an independently verified tarball + sidecar): `firefox.candidate` (validated `buildN`, also settable via `fireforge config firefox.candidate` and cleared with `--clear-candidate`) switches resolution to `pub/<product>/candidates/<version>-candidates/<buildN>/source/…` with the matching `SHA256SUMS` alongside the build directory; devedition and ESR fall out of the existing product/version handling, cache filenames gain a `-<buildN>` suffix so candidate artifacts never collide with release artifacts, and the sha256-pin trust model is unchanged (an explicit `firefox.sha256` pin still fails closed and takes precedence over the published checksums). Recording the candidate in exported patches' source provenance was considered and deferred — it cascades into `PatchMetadata` schema/validation churn across the manifest round-trip surface.
|
|
35
|
+
|
|
3
36
|
## 0.37.0
|
|
4
37
|
|
|
5
38
|
- **`fireforge test --build` now records the stale-build baseline** (friction-log item 1, inefficiency — "stale-build guard re-arms across invocation shapes"): a green pre-test build refreshes `.fireforge/last-build.json` through the same `writeBuildBaseline` path as `fireforge build`/`build --ui`, so after one `test --build` over a set of edited files, a subsequent plain `fireforge test` over the same files — per-file, per-directory, or full suite — passes the gate without a second redundant `--build` round trip. The baseline is content-fingerprint-based and project-scoped, which is also per-obj-dir (multi-objdir checkouts are already refused up front), so no per-invocation-shape keying is needed; both directions (file-scoped rebuild → full-suite run, and full/directory rebuild → file run) are covered by tests.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](LICENSE.md)
|
|
5
5
|
[](package.json)
|
|
6
6
|
|
|
7
|
-
FireForge is a CLI tool for maintaining Firefox-based browser forks. It downloads Firefoxs source code, lets you export changes as patches, helps apply them and has some neat/mad code quality enforcement, built by taking a lot of learnings from the existing Firefox source code and trying to make the process as robust as can be. Ideally, FireForge makes everything from managing custom UI components to enforcing some rudimentary typechecking much easier, but I'll be honest, there is only so much one can do, so understanding upstream is advised, especially once future updates come into the mix. I cannot guarantee how reliably these will apply, but I did try to assist in that process as much as I can by learning from prior Firefox versions which had major changes made to them, though this is of course still no guarantee for future success or that the methods will remain reliable. Beware of that.
|
|
7
|
+
FireForge is a CLI tool for maintaining Firefox-based browser forks. It downloads Firefoxs source code, lets you export changes as patches, helps apply them and has some neat/mad code quality enforcement, built by taking a lot of learnings from the existing Firefox source code and trying to make the process as robust as can be. Ideally, FireForge makes everything from managing custom UI components to enforcing some rudimentary typechecking much easier, but I'll be honest, there is only so much one can do, so understanding upstream is advised, especially once future updates come into the mix. ~~I cannot guarantee how reliably these will apply, but I did try to assist in that process as much as I can by learning from prior Firefox versions which had major changes made to them, though this is of course still no guarantee for future success or that the methods will remain reliable. Beware of that.~~ Edit 16.07.2026: I have now upgraded and downgraded and sidegraded, really all the graded, across all the versions in many scenarios with modifications so far removed from upstream that I think it's fair to say upgrading is fairly reliable.
|
|
8
8
|
|
|
9
9
|
Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon](https://github.com/dothq/melon).
|
|
10
10
|
|
|
@@ -3,7 +3,7 @@ import { Command, InvalidArgumentError as CommanderInvalidArgumentError } from '
|
|
|
3
3
|
import { validateBrandOverride } from '../core/brand-validation.js';
|
|
4
4
|
import { auditBuildArtifacts } from '../core/build-audit.js';
|
|
5
5
|
import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
|
|
6
|
-
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
6
|
+
import { describeSignalShapedExit, prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
7
7
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
8
8
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
9
9
|
import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, runMach, withBuildLock, } from '../core/mach.js';
|
|
@@ -14,7 +14,7 @@ import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
|
14
14
|
import { toError } from '../utils/errors.js';
|
|
15
15
|
import { checkDiskSpace, pathExists } from '../utils/fs.js';
|
|
16
16
|
import { error, info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
17
|
-
import { pickDefined } from '../utils/options.js';
|
|
17
|
+
import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../utils/options.js';
|
|
18
18
|
import { isPositiveInteger } from '../utils/validation.js';
|
|
19
19
|
function parseJobCount(value) {
|
|
20
20
|
const parsed = Number(value);
|
|
@@ -147,6 +147,7 @@ function buildFailureDiagnostics(result, engineDir, objDir, machCommand) {
|
|
|
147
147
|
: `cd ${engineDir} && ./mach build -v`;
|
|
148
148
|
return [
|
|
149
149
|
`Build failed with exit code ${result.exitCode}.`,
|
|
150
|
+
describeSignalShapedExit(result.exitCode),
|
|
150
151
|
`Mach phase: ${machCommand}`,
|
|
151
152
|
makeError ? `Last make error: ${makeError}` : undefined,
|
|
152
153
|
makeContext ? `Recent make context:\n${makeContext}` : undefined,
|
|
@@ -326,17 +327,21 @@ export async function buildCommand(projectRoot, options) {
|
|
|
326
327
|
// Record a fresh baseline only on clean success so the next run audits
|
|
327
328
|
// against this build's HEAD. A failed build keeps the prior baseline so
|
|
328
329
|
// the next attempt still catches long-standing packaging drops.
|
|
330
|
+
await recordFullBuildBaseline(projectRoot, paths.engine, config.binaryName, options.ui === true);
|
|
331
|
+
outro(`Build completed in ${timeStr}!`);
|
|
332
|
+
}
|
|
333
|
+
/** Persists the full-coverage baseline; a failed write never fails the build. */
|
|
334
|
+
async function recordFullBuildBaseline(projectRoot, engineDir, binaryName, ui) {
|
|
329
335
|
try {
|
|
330
|
-
await writeBuildBaseline(projectRoot,
|
|
336
|
+
await writeBuildBaseline(projectRoot, engineDir, binaryName, 'full', undefined, ui ? 'fireforge build --ui' : 'fireforge build');
|
|
331
337
|
}
|
|
332
338
|
catch (baselineError) {
|
|
333
339
|
verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
|
|
334
340
|
}
|
|
335
|
-
outro(`Build completed in ${timeStr}!`);
|
|
336
341
|
}
|
|
337
342
|
/** Registers the build command on the CLI program. */
|
|
338
343
|
export function registerBuild(program, { getProjectRoot, withErrorHandling }) {
|
|
339
|
-
program
|
|
344
|
+
const build = program
|
|
340
345
|
.command('build')
|
|
341
346
|
.description('Build the browser (auto-applies Furnace components first)')
|
|
342
347
|
.option('--ui', 'Fast UI-only rebuild')
|
|
@@ -360,9 +365,9 @@ export function registerBuild(program, { getProjectRoot, withErrorHandling }) {
|
|
|
360
365
|
'and runs mach configure — preserving up to ~20 GB of obj-* artefacts on',
|
|
361
366
|
'a relocation. The rewriter refuses any change that is not a pure prefix',
|
|
362
367
|
'move, in which case a clean rebuild is still required.',
|
|
363
|
-
].join('\n'))
|
|
364
|
-
|
|
368
|
+
].join('\n'));
|
|
369
|
+
addWaitLockOption(build).action(withErrorHandling(async (options) => {
|
|
365
370
|
const projectRoot = getProjectRoot();
|
|
366
|
-
await withEngineSessionLock(projectRoot, 'build', () => buildCommand(projectRoot, pickDefined(options)));
|
|
371
|
+
await withEngineSessionLock(projectRoot, 'build', () => buildCommand(projectRoot, pickDefined(options)), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
367
372
|
}));
|
|
368
373
|
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { confirm } from '@clack/prompts';
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { getProjectPaths } from '../core/config.js';
|
|
5
|
+
import { applyDiscardBaseline, describeConflictWarning, describeDiscardBaseline, describeDiscardOutcome, planDiscardBaselines, planUpstreamDiscards, summarizeDiscardBaselines, } from '../core/discard-baseline.js';
|
|
5
6
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
6
7
|
import { getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
7
|
-
import { discardStatusEntry } from '../core/git-file-ops.js';
|
|
8
8
|
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
9
9
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
10
10
|
import { GitError } from '../errors/git.js';
|
|
@@ -12,6 +12,42 @@ import { toError } from '../utils/errors.js';
|
|
|
12
12
|
import { pathExists } from '../utils/fs.js';
|
|
13
13
|
import { info, intro, isCancel, outro, spinner, warn } from '../utils/logger.js';
|
|
14
14
|
import { pickDefined } from '../utils/options.js';
|
|
15
|
+
/**
|
|
16
|
+
* Shared interactive confirmation for the single-file and directory paths.
|
|
17
|
+
* Returns false when the operator cancelled (the caller printed the outro).
|
|
18
|
+
*/
|
|
19
|
+
async function confirmDiscard(message, hint, options) {
|
|
20
|
+
if (options.yes || options.dryRun)
|
|
21
|
+
return true;
|
|
22
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
23
|
+
if (!isInteractive) {
|
|
24
|
+
throw new InvalidArgumentError('Interactive confirmation not available. Use --yes flag to discard without confirmation.', hint);
|
|
25
|
+
}
|
|
26
|
+
const confirmed = await confirm({ message, initialValue: false });
|
|
27
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
28
|
+
outro('Discard cancelled');
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Plans the restore baseline for `entries`: the patch-applied baseline for
|
|
35
|
+
* patch-claimed paths (FORGE F1), or pristine HEAD when `--to-upstream`
|
|
36
|
+
* explicitly requests the legacy semantics.
|
|
37
|
+
*/
|
|
38
|
+
async function planDiscards(patchesDir, engineDir, entries, options) {
|
|
39
|
+
if (options.toUpstream)
|
|
40
|
+
return planUpstreamDiscards(entries);
|
|
41
|
+
return planDiscardBaselines(patchesDir, engineDir, entries);
|
|
42
|
+
}
|
|
43
|
+
/** Dry-run label including the rename pair and the restore-baseline suffix. */
|
|
44
|
+
function dryRunLabel(plan) {
|
|
45
|
+
const { entry } = plan;
|
|
46
|
+
const target = entry.originalPath && entry.originalPath !== entry.file
|
|
47
|
+
? `${entry.originalPath} -> ${entry.file}`
|
|
48
|
+
: entry.file;
|
|
49
|
+
return `${target} (${describeDiscardBaseline(plan)})`;
|
|
50
|
+
}
|
|
15
51
|
/**
|
|
16
52
|
* Discards every status entry whose path lives under `dirPath`. Used by
|
|
17
53
|
* `discardCommand` as a directory-recursion fallback when the operator
|
|
@@ -23,28 +59,15 @@ import { pickDefined } from '../utils/options.js';
|
|
|
23
59
|
* discard runs sequentially under its own try/catch so a failure on one
|
|
24
60
|
* file is reported but does not block the remaining files in the batch.
|
|
25
61
|
*/
|
|
26
|
-
async function discardDirectoryEntries(projectRoot, engineDir, dirPath, entries, options) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
const confirmed = await confirm({
|
|
33
|
-
message: `Discard changes to ${entries.length} file${entries.length === 1 ? '' : 's'} under ${dirPath}/?`,
|
|
34
|
-
initialValue: false,
|
|
35
|
-
});
|
|
36
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
37
|
-
outro('Discard cancelled');
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
62
|
+
async function discardDirectoryEntries(projectRoot, engineDir, patchesDir, dirPath, entries, options) {
|
|
63
|
+
const proceed = await confirmDiscard(`Discard changes to ${entries.length} file${entries.length === 1 ? '' : 's'} under ${dirPath}/?`, 'Use: fireforge discard <directory> --yes', options);
|
|
64
|
+
if (!proceed)
|
|
65
|
+
return;
|
|
66
|
+
const plans = await planDiscards(patchesDir, engineDir, entries, options);
|
|
41
67
|
if (options.dryRun) {
|
|
42
68
|
info(`Would discard changes to ${entries.length} file(s) under ${dirPath}/:`);
|
|
43
|
-
for (const
|
|
44
|
-
|
|
45
|
-
? `${entry.originalPath} -> ${entry.file}`
|
|
46
|
-
: entry.file;
|
|
47
|
-
info(` ${target}`);
|
|
69
|
+
for (const plan of plans) {
|
|
70
|
+
info(` ${dryRunLabel(plan)}`);
|
|
48
71
|
}
|
|
49
72
|
outro('Dry run complete — no changes made');
|
|
50
73
|
return;
|
|
@@ -52,20 +75,26 @@ async function discardDirectoryEntries(projectRoot, engineDir, dirPath, entries,
|
|
|
52
75
|
const s = spinner(`Discarding ${entries.length} file(s) under ${dirPath}/...`);
|
|
53
76
|
let succeeded = 0;
|
|
54
77
|
const failures = [];
|
|
78
|
+
const appliedPlans = [];
|
|
55
79
|
try {
|
|
56
|
-
for (const
|
|
80
|
+
for (const plan of plans) {
|
|
57
81
|
try {
|
|
58
|
-
await
|
|
82
|
+
await applyDiscardBaseline(engineDir, plan);
|
|
83
|
+
appliedPlans.push(plan);
|
|
59
84
|
succeeded += 1;
|
|
60
85
|
}
|
|
61
86
|
catch (error) {
|
|
62
|
-
failures.push(`${entry.file}: ${toError(error).message}`);
|
|
87
|
+
failures.push(`${plan.entry.file}: ${toError(error).message}`);
|
|
63
88
|
}
|
|
64
89
|
}
|
|
65
90
|
s.stop(`Discarded ${succeeded} of ${entries.length} file(s) under ${dirPath}/${failures.length > 0 ? ` (${failures.length} failed)` : ''}`);
|
|
66
91
|
for (const failure of failures) {
|
|
67
92
|
warn(` ${failure}`);
|
|
68
93
|
}
|
|
94
|
+
for (const plan of appliedPlans) {
|
|
95
|
+
if (plan.conflicted)
|
|
96
|
+
warn(describeConflictWarning(plan));
|
|
97
|
+
}
|
|
69
98
|
try {
|
|
70
99
|
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
71
100
|
const dirIsFurnace = [...furnacePrefixes].some((prefix) => `${dirPath}/`.startsWith(prefix) || prefix.startsWith(`${dirPath}/`));
|
|
@@ -79,7 +108,7 @@ async function discardDirectoryEntries(projectRoot, engineDir, dirPath, entries,
|
|
|
79
108
|
if (failures.length > 0) {
|
|
80
109
|
throw new GeneralError(`Failed to discard ${failures.length} file(s) under ${dirPath}/. See warnings above.`);
|
|
81
110
|
}
|
|
82
|
-
outro(
|
|
111
|
+
outro(summarizeDiscardBaselines(appliedPlans, succeeded));
|
|
83
112
|
}
|
|
84
113
|
catch (error) {
|
|
85
114
|
if (!(error instanceof GeneralError)) {
|
|
@@ -138,40 +167,33 @@ export async function discardCommand(projectRoot, file, options = {}) {
|
|
|
138
167
|
const dirPrefix = `${dirPath}/`;
|
|
139
168
|
const dirEntries = statusEntries.filter((entry) => entry.file.startsWith(dirPrefix) || entry.originalPath?.startsWith(dirPrefix));
|
|
140
169
|
if (dirEntries.length > 0) {
|
|
141
|
-
await discardDirectoryEntries(projectRoot, paths.engine, dirPath, dirEntries, options);
|
|
170
|
+
await discardDirectoryEntries(projectRoot, paths.engine, paths.patches, dirPath, dirEntries, options);
|
|
142
171
|
return;
|
|
143
172
|
}
|
|
144
173
|
throw new GeneralError(`File "${file}" has no changes to discard.`);
|
|
145
174
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
message: `Discard all changes to ${statusEntry.file}?`,
|
|
153
|
-
initialValue: false,
|
|
154
|
-
});
|
|
155
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
156
|
-
outro('Discard cancelled');
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
175
|
+
const proceed = await confirmDiscard(`Discard all changes to ${statusEntry.file}?`, 'Use: fireforge discard <file> --yes', options);
|
|
176
|
+
if (!proceed)
|
|
177
|
+
return;
|
|
178
|
+
const [plan] = await planDiscards(paths.patches, paths.engine, [statusEntry], options);
|
|
179
|
+
if (!plan) {
|
|
180
|
+
throw new GeneralError(`File "${file}" has no changes to discard.`);
|
|
159
181
|
}
|
|
160
182
|
if (options.dryRun) {
|
|
161
183
|
// Show the rename pair regardless of which side the operator passed —
|
|
162
184
|
// passing the NEW path of a rename used to hide that discarding also
|
|
163
185
|
// resurrects the old path, exactly when the full picture matters most.
|
|
164
|
-
|
|
165
|
-
? `${statusEntry.originalPath} -> ${statusEntry.file}`
|
|
166
|
-
: statusEntry.file;
|
|
167
|
-
info(`Would discard changes to: ${target}`);
|
|
186
|
+
info(`Would discard changes to: ${dryRunLabel(plan)}`);
|
|
168
187
|
outro('Dry run complete — no changes made');
|
|
169
188
|
return;
|
|
170
189
|
}
|
|
171
190
|
const s = spinner(`Discarding changes to ${file}...`);
|
|
172
191
|
try {
|
|
173
|
-
await
|
|
192
|
+
await applyDiscardBaseline(paths.engine, plan);
|
|
174
193
|
s.stop(`Discarded changes to ${file}`);
|
|
194
|
+
if (plan.conflicted) {
|
|
195
|
+
warn(describeConflictWarning(plan));
|
|
196
|
+
}
|
|
175
197
|
// Warn when the discarded file is managed by Furnace so the user knows
|
|
176
198
|
// to re-apply if they want the component's deployed state back.
|
|
177
199
|
try {
|
|
@@ -183,16 +205,18 @@ export async function discardCommand(projectRoot, file, options = {}) {
|
|
|
183
205
|
catch {
|
|
184
206
|
// Furnace config may not exist — skip silently
|
|
185
207
|
}
|
|
186
|
-
outro(
|
|
208
|
+
outro(describeDiscardOutcome(plan, options.toUpstream === true));
|
|
187
209
|
}
|
|
188
210
|
catch (error) {
|
|
189
211
|
s.error('Discard failed');
|
|
190
|
-
if (error instanceof GitError) {
|
|
212
|
+
if (error instanceof GitError || error instanceof GeneralError) {
|
|
191
213
|
throw error;
|
|
192
214
|
}
|
|
193
|
-
throw new GitError(`Failed to discard ${file}`,
|
|
194
|
-
?
|
|
195
|
-
|
|
215
|
+
throw new GitError(`Failed to discard ${file}`, plan.kind === 'unmanaged'
|
|
216
|
+
? statusEntry.isUntracked
|
|
217
|
+
? `rm ${statusEntry.file}`
|
|
218
|
+
: `restore --source HEAD --staged --worktree -- ${statusEntry.file}`
|
|
219
|
+
: `write patch baseline for ${statusEntry.file}`,
|
|
196
220
|
// Always attach the cause via toError so thrown primitives (strings,
|
|
197
221
|
// numbers) produced by poorly-behaved utilities still propagate as
|
|
198
222
|
// an Error, preserving stack traces for verbose-mode triage.
|
|
@@ -203,8 +227,9 @@ export async function discardCommand(projectRoot, file, options = {}) {
|
|
|
203
227
|
export function registerDiscard(program, { getProjectRoot, withErrorHandling }) {
|
|
204
228
|
program
|
|
205
229
|
.command('discard <file>')
|
|
206
|
-
.description('Discard changes to a specific file (
|
|
230
|
+
.description('Discard changes to a specific file, restoring patch-claimed paths to their patch-applied baseline (unmanaged untracked files are deleted). Pass a directory path to discard every modified or untracked file beneath it; the operation walks the status output and reverts each match individually.')
|
|
207
231
|
.option('--dry-run', 'Show what would be discarded without doing it')
|
|
232
|
+
.option('--to-upstream', 'Restore to pristine upstream (HEAD) instead of the patch-applied baseline; deletes patch-created files')
|
|
208
233
|
.option('-y, --yes', 'Skip confirmation prompt')
|
|
209
234
|
.action(withErrorHandling(async (file, options) => {
|
|
210
235
|
await discardCommand(getProjectRoot(), file, pickDefined(options));
|
|
@@ -160,7 +160,7 @@ async function restorePreviousEngine(args) {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
async function downloadAndExtractFirefox(args) {
|
|
163
|
-
const { version, product, engineDir, cacheDir, sha256 } = args;
|
|
163
|
+
const { version, product, engineDir, cacheDir, sha256, candidate } = args;
|
|
164
164
|
let s = spinner(`Downloading Firefox ${version}...`);
|
|
165
165
|
let lastPercent = 0;
|
|
166
166
|
const phaseState = { value: 'download' };
|
|
@@ -181,7 +181,7 @@ async function downloadAndExtractFirefox(args) {
|
|
|
181
181
|
}
|
|
182
182
|
}, sha256, (message) => {
|
|
183
183
|
s.message(message);
|
|
184
|
-
});
|
|
184
|
+
}, candidate);
|
|
185
185
|
s.stop(phaseState.value === 'extract'
|
|
186
186
|
? `Firefox ${version} extracted`
|
|
187
187
|
: `Firefox ${version} downloaded`);
|
|
@@ -402,6 +402,9 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
402
402
|
engineDir: installEngineDir,
|
|
403
403
|
cacheDir,
|
|
404
404
|
...(config.firefox.sha256 !== undefined ? { sha256: config.firefox.sha256 } : {}),
|
|
405
|
+
...(config.firefox.candidate !== undefined
|
|
406
|
+
? { candidate: config.firefox.candidate }
|
|
407
|
+
: {}),
|
|
405
408
|
});
|
|
406
409
|
if (replacementEngineDir && backupEngineDir) {
|
|
407
410
|
warn('Activating replacement engine directory...');
|
|
@@ -39,15 +39,19 @@ export declare function computePlacementPlan(manifestPatches: PatchMetadata[], n
|
|
|
39
39
|
export declare function computeExactPlacementPlan(manifestPatches: PatchMetadata[], newPatchCategory: PatchCategory, newPatchName: string, requestedOrder: number): PlacementPlan;
|
|
40
40
|
/**
|
|
41
41
|
* Resolves a placement plan from CLI flags against the current manifest.
|
|
42
|
+
* When `config` is provided, positional plans (`--before`/`--after`) are
|
|
43
|
+
* refused up front if their renumber would touch a reserved range — one
|
|
44
|
+
* error for the whole shift instead of per-patch policy findings. The
|
|
45
|
+
* exact `--order` branch never renames, so it bypasses that gate.
|
|
42
46
|
*/
|
|
43
|
-
export declare function resolvePlacementPlan(patchesDir: string, options: ExportOptions, category: PatchCategory, name: string): Promise<PlacementPlan>;
|
|
47
|
+
export declare function resolvePlacementPlan(patchesDir: string, options: ExportOptions, category: PatchCategory, name: string, config?: FireForgeConfig): Promise<PlacementPlan>;
|
|
44
48
|
/**
|
|
45
49
|
* Projects the placement through cross-patch lint to detect forward-imports
|
|
46
50
|
* the renumber would introduce *or* that the new patch itself would
|
|
47
51
|
* introduce by landing earlier than one of its dependencies. Returns null
|
|
48
52
|
* when the projection is clean.
|
|
49
53
|
*/
|
|
50
|
-
export declare function projectPlacementForLint(patchesDir: string, plan: PlacementPlan, diff: string): Promise<ConflictReport | null>;
|
|
54
|
+
export declare function projectPlacementForLint(patchesDir: string, plan: PlacementPlan, diff: string, config?: FireForgeConfig): Promise<ConflictReport | null>;
|
|
51
55
|
/**
|
|
52
56
|
* Builds the change-summary lines printed by the placement confirmation.
|
|
53
57
|
*/
|
|
@@ -20,7 +20,7 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
|
20
20
|
import { toError } from '../utils/errors.js';
|
|
21
21
|
import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
|
|
22
22
|
import { info, warn } from '../utils/logger.js';
|
|
23
|
-
import {
|
|
23
|
+
import { assertPlacementAvoidsReservedRanges } from './export-placement-policy.js';
|
|
24
24
|
import { findPartialOwnershipOverlap } from './export-shared.js';
|
|
25
25
|
function buildFilenameForPlacement(category, name, order, width) {
|
|
26
26
|
const padded = String(order).padStart(Math.max(3, width), '0');
|
|
@@ -113,8 +113,12 @@ export function computeExactPlacementPlan(manifestPatches, newPatchCategory, new
|
|
|
113
113
|
}
|
|
114
114
|
/**
|
|
115
115
|
* Resolves a placement plan from CLI flags against the current manifest.
|
|
116
|
+
* When `config` is provided, positional plans (`--before`/`--after`) are
|
|
117
|
+
* refused up front if their renumber would touch a reserved range — one
|
|
118
|
+
* error for the whole shift instead of per-patch policy findings. The
|
|
119
|
+
* exact `--order` branch never renames, so it bypasses that gate.
|
|
116
120
|
*/
|
|
117
|
-
export async function resolvePlacementPlan(patchesDir, options, category, name) {
|
|
121
|
+
export async function resolvePlacementPlan(patchesDir, options, category, name, config) {
|
|
118
122
|
// ForWrite: placement planning feeds a manifest rewrite; a corrupt
|
|
119
123
|
// manifest read as empty would allocate colliding orders.
|
|
120
124
|
const manifest = await loadPatchesManifestForWrite(patchesDir);
|
|
@@ -147,7 +151,9 @@ export async function resolvePlacementPlan(patchesDir, options, category, name)
|
|
|
147
151
|
}
|
|
148
152
|
targetOrder = anchor.order + 1;
|
|
149
153
|
}
|
|
150
|
-
|
|
154
|
+
const plan = computePlacementPlan(existingPatches, category, name, targetOrder);
|
|
155
|
+
assertPlacementAvoidsReservedRanges(plan, existingPatches, config);
|
|
156
|
+
return plan;
|
|
151
157
|
}
|
|
152
158
|
/**
|
|
153
159
|
* Extracts the newly-created files a diff would produce and builds the
|
|
@@ -170,8 +176,8 @@ function buildNewFilesFromDiff(diff) {
|
|
|
170
176
|
* introduce by landing earlier than one of its dependencies. Returns null
|
|
171
177
|
* when the projection is clean.
|
|
172
178
|
*/
|
|
173
|
-
export async function projectPlacementForLint(patchesDir, plan, diff) {
|
|
174
|
-
const baseCtx = await buildPatchQueueContext(patchesDir);
|
|
179
|
+
export async function projectPlacementForLint(patchesDir, plan, diff, config) {
|
|
180
|
+
const baseCtx = await buildPatchQueueContext(patchesDir, config);
|
|
175
181
|
const projectedEntries = baseCtx.entries.map((entry) => {
|
|
176
182
|
const rename = plan.renameMap.get(entry.filename);
|
|
177
183
|
if (!rename)
|
|
@@ -193,7 +199,10 @@ export async function projectPlacementForLint(patchesDir, plan, diff) {
|
|
|
193
199
|
modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff),
|
|
194
200
|
});
|
|
195
201
|
projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
|
|
196
|
-
const projectedIssues = lintPatchQueue({
|
|
202
|
+
const projectedIssues = lintPatchQueue({
|
|
203
|
+
entries: projectedEntries,
|
|
204
|
+
...(baseCtx.patchPolicy ? { patchPolicy: baseCtx.patchPolicy } : {}),
|
|
205
|
+
}).filter((i) => i.severity === 'error');
|
|
197
206
|
if (projectedIssues.length === 0)
|
|
198
207
|
return null;
|
|
199
208
|
return {
|
|
@@ -225,11 +234,11 @@ export function placementSummary(plan) {
|
|
|
225
234
|
*/
|
|
226
235
|
export async function commitPlacementExport(input) {
|
|
227
236
|
return withPatchDirectoryLock(input.patchesDir, async () => {
|
|
228
|
-
const currentPlan = await resolvePlacementPlan(input.patchesDir, input.options, input.category, input.name);
|
|
237
|
+
const currentPlan = await resolvePlacementPlan(input.patchesDir, input.options, input.category, input.name, input.config);
|
|
229
238
|
if (!placementPlansEqual(currentPlan, input.expectedPlan)) {
|
|
230
239
|
throw new InvalidArgumentError('Patch queue changed while waiting for export confirmation. Re-run the command to recompute placement.', 'export placement');
|
|
231
240
|
}
|
|
232
|
-
const conflicts = await projectPlacementForLint(input.patchesDir, currentPlan, input.diff);
|
|
241
|
+
const conflicts = await projectPlacementForLint(input.patchesDir, currentPlan, input.diff, input.config);
|
|
233
242
|
if (conflicts && input.unsafeOverride !== true) {
|
|
234
243
|
throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. ` +
|
|
235
244
|
'If the conflict names files owned by another patch (e.g. duplicate-new-file-creation), ' +
|
|
@@ -237,9 +246,6 @@ export async function commitPlacementExport(input) {
|
|
|
237
246
|
'do NOT bypass with --force-unsafe. Pass --force-unsafe only for a reviewed placement conflict.', '--force-unsafe');
|
|
238
247
|
}
|
|
239
248
|
const originalManifest = await loadPatchesManifestForWrite(input.patchesDir);
|
|
240
|
-
if (originalManifest !== null) {
|
|
241
|
-
assertPlacementPreservesReservedRanges(currentPlan, originalManifest.patches, input.config, input.category);
|
|
242
|
-
}
|
|
243
249
|
if (input.config !== undefined) {
|
|
244
250
|
const renamed = originalManifest !== null
|
|
245
251
|
? applyRenameMapToManifest(originalManifest, currentPlan.renameMap)
|
|
@@ -12,7 +12,6 @@ import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
|
|
|
12
12
|
import { InvalidArgumentError } from '../errors/base.js';
|
|
13
13
|
import { outro } from '../utils/logger.js';
|
|
14
14
|
import { placementSummary, projectPlacementForLint, resolvePlacementPlan, } from './export-flow.js';
|
|
15
|
-
import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
|
|
16
15
|
/**
|
|
17
16
|
* Spreadable optional metadata (`tier`, `lintIgnore`) derived from the
|
|
18
17
|
* export flags. Every manifest-row construction site in this command
|
|
@@ -41,12 +40,11 @@ export async function gatePlacementPlan(args) {
|
|
|
41
40
|
if (options.supersede) {
|
|
42
41
|
throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
|
|
43
42
|
}
|
|
44
|
-
|
|
43
|
+
// resolvePlacementPlan runs the reserved-range gate itself when config
|
|
44
|
+
// is passed — one up-front error per run instead of per-patch findings.
|
|
45
|
+
const placementPlan = await resolvePlacementPlan(patchesDir, options, selectedCategory, patchName, config);
|
|
45
46
|
const currentManifest = await loadPatchesManifest(patchesDir);
|
|
46
|
-
|
|
47
|
-
assertPlacementPreservesReservedRanges(placementPlan, currentManifest.patches, config, selectedCategory);
|
|
48
|
-
}
|
|
49
|
-
const conflicts = await projectPlacementForLint(patchesDir, placementPlan, diff);
|
|
47
|
+
const conflicts = await projectPlacementForLint(patchesDir, placementPlan, diff, config);
|
|
50
48
|
const renamed = currentManifest !== null
|
|
51
49
|
? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
|
|
52
50
|
: buildProjectedManifest(null, []);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Policy-aware checks for export placement plans.
|
|
3
3
|
*/
|
|
4
|
-
import type {
|
|
4
|
+
import type { PatchMetadata } from '../types/commands/index.js';
|
|
5
5
|
import type { FireForgeConfig } from '../types/config.js';
|
|
6
6
|
export interface PlacementPolicyPlan {
|
|
7
7
|
insertionOrder: number;
|
|
@@ -10,5 +10,13 @@ export interface PlacementPolicyPlan {
|
|
|
10
10
|
newOrder: number;
|
|
11
11
|
}>;
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Refuses positional placement plans whose renumber would touch a
|
|
15
|
+
* `patchPolicy.reservedRanges` block — either by moving a patch INTO a
|
|
16
|
+
* reserved range or by moving a patch that currently sits inside one.
|
|
17
|
+
* Throws a single up-front error for the first reserved range hit (the
|
|
18
|
+
* per-patch alternative surfaced one confusing finding per shifted
|
|
19
|
+
* patch), suggesting the first free `--order` below the block when one
|
|
20
|
+
* exists. Exact `--order` plans have an empty rename map and pass.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertPlacementAvoidsReservedRanges(plan: PlacementPolicyPlan, manifestPatches: readonly PatchMetadata[], config: FireForgeConfig | undefined): void;
|